// 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(''); 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 += ``; } res.send(` ${rects}`); }); // ---------- 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 };