const express = require("express"); const fs = require("fs/promises"); const path = require("path"); const crypto = require("crypto"); const statsDb = require("./stats-db"); const app = express(); const PORT = process.env.PORT || 5180; const DB_PATH = path.join(__dirname, "data", "shaders.json"); const THUMB_DIR = path.join(__dirname, "data", "thumbnails"); app.use(express.json({ limit: "4mb" })); app.use(express.static(__dirname)); async function ensureDb() { await fs.mkdir(path.dirname(DB_PATH), { recursive: true }); try { await fs.access(DB_PATH); } catch { await fs.writeFile(DB_PATH, "[]", "utf8"); } } async function ensureThumbnailsDir() { await fs.mkdir(THUMB_DIR, { recursive: true }); } function thumbPath(id) { return path.join(THUMB_DIR, `${id}.png`); } async function thumbFileExists(id) { try { await fs.access(thumbPath(id)); return true; } catch { return false; } } function downloadFilenameBase(name) { const s = String(name || "shader").trim() || "shader"; return s.replace(/[\\/:*?"<>|]/g, "_").slice(0, 120); } function decodePngBase64(s) { if (!s || typeof s !== "string") return null; const t = s.trim(); const m = /^data:image\/png;base64,(.+)$/i.exec(t); const b64 = m ? m[1] : t.replace(/\s/g, ""); try { return Buffer.from(b64, "base64"); } catch { return null; } } function isPngBuffer(buf) { return ( buf && buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a ); } async function readDb() { await ensureDb(); const raw = await fs.readFile(DB_PATH, "utf8"); return JSON.parse(raw); } async function writeDb(list) { await ensureDb(); await fs.writeFile(DB_PATH, JSON.stringify(list, null, 2), "utf8"); } function extractFunctionSection(code) { const endMarker = code.indexOf("void ANGLE__0_main"); const end = endMarker >= 0 ? endMarker : code.length; const firstFn = code.search( /(metal::float[234](x2)?|float|void)\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*\{/m ); if (firstFn < 0 || firstFn >= end) return ""; return code.slice(firstFn, end); } function isAngleLike(code) { return ( code.includes("_umainImage") && (code.includes("ANGLE_") || code.includes("metal::") || code.includes("[[function_constant") || code.includes("float2") || code.includes("float3")) ); } function extractFunctionBlocks(code) { const blocks = []; const sig = /(?:^|\n)\s*(?:void|float|vec[234]|mat[234]|float[234](?:x[234])?)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*\{/g; let m; while ((m = sig.exec(code)) !== null) { const name = m[1]; if (name === "main0" || name === "ANGLE__0_main") continue; let i = code.indexOf("{", m.index); if (i < 0) continue; let depth = 0; let end = -1; for (let p = i; p < code.length; p++) { const ch = code[p]; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { end = p + 1; break; } } } if (end > i) { blocks.push(code.slice(m.index, end)); sig.lastIndex = end; } } return blocks; } function buildFunctionMap(blocks) { const map = new Map(); for (const block of blocks) { const m = block.match(/^\s*(?:void|float|vec[234]|mat[234]|int|uint)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/m); if (!m) continue; map.set(m[1], block); } return map; } function collectFunctionDependencies(fnMap, roots) { const keep = new Set(); const queue = [...roots]; while (queue.length) { const name = queue.shift(); if (keep.has(name)) continue; const block = fnMap.get(name); if (!block) continue; keep.add(name); const callPattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; let m; while ((m = callPattern.exec(block)) !== null) { const callee = m[1]; if (fnMap.has(callee) && !keep.has(callee)) queue.push(callee); } } return keep; } function convertAngleLikeToGlsl(code) { let s = code; const cut = s.indexOf("fragment ANGLE_FragmentOut"); if (cut > 0) s = s.slice(0, cut); s = s .replace(/metal::fast::normalize/g, "normalize") .replace(/metal::/g, "") .replace(/\[\[[^\]]+\]\]/g, "") .replace(/\bthread\b/g, "") .replace(/\bconstant\b/g, "") .replace(/\bprecise::tanh\b/g, "tanh") .replace(/\bfloat2x2\b/g, "mat2") .replace(/\bfloat3x3\b/g, "mat3") .replace(/\bfloat4x4\b/g, "mat4") .replace(/\bfloat2\b/g, "vec2") .replace(/\bfloat3\b/g, "vec3") .replace(/\bfloat4\b/g, "vec4") .replace(/\bint2\b/g, "ivec2") .replace(/\bint3\b/g, "ivec3") .replace(/\bint4\b/g, "ivec4") .replace(/\buint2\b/g, "uvec2") .replace(/\buint3\b/g, "uvec3") .replace(/\buint4\b/g, "uvec4") .replace(/\buint32_t\b/g, "uint") .replace(/\bANGLE_userUniforms\._uiResolution\b/g, "iResolution") .replace(/\bANGLE_userUniforms\._uiTime\b/g, "iTime") .replace(/\bANGLE_userUniforms\._uiMouse\b/g, "iMouse") .replace(/\bANGLE_userUniforms\./g, "") .replace(/ANGLE_texture\(\s*ANGLE_textureEnvs\._uiChannel0\s*,\s*([^)]+)\)/g, "texture(iChannel0, $1)") .replace(/ANGLE_texture\(\s*ANGLE_textureEnvs\._uiChannel1\s*,\s*([^)]+)\)/g, "texture(iChannel1, $1)") .replace(/ANGLE_texture\(\s*ANGLE_textureEnvs\._uiChannel2\s*,\s*([^)]+)\)/g, "texture(iChannel2, $1)") .replace(/ANGLE_texture\(\s*ANGLE_textureEnvs\._uiChannel3\s*,\s*([^)]+)\)/g, "texture(iChannel3, $1)") .replace(/ANGLE_texelFetch\(\s*ANGLE_textureEnvs\._uiChannel0\s*,\s*([^,]+)\s*,\s*([^)]+)\)/g, "texelFetch(iChannel0, $1, $2)") .replace(/ANGLE_texelFetch\(\s*ANGLE_textureEnvs\._uiChannel1\s*,\s*([^,]+)\s*,\s*([^)]+)\)/g, "texelFetch(iChannel1, $1, $2)") .replace(/ANGLE_texelFetch\(\s*ANGLE_textureEnvs\._uiChannel2\s*,\s*([^,]+)\s*,\s*([^)]+)\)/g, "texelFetch(iChannel2, $1, $2)") .replace(/ANGLE_texelFetch\(\s*ANGLE_textureEnvs\._uiChannel3\s*,\s*([^,]+)\s*,\s*([^)]+)\)/g, "texelFetch(iChannel3, $1, $2)") .replace(/ANGLE_texture\([^)]*\)/g, "vec4(0.0)") .replace(/ANGLE_texelFetch\([^)]*\)/g, "vec4(0.0)") .replace(/\bANGLE_mod\(/g, "mod(") .replace(/\bANGLE_out\(([^)]+)\)/g, "$1") .replace(/\bANGLE_swizzle_ref\(([^)]+)\)/g, "$1") .replace(/\bANGLE_elem_ref\(([^)]+)\)/g, "$1") .replace(/\bANGLE_addressof\(([^)]+)\)/g, "$1") .replace(/\bANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,?/g, "") .replace(/\bANGLE_TextureEnvs\s*&\s*ANGLE_textureEnvs\s*,?/g, "") .replace(/\bANGLE_NonConstGlobals\s*&\s*ANGLE_nonConstGlobals\s*,?/g, "") .replace(/\(\s*,/g, "(") .replace(/,\s*,/g, ",") .replace(/,\s*\)/g, ")") .replace(/\bANGLE_nonConstGlobals\./g, "") .replace(/(\d+\.\d+|\d+)f\b/g, "$1") .replace(/;\s*;/g, ";"); s = s.replace(/void\s+_umainImage\s*\([^)]*\)/g, "void _umainImage(out vec4 _ufragColor, vec2 _ufragCoord)"); const fnBlocks = extractFunctionBlocks(s).filter((b) => /(ANGLE_sc|ANGLE_sd|_u|mainImage|ANGLE_loopForwardProgress|_umainImage)/.test(b) ); const fnMap = buildFunctionMap(fnBlocks); const keepNames = collectFunctionDependencies(fnMap, ["mainImage", "_umainImage"]); if (fnMap.has("ANGLE_loopForwardProgress")) keepNames.add("ANGLE_loopForwardProgress"); const blocks = [...fnMap.entries()] .filter(([name]) => keepNames.has(name)) .map(([, block]) => block); if (!blocks.length || !s.includes("_umainImage")) return ""; const hasMainImage = blocks.some((b) => /void\s+mainImage\s*\(/.test(b)); const withoutDupLoop = []; let seenLoop = false; for (const b of blocks) { if (/void\s+ANGLE_loopForwardProgress\s*\(/.test(b)) { if (seenLoop) continue; seenLoop = true; withoutDupLoop.push("void ANGLE_loopForwardProgress() {}"); continue; } withoutDupLoop.push(b); } if (!hasMainImage) { withoutDupLoop.push("void mainImage(out vec4 fragColor, in vec2 fragCoord) { _umainImage(fragColor, fragCoord); }"); } return withoutDupLoop.join("\n\n"); } /** Strip ShaderToy export preamble (uniform iResolution…), fix `}uniform` gluing, keep first mainImage only. */ function sanitizeShadertoyUserGlsl(code) { if (!code || typeof code !== "string") return ""; let s = code.replace(/\r\n/g, "\n"); s = s.replace(/\}\s*(?=uniform\s+)/g, "}\n"); const lines = s.split("\n"); s = lines .filter((line) => !/^\s*uniform\s+/.test(line.trim())) .join("\n"); s = s.replace(/^\s+/, "").replace(/\s+$/, ""); const re = /\bvoid\s+mainImage\s*\(/g; const matches = [...s.matchAll(re)]; if (matches.length <= 1) return s; const start = matches[0].index; const braceStart = s.indexOf("{", s.indexOf("(", start)); if (braceStart < 0) return s; let depth = 0; let i = braceStart; for (; i < s.length; i++) { const ch = s[i]; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { i++; break; } } } const firstMain = s.slice(start, i); const prefix = s.slice(0, start).trimEnd(); return (prefix ? `${prefix}\n\n` : "") + firstMain.trim(); } function normalizeIncomingCode(code) { if (!code || typeof code !== "string") return { error: "code 不能为空", normalized: "" }; if (isAngleLike(code)) { const converted = convertAngleLikeToGlsl(code); if (!converted) { return { error: "自动解构失败:请检查粘贴内容是否完整。", normalized: "" }; } return { error: "", normalized: converted }; } const sanitized = sanitizeShadertoyUserGlsl(code); if (!sanitized.includes("mainImage")) { return { error: "code 必须包含 mainImage", normalized: "" }; } return { error: "", normalized: sanitized }; } async function autoNormalizeStoredShaders() { const shaders = await readDb(); let changed = false; const next = shaders.map((item) => { const code = String(item.code || ""); const angle = isAngleLike(code) || code.includes("struct ANGLE_"); if (angle) { const { error, normalized } = normalizeIncomingCode(code); if (!error && normalized && normalized !== code) { changed = true; return { ...item, code: normalized, updatedAt: new Date().toISOString(), sourceFormat: "angle-metal-auto-converted", }; } return item; } const { error, normalized } = normalizeIncomingCode(code); if (!error && normalized && normalized !== code) { changed = true; const fixTag = item.sourceFormat === "angle-metal-auto-converted" ? { sourceFormat: "glsl" } : {}; return { ...item, code: normalized, updatedAt: new Date().toISOString(), ...fixTag, }; } return item; }); if (changed) await writeDb(next); } app.get("/api/shaders", async (_req, res) => { try { await ensureThumbnailsDir(); let shaders = await readDb(); shaders = shaders.map((item) => { const raw = String(item.code || ""); const { error, normalized } = normalizeIncomingCode(raw); if (error || !normalized) return item; if (normalized === raw) return item; const next = { ...item, code: normalized }; if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted"; return next; }); const visitorId = String(_req.get("x-visitor-id") || "").trim(); const out = await Promise.all( shaders.map(async (item) => { const has = await thumbFileExists(item.id); const thumbnailUrl = has ? `/api/shaders/${encodeURIComponent(item.id)}/thumbnail` : null; const merged = statsDb.mergeItem({ ...item, thumbnailUrl }, visitorId); return merged; }) ); res.json(out); } catch { res.status(500).json({ error: "读取失败" }); } }); app.get("/api/shaders/:id/download", async (req, res) => { try { const shaders = await readDb(); const item = shaders.find((it) => it.id === req.params.id); if (!item) { return res.status(404).json({ error: "未找到该 shader" }); } const raw = item.sourceGlsl != null && String(item.sourceGlsl).length > 0 ? String(item.sourceGlsl) : String(item.code || ""); const base = downloadFilenameBase(item.name); const fullName = `${base}.glsl`; const asciiName = `${base.replace(/[^\x20-\x7e]/g, "_")}.glsl`; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader( "Content-Disposition", `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fullName)}` ); res.send(raw); } catch { res.status(500).json({ error: "导出失败" }); } }); app.post("/api/shaders", async (req, res) => { const { name, author = "unknown", code } = req.body || {}; if (!name || typeof name !== "string") { return res.status(400).json({ error: "name 必填" }); } const { error: parseError, normalized } = normalizeIncomingCode(code); if (parseError) { return res.status(400).json({ error: parseError }); } try { const shaders = await readDb(); const sourceGlsl = typeof code === "string" ? code : ""; const item = { id: crypto.randomUUID(), name: name.trim(), author: String(author || "unknown").trim() || "unknown", code: normalized, sourceGlsl, views: Math.floor(3000 + Math.random() * 22000), likes: Math.floor(40 + Math.random() * 700), createdAt: new Date().toISOString(), sourceFormat: isAngleLike(code) ? "angle-metal-auto-converted" : "glsl", }; shaders.unshift(item); await writeDb(shaders); await statsDb.insertStats(item.id, item.views, item.likes); res.status(201).json(statsDb.mergeItem(item, "")); } catch { res.status(500).json({ error: "保存失败" }); } }); app.post("/api/shaders/:id/view", async (req, res) => { try { const shaders = await readDb(); const item = shaders.find((it) => it.id === req.params.id); if (!item) { return res.status(404).json({ error: "未找到该 shader" }); } const row = await statsDb.incrementView(item.id, item); res.json({ views: row.views, likes: row.likes }); } catch { res.status(500).json({ error: "更新失败" }); } }); app.post("/api/shaders/:id/like", async (req, res) => { const visitorId = String((req.body && req.body.visitorId) || "").trim(); if (!visitorId) { return res.status(400).json({ error: "visitorId 必填" }); } try { const shaders = await readDb(); const item = shaders.find((it) => it.id === req.params.id); if (!item) { return res.status(404).json({ error: "未找到该 shader" }); } const out = await statsDb.tryLike(item.id, visitorId, item); res.json(out); } catch { res.status(500).json({ error: "点赞失败" }); } }); app.get("/api/shaders/:id/thumbnail", async (req, res) => { try { const p = thumbPath(req.params.id); await fs.access(p); res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "public, max-age=86400"); res.sendFile(path.resolve(p)); } catch { res.status(404).end(); } }); app.post("/api/shaders/:id/thumbnail", async (req, res) => { const { pngBase64 } = req.body || {}; const buf = decodePngBase64(pngBase64); if (!buf || buf.length < 32) { return res.status(400).json({ error: "无效 PNG 数据" }); } if (!isPngBuffer(buf)) { return res.status(400).json({ error: "必须是 PNG" }); } if (buf.length > 2 * 1024 * 1024) { return res.status(400).json({ error: "缩略图过大" }); } try { const shaders = await readDb(); const idx = shaders.findIndex((it) => it.id === req.params.id); if (idx < 0) { return res.status(404).json({ error: "未找到该 shader" }); } await ensureThumbnailsDir(); await fs.writeFile(thumbPath(req.params.id), buf); const thumbnailAt = new Date().toISOString(); shaders[idx] = { ...shaders[idx], thumbnailAt }; await writeDb(shaders); const thumbnailUrl = `/api/shaders/${encodeURIComponent(req.params.id)}/thumbnail`; res.json({ ok: true, thumbnailUrl, thumbnailAt }); } catch { res.status(500).json({ error: "保存缩略图失败" }); } }); app.delete("/api/shaders/:id", async (req, res) => { try { const shaders = await readDb(); const next = shaders.filter((it) => it.id !== req.params.id); if (next.length === shaders.length) { return res.status(404).json({ error: "未找到该 shader" }); } await writeDb(next); await statsDb.deleteStats(req.params.id); await ensureThumbnailsDir(); try { await fs.unlink(thumbPath(req.params.id)); } catch (_) { /* no thumb */ } res.status(204).end(); } catch { res.status(500).json({ error: "删除失败" }); } }); async function start() { await ensureDb(); await statsDb.load(); const initialList = await readDb(); await statsDb.migrateIfEmpty(initialList); try { await autoNormalizeStoredShaders(); } catch (_) {} app.listen(PORT, () => { console.log(`Server running: http://localhost:${PORT}`); }); } start().catch((err) => { console.error(err); process.exit(1); });