618 lines
20 KiB
JavaScript
618 lines
20 KiB
JavaScript
import crypto from "node:crypto";
|
||
import fs from "node:fs";
|
||
import fsp from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
import express from "express";
|
||
import multer from "multer";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const projectRoot = path.resolve(__dirname, "..");
|
||
const dataDir = path.join(__dirname, "data");
|
||
const uploadDir = path.join(__dirname, "uploads");
|
||
const packageDir = path.join(uploadDir, "packages");
|
||
const iconDir = path.join(uploadDir, "icons");
|
||
const dataFile = path.join(dataDir, "software.json");
|
||
const playerFile = path.join(dataDir, "players.json");
|
||
const port = Number(process.env.PORT || 3030);
|
||
const adminToken = process.env.ADMIN_TOKEN || "sumi-admin";
|
||
const playerSessionCookie = "sumi_player_session";
|
||
const playerSessionTtlMs = Number(process.env.PLAYER_SESSION_TTL_MS || 7 * 24 * 60 * 60 * 1000);
|
||
const passwordIterations = 120000;
|
||
|
||
const app = express();
|
||
const playerSessions = new Map();
|
||
|
||
function slugify(value) {
|
||
const slug = String(value || "")
|
||
.normalize("NFKD")
|
||
.replace(/[^\w\s.-]/g, "")
|
||
.trim()
|
||
.replace(/[\s_.]+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.toLowerCase()
|
||
.slice(0, 64);
|
||
return slug || `software-${Date.now()}`;
|
||
}
|
||
|
||
function parseBoolean(value, fallback = false) {
|
||
if (value === undefined || value === null || value === "") return fallback;
|
||
return ["1", "true", "on", "yes"].includes(String(value).toLowerCase());
|
||
}
|
||
|
||
function parseTags(value) {
|
||
if (!value) return [];
|
||
if (Array.isArray(value)) return value.flatMap(parseTags);
|
||
return String(value)
|
||
.split(/[,,\n]/)
|
||
.map((tag) => tag.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 8);
|
||
}
|
||
|
||
function formatSize(bytes = 0) {
|
||
const units = ["B", "KB", "MB", "GB"];
|
||
let size = Number(bytes);
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
async function ensureStorage() {
|
||
await Promise.all([
|
||
fsp.mkdir(dataDir, { recursive: true }),
|
||
fsp.mkdir(packageDir, { recursive: true }),
|
||
fsp.mkdir(iconDir, { recursive: true }),
|
||
]);
|
||
try {
|
||
await fsp.access(dataFile);
|
||
} catch {
|
||
await writeStore({ items: [] });
|
||
}
|
||
|
||
try {
|
||
await fsp.access(playerFile);
|
||
} catch {
|
||
await writePlayers({
|
||
players: [
|
||
createPlayerRecord(process.env.PLAYER_USERNAME || "player", process.env.PLAYER_PASSWORD || "sumi-player"),
|
||
],
|
||
});
|
||
}
|
||
|
||
if (process.env.PLAYER_USERNAME && process.env.PLAYER_PASSWORD) {
|
||
await upsertEnvPlayer();
|
||
}
|
||
}
|
||
|
||
async function readStore() {
|
||
const raw = await fsp.readFile(dataFile, "utf8");
|
||
const parsed = JSON.parse(raw || "{}");
|
||
return { items: Array.isArray(parsed.items) ? parsed.items : [] };
|
||
}
|
||
|
||
async function writeStore(store) {
|
||
const tmpFile = `${dataFile}.${process.pid}.tmp`;
|
||
await fsp.writeFile(tmpFile, `${JSON.stringify({ items: store.items }, null, 2)}\n`, "utf8");
|
||
await fsp.rename(tmpFile, dataFile);
|
||
}
|
||
|
||
async function readPlayers() {
|
||
const raw = await fsp.readFile(playerFile, "utf8");
|
||
const parsed = JSON.parse(raw || "{}");
|
||
return { players: Array.isArray(parsed.players) ? parsed.players : [] };
|
||
}
|
||
|
||
async function writePlayers(store) {
|
||
const tmpFile = `${playerFile}.${process.pid}.tmp`;
|
||
await fsp.writeFile(tmpFile, `${JSON.stringify({ players: store.players }, null, 2)}\n`, "utf8");
|
||
await fsp.rename(tmpFile, playerFile);
|
||
}
|
||
|
||
function createPlayerRecord(username, password, existing = {}) {
|
||
return {
|
||
...existing,
|
||
username,
|
||
displayName: existing.displayName || "玩家",
|
||
active: true,
|
||
passwordHash: hashPassword(password),
|
||
updatedAt: new Date().toISOString(),
|
||
createdAt: existing.createdAt || new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
async function upsertEnvPlayer() {
|
||
const username = process.env.PLAYER_USERNAME;
|
||
const password = process.env.PLAYER_PASSWORD;
|
||
const store = await readPlayers();
|
||
const index = store.players.findIndex((player) => player.username === username);
|
||
if (index === -1) {
|
||
store.players.push(createPlayerRecord(username, password));
|
||
} else {
|
||
const existing = store.players[index];
|
||
if (existing.active !== false && verifyPassword(password, existing.passwordHash)) {
|
||
return;
|
||
}
|
||
store.players[index] = createPlayerRecord(username, password, store.players[index]);
|
||
}
|
||
await writePlayers(store);
|
||
}
|
||
|
||
async function hashFile(filePath) {
|
||
const hash = crypto.createHash("sha256");
|
||
await new Promise((resolve, reject) => {
|
||
const stream = fs.createReadStream(filePath);
|
||
stream.on("data", (chunk) => hash.update(chunk));
|
||
stream.on("error", reject);
|
||
stream.on("end", resolve);
|
||
});
|
||
return hash.digest("hex");
|
||
}
|
||
|
||
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
|
||
const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex");
|
||
return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`;
|
||
}
|
||
|
||
function verifyPassword(password, encoded = "") {
|
||
const [scheme, iterationText, salt, expectedHash] = String(encoded).split("$");
|
||
if (scheme !== "pbkdf2_sha256" || !iterationText || !salt || !expectedHash) return false;
|
||
const iterations = Number.parseInt(iterationText, 10);
|
||
const actual = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256");
|
||
const expected = Buffer.from(expectedHash, "hex");
|
||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||
}
|
||
|
||
function getToken(req) {
|
||
const auth = req.get("authorization") || "";
|
||
if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
|
||
return req.get("x-admin-token") || req.query.token || "";
|
||
}
|
||
|
||
function parseCookies(req) {
|
||
return Object.fromEntries(
|
||
String(req.get("cookie") || "")
|
||
.split(";")
|
||
.map((pair) => pair.trim())
|
||
.filter(Boolean)
|
||
.map((pair) => {
|
||
const index = pair.indexOf("=");
|
||
if (index === -1) return [pair, ""];
|
||
return [pair.slice(0, index), decodeURIComponent(pair.slice(index + 1))];
|
||
}),
|
||
);
|
||
}
|
||
|
||
function isHttpsRequest(req) {
|
||
return req.secure || req.get("x-forwarded-proto") === "https";
|
||
}
|
||
|
||
function setSessionCookie(req, res, token) {
|
||
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
||
res.setHeader(
|
||
"Set-Cookie",
|
||
`${playerSessionCookie}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(playerSessionTtlMs / 1000)}${secure}`,
|
||
);
|
||
}
|
||
|
||
function clearSessionCookie(req, res) {
|
||
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
||
res.setHeader("Set-Cookie", `${playerSessionCookie}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
|
||
}
|
||
|
||
function publicPlayer(player) {
|
||
if (!player) return null;
|
||
return {
|
||
username: player.username,
|
||
displayName: player.displayName || player.username,
|
||
};
|
||
}
|
||
|
||
function getPlayerSession(req) {
|
||
const token = parseCookies(req)[playerSessionCookie];
|
||
if (!token) return null;
|
||
const session = playerSessions.get(token);
|
||
if (!session) return null;
|
||
if (session.expiresAt <= Date.now()) {
|
||
playerSessions.delete(token);
|
||
return null;
|
||
}
|
||
session.expiresAt = Date.now() + playerSessionTtlMs;
|
||
return { token, session };
|
||
}
|
||
|
||
function requireAdmin(req, res, next) {
|
||
if (getToken(req) !== adminToken) {
|
||
res.status(401).json({ error: "Invalid admin token" });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
function requirePlayer(req, res, next) {
|
||
const playerSession = getPlayerSession(req);
|
||
if (!playerSession) {
|
||
const loginUrl = `/login.html?next=${encodeURIComponent(req.originalUrl)}`;
|
||
const accept = req.get("accept") || "";
|
||
if (accept.includes("text/html") && !accept.includes("application/json")) {
|
||
res.redirect(302, loginUrl);
|
||
return;
|
||
}
|
||
res.status(401).json({ error: "Player login required", loginUrl });
|
||
return;
|
||
}
|
||
req.player = playerSession.session.player;
|
||
next();
|
||
}
|
||
|
||
function publicItem(item, includePrivate = false) {
|
||
const copy = { ...item };
|
||
if (!includePrivate) {
|
||
delete copy.storagePath;
|
||
delete copy.iconStoragePath;
|
||
delete copy.externalDownloadUrl;
|
||
if (resolveDownloadUrl(item)) {
|
||
copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`;
|
||
}
|
||
}
|
||
return copy;
|
||
}
|
||
|
||
function sortSoftware(items) {
|
||
return [...items].sort((a, b) => {
|
||
if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1;
|
||
if ((a.sort ?? 100) !== (b.sort ?? 100)) return (a.sort ?? 100) - (b.sort ?? 100);
|
||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||
});
|
||
}
|
||
|
||
async function makeFilePatch(files = {}) {
|
||
const patch = {};
|
||
const packageFile = files.package?.[0];
|
||
const iconFile = files.icon?.[0];
|
||
|
||
if (packageFile) {
|
||
patch.storagePath = packageFile.path;
|
||
patch.originalFileName = packageFile.originalname;
|
||
patch.fileName = packageFile.filename;
|
||
patch.sizeBytes = packageFile.size;
|
||
patch.size = formatSize(packageFile.size);
|
||
patch.sha256 = await hashFile(packageFile.path);
|
||
patch.downloadUrl = `/uploads/packages/${encodeURIComponent(packageFile.filename)}`;
|
||
patch.externalDownloadUrl = "";
|
||
}
|
||
|
||
if (iconFile) {
|
||
patch.iconStoragePath = iconFile.path;
|
||
patch.iconFileName = iconFile.filename;
|
||
patch.iconUrl = `/uploads/icons/${encodeURIComponent(iconFile.filename)}`;
|
||
}
|
||
|
||
return patch;
|
||
}
|
||
|
||
function getBodyPatch(body, existing = {}) {
|
||
const now = new Date().toISOString();
|
||
return {
|
||
name: String(body.name || existing.name || "").trim(),
|
||
version: String(body.version || existing.version || "1.0.0").trim(),
|
||
category: String(body.category || existing.category || "tools").trim(),
|
||
description: String(body.description || existing.description || "").trim(),
|
||
changelog: String(body.changelog || existing.changelog || "").trim(),
|
||
publisher: String(body.publisher || existing.publisher || "sumi.work").trim(),
|
||
platform: String(body.platform || existing.platform || "Windows").trim(),
|
||
homepage: String(body.homepage || existing.homepage || "").trim(),
|
||
tags: parseTags(body.tags ?? existing.tags),
|
||
sort: Number.parseInt(body.sort ?? existing.sort ?? 100, 10),
|
||
active: parseBoolean(body.active, existing.active !== false),
|
||
featured: parseBoolean(body.featured, Boolean(existing.featured)),
|
||
externalDownloadUrl: String(body.downloadUrl || existing.externalDownloadUrl || "").trim(),
|
||
updatedAt: now,
|
||
};
|
||
}
|
||
|
||
function resolveDownloadUrl(item) {
|
||
return item.downloadUrl || item.externalDownloadUrl || "";
|
||
}
|
||
|
||
function isPathInside(parent, child) {
|
||
const relative = path.relative(parent, child);
|
||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||
}
|
||
|
||
function resolvePackagePath(item) {
|
||
if (item.storagePath) return path.resolve(item.storagePath);
|
||
if (item.downloadUrl?.startsWith("/uploads/packages/")) {
|
||
return path.resolve(packageDir, path.basename(decodeURIComponent(item.downloadUrl)));
|
||
}
|
||
return "";
|
||
}
|
||
|
||
const storage = multer.diskStorage({
|
||
destination(req, file, callback) {
|
||
callback(null, file.fieldname === "icon" ? iconDir : packageDir);
|
||
},
|
||
filename(req, file, callback) {
|
||
const ext = path.extname(file.originalname || "").slice(0, 24);
|
||
const base = slugify(path.basename(file.originalname || "upload", ext));
|
||
const suffix = crypto.randomBytes(6).toString("hex");
|
||
callback(null, `${Date.now()}-${suffix}-${base}${ext}`);
|
||
},
|
||
});
|
||
|
||
const upload = multer({
|
||
storage,
|
||
limits: {
|
||
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024),
|
||
files: 2,
|
||
},
|
||
fileFilter(req, file, callback) {
|
||
if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) {
|
||
callback(new Error("Icon must be an image file"));
|
||
return;
|
||
}
|
||
callback(null, true);
|
||
},
|
||
});
|
||
|
||
app.disable("x-powered-by");
|
||
app.use(express.json({ limit: "1mb" }));
|
||
app.use(express.urlencoded({ extended: true }));
|
||
app.use("/uploads/icons", express.static(iconDir, { fallthrough: false }));
|
||
|
||
app.get("/api/health", (req, res) => {
|
||
res.json({ ok: true, service: "sumi-software-center" });
|
||
});
|
||
|
||
app.get("/api/auth/me", (req, res) => {
|
||
const playerSession = getPlayerSession(req);
|
||
if (!playerSession) {
|
||
res.json({ authenticated: false, player: null });
|
||
return;
|
||
}
|
||
setSessionCookie(req, res, playerSession.token);
|
||
res.json({ authenticated: true, player: publicPlayer(playerSession.session.player) });
|
||
});
|
||
|
||
app.post("/api/auth/login", async (req, res, next) => {
|
||
try {
|
||
const username = String(req.body.username || "").trim();
|
||
const password = String(req.body.password || "");
|
||
const nextUrl = String(req.body.next || "/software.html");
|
||
const { players } = await readPlayers();
|
||
const player = players.find((entry) => entry.username === username && entry.active !== false);
|
||
|
||
if (!player || !verifyPassword(password, player.passwordHash)) {
|
||
res.status(401).json({ error: "账号或密码错误" });
|
||
return;
|
||
}
|
||
|
||
const token = crypto.randomBytes(32).toString("base64url");
|
||
playerSessions.set(token, {
|
||
player: publicPlayer(player),
|
||
createdAt: Date.now(),
|
||
expiresAt: Date.now() + playerSessionTtlMs,
|
||
});
|
||
setSessionCookie(req, res, token);
|
||
res.json({ authenticated: true, player: publicPlayer(player), next: nextUrl });
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.post("/api/auth/logout", (req, res) => {
|
||
const playerSession = getPlayerSession(req);
|
||
if (playerSession) {
|
||
playerSessions.delete(playerSession.token);
|
||
}
|
||
clearSessionCookie(req, res);
|
||
res.json({ authenticated: false });
|
||
});
|
||
|
||
app.get("/api/software", async (req, res, next) => {
|
||
try {
|
||
const { items } = await readStore();
|
||
const category = String(req.query.category || "all");
|
||
const keyword = String(req.query.keyword || "").trim().toLowerCase();
|
||
const visibleItems = items
|
||
.filter((item) => item.active !== false)
|
||
.filter((item) => category === "all" || !category || item.category === category)
|
||
.filter((item) => {
|
||
if (!keyword) return true;
|
||
return [item.name, item.description, item.publisher, ...(item.tags || [])]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(keyword);
|
||
});
|
||
res.json({ items: sortSoftware(visibleItems).map((item) => publicItem(item)) });
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.get("/api/software/:id/latest", async (req, res, next) => {
|
||
try {
|
||
const { items } = await readStore();
|
||
const item = items.find((entry) => entry.id === req.params.id && entry.active !== false);
|
||
if (!item) {
|
||
res.status(404).json({ error: "Software not found" });
|
||
return;
|
||
}
|
||
res.json({
|
||
id: item.id,
|
||
name: item.name,
|
||
version: item.version,
|
||
changelog: item.changelog,
|
||
updatedAt: item.updatedAt,
|
||
size: item.size,
|
||
sizeBytes: item.sizeBytes,
|
||
sha256: item.sha256,
|
||
downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`,
|
||
});
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => {
|
||
try {
|
||
const store = await readStore();
|
||
const item = store.items.find((entry) => entry.id === req.params.id && entry.active !== false);
|
||
if (!item) {
|
||
res.status(404).json({ error: "Software not found" });
|
||
return;
|
||
}
|
||
|
||
const downloadUrl = resolveDownloadUrl(item);
|
||
if (!downloadUrl) {
|
||
res.status(404).json({ error: "No package has been uploaded" });
|
||
return;
|
||
}
|
||
|
||
item.downloadCount = (item.downloadCount || 0) + 1;
|
||
await writeStore(store);
|
||
|
||
const packagePath = resolvePackagePath(item);
|
||
if (packagePath) {
|
||
if (!isPathInside(packageDir, packagePath)) {
|
||
res.status(403).json({ error: "Invalid package path" });
|
||
return;
|
||
}
|
||
await fsp.access(packagePath);
|
||
res.download(packagePath, item.originalFileName || item.fileName || path.basename(packagePath));
|
||
return;
|
||
}
|
||
|
||
res.redirect(downloadUrl);
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.get("/api/admin/software", requireAdmin, async (req, res, next) => {
|
||
try {
|
||
const { items } = await readStore();
|
||
res.json({ items: sortSoftware(items).map((item) => publicItem(item, true)) });
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
|
||
try {
|
||
const bodyPatch = getBodyPatch(req.body);
|
||
if (!bodyPatch.name) {
|
||
res.status(400).json({ error: "Software name is required" });
|
||
return;
|
||
}
|
||
|
||
const store = await readStore();
|
||
const baseId = slugify(bodyPatch.name);
|
||
let id = baseId;
|
||
while (store.items.some((item) => item.id === id)) {
|
||
id = `${baseId}-${crypto.randomBytes(3).toString("hex")}`;
|
||
}
|
||
|
||
const filePatch = await makeFilePatch(req.files);
|
||
const now = new Date().toISOString();
|
||
const externalDownloadUrl = bodyPatch.externalDownloadUrl;
|
||
const item = {
|
||
id,
|
||
createdAt: now,
|
||
downloadCount: 0,
|
||
...bodyPatch,
|
||
...filePatch,
|
||
downloadUrl: filePatch.downloadUrl || externalDownloadUrl,
|
||
externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl,
|
||
};
|
||
|
||
store.items.push(item);
|
||
await writeStore(store);
|
||
res.status(201).json({ item: publicItem(item, true) });
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
|
||
try {
|
||
const store = await readStore();
|
||
const index = store.items.findIndex((item) => item.id === req.params.id);
|
||
if (index === -1) {
|
||
res.status(404).json({ error: "Software not found" });
|
||
return;
|
||
}
|
||
|
||
const existing = store.items[index];
|
||
const bodyPatch = getBodyPatch(req.body, existing);
|
||
if (!bodyPatch.name) {
|
||
res.status(400).json({ error: "Software name is required" });
|
||
return;
|
||
}
|
||
|
||
const filePatch = await makeFilePatch(req.files);
|
||
const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || "";
|
||
const item = {
|
||
...existing,
|
||
...bodyPatch,
|
||
...filePatch,
|
||
downloadUrl,
|
||
externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl,
|
||
};
|
||
|
||
store.items[index] = item;
|
||
await writeStore(store);
|
||
res.json({ item: publicItem(item, true) });
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => {
|
||
try {
|
||
const store = await readStore();
|
||
const initialLength = store.items.length;
|
||
store.items = store.items.filter((item) => item.id !== req.params.id);
|
||
if (store.items.length === initialLength) {
|
||
res.status(404).json({ error: "Software not found" });
|
||
return;
|
||
}
|
||
await writeStore(store);
|
||
res.status(204).end();
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
|
||
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
|
||
app.get("/login", (req, res) => res.redirect(301, "/login.html"));
|
||
app.get("/admin/software", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
|
||
app.get("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
|
||
app.get("/index.html", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
|
||
app.get("/software.html", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
|
||
app.get("/login.html", (req, res) => res.sendFile(path.join(projectRoot, "login.html")));
|
||
app.get("/admin/software.html", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
|
||
app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false }));
|
||
|
||
app.use((error, req, res, next) => {
|
||
console.error(error);
|
||
res.status(500).json({ error: error.message || "Internal server error" });
|
||
});
|
||
|
||
await ensureStorage();
|
||
|
||
app.listen(port, () => {
|
||
console.log(`sumi.work software center running at http://localhost:${port}`);
|
||
if (!process.env.ADMIN_TOKEN) {
|
||
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
|
||
}
|
||
if (!process.env.PLAYER_USERNAME || !process.env.PLAYER_PASSWORD) {
|
||
console.log("PLAYER_USERNAME/PLAYER_PASSWORD is not set. Using development player: player / sumi-player");
|
||
}
|
||
});
|