CompactAI's picture
Upload 126 files
f6b8770 verified
Raw
History Blame Contribute Delete
18.6 kB
// REST API: auth, users, avatars, catalog, games, friends.
const express = require('express');
const crypto = require('crypto');
const { get, save, uid, findUserByName, publicUser } = require('./db');
const router = express.Router();
router.use(express.json({ limit: '4mb' }));
const MAX_PARTS = 1500;
const PART_TYPES = ['block', 'spawn', 'checkpoint', 'kill', 'coin', 'goal', 'bounce', 'speed', 'water', 'deco', 'mover', 'tele'];
const MODES = ['sandbox', 'obby', 'collect', 'tag'];
const GENRES = ['Obby', 'Collecting', 'Tag', 'Social', 'Adventure', 'Fighting', 'Tycoon', 'Other'];
// ---------- auth helpers ----------
function hashPass(password, salt) {
return crypto.scryptSync(password, salt, 64).toString('hex');
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((c) => {
const i = c.indexOf('=');
if (i > 0) out[c.slice(0, i).trim()] = decodeURIComponent(c.slice(i + 1).trim());
});
return out;
}
function userFromReq(req) {
const db = get();
const token = parseCookies(req).glintsession;
if (!token) return null;
const uidv = db.sessions[token];
return (uidv && db.users[uidv]) || null;
}
function requireAuth(req, res, next) {
const u = userFromReq(req);
if (!u) return res.status(401).json({ error: 'Not logged in' });
req.user = u;
next();
}
function setSession(res, userId) {
const db = get();
const token = uid(24);
db.sessions[token] = userId;
save();
res.setHeader('Set-Cookie', `glintsession=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`);
}
// ---------- auth routes ----------
router.post('/register', (req, res) => {
const db = get();
const { username, password } = req.body || {};
if (!username || !/^[A-Za-z0-9_]{3,20}$/.test(username))
return res.status(400).json({ error: 'Username: 3-20 letters, numbers, underscores' });
if (!password || password.length < 4)
return res.status(400).json({ error: 'Password: at least 4 characters' });
if (findUserByName(username))
return res.status(409).json({ error: 'Username taken' });
const id = uid(10);
const salt = crypto.randomBytes(16).toString('hex');
const bodyPalette = ['#ffd23f', '#e54b4b', '#2a7de1', '#3aa14f', '#f0a020', '#8a4fff', '#19c2ff', '#ff8f3c'];
const pick = () => bodyPalette[Math.floor(Math.random() * bodyPalette.length)];
const torso = pick();
db.users[id] = {
id, username, salt,
passhash: hashPass(password, salt),
created: Date.now(),
glimmers: 250, // signup bonus
avatar: {
bodyColors: { head: '#ffd23f', torso, leftArm: '#ffd23f', rightArm: '#ffd23f', leftLeg: '#37474f', rightLeg: '#37474f' },
hat: null,
face: 'face_smile',
},
inventory: ['face_smile'],
friends: [],
friendRequests: [],
lastDaily: dayKey(),
stats: { visitsReceived: 0, joins: 0 },
};
setSession(res, id);
res.json({ ok: true, user: meView(db.users[id]) });
});
router.post('/login', (req, res) => {
const { username, password } = req.body || {};
const u = findUserByName(username || '');
if (!u || u.system || u.passhash !== hashPass(password || '', u.salt))
return res.status(401).json({ error: 'Wrong username or password' });
setSession(res, u.id);
res.json({ ok: true, user: meView(u) });
});
router.post('/logout', (req, res) => {
const db = get();
const token = parseCookies(req).glintsession;
if (token) delete db.sessions[token];
save();
res.setHeader('Set-Cookie', 'glintsession=; Path=/; Max-Age=0');
res.json({ ok: true });
});
function dayKey() {
return new Date().toISOString().slice(0, 10);
}
function meView(u) {
return {
id: u.id, username: u.username, glimmers: u.glimmers, avatar: u.avatar,
inventory: u.inventory, friends: u.friends, friendRequests: u.friendRequests,
created: u.created, stats: u.stats,
};
}
router.get('/me', (req, res) => {
const u = userFromReq(req);
if (!u) return res.json({ user: null });
let dailyBonus = 0;
if (u.lastDaily !== dayKey()) {
u.lastDaily = dayKey();
u.glimmers += 25;
dailyBonus = 25;
save();
}
res.json({ user: meView(u), dailyBonus });
});
// ---------- avatar / catalog ----------
router.get('/catalog', (req, res) => {
res.json({ items: get().catalog });
});
router.post('/catalog/:itemId/buy', requireAuth, (req, res) => {
const db = get();
const item = db.catalog.find((i) => i.id === req.params.itemId);
if (!item) return res.status(404).json({ error: 'No such item' });
if (req.user.inventory.includes(item.id)) return res.status(400).json({ error: 'Already owned' });
if (req.user.glimmers < item.price) return res.status(400).json({ error: 'Not enough Glimmers' });
req.user.glimmers -= item.price;
req.user.inventory.push(item.id);
save();
res.json({ ok: true, glimmers: req.user.glimmers, inventory: req.user.inventory });
});
router.post('/avatar', requireAuth, (req, res) => {
const db = get();
const { bodyColors, hat, face } = req.body || {};
const av = req.user.avatar;
if (bodyColors && typeof bodyColors === 'object') {
for (const k of ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg']) {
if (typeof bodyColors[k] === 'string' && /^#[0-9a-fA-F]{6}$/.test(bodyColors[k]))
av.bodyColors[k] = bodyColors[k];
}
}
if (hat === null) av.hat = null;
else if (typeof hat === 'string') {
if (!req.user.inventory.includes(hat)) return res.status(400).json({ error: 'You do not own that hat' });
if (!db.catalog.find((i) => i.id === hat && i.type === 'hat')) return res.status(400).json({ error: 'Not a hat' });
av.hat = hat;
}
if (typeof face === 'string') {
if (!req.user.inventory.includes(face)) return res.status(400).json({ error: 'You do not own that face' });
if (!db.catalog.find((i) => i.id === face && i.type === 'face')) return res.status(400).json({ error: 'Not a face' });
av.face = face;
}
save();
res.json({ ok: true, avatar: av });
});
// ---------- games ----------
function gameView(g, viewer) {
const db = get();
return {
id: g.id, name: g.name, desc: g.desc, genre: g.genre,
owner: publicUser(db.users[g.ownerId]),
collaborators: g.collaborators.map((id) => publicUser(db.users[id])).filter(Boolean),
public: g.public, official: !!g.official,
created: g.created, updated: g.updated,
visits: g.visits, likeCount: g.likes.length, favCount: g.favorites.length,
liked: viewer ? g.likes.includes(viewer.id) : false,
favorited: viewer ? g.favorites.includes(viewer.id) : false,
config: g.config,
partCount: g.world.parts.length,
canEdit: viewer ? g.ownerId === viewer.id || g.collaborators.includes(viewer.id) : false,
};
}
router.get('/games', (req, res) => {
const db = get();
const viewer = userFromReq(req);
const { q, sort, creator, mine, favorites } = req.query;
let list = Object.values(db.games);
if (mine === '1') {
if (!viewer) return res.status(401).json({ error: 'Not logged in' });
list = list.filter((g) => g.ownerId === viewer.id || g.collaborators.includes(viewer.id));
} else if (favorites === '1') {
if (!viewer) return res.status(401).json({ error: 'Not logged in' });
list = list.filter((g) => g.favorites.includes(viewer.id));
} else if (creator) {
list = list.filter((g) => {
const u = db.users[g.ownerId];
return u && u.username.toLowerCase() === String(creator).toLowerCase() && g.public;
});
} else {
list = list.filter((g) => g.public);
}
if (q) {
const needle = String(q).toLowerCase();
list = list.filter((g) => g.name.toLowerCase().includes(needle) || (g.desc || '').toLowerCase().includes(needle));
}
if (sort === 'new') list.sort((a, b) => b.created - a.created);
else if (sort === 'liked') list.sort((a, b) => b.likes.length - a.likes.length);
else list.sort((a, b) => b.visits - a.visits); // popular default
res.json({ games: list.slice(0, 100).map((g) => gameView(g, viewer)) });
});
router.post('/games', requireAuth, (req, res) => {
const db = get();
const name = String((req.body || {}).name || '').trim().slice(0, 50) || 'Untitled Place';
const id = uid(10);
db.games[id] = {
id, name,
desc: '',
genre: 'Other',
ownerId: req.user.id,
collaborators: [],
public: false,
created: Date.now(),
updated: Date.now(),
visits: 0, likes: [], favorites: [],
config: { maxPlayers: 12, mode: 'sandbox', gravity: 50, walkSpeed: 12, jumpPower: 20, respawnTime: 2 },
world: {
parts: [
{ id: 'p1', type: 'block', x: 0, y: -0.5, z: 0, sx: 48, sy: 1, sz: 48, color: '#3aa14f' },
{ id: 'p2', type: 'spawn', x: 0, y: 0.25, z: 0, sx: 6, sy: 0.5, sz: 6, color: '#cfd8dc' },
],
},
};
save();
res.json({ ok: true, game: gameView(db.games[id], req.user) });
});
router.get('/games/:id', (req, res) => {
const db = get();
const g = db.games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
const viewer = userFromReq(req);
if (!g.public && (!viewer || !(g.ownerId === viewer.id || g.collaborators.includes(viewer.id))))
return res.status(403).json({ error: 'This place is private' });
res.json({ game: gameView(g, viewer) });
});
router.delete('/games/:id', requireAuth, (req, res) => {
const db = get();
const g = db.games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
if (g.ownerId !== req.user.id) return res.status(403).json({ error: 'Only the owner can delete' });
delete db.games[req.params.id];
save();
res.json({ ok: true });
});
router.post('/games/:id/like', requireAuth, (req, res) => {
const g = get().games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
const i = g.likes.indexOf(req.user.id);
if (i >= 0) g.likes.splice(i, 1); else g.likes.push(req.user.id);
save();
res.json({ ok: true, likeCount: g.likes.length, liked: i < 0 });
});
router.post('/games/:id/favorite', requireAuth, (req, res) => {
const g = get().games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
const i = g.favorites.indexOf(req.user.id);
if (i >= 0) g.favorites.splice(i, 1); else g.favorites.push(req.user.id);
save();
res.json({ ok: true, favCount: g.favorites.length, favorited: i < 0 });
});
router.post('/games/:id/collaborators', requireAuth, (req, res) => {
const g = get().games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
if (g.ownerId !== req.user.id) return res.status(403).json({ error: 'Only the owner manages collaborators' });
const { username, action } = req.body || {};
const target = findUserByName(username || '');
if (!target) return res.status(404).json({ error: 'No such user' });
if (target.id === g.ownerId) return res.status(400).json({ error: 'Owner already has access' });
if (action === 'remove') {
g.collaborators = g.collaborators.filter((id) => id !== target.id);
} else {
if (!g.collaborators.includes(target.id)) g.collaborators.push(target.id);
}
save();
res.json({ ok: true, collaborators: g.collaborators.map((id) => publicUser(get().users[id])) });
});
// Studio also saves config via WS; this REST route covers the game page settings.
router.post('/games/:id/config', requireAuth, (req, res) => {
const g = get().games[req.params.id];
if (!g) return res.status(404).json({ error: 'Game not found' });
if (!(g.ownerId === req.user.id || g.collaborators.includes(req.user.id)))
return res.status(403).json({ error: 'No edit access' });
applyConfig(g, req.body || {});
save();
res.json({ ok: true, game: gameView(g, req.user) });
});
function clampNum(v, lo, hi, dflt) {
const n = Number(v);
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
}
function applyConfig(g, body) {
if (typeof body.name === 'string' && body.name.trim()) g.name = body.name.trim().slice(0, 50);
if (typeof body.desc === 'string') g.desc = body.desc.slice(0, 1000);
if (GENRES.includes(body.genre)) g.genre = body.genre;
if (typeof body.public === 'boolean') g.public = body.public;
const c = body.config || {};
if (MODES.includes(c.mode)) g.config.mode = c.mode;
if (c.maxPlayers !== undefined) g.config.maxPlayers = Math.round(clampNum(c.maxPlayers, 1, 50, g.config.maxPlayers));
if (c.gravity !== undefined) g.config.gravity = clampNum(c.gravity, 5, 200, g.config.gravity);
if (c.walkSpeed !== undefined) g.config.walkSpeed = clampNum(c.walkSpeed, 4, 40, g.config.walkSpeed);
if (c.jumpPower !== undefined) g.config.jumpPower = clampNum(c.jumpPower, 5, 60, g.config.jumpPower);
if (c.respawnTime !== undefined) g.config.respawnTime = clampNum(c.respawnTime, 0, 10, g.config.respawnTime);
g.updated = Date.now();
}
// Validate a part coming from studio clients (shared with realtime.js).
function sanitizePart(p) {
if (!p || typeof p !== 'object') return null;
if (!PART_TYPES.includes(p.type)) return null;
const out = {
id: String(p.id || uid(8)).slice(0, 24),
type: p.type,
x: clampNum(p.x, -2000, 2000, 0),
y: clampNum(p.y, -500, 2000, 0),
z: clampNum(p.z, -2000, 2000, 0),
sx: clampNum(p.sx, 0.2, 512, 4),
sy: clampNum(p.sy, 0.2, 512, 1),
sz: clampNum(p.sz, 0.2, 512, 4),
color: /^#[0-9a-fA-F]{6}$/.test(p.color) ? p.color : '#aaaaaa',
};
if (p.type === 'mover') {
out.dx = clampNum(p.dx, -64, 64, 0);
out.dy = clampNum(p.dy, -64, 64, 0);
out.dz = clampNum(p.dz, -64, 64, 0);
out.period = clampNum(p.period, 0.5, 60, 4);
}
if (p.type === 'tele') out.channel = Math.round(clampNum(p.channel, 1, 99, 1));
if (p.type === 'checkpoint') out.stage = Math.round(clampNum(p.stage, 1, 999, 1));
return out;
}
// ---------- thumbnails: top-down SVG render of the world ----------
router.get('/games/:id/thumb.svg', (req, res) => {
const g = get().games[req.params.id];
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'no-cache');
if (!g) return res.send('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"/>');
const parts = g.world.parts;
let minX = -10, maxX = 10, minZ = -10, maxZ = 10;
for (const p of parts) {
minX = Math.min(minX, p.x - p.sx / 2); maxX = Math.max(maxX, p.x + p.sx / 2);
minZ = Math.min(minZ, p.z - p.sz / 2); maxZ = Math.max(maxZ, p.z + p.sz / 2);
}
const W = 320, H = 180, pad = 8;
const scale = Math.min((W - pad * 2) / (maxX - minX), (H - pad * 2) / (maxZ - minZ));
const ox = W / 2 - ((minX + maxX) / 2) * scale;
const oz = H / 2 - ((minZ + maxZ) / 2) * scale;
const sorted = [...parts].sort((a, b) => (a.y + a.sy / 2) - (b.y + b.sy / 2));
let rects = '';
for (const p of sorted) {
const x = (p.x - p.sx / 2) * scale + ox;
const yv = (p.z - p.sz / 2) * scale + oz;
const w = Math.max(1.5, p.sx * scale);
const h = Math.max(1.5, p.sz * scale);
const glow = p.type === 'coin' ? ' rx="50%"' : ' rx="1"';
rects += `<rect x="${x.toFixed(1)}" y="${yv.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="${p.color}"${glow} opacity="${p.type === 'water' ? 0.7 : 0.95}"/>`;
}
res.send(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0e2233"/><stop offset="1" stop-color="#1d4257"/></linearGradient></defs>
<rect width="${W}" height="${H}" fill="url(#sky)"/>${rects}</svg>`);
});
// ---------- users / profiles ----------
router.get('/users/:username', (req, res) => {
const db = get();
const u = findUserByName(req.params.username);
if (!u) return res.status(404).json({ error: 'No such user' });
const games = Object.values(db.games)
.filter((g) => g.ownerId === u.id && g.public)
.sort((a, b) => b.visits - a.visits)
.map((g) => gameView(g, userFromReq(req)));
res.json({
user: publicUser(u),
friendCount: u.friends.length,
games,
});
});
// ---------- friends ----------
router.post('/friends/request', requireAuth, (req, res) => {
const target = findUserByName((req.body || {}).username || '');
if (!target) return res.status(404).json({ error: 'No such user' });
if (target.id === req.user.id) return res.status(400).json({ error: 'That is you' });
if (target.system) return res.status(400).json({ error: 'Glint appreciates it, but no' });
if (req.user.friends.includes(target.id)) return res.status(400).json({ error: 'Already friends' });
if (target.friendRequests.includes(req.user.id)) return res.status(400).json({ error: 'Request already sent' });
// if they already requested us, auto-accept
if (req.user.friendRequests.includes(target.id)) {
req.user.friendRequests = req.user.friendRequests.filter((id) => id !== target.id);
req.user.friends.push(target.id);
target.friends.push(req.user.id);
save();
return res.json({ ok: true, becameFriends: true });
}
target.friendRequests.push(req.user.id);
save();
res.json({ ok: true });
});
router.post('/friends/respond', requireAuth, (req, res) => {
const db = get();
const { userId, accept } = req.body || {};
if (!req.user.friendRequests.includes(userId)) return res.status(404).json({ error: 'No such request' });
req.user.friendRequests = req.user.friendRequests.filter((id) => id !== userId);
if (accept && db.users[userId]) {
req.user.friends.push(userId);
db.users[userId].friends.push(req.user.id);
}
save();
res.json({ ok: true });
});
router.delete('/friends/:userId', requireAuth, (req, res) => {
const db = get();
const other = db.users[req.params.userId];
req.user.friends = req.user.friends.filter((id) => id !== req.params.userId);
if (other) other.friends = other.friends.filter((id) => id !== req.user.id);
save();
res.json({ ok: true });
});
router.get('/friends', requireAuth, (req, res) => {
const db = get();
const presence = router.presence || (() => ({}));
const friends = req.user.friends.map((id) => {
const u = db.users[id];
if (!u) return null;
const p = presence()[id];
return Object.assign(publicUser(u), {
online: !!p,
playing: p && p.gameId ? { gameId: p.gameId, gameName: (db.games[p.gameId] || {}).name } : null,
});
}).filter(Boolean);
const requests = req.user.friendRequests.map((id) => publicUser(db.users[id])).filter(Boolean);
res.json({ friends, requests });
});
module.exports = { router, userFromReq, sanitizePart, applyConfig, gameView, MAX_PARTS };