Spaces:
Running
Running
| // 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 }; | |