import crypto from "node:crypto"; import { createWriteStream, promises as fs } from "node:fs"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { promisify } from "node:util"; import express from "express"; import multer from "multer"; import { marked } from "marked"; import sanitizeHtml from "sanitize-html"; import unzipper from "unzipper"; const app = express(); const rootDir = path.resolve(process.cwd()); const dataDir = path.resolve(process.env.DATA_DIR || path.join(rootDir, "data")); const projectsDir = path.join(dataDir, "projects"); const uploadsDir = path.join(dataDir, "uploads"); const temporaryDir = path.join(dataDir, "tmp"); const databaseFile = path.join(dataDir, "projects.json"); const port = Number(process.env.PORT || 3000); const maxSourceBytes = Number(process.env.MAX_SOURCE_MB || 200) * 1024 * 1024; const maxSourceFiles = Number(process.env.MAX_SOURCE_FILES || 5000); const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000; const initialCoinBalance = Number(process.env.INITIAL_COIN_BALANCE || 20); const maxCoinsPerProject = Number(process.env.MAX_COINS_PER_PROJECT || 2); const forumCategories = ["综合讨论", "项目交流", "求助问答", "教程分享"]; const scryptAsync = promisify(crypto.scrypt); const regularUpload = multer({ dest: temporaryDir, limits: { fileSize: Number(process.env.MAX_UPLOAD_MB || 50) * 1024 * 1024, files: 10, fields: 20, }, }); const sourceUpload = multer({ dest: temporaryDir, limits: { fileSize: Number(process.env.MAX_SOURCE_ARCHIVE_MB || 50) * 1024 * 1024, files: 1, fields: 10, }, }); marked.setOptions({ gfm: true, breaks: false }); await initializeStorage(); await migrateDatabase(); app.disable("x-powered-by"); app.set("trust proxy", 1); app.use(express.json({ limit: "2mb" })); app.use(express.urlencoded({ extended: false, limit: "2mb" })); app.use("/assets", express.static(path.join(rootDir, "public"), { etag: true, maxAge: 0, setHeaders(response) { response.setHeader("Cache-Control", "no-cache"); }, })); app.use((_request, response, next) => { response.setHeader("X-Content-Type-Options", "nosniff"); response.setHeader("X-Frame-Options", "DENY"); response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); response.setHeader( "Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", ); next(); }); app.use(authenticateSession); const writeAttempts = new Map(); function writeRateLimit(request, response, next) { const now = Date.now(); const client = request.ip || "unknown"; const recent = (writeAttempts.get(client) || []).filter((time) => now - time < 10 * 60_000); if (recent.length >= 30) return sendError(request, response, 429, "操作过于频繁,请稍后再试"); recent.push(now); writeAttempts.set(client, recent); next(); } app.get("/", async (request, response, next) => { try { const viewStats = await recordHomepageView(request, response); const database = await readDatabase(); const query = String(request.query.q || "").trim().toLowerCase(); const projects = database.projects .filter((project) => project.status === "published") .filter((project) => { if (!query) return true; return [project.title, project.summary, project.language, ...(project.tags || [])] .some((value) => String(value || "").toLowerCase().includes(query)); }) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); const latestPosts = [...database.forumPosts] .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) .slice(0, 4); response.send(renderHome(projects, query, request.user, latestPosts, viewStats)); } catch (error) { next(error); } }); app.get("/projects", async (request, response, next) => { try { const database = await readDatabase(); const query = String(request.query.q || "").trim().toLowerCase(); const projects = database.projects .filter((project) => project.status === "published") .filter((project) => { if (!query) return true; return [project.title, project.summary, project.language, project.ownerName, ...(project.tags || [])] .some((value) => String(value || "").toLowerCase().includes(query)); }) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); response.send(renderProjectList(projects, query, request.user)); } catch (error) { next(error); } }); app.get("/projects/:id", async (request, response, next) => { try { const project = await findProject(request.params.id); if (!project || !canViewProject(project, request.user)) { return response.status(404).send(renderErrorPage(404, "项目不存在")); } response.send(renderProject(project, request.user)); } catch (error) { next(error); } }); app.get("/register", redirectIfLoggedIn, (request, response) => { response.send(renderRegisterPage(request.user)); }); app.post("/register", writeRateLimit, requireSameOrigin, redirectIfLoggedIn, async (request, response) => { try { const { token } = await registerUser(request.body); setSessionCookie(response, token); response.redirect(303, "/account?welcome=1"); } catch (error) { response.status(400).send(renderRegisterPage(null, error.message, request.body)); } }); app.get("/login", redirectIfLoggedIn, (request, response) => { response.send(renderLoginPage(request.user, "", {}, safeNextPath(request.query.next) || "")); }); app.post("/login", writeRateLimit, requireSameOrigin, redirectIfLoggedIn, async (request, response) => { try { const token = await loginUser(request.body); setSessionCookie(response, token); const nextPath = safeNextPath(request.body.next); response.redirect(303, nextPath || "/account"); } catch (error) { response.status(401).send( renderLoginPage(null, error.message, request.body, safeNextPath(request.body.next) || ""), ); } }); app.post("/logout", requireLogin, requireSameOrigin, async (request, response) => { await destroySession(request.sessionToken); clearSessionCookie(response); response.redirect(303, "/"); }); app.get("/account", requireLogin, async (request, response) => { const database = await readDatabase(); const projects = database.projects .filter((project) => project.ownerId === request.user.id) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); const favoriteProjects = database.projects .filter( (project) => project.status === "published" && (project.favorites || []).includes(request.user.id), ) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); const apiKey = await consumeApiKeyFlash(request.sessionToken); const checkInReward = Number(request.query.reward || 0); response.send( renderAccountPage(request.user, projects, favoriteProjects, apiKey, checkInReward), ); }); app.post("/account/api-key", requireLogin, requireSameOrigin, async (request, response) => { if (!await rotateUserApiKey(request.user.id, request.sessionToken)) { return response.status(404).send(renderErrorPage(404, "用户不存在", request.user)); } response.redirect(303, "/account?key=rotated"); }); app.post("/account/check-in", requireLogin, requireSameOrigin, async (request, response) => { try { const today = currentDayKey(); const yesterday = dayKeyOffset(-1); const reward = crypto.randomInt(1, 4); await mutateDatabase(async (database) => { const user = database.users.find((candidate) => candidate.id === request.user.id); if (!user) throw new Error("用户不存在"); if (user.lastCheckIn === today) throw new Error("今天已经签到过了"); user.checkInStreak = user.lastCheckIn === yesterday ? (user.checkInStreak || 0) + 1 : 1; user.lastCheckIn = today; user.coinBalance = Number(user.coinBalance || 0) + reward; user.checkInHistory ||= []; user.checkInHistory.push({ date: today, reward, createdAt: new Date().toISOString(), }); if (user.checkInHistory.length > 365) user.checkInHistory.shift(); }); response.redirect(303, `/account?reward=${reward}`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }); app.get("/publish", requireLogin, (request, response) => { response.send(renderPublishPage(request.user)); }); app.get("/guide", (request, response) => response.send(renderGuidePage(request, request.user))); app.get("/docs", (_request, response) => response.redirect(301, "/guide")); app.get("/forum", async (request, response, next) => { try { const database = await readDatabase(); const query = String(request.query.q || "").trim().toLowerCase(); const category = forumCategories.includes(request.query.category) ? request.query.category : ""; const posts = database.forumPosts .filter((post) => !category || post.category === category) .filter((post) => { if (!query) return true; return [post.title, post.content, post.authorName, post.category] .some((value) => String(value || "").toLowerCase().includes(query)); }) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); response.send(renderForumPage(posts, query, category, request.user)); } catch (error) { next(error); } }); app.get("/forum/new", requireLogin, (request, response) => { response.send(renderNewForumPostPage(request.user)); }); app.post( "/forum/posts", writeRateLimit, requireLogin, requireSameOrigin, async (request, response) => { try { const title = String(request.body.title || "").trim(); const content = String(request.body.content || "").trim(); const category = forumCategories.includes(request.body.category) ? request.body.category : "综合讨论"; if (!title || title.length > 120) throw new Error("帖子标题需为 1–120 个字符"); if (!content || content.length > 20_000) throw new Error("帖子内容需为 1–20000 个字符"); const now = new Date().toISOString(); const post = { id: createForumPostId(title), authorId: request.user.id, authorName: request.user.username, title, content, category, replies: [], createdAt: now, updatedAt: now, }; await mutateDatabase(async (database) => { if (database.forumPosts.length >= 10_000) throw new Error("论坛帖子数量已达到上限"); database.forumPosts.push(post); }); response.redirect(303, `/forum/${encodeURIComponent(post.id)}`); } catch (error) { response.status(400).send( renderNewForumPostPage(request.user, error.message, request.body), ); } }, ); app.get("/forum/:id", async (request, response) => { const post = await findForumPost(request.params.id); if (!post) return response.status(404).send(renderErrorPage(404, "帖子不存在", request.user)); response.send(renderForumPostPage(post, request.user)); }); app.post( "/forum/:id/replies", writeRateLimit, requireLogin, requireSameOrigin, async (request, response) => { try { const content = String(request.body.content || "").trim(); if (!content || content.length > 5000) throw new Error("回复需为 1–5000 个字符"); await mutateDatabase(async (database) => { const post = database.forumPosts.find((candidate) => candidate.id === request.params.id); if (!post) throw new Error("帖子不存在"); if ((post.replies || []).length >= 2000) throw new Error("该帖子回复数量已达到上限"); post.replies ||= []; post.replies.push({ id: crypto.randomUUID(), authorId: request.user.id, authorName: request.user.username, content, createdAt: new Date().toISOString(), }); post.updatedAt = new Date().toISOString(); }); response.redirect(303, `/forum/${encodeURIComponent(request.params.id)}#replies`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }, ); app.post( "/forum/:id/delete", writeRateLimit, requireLogin, requireSameOrigin, async (request, response) => { const post = await findForumPost(request.params.id); if (!post) return response.status(404).send(renderErrorPage(404, "帖子不存在", request.user)); if (post.authorId !== request.user.id) { return response.status(403).send(renderErrorPage(403, "你没有权限删除该帖子", request.user)); } await mutateDatabase(async (database) => { database.forumPosts = database.forumPosts.filter( (candidate) => candidate.id !== request.params.id, ); }); response.redirect(303, "/forum"); }, ); app.post( "/forum/:id/replies/:replyId/delete", writeRateLimit, requireLogin, requireSameOrigin, async (request, response) => { const post = await findForumPost(request.params.id); if (!post) return response.status(404).send(renderErrorPage(404, "帖子不存在", request.user)); const reply = (post.replies || []).find((candidate) => candidate.id === request.params.replyId); if (!reply) return response.status(404).send(renderErrorPage(404, "回复不存在", request.user)); if (reply.authorId !== request.user.id && post.authorId !== request.user.id) { return response.status(403).send(renderErrorPage(403, "你没有权限删除该回复", request.user)); } await mutateDatabase(async (database) => { const current = database.forumPosts.find((candidate) => candidate.id === request.params.id); if (current) { current.replies = (current.replies || []).filter( (candidate) => candidate.id !== request.params.replyId, ); } }); response.redirect(303, `/forum/${encodeURIComponent(request.params.id)}#replies`); }, ); app.get("/health", async (_request, response) => { const database = await readDatabase(); response.json({ ok: true, service: "hdycode", mode: "native-project-hosting", projects: database.projects.length, time: new Date().toISOString(), }); }); app.post("/publish/project", writeRateLimit, requireLogin, requireSameOrigin, async (request, response) => { try { const project = await createProject({ title: request.body.title, summary: request.body.summary, tags: parseTags(request.body.tags), owner: request.user, }); response.redirect(303, `/projects/${encodeURIComponent(project.id)}?created=1`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }); app.post( "/projects/:id/source", writeRateLimit, requireLogin, requireSameOrigin, requireProjectOwner, sourceUpload.single("archive"), async (request, response) => { try { if (!request.file) throw new Error("请选择 ZIP 项目包"); const project = await publishSource(request.params.id, request.file, { version: request.body.version, changelog: request.body.changelog, }); response.redirect(303, `/projects/${encodeURIComponent(project.id)}?source=1`); } catch (error) { await removeTemporaryFiles(request.file ? [request.file] : []); response.status(400).send(renderErrorPage(400, error.message)); } }, ); app.post( "/projects/:id/publish", writeRateLimit, requireLogin, requireSameOrigin, requireProjectOwner, async (request, response) => { try { const project = await publishProject(request.params.id); response.redirect(303, `/projects/${encodeURIComponent(project.id)}?published=1`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }, ); app.post( "/projects/:id/like", writeRateLimit, requireLogin, requireSameOrigin, requirePublishedProject, async (request, response, next) => { try { await updateProject(request.params.id, (project) => { const likes = new Set(project.likes || []); if (likes.has(request.user.id)) likes.delete(request.user.id); else likes.add(request.user.id); return { ...project, likes: [...likes] }; }); response.redirect(303, `/projects/${encodeURIComponent(request.params.id)}#community`); } catch (error) { next(error); } }, ); app.post( "/projects/:id/favorite", writeRateLimit, requireLogin, requireSameOrigin, requirePublishedProject, async (request, response, next) => { try { await updateProject(request.params.id, (project) => { const favorites = new Set(project.favorites || []); if (favorites.has(request.user.id)) favorites.delete(request.user.id); else favorites.add(request.user.id); return { ...project, favorites: [...favorites] }; }); response.redirect(303, `/projects/${encodeURIComponent(request.params.id)}#community`); } catch (error) { next(error); } }, ); app.post( "/projects/:id/coins", writeRateLimit, requireLogin, requireSameOrigin, requirePublishedProject, async (request, response) => { try { const amount = Number(request.body.amount); if (![1, 2].includes(amount)) throw new Error("每次只能投入 1 或 2 枚硬币"); await mutateDatabase(async (database) => { const project = database.projects.find((candidate) => candidate.id === request.params.id); const user = database.users.find((candidate) => candidate.id === request.user.id); if (!project || project.status !== "published") throw new Error("项目不存在"); if (!user) throw new Error("用户不存在"); if (project.ownerId === user.id) throw new Error("不能给自己的项目投币"); const previous = (project.coinContributions || []) .filter((entry) => entry.userId === user.id) .reduce((sum, entry) => sum + entry.amount, 0); if (previous + amount > maxCoinsPerProject) { throw new Error(`每个项目最多投入 ${maxCoinsPerProject} 枚硬币`); } if (user.coinBalance < amount) throw new Error("硬币余额不足"); user.coinBalance -= amount; project.coinContributions ||= []; project.coinContributions.push({ id: crypto.randomUUID(), userId: user.id, username: user.username, amount, createdAt: new Date().toISOString(), }); }); response.redirect(303, `/projects/${encodeURIComponent(request.params.id)}#community`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }, ); app.post( "/projects/:id/comments", writeRateLimit, requireLogin, requireSameOrigin, requirePublishedProject, async (request, response) => { try { const content = String(request.body.content || "").trim(); if (!content) throw new Error("评论内容不能为空"); if (content.length > 1000) throw new Error("评论不能超过 1000 个字符"); await updateProject(request.params.id, (project) => { if ((project.comments || []).length >= 1000) { throw new Error("该项目评论数量已达到上限"); } return { ...project, comments: [...(project.comments || []), { id: crypto.randomUUID(), userId: request.user.id, username: request.user.username, content, createdAt: new Date().toISOString(), }], }; }); response.redirect(303, `/projects/${encodeURIComponent(request.params.id)}#comments`); } catch (error) { response.status(400).send(renderErrorPage(400, error.message, request.user)); } }, ); app.post( "/projects/:id/comments/:commentId/delete", writeRateLimit, requireLogin, requireSameOrigin, requirePublishedProject, async (request, response) => { const comment = (request.project.comments || []).find( (candidate) => candidate.id === request.params.commentId, ); if (!comment) return response.status(404).send(renderErrorPage(404, "评论不存在", request.user)); if (comment.userId !== request.user.id && request.project.ownerId !== request.user.id) { return response.status(403).send(renderErrorPage(403, "你没有权限删除这条评论", request.user)); } await updateProject(request.params.id, (project) => ({ ...project, comments: (project.comments || []).filter( (candidate) => candidate.id !== request.params.commentId, ), })); response.redirect(303, `/projects/${encodeURIComponent(request.params.id)}#comments`); }, ); app.post( "/projects/:id/tutorials", writeRateLimit, requireLogin, requireSameOrigin, requireProjectOwner, regularUpload.single("attachment"), async (request, response) => { try { const project = await findProject(request.params.id); if (!project) throw new Error("项目不存在"); const title = String(request.body.title || "").trim(); const content = String(request.body.content || "").trim(); if (!title || !content) throw new Error("教程标题和内容不能为空"); const attachment = request.file ? await persistAttachment(project.id, request.file) : null; const tutorial = { id: crypto.randomUUID(), title: title.slice(0, 120), content: content.slice(0, 200_000), attachment, createdAt: new Date().toISOString(), }; await updateProject(project.id, (current) => ({ ...current, tutorials: [...(current.tutorials || []), tutorial], updatedAt: new Date().toISOString(), })); response.redirect(303, `/projects/${encodeURIComponent(project.id)}#tutorials`); } catch (error) { await removeTemporaryFiles(request.file ? [request.file] : []); response.status(400).send(renderErrorPage(400, error.message)); } }, ); app.post( "/projects/:id/files", writeRateLimit, requireLogin, requireSameOrigin, requireProjectOwner, regularUpload.array("files", 10), async (request, response) => { try { const project = await findProject(request.params.id); if (!project) throw new Error("项目不存在"); if (!request.files?.length) throw new Error("请选择要上传的文件"); const files = []; for (const file of request.files) files.push(await persistAttachment(project.id, file)); await updateProject(project.id, (current) => ({ ...current, files: [...(current.files || []), ...files], updatedAt: new Date().toISOString(), })); response.redirect(303, `/projects/${encodeURIComponent(project.id)}#files`); } catch (error) { await removeTemporaryFiles(request.files || []); response.status(400).send(renderErrorPage(400, error.message)); } }, ); app.get("/downloads/:projectId/source", async (request, response) => { const project = await findProject(request.params.projectId); if (!project?.source || !canViewProject(project, request.user)) { return response.status(404).send(renderErrorPage(404, "项目包不存在")); } await sendStoredDownload(response, project.source); }); app.get("/downloads/:projectId/releases/:releaseId", async (request, response) => { const project = await findProject(request.params.projectId); if (!project || !canViewProject(project, request.user)) { return response.status(404).send(renderErrorPage(404, "项目不存在")); } const release = (project.releases || []).find( (candidate) => candidate.id === request.params.releaseId, ); if (!release) return response.status(404).send(renderErrorPage(404, "版本不存在")); await sendStoredDownload(response, release); }); app.get("/downloads/:projectId/:fileId", async (request, response) => { const project = await findProject(request.params.projectId); if (!project || !canViewProject(project, request.user)) { return response.status(404).send(renderErrorPage(404, "项目不存在")); } const candidates = [ ...(project.files || []), ...(project.tutorials || []).map((tutorial) => tutorial.attachment).filter(Boolean), ]; const file = candidates.find((candidate) => candidate.id === request.params.fileId); if (!file) return response.status(404).send(renderErrorPage(404, "文件不存在")); await sendStoredDownload(response, file); }); app.get("/api/v1/projects", async (_request, response) => { const database = await readDatabase(); const projects = database.projects.filter((project) => project.status === "published"); response.json({ data: projects.map(publicProject), total: projects.length }); }); app.post("/api/v1/projects", writeRateLimit, requireApiKey, async (request, response) => { try { const project = await createProject({ title: request.body.title, summary: request.body.summary, tags: parseTags(request.body.tags), owner: request.apiUser, }); response.status(201).json({ data: publicProject(project, true) }); } catch (error) { response.status(400).json({ error: "create_failed", message: error.message }); } }); app.get("/api/v1/projects/:id", async (request, response) => { const project = await findProject(request.params.id); if (!project || !canViewProject(project, request.user)) { return response.status(404).json({ error: "project_not_found" }); } response.json({ data: publicProject(project, true) }); }); app.patch( "/api/v1/projects/:id", writeRateLimit, requireApiKey, requireProjectOwner, async (request, response) => { try { const updated = await updateProject(request.params.id, (project) => ({ ...project, title: request.body.title ? String(request.body.title).trim().slice(0, 120) : project.title, summary: request.body.summary !== undefined ? String(request.body.summary).trim().slice(0, 500) : project.summary, tags: request.body.tags !== undefined ? unique(parseTags(request.body.tags)).slice(0, 12) : project.tags, updatedAt: new Date().toISOString(), })); response.json({ data: publicProject(updated, true) }); } catch (error) { response.status(400).json({ error: "update_failed", message: error.message }); } }, ); app.get("/api/v1/projects/:id/tree", async (request, response) => { const project = await findProject(request.params.id); if (!project || !canViewProject(project, request.user)) { return response.status(404).json({ error: "project_not_found" }); } response.json({ data: project.tree || [], truncated: Boolean(project.treeTruncated) }); }); app.get("/api/v1/projects/:id/raw", async (request, response) => { const project = await findProject(request.params.id); if (!project || !canViewProject(project, request.user)) { return response.status(404).json({ error: "project_not_found" }); } if (!project.source) return response.status(404).json({ error: "source_not_uploaded" }); const requestedPath = normalizeArchivePath(String(request.query.path || "")); if (!requestedPath || isIgnoredSourcePath(requestedPath)) { return response.status(400).json({ error: "invalid_path" }); } const projectDirectory = path.join(projectsDir, project.id); const absolutePath = path.resolve(projectDirectory, requestedPath); if (!isPathInside(projectDirectory, absolutePath)) { return response.status(400).json({ error: "invalid_path" }); } try { const stats = await fs.stat(absolutePath); if (!stats.isFile() || stats.size > 2 * 1024 * 1024) { return response.status(400).json({ error: "file_not_readable" }); } const buffer = await fs.readFile(absolutePath); if (buffer.subarray(0, 8192).includes(0)) { return response.status(415).json({ error: "binary_file" }); } response.type("text/plain; charset=utf-8").send(buffer.toString("utf8")); } catch { response.status(404).json({ error: "file_not_found" }); } }); app.post( "/api/v1/projects/:id/source", writeRateLimit, requireApiKey, requireProjectOwner, sourceUpload.single("archive"), async (request, response) => { try { if (!request.file) throw new Error("archive is required"); const project = await publishSource(request.params.id, request.file, { version: request.body.version, changelog: request.body.changelog, }); response.status(201).json({ data: publicProject(project, true) }); } catch (error) { await removeTemporaryFiles(request.file ? [request.file] : []); response.status(400).json({ error: "source_upload_failed", message: error.message }); } }, ); app.post( "/api/v1/projects/:id/publish", writeRateLimit, requireApiKey, requireProjectOwner, async (request, response) => { try { const project = await publishProject(request.params.id); response.json({ data: publicProject(project, true) }); } catch (error) { response.status(400).json({ error: "publish_failed", message: error.message }); } }, ); app.post( "/api/v1/projects/:id/tutorials", writeRateLimit, requireApiKey, requireProjectOwner, regularUpload.single("attachment"), async (request, response) => { try { const project = await findProject(request.params.id); if (!project) { await removeTemporaryFiles(request.file ? [request.file] : []); return response.status(404).json({ error: "project_not_found" }); } const title = String(request.body.title || "").trim(); const content = String(request.body.content || "").trim(); if (!title || !content) throw new Error("title and content are required"); const attachment = request.file ? await persistAttachment(project.id, request.file) : null; const tutorial = { id: crypto.randomUUID(), title: title.slice(0, 120), content: content.slice(0, 200_000), attachment, createdAt: new Date().toISOString(), }; const updated = await updateProject(project.id, (current) => ({ ...current, tutorials: [...(current.tutorials || []), tutorial], updatedAt: new Date().toISOString(), })); response.status(201).json({ data: tutorial, project: publicProject(updated) }); } catch (error) { await removeTemporaryFiles(request.file ? [request.file] : []); response.status(400).json({ error: "tutorial_failed", message: error.message }); } }, ); app.post( "/api/v1/projects/:id/files", writeRateLimit, requireApiKey, requireProjectOwner, regularUpload.array("files", 10), async (request, response) => { try { const project = await findProject(request.params.id); if (!project) { await removeTemporaryFiles(request.files || []); return response.status(404).json({ error: "project_not_found" }); } if (!request.files?.length) throw new Error("files are required"); const files = []; for (const file of request.files) files.push(await persistAttachment(project.id, file)); await updateProject(project.id, (current) => ({ ...current, files: [...(current.files || []), ...files], updatedAt: new Date().toISOString(), })); response.status(201).json({ data: files }); } catch (error) { await removeTemporaryFiles(request.files || []); response.status(400).json({ error: "upload_failed", message: error.message }); } }, ); app.get("/api/v1/openapi.json", (request, response) => response.json(openApiDocument(request))); app.use((_request, response) => response.status(404).send(renderErrorPage(404, "页面不存在"))); app.use((error, request, response, _next) => { console.error(error); if (error instanceof multer.MulterError) { return sendError(request, response, 400, `上传失败:${error.message}`); } sendError(request, response, 500, "服务器暂时无法处理该请求"); }); app.listen(port, "0.0.0.0", () => { console.log(`hdycode platform listening on http://0.0.0.0:${port}`); }); async function initializeStorage() { await Promise.all([ fs.mkdir(projectsDir, { recursive: true }), fs.mkdir(uploadsDir, { recursive: true }), fs.mkdir(temporaryDir, { recursive: true }), ]); try { await fs.access(databaseFile); } catch { await writeDatabase({ version: 6, users: [], sessions: [], projects: [], forumPosts: [], dailyStats: [], }); } } async function migrateDatabase() { const database = await readDatabase(); database.version = 6; database.users ||= []; database.sessions ||= []; database.projects ||= []; database.forumPosts ||= []; database.dailyStats ||= []; database.sessions = database.sessions.filter( (session) => new Date(session.expiresAt).getTime() > Date.now(), ); for (const user of database.users) { if (!Number.isFinite(user.coinBalance)) user.coinBalance = initialCoinBalance; user.checkInHistory ||= []; user.lastCheckIn ||= null; user.checkInStreak ||= 0; } for (const project of database.projects) { project.ownerId ||= null; project.ownerName ||= project.ownerId ? "未知用户" : "平台"; project.status ||= project.source ? "published" : "draft"; project.publishedAt ||= project.status === "published" ? project.source?.uploadedAt || project.createdAt : null; if (!Array.isArray(project.releases)) { if (project.source) { project.source.version ||= "v1.0.0"; project.source.changelog ||= "首次发布"; project.releases = [project.source]; } else { project.releases = []; } } project.likes ||= []; project.favorites ||= []; project.coinContributions ||= []; project.comments ||= []; } await writeDatabase(database); } let databaseQueue = Promise.resolve(); async function readDatabase() { return JSON.parse(await fs.readFile(databaseFile, "utf8")); } async function writeDatabase(database) { const temporaryFile = `${databaseFile}.${crypto.randomUUID()}.tmp`; await fs.writeFile(temporaryFile, `${JSON.stringify(database, null, 2)}\n`, "utf8"); await fs.rename(temporaryFile, databaseFile); } function mutateDatabase(mutator) { const operation = databaseQueue.then(async () => { const database = await readDatabase(); const result = await mutator(database); await writeDatabase(database); return result; }); databaseQueue = operation.catch(() => {}); return operation; } async function findProject(id) { const database = await readDatabase(); return database.projects.find((project) => project.id === id) || null; } async function findForumPost(id) { const database = await readDatabase(); return database.forumPosts.find((post) => post.id === id) || null; } async function recordHomepageView(request, response) { const cookies = parseCookies(request.get("cookie") || ""); let visitorToken = cookies.hdy_visitor; if (!visitorToken) { visitorToken = crypto.randomBytes(18).toString("base64url"); response.append( "Set-Cookie", `hdy_visitor=${visitorToken}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000`, ); } const visitorHash = hashToken(visitorToken); const today = currentDayKey(); return mutateDatabase(async (database) => { database.dailyStats ||= []; database.dailyStats = database.dailyStats .filter((stat) => stat.date >= dayKeyOffset(-120)); let stat = database.dailyStats.find((candidate) => candidate.date === today); if (!stat) { stat = { date: today, views: 0, visitorHashes: [] }; database.dailyStats.push(stat); } stat.views += 1; if (!stat.visitorHashes.includes(visitorHash)) stat.visitorHashes.push(visitorHash); return { date: today, views: stat.views, uniqueVisitors: stat.visitorHashes.length, totalViews: database.dailyStats.reduce((sum, item) => sum + item.views, 0), }; }); } async function registerUser(input) { const username = String(input.username || "").trim(); const email = String(input.email || "").trim().toLowerCase(); const password = String(input.password || ""); if (!/^[\p{L}\p{N}_-]{3,24}$/u.test(username)) { throw new Error("用户名需为 3–24 位文字、字母、数字、下划线或短横线"); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 160) { throw new Error("请输入有效的邮箱地址"); } if (password.length < 8 || password.length > 128) { throw new Error("密码长度需为 8–128 位"); } const salt = crypto.randomBytes(16).toString("hex"); const passwordHash = (await scryptAsync(password, salt, 64)).toString("hex"); const apiKeyValue = generateUserApiKey(); const { token, session } = createSessionRecord(null, apiKeyValue); const now = new Date().toISOString(); let createdUser; await mutateDatabase(async (database) => { if (database.users.some((user) => user.username.toLowerCase() === username.toLowerCase())) { throw new Error("该用户名已被使用"); } if (database.users.some((user) => user.email === email)) { throw new Error("该邮箱已注册"); } createdUser = { id: crypto.randomUUID(), username, email, passwordSalt: salt, passwordHash, apiKeyHash: hashToken(apiKeyValue), coinBalance: initialCoinBalance, checkInHistory: [], lastCheckIn: null, checkInStreak: 0, createdAt: now, }; session.userId = createdUser.id; database.users.push(createdUser); database.sessions.push(session); }); return { user: safeUser(createdUser), token }; } async function loginUser(input) { const identifier = String(input.identifier || "").trim().toLowerCase(); const password = String(input.password || ""); const database = await readDatabase(); const user = database.users.find( (candidate) => candidate.email === identifier || candidate.username.toLowerCase() === identifier, ); const salt = user?.passwordSalt || "00000000000000000000000000000000"; const actualHash = (await scryptAsync(password, salt, 64)).toString("hex"); const expected = Buffer.from(user?.passwordHash || "0".repeat(128), "hex"); const actual = Buffer.from(actualHash, "hex"); if (!user || expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { throw new Error("用户名、邮箱或密码不正确"); } const { token, session } = createSessionRecord(user.id); await mutateDatabase(async (current) => { const now = Date.now(); current.sessions = current.sessions.filter( (item) => new Date(item.expiresAt).getTime() > now && item.userId !== user.id, ); current.sessions.push(session); }); return token; } async function authenticateSession(request, response, next) { try { const token = parseCookies(request.get("cookie") || "").code_session; if (!token) return next(); const database = await readDatabase(); const tokenHash = hashToken(token); const session = database.sessions.find( (candidate) => candidate.tokenHash === tokenHash && new Date(candidate.expiresAt).getTime() > Date.now(), ); if (!session) { clearSessionCookie(response); return next(); } const user = database.users.find((candidate) => candidate.id === session.userId); if (user) { request.user = safeUser(user); request.sessionToken = token; } next(); } catch (error) { next(error); } } async function destroySession(token) { if (!token) return; const tokenHash = hashToken(token); await mutateDatabase(async (database) => { database.sessions = database.sessions.filter((session) => session.tokenHash !== tokenHash); }); } async function consumeApiKeyFlash(token) { if (!token) return null; const tokenHash = hashToken(token); let apiKeyValue = null; await mutateDatabase(async (database) => { const session = database.sessions.find((candidate) => candidate.tokenHash === tokenHash); if (session?.flashApiKey) { apiKeyValue = session.flashApiKey; session.flashApiKey = null; } }); return apiKeyValue; } async function rotateUserApiKey(userId, sessionToken) { const apiKeyValue = generateUserApiKey(); const sessionHash = hashToken(sessionToken); let found = false; await mutateDatabase(async (database) => { const user = database.users.find((candidate) => candidate.id === userId); if (!user) return; found = true; user.apiKeyHash = hashToken(apiKeyValue); const session = database.sessions.find((candidate) => candidate.tokenHash === sessionHash); if (session) session.flashApiKey = apiKeyValue; }); return found ? apiKeyValue : null; } function createSessionRecord(userId, flashApiKey = null) { const token = crypto.randomBytes(32).toString("base64url"); const now = new Date(); return { token, session: { id: crypto.randomUUID(), userId, tokenHash: hashToken(token), flashApiKey, createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + sessionLifetimeMs).toISOString(), }, }; } function setSessionCookie(response, token) { response.setHeader( "Set-Cookie", `code_session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.floor(sessionLifetimeMs / 1000)}`, ); } function clearSessionCookie(response) { response.setHeader( "Set-Cookie", "code_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0", ); } function parseCookies(header) { return Object.fromEntries( header.split(";").map((part) => { const index = part.indexOf("="); if (index < 0) return [part.trim(), ""]; return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())]; }).filter(([name]) => name), ); } function requireLogin(request, response, next) { if (request.user) return next(); if (request.path.startsWith("/api/")) { return response.status(401).json({ error: "login_required" }); } const nextPath = encodeURIComponent(request.originalUrl || "/account"); response.redirect(303, `/login?next=${nextPath}`); } function redirectIfLoggedIn(request, response, next) { if (request.user) return response.redirect(303, "/account"); next(); } function requireSameOrigin(request, response, next) { const origin = request.get("origin"); if (!origin) return next(); try { if (new URL(origin).host === request.get("host")) return next(); } catch { // Invalid origins are rejected below. } sendError(request, response, 403, "请求来源无效"); } async function requireProjectOwner(request, response, next) { try { const project = await findProject(request.params.id); if (!project) return sendError(request, response, 404, "项目不存在"); const user = request.apiUser || request.user; if (!user || project.ownerId !== user.id) { return sendError(request, response, 403, "你没有权限维护这个项目"); } request.project = project; next(); } catch (error) { next(error); } } async function requirePublishedProject(request, response, next) { try { const project = await findProject(request.params.id); if (!project || project.status !== "published") { return response.status(404).send(renderErrorPage(404, "项目不存在", request.user)); } request.project = project; next(); } catch (error) { next(error); } } function canViewProject(project, user) { return project.status === "published" || Boolean(user && project.ownerId === user.id); } function safeUser(user) { return { id: user.id, username: user.username, email: user.email, coinBalance: Number(user.coinBalance || 0), lastCheckIn: user.lastCheckIn || null, checkInStreak: Number(user.checkInStreak || 0), totalCheckIns: user.checkInHistory?.length || 0, createdAt: user.createdAt, }; } function generateUserApiKey() { return `code_user_${crypto.randomBytes(24).toString("base64url")}`; } function hashToken(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); } function safeNextPath(value) { const nextPath = String(value || ""); return nextPath.startsWith("/") && !nextPath.startsWith("//") ? nextPath : null; } async function createProject({ title, summary, tags, owner }) { const cleanTitle = String(title || "").trim(); if (!cleanTitle) throw new Error("项目名称不能为空"); if (!owner?.id) throw new Error("登录后才可以创建项目"); const now = new Date().toISOString(); const project = { id: createProjectId(cleanTitle), ownerId: owner.id, ownerName: owner.username, title: cleanTitle.slice(0, 120), summary: String(summary || "这个项目还没有简介。").trim().slice(0, 500), tags: unique(tags || []).slice(0, 12), readme: "", readmePath: null, language: "待识别", license: null, tree: [], treeTruncated: false, fileCount: 0, projectSize: 0, source: null, releases: [], status: "draft", publishedAt: null, likes: [], favorites: [], coinContributions: [], comments: [], tutorials: [], files: [], createdAt: now, updatedAt: now, }; return mutateDatabase(async (database) => { database.version = 6; database.projects.push(project); return project; }); } async function updateProject(id, updater) { return mutateDatabase(async (database) => { const index = database.projects.findIndex((project) => project.id === id); if (index < 0) throw new Error("项目不存在"); database.projects[index] = updater(database.projects[index]); return database.projects[index]; }); } async function publishProject(projectId) { const project = await findProject(projectId); if (!project) throw new Error("项目不存在"); if (!project.source) throw new Error("请先上传项目源码,再公开发布"); if (project.status === "published") return project; return updateProject(projectId, (current) => ({ ...current, status: "published", publishedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })); } async function publishSource(projectId, file, releaseInput = {}) { const project = await findProject(projectId); if (!project) throw new Error("项目不存在"); if (!String(file.originalname || "").toLowerCase().endsWith(".zip")) { throw new Error("项目源码目前仅支持 ZIP 格式"); } const version = normalizeVersion( releaseInput.version || (project.releases?.length ? `v${project.releases.length + 1}.0.0` : "v1.0.0"), ); if ((project.releases || []).some( (release) => String(release.version).toLowerCase() === version.toLowerCase(), )) { throw new Error(`版本 ${version} 已经存在,请使用新的版本号`); } const changelog = String( releaseInput.changelog || (project.releases?.length ? "代码更新" : "首次发布"), ).trim().slice(0, 2000); const stagingDirectory = path.join(temporaryDir, `source-${crypto.randomUUID()}`); const destinationDirectory = path.join(projectsDir, project.id); await fs.mkdir(stagingDirectory, { recursive: true }); try { await extractZipSafely(file.path, stagingDirectory); const scan = await scanProject(stagingDirectory); if (!scan.files.length) throw new Error("ZIP 项目包中没有可发布的文件"); const metadata = await inspectProject(stagingDirectory, scan); const uploadDirectory = path.join(uploadsDir, project.id); await fs.mkdir(uploadDirectory, { recursive: true }); const storedName = `source-${crypto.randomUUID()}.zip`; const storedPath = path.join(project.id, storedName); await fs.rename(file.path, path.join(uploadsDir, storedPath)); const source = { id: crypto.randomUUID(), originalName: String(file.originalname).slice(0, 240), storedPath, size: file.size, version, changelog, uploadedAt: new Date().toISOString(), }; await fs.rm(destinationDirectory, { recursive: true, force: true }); await fs.rename(stagingDirectory, destinationDirectory); const updated = await updateProject(project.id, (current) => ({ ...current, readme: metadata.readme, readmePath: metadata.readmePath, language: metadata.language, license: metadata.license, tags: unique([...(current.tags || []), ...metadata.tags]).slice(0, 12), summary: current.summary === "这个项目还没有简介。" && metadata.summary ? metadata.summary.slice(0, 500) : current.summary, tree: scan.files.slice(0, 1000), treeTruncated: scan.files.length > 1000, fileCount: scan.files.length, projectSize: scan.totalBytes, source, releases: [...(current.releases || []), source], updatedAt: new Date().toISOString(), })); return updated; } catch (error) { await fs.rm(stagingDirectory, { recursive: true, force: true }); throw error; } } async function extractZipSafely(archivePath, destination) { let archive; try { archive = await unzipper.Open.file(archivePath); } catch { throw new Error("无法读取 ZIP 项目包,请确认文件没有损坏"); } if (archive.files.length > maxSourceFiles) { throw new Error(`项目文件数量超过 ${maxSourceFiles} 个限制`); } const validated = archive.files.map((entry) => { const entryPath = normalizeArchivePath(entry.path); if (!entryPath || entryPath.startsWith("../") || path.posix.isAbsolute(entryPath)) { throw new Error("ZIP 中包含不安全的文件路径"); } if (isIgnoredSourcePath(entryPath)) { throw new Error(`ZIP 中包含禁止发布的路径:${entryPath}`); } const mode = (entry.externalFileAttributes >>> 16) & 0o170000; if (mode === 0o120000) throw new Error("ZIP 中不能包含符号链接"); const size = Number(entry.uncompressedSize ?? entry.vars?.uncompressedSize ?? 0); return { entry, entryPath, size }; }); const totalBytes = validated.reduce((sum, item) => sum + item.size, 0); if (totalBytes > maxSourceBytes) { throw new Error(`项目解压后超过 ${Math.round(maxSourceBytes / 1024 / 1024)}MB 限制`); } const filePaths = validated .filter(({ entry }) => entry.type !== "Directory") .map(({ entryPath }) => entryPath); const roots = new Set(filePaths.map((entryPath) => entryPath.split("/")[0])); const stripRoot = roots.size === 1 && filePaths.length > 0 && filePaths.every((entryPath) => entryPath.includes("/")) ? [...roots][0] : null; for (const { entry, entryPath } of validated) { let relativePath = stripRoot && entryPath.startsWith(`${stripRoot}/`) ? entryPath.slice(stripRoot.length + 1) : entryPath; relativePath = normalizeArchivePath(relativePath); if (!relativePath || isIgnoredSourcePath(relativePath)) continue; const absolutePath = path.resolve(destination, relativePath); if (!isPathInside(destination, absolutePath)) throw new Error("ZIP 中包含不安全的文件路径"); if (entry.type === "Directory" || entryPath.endsWith("/")) { await fs.mkdir(absolutePath, { recursive: true }); continue; } await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await pipeline(entry.stream(), createWriteStream(absolutePath, { mode: 0o644 })); } } async function scanProject(directory) { const files = []; let totalBytes = 0; const pending = [directory]; while (pending.length) { const current = pending.pop(); const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(current, entry.name); const relativePath = path.relative(directory, absolutePath).split(path.sep).join("/"); if (isIgnoredSourcePath(relativePath) || entry.isSymbolicLink()) continue; if (entry.isDirectory()) pending.push(absolutePath); else if (entry.isFile()) { const stats = await fs.stat(absolutePath); totalBytes += stats.size; files.push({ path: relativePath, size: stats.size }); } } } if (totalBytes > maxSourceBytes) { throw new Error(`项目解压后超过 ${Math.round(maxSourceBytes / 1024 / 1024)}MB 限制`); } files.sort((a, b) => a.path.localeCompare(b.path)); return { files, totalBytes }; } async function inspectProject(directory, scan) { const readmeFile = scan.files.find((file) => /^readme(?:\.[^/]+)?$/i.test(file.path)); const packageFile = scan.files.find((file) => file.path === "package.json"); let packageMetadata = {}; if (packageFile) { try { packageMetadata = JSON.parse(await fs.readFile(path.join(directory, packageFile.path), "utf8")); } catch { packageMetadata = {}; } } let readme = ""; if (readmeFile && readmeFile.size <= 2 * 1024 * 1024) { readme = await fs.readFile(path.join(directory, readmeFile.path), "utf8"); } return { readme, readmePath: readmeFile?.path || null, language: detectLanguage(scan.files), license: packageMetadata.license || scan.files.find((file) => /^licen[cs]e(?:\.[^/]+)?$/i.test(file.path))?.path || null, tags: Array.isArray(packageMetadata.keywords) ? packageMetadata.keywords : [], summary: packageMetadata.description || extractReadmeSummary(readme) || "", }; } async function persistAttachment(projectId, file) { const projectDirectory = path.join(uploadsDir, projectId); await fs.mkdir(projectDirectory, { recursive: true }); const extension = path.extname(file.originalname).slice(0, 16).toLowerCase(); const storedName = `${crypto.randomUUID()}${extension}`; const destination = path.join(projectDirectory, storedName); await fs.rename(file.path, destination); return { id: crypto.randomUUID(), originalName: String(file.originalname || "file").slice(0, 240), storedPath: path.join(projectId, storedName), size: file.size, mimeType: file.mimetype || "application/octet-stream", createdAt: new Date().toISOString(), }; } async function sendStoredDownload(response, file) { const absolutePath = path.resolve(uploadsDir, file.storedPath); if (!isPathInside(uploadsDir, absolutePath)) { return response.status(400).send(renderErrorPage(400, "文件路径无效")); } try { await fs.access(absolutePath); response.setHeader("Content-Type", "application/octet-stream"); response.setHeader("Content-Disposition", contentDisposition(file.originalName)); response.sendFile(absolutePath); } catch { response.status(404).send(renderErrorPage(404, "文件已丢失")); } } async function removeTemporaryFiles(files) { await Promise.all( files.filter(Boolean).map((file) => fs.rm(file.path, { force: true }).catch(() => {})), ); } async function requireApiKey(request, response, next) { try { const authorization = String(request.get("authorization") || ""); const supplied = request.get("x-api-key") || (authorization.toLowerCase().startsWith("bearer ") ? authorization.slice(7) : ""); if (!supplied) { return response.status(401).json({ error: "unauthorized", message: "Provide your personal API key in the X-API-Key header.", }); } const database = await readDatabase(); const suppliedHash = hashToken(supplied); const user = database.users.find((candidate) => candidate.apiKeyHash === suppliedHash); if (!user) { return response.status(401).json({ error: "unauthorized", message: "The personal API key is invalid or has been rotated.", }); } request.apiUser = safeUser(user); next(); } catch (error) { next(error); } } function publicProject(project, includeContent = false) { const result = { id: project.id, title: project.title, summary: project.summary, owner: { id: project.ownerId, username: project.ownerName, }, language: project.language, license: project.license, tags: project.tags || [], fileCount: project.fileCount || 0, projectSize: project.projectSize || 0, status: project.status || "draft", publishedAt: project.publishedAt || null, sourceUploaded: Boolean(project.source), sourceDownloadUrl: project.source ? `/downloads/${project.id}/source` : null, currentVersion: project.source?.version || null, releases: (project.releases || []).map((release) => ({ id: release.id, version: release.version, changelog: release.changelog, size: release.size, uploadedAt: release.uploadedAt, downloadUrl: `/downloads/${project.id}/releases/${release.id}`, })), tutorialCount: project.tutorials?.length || 0, attachmentCount: project.files?.length || 0, likeCount: project.likes?.length || 0, favoriteCount: project.favorites?.length || 0, coinCount: (project.coinContributions || []).reduce( (sum, contribution) => sum + contribution.amount, 0, ), commentCount: project.comments?.length || 0, createdAt: project.createdAt, updatedAt: project.updatedAt, url: `/projects/${project.id}`, }; if (includeContent) { result.readme = project.readme; result.readmePath = project.readmePath; result.tutorials = project.tutorials || []; result.files = project.files || []; result.comments = project.comments || []; } return result; } function renderHome(projects, query, user, latestPosts, viewStats) { const publishHref = user ? "/publish" : "/register"; const publishLabel = user ? "发布新项目" : "注册并发布"; const cards = projects.length ? projects.map(projectCard).join("") : `
</>

${query ? "没有找到相关项目" : "发布属于我们的第一个项目"}

${query ? "换个关键词试试。" : "无需 GitHub,项目源码、教程和附件都保存在我们自己的服务器。"}

${publishLabel}
`; const forumPreview = latestPosts.length ? latestPosts.map((post) => ` ${escapeHtml(post.category)}

${escapeHtml(post.title)}

${escapeHtml(post.authorName)} · ${post.replies?.length || 0} 条回复 · ${formatDate(post.updatedAt)}

`).join("") : `
论坛还没有帖子,来发起第一个讨论吧。
`; return pageShell({ title: "hdycode · 开放项目社区", description: "直接发布项目、教程和文件,不依赖第三方代码平台。", user, content: `
CREATE · PUBLISH · SHARE

把你的项目
正式发布。

从源码、README 到教程和附件,在自己的项目社区完整展示作品,让更多人发现和使用。

project publish

项目 hdycode-project.zip

README · 源码 · 教程 · 附件

03

正在上传并整理项目内容…

✓ 项目已发布,可以分享了
HOW IT WORKS

五步完成项目发布

查看完整说明 →
01注册登录

创建你的项目账户

02创建项目

填写名称、介绍和标签

03上传源码

上传整理好的 ZIP 项目包

04完善内容

补充教程、文档和附件

05确认发布

检查内容并正式公开项目

后续更新

准备新版代码 → 填写版本号与更新说明 → 上传新 ZIP → 自动保留历史版本

查看更新流程 →
EXPLORE

发现项目

${cards}
${projects.length ? `
查看全部项目 →
` : ""}
COMMUNITY

社区讨论

进入论坛 →
${forumPreview}
今日浏览${formatCount(viewStats.views)}
今日访客${formatCount(viewStats.uniqueVisitors)}
累计浏览${formatCount(viewStats.totalViews)}

${escapeHtml(viewStats.date)} · hdycode 每日访问统计

`, }); } function renderProjectList(projects, query, user) { const cards = projects.length ? projects.map(projectCard).join("") : `
</>

${query ? "没有找到相关项目" : "暂时还没有公开项目"}

${query ? "尝试搜索其他名称、作者、语言或标签。" : "注册登录后即可发布第一个项目。"}

${user ? "发布项目" : "注册并发布"}
`; return pageShell({ title: "项目列表 · hdycode", description: "浏览平台上公开发布的全部项目。", user, content: `
ALL PROJECTS

项目列表

共 ${projects.length} 个项目,源码、文档和教程均托管在我们自己的平台。

${cards}
`, }); } function projectCard(project) { return `
${escapeHtml(project.language)}

${escapeHtml(project.title)}

${escapeHtml(project.summary)}

${(project.tags || []).slice(0, 3) .map((tag) => `#${escapeHtml(tag)}`).join("")}
♥ ${project.likes?.length || 0} ★ ${project.favorites?.length || 0} ● ${(project.coinContributions || []).reduce((sum, item) => sum + item.amount, 0)} 💬 ${project.comments?.length || 0}
由 ${escapeHtml(project.ownerName || "平台")} 发布 ${escapeHtml(project.source?.version || "暂无版本")}
`; } function renderProject(project, user) { const readme = project.readme ? renderMarkdown(project.readme) : `
项目包中还没有 README。
`; const tutorials = (project.tutorials || []).length ? project.tutorials.map((tutorial, index) => `
${String(index + 1).padStart(2, "0")}

${escapeHtml(tutorial.title)}

${renderMarkdown(tutorial.content)}
${tutorial.attachment ? fileRow(project.id, tutorial.attachment, true) : ""}
`).join("") : `
还没有教程。
`; const files = (project.files || []).length ? project.files.map((file) => fileRow(project.id, file)).join("") : `
暂时没有额外附件。
`; const tree = (project.tree || []).length ? `
${project.tree.slice(0, 120).map((file) => ` ${escapeHtml(file.path)}${formatBytes(file.size)} `).join("")} ${project.treeTruncated || project.tree.length > 120 ? `

文件较多,完整目录请通过 API 读取。

` : ""}
` : `
还没有上传项目源码。
`; const canManage = Boolean(user && project.ownerId === user.id); const liked = Boolean(user && (project.likes || []).includes(user.id)); const favorited = Boolean(user && (project.favorites || []).includes(user.id)); const totalCoins = (project.coinContributions || []).reduce( (sum, contribution) => sum + contribution.amount, 0, ); const userCoins = user ? (project.coinContributions || []) .filter((contribution) => contribution.userId === user.id) .reduce((sum, contribution) => sum + contribution.amount, 0) : 0; const remainingCoins = Math.max(0, maxCoinsPerProject - userCoins); const spendableCoins = user ? Math.min(remainingCoins, user.coinBalance) : 0; const socialBar = project.status === "published" ? `
支持这个项目登录后可以互动
${user ? `
` : `登录点赞${project.likes?.length || 0} 登录收藏${project.favorites?.length || 0}`}
投币${totalCoins}
${user && !canManage && spendableCoins > 0 ? `
${spendableCoins >= 2 ? `` : ""}
` : `${canManage ? "不能给自己的项目投币" : user ? user.coinBalance <= 0 ? "硬币余额不足" : `已投 ${userCoins} 枚` : "登录后投币"}`}
${user ? `
我的硬币余额:${user.coinBalance}
` : ""}
` : ""; const comments = (project.comments || []).length ? project.comments.map((comment) => { const canDelete = Boolean( user && (comment.userId === user.id || project.ownerId === user.id), ); return `
${escapeHtml((comment.username || "用").slice(0, 1).toUpperCase())}
${escapeHtml(comment.username || "用户")}

${escapeHtml(comment.content).replaceAll("\n", "
")}

${canDelete ? `
` : ""}
`; }).join("") : `
还没有评论,来留下第一条评论吧。
`; const commentSection = project.status === "published" ? `
评论${project.comments?.length || 0} 条
${comments}
${user ? `
${escapeHtml(user.username.slice(0, 1).toUpperCase())}
` : `
登录后发表评论
`}
` : ""; const releases = (project.releases || []).length ? [...project.releases].reverse().map((release, index) => `
${index === 0 ? "NOW" : "OLD"}

${escapeHtml(release.version || "未标记版本")}

${escapeHtml(release.changelog || "暂无更新说明")}

下载该版本 · ${formatBytes(release.size)} ↓
`).join("") : `
还没有发布代码版本。
`; const hasSupportingContent = Boolean( project.readme || project.tutorials?.length || project.files?.length, ); const publishFlow = canManage ? `
PUBLISHING STATUS

${project.status === "published" ? "项目已公开发布" : "完成发布流程"}

${project.status === "published" ? "已发布" : "草稿"}
1创建项目基本资料已保存
2上传源码${project.source ? `当前 ${escapeHtml(project.source.version || "")}` : "上传首个 ZIP 版本"}
3完善内容README、教程和附件
4确认发布公开到项目列表
${project.status === "draft" ? `

${project.source ? "源码已准备好。确认项目内容无误后即可公开发布。" : "请先在右侧上传项目源码,再进行公开发布。"}

${project.source ? `
` : `等待上传源码`}
` : `

项目已出现在公开项目列表中。后续上传新版本会立即更新当前代码,并保留历史版本。

查看项目列表
`}
` : ""; const maintenance = canManage ? `
${project.source ? "发布代码新版本" : "上传首个代码版本"}


` : `

项目作者

${escapeHtml((project.ownerName || "平").slice(0, 1).toUpperCase())}
${escapeHtml(project.ownerName || "平台")}

${user ? "只有项目作者可以更新源码和内容。" : "登录后可以发布你自己的项目。"}

${user ? "" : `登录`}
`; return pageShell({ title: `${project.title} · hdycode`, description: project.summary, user, content: `
${project.status === "published" ? "PUBLISHED PROJECT" : "DRAFT PROJECT"}

${escapeHtml(project.title)}

${escapeHtml(project.summary)}

${project.source ? `下载项目包 ↓` : `等待上传源码`}
${formatCount(project.fileCount || 0)} 源码文件 ${project.tutorials?.length || 0} 教程 ${formatBytes(project.projectSize || 0)} 解压大小 ${escapeHtml(project.source?.version || "—")} 当前版本
${socialBar}
${publishFlow}
README${escapeHtml(project.readmePath || "未上传")}
${readme}
版本记录${project.releases?.length || 0} 个版本
${releases}
项目文件${project.fileCount || 0} 个
${tree}
教程${project.tutorials?.length || 0} 篇
${tutorials}
附件${project.files?.length || 0} 个
${files}
${commentSection}
`, }); } function fileRow(projectId, file, compact = false) { return ` ${escapeHtml(file.originalName)}${formatBytes(file.size)} · ${formatDate(file.createdAt)} `; } function renderForumPage(posts, query, category, user) { const postList = posts.length ? posts.map((post) => `
${escapeHtml(post.category)}

${escapeHtml(post.title)}

${escapeHtml(forumExcerpt(post.content))}

${escapeHtml(post.authorName.slice(0, 1).toUpperCase())} ${escapeHtml(post.authorName)} ${post.replies?.length || 0} 条回复 进入讨论 →
`).join("") : `
#

暂时没有相关帖子

发起一个话题,与社区成员交流。

发布帖子
`; return pageShell({ title: "社区论坛 · hdycode", description: "发布话题、交流项目、分享教程和讨论问题。", user, content: `
COMMUNITY FORUM

一起交流,
一起把项目做好。

分享进展、提出问题、交流经验,或者随便聊聊正在做的事情。

发布新帖子 +
全部 ${forumCategories.map((item) => `${escapeHtml(item)}`).join("")}
${postList}
`, }); } function renderNewForumPostPage(user, error = "", values = {}) { return pageShell({ title: "发布帖子 · hdycode", description: "在社区论坛发布新的讨论话题。", user, content: `
START A DISCUSSION

发起一个
新话题。

可以分享项目进度、寻求帮助、发布教程或与其他成员交流想法。内容支持 Markdown。

#

发布帖子

清晰的标题更容易获得回复

${error ? `
${escapeHtml(error)}
` : ""}
`, }); } function renderForumPostPage(post, user) { const replies = (post.replies || []).length ? post.replies.map((reply, index) => { const canDelete = Boolean( user && (reply.authorId === user.id || post.authorId === user.id), ); return `
${escapeHtml(reply.authorName.slice(0, 1).toUpperCase())}
${escapeHtml(reply.authorName)}#${index + 1}

${escapeHtml(reply.content).replaceAll("\n", "
")}

${canDelete ? `
` : ""}
`; }).join("") : `
还没有回复,来参与讨论吧。
`; return pageShell({ title: `${post.title} · hdycode 论坛`, description: forumExcerpt(post.content), user, content: `
${escapeHtml(post.category)}

${escapeHtml(post.title)}

${escapeHtml(post.authorName.slice(0, 1).toUpperCase())}
${escapeHtml(post.authorName)}${formatDateTime(post.createdAt)} 发布 · ${post.replies?.length || 0} 条回复
${renderMarkdown(post.content)}
${user?.id === post.authorId ? `
` : ""}
讨论回复${post.replies?.length || 0} 条
${replies}
${user ? `
${escapeHtml(user.username.slice(0, 1).toUpperCase())}
` : ``}
`, }); } function renderRegisterPage(user, error = "", values = {}) { return pageShell({ title: "注册 · hdycode", description: "注册账户并发布自己的项目。", user, content: `
JOIN THE COMMUNITY

创建你的
项目账户。

注册后可以发布项目源码、教程和附件,集中管理自己的作品与发布记录。

注册账户

创建后立即登录

${error ? `
${escapeHtml(error)}
` : ""}

已有账户?直接登录

`, }); } function renderLoginPage(user, error = "", values = {}, nextPath = "") { return pageShell({ title: "登录 · hdycode", description: "登录后发布和维护项目。", user, content: `
WELCOME BACK

继续发布
你的项目。

登录后管理项目源码、教程和附件。游客仍然可以浏览所有公开项目。

登录

使用用户名或邮箱

${error ? `
${escapeHtml(error)}
` : ""}

还没有账户?立即注册

`, }); } function renderAccountPage(user, projects, favoriteProjects, apiKeyValue, checkInReward = 0) { const checkedInToday = user.lastCheckIn === currentDayKey(); const projectItems = projects.length ? projects.map((project) => ` ${escapeHtml(project.title)} ${escapeHtml(project.source?.version || "未上传版本")} · ${project.fileCount || 0} 个文件 · ${formatDate(project.updatedAt)} `).join("") : `
你还没有发布项目。
`; const favoriteItems = favoriteProjects.length ? favoriteProjects.map((project) => ` ${escapeHtml(project.title)}由 ${escapeHtml(project.ownerName || "平台")} 发布 · ${escapeHtml(project.source?.version || "暂无版本")} `).join("") : `
你还没有收藏项目。
`; return pageShell({ title: `${user.username} · 账户中心`, description: "管理账户、项目和自动化 API Key。", user, content: `
${escapeHtml(user.username.slice(0, 1).toUpperCase())}
ACCOUNT

${escapeHtml(user.username)}

${escapeHtml(user.email)} · ${formatDate(user.createdAt)} 加入 · 余额 ${user.coinBalance} 枚硬币

创建项目 +
`, }); } function renderPublishPage(user) { return pageShell({ title: "创建项目 · hdycode", description: "在我们自己的平台直接创建并发布项目。", user, content: `
PUBLISH DIRECTLY

创建属于
我们的项目。

先创建项目页面,再上传源码 ZIP、教程和附件。项目内容全部保存在本地服务器,与 GitHub 无关。

新建项目

创建后即可上传源码

`, }); } function renderGuidePage(_request, user) { return pageShell({ title: "发布流程 · hdycode", description: "从创建账户到公开分享,了解完整项目发布流程。", user, content: `
PUBLISHING GUIDE

从项目到作品,
五步完成发布。

整理项目、上传源码、完善说明,然后把项目链接分享给其他人。整个过程不依赖第三方代码平台。

${user ? "开始创建项目" : "注册并开始"} →
02
CREATE

填写项目资料

创建项目页面,填写名称、一句话介绍和标签。清晰的介绍能帮助其他人快速了解项目用途。

03
UPLOAD

整理并上传源码

将项目打包为 ZIP。不要包含依赖目录、环境变量、密钥和个人配置文件。

zip -r project.zip . -x '.git/*' 'node_modules/*' '.env*'
04
IMPROVE

完善项目内容

平台会自动读取 README 和文件目录。你还可以添加使用教程、PDF 文档、安装包或其他附件。

README使用教程项目附件版本更新
05
PUBLISH

确认并公开发布

在项目页面检查介绍、源码目录和下载内容,点击“确认并公开发布”。项目随后会出现在公开列表中。

浏览已发布项目 →
CODE UPDATE FLOW

代码后续更新流程

已发布项目无需重新创建。每次上传新版本都会更新当前源码,并保留旧版本和更新说明。

U1
PREPARE

准备新版代码

完成开发和测试后重新整理项目,更新 README,并像首次发布一样排除依赖、环境变量和密钥文件。

U2
VERSION

填写版本与更新说明

进入项目维护区,填写新的版本号,例如 v1.1.0,并说明新增功能、修复内容或不兼容变更。

U3
RELEASE

上传并发布新版本

上传新版 ZIP 后,项目页面会立即切换到最新源码。历史版本仍保留在“版本记录”中供用户下载。

`, }); } function renderErrorPage(status, message, user = null) { return pageShell({ title: `${status} · hdycode`, user, content: `
${status}

${escapeHtml(message)}

返回首页,或检查提交内容后重试。

返回首页
`, }); } function pageShell({ title, description = "", content, user = null }) { const accountNavigation = user ? `${escapeHtml(user.username.slice(0, 1).toUpperCase())}${escapeHtml(user.username)} 创建项目 ` : `登录注册 `; return ` ${escapeHtml(title)}
${content}
`; } function renderMarkdown(markdown) { return sanitizeHtml(marked.parse(String(markdown || "")), { allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "details", "summary", "kbd", "del"]), allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, a: ["href", "name", "target", "rel"], img: ["src", "alt", "title", "width", "height"], code: ["class"], }, allowedSchemes: ["http", "https", "mailto"], transformTags: { a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "nofollow noreferrer" }), }, }); } function openApiDocument(request) { const serverUrl = `${request.protocol}://${request.get("host")}`; const projectIdParameter = { name: "id", in: "path", required: true, schema: { type: "string" }, }; return { openapi: "3.1.0", info: { title: "hdycode Project Publishing API", version: "0.7.0", description: "Publish local projects, source archives, tutorials and files to a registered hdycode account using its personal API key.", }, servers: [{ url: serverUrl }], components: { securitySchemes: { ApiKey: { type: "apiKey", in: "header", name: "X-API-Key" } }, }, paths: { "/api/v1/projects": { get: { summary: "List projects", responses: { 200: { description: "Project list" } } }, post: { summary: "Create a native project", security: [{ ApiKey: [] }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["title"], properties: { title: { type: "string" }, summary: { type: "string" }, tags: { type: "array", items: { type: "string" } }, }, }, }, }, }, responses: { 201: { description: "Created project" } }, }, }, "/api/v1/projects/{id}": { get: { summary: "Read a project", parameters: [projectIdParameter], responses: { 200: { description: "Project details" } }, }, patch: { summary: "Update project title, summary or tags", security: [{ ApiKey: [] }], parameters: [projectIdParameter], requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string" }, tags: { type: "array", items: { type: "string" } }, }, }, }, }, }, responses: { 200: { description: "Updated project" } }, }, }, "/api/v1/projects/{id}/source": { post: { summary: "Upload the first source version or publish a new code version", security: [{ ApiKey: [] }], parameters: [projectIdParameter], requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["archive"], properties: { archive: { type: "string", format: "binary" }, version: { type: "string", example: "v1.0.0" }, changelog: { type: "string" }, }, }, }, }, }, responses: { 201: { description: "Published source archive" } }, }, }, "/api/v1/projects/{id}/publish": { post: { summary: "Publish a draft project to the public project list", security: [{ ApiKey: [] }], parameters: [projectIdParameter], responses: { 200: { description: "Published project" } }, }, }, "/api/v1/projects/{id}/tree": { get: { summary: "Read the project file tree", parameters: [projectIdParameter], responses: { 200: { description: "Project tree" } }, }, }, "/api/v1/projects/{id}/raw": { get: { summary: "Read a text file from the project", parameters: [ projectIdParameter, { name: "path", in: "query", required: true, schema: { type: "string" } }, ], responses: { 200: { description: "Raw UTF-8 file" } }, }, }, "/api/v1/projects/{id}/tutorials": { post: { summary: "Publish a Markdown tutorial", security: [{ ApiKey: [] }], parameters: [projectIdParameter], requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["title", "content"], properties: { title: { type: "string" }, content: { type: "string" }, attachment: { type: "string", format: "binary" }, }, }, }, }, }, responses: { 201: { description: "Published tutorial" } }, }, }, "/api/v1/projects/{id}/files": { post: { summary: "Upload project attachments", security: [{ ApiKey: [] }], parameters: [projectIdParameter], requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["files"], properties: { files: { type: "array", items: { type: "string", format: "binary" } }, }, }, }, }, }, responses: { 201: { description: "Uploaded files" } }, }, }, }, }; } function sendError(request, response, status, message) { if (request.path.startsWith("/api/")) return response.status(status).json({ error: message }); response.status(status).send(renderErrorPage(status, message)); } function normalizeVersion(value) { const version = String(value || "").trim(); if (!/^[vV]?[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/.test(version)) { throw new Error("版本号需为 1–32 位,可使用字母、数字、点、短横线或下划线"); } return version; } function currentDayKey(date = new Date()) { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${values.year}-${values.month}-${values.day}`; } function dayKeyOffset(days) { return currentDayKey(new Date(Date.now() + days * 24 * 60 * 60 * 1000)); } function createForumPostId(title) { const slug = String(title) .normalize("NFKD") .replace(/[^\w\u4e00-\u9fff-]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() .slice(0, 48) || "topic"; return `${slug}-${crypto.randomBytes(3).toString("hex")}`; } function createProjectId(title) { const slug = String(title) .normalize("NFKD") .replace(/[^\w\u4e00-\u9fff-]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() .slice(0, 50) || "project"; return `${slug}-${crypto.randomBytes(3).toString("hex")}`; } function normalizeArchivePath(value) { const cleaned = String(value || "").replaceAll("\\", "/").replace(/^\.\/+/, ""); if (!cleaned) return ""; const normalized = path.posix.normalize(cleaned); return normalized === "." ? "" : normalized; } function isIgnoredSourcePath(value) { const parts = normalizeArchivePath(value).split("/"); return parts.some((part) => part === ".git" || part === "node_modules" || part === ".ssh" || part === ".DS_Store" || part === "id_rsa" || (part === ".env" || (part.startsWith(".env.") && part !== ".env.example")), ); } function detectLanguage(files) { const languages = { ".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript", ".ts": "TypeScript", ".tsx": "TypeScript", ".jsx": "JavaScript", ".py": "Python", ".go": "Go", ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".php": "PHP", ".rb": "Ruby", ".swift": "Swift", ".c": "C", ".h": "C", ".cpp": "C++", ".cs": "C#", ".vue": "Vue", ".svelte": "Svelte", }; const scores = new Map(); for (const file of files) { const language = languages[path.extname(file.path).toLowerCase()]; if (language) scores.set(language, (scores.get(language) || 0) + Math.max(file.size, 1)); } return [...scores.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "其他"; } function extractReadmeSummary(readme) { return String(readme || "") .replace(/```[\s\S]*?```/g, " ") .replace(/!\[[^\]]*]\([^)]*\)/g, " ") .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") .replace(/<[^>]+>/g, " ") .replace(/^[#>*+\-\d.\s]+/gm, "") .split(/\n\s*\n/) .map((value) => value.replace(/\s+/g, " ").trim()) .find((value) => value.length >= 20) ?.slice(0, 500); } function forumExcerpt(content) { return String(content || "") .replace(/```[\s\S]*?```/g, " ") .replace(/!\[[^\]]*]\([^)]*\)/g, " ") .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") .replace(/[#>*_`~\-]/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, 180); } function parseTags(value) { if (Array.isArray(value)) return value.map(String).map((tag) => tag.trim()).filter(Boolean); return String(value || "").split(/[,,]/).map((tag) => tag.trim()).filter(Boolean); } function unique(values) { return [...new Set(values.map(String).map((value) => value.trim()).filter(Boolean))]; } function isPathInside(parent, child) { const relative = path.relative(path.resolve(parent), path.resolve(child)); return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); } function contentDisposition(filename) { const ascii = String(filename).replace(/[^\x20-\x7E]+/g, "_").replace(/["\\]/g, "_"); return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") .replaceAll('"', """).replaceAll("'", "'"); } function escapeAttribute(value) { return escapeHtml(value).replaceAll("`", "`"); } function formatDate(value) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric", }).format(date); } function formatDateTime(value) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }).format(date); } function formatBytes(bytes) { const number = Number(bytes || 0); if (number < 1024) return `${number} B`; if (number < 1024 ** 2) return `${(number / 1024).toFixed(1)} KB`; if (number < 1024 ** 3) return `${(number / 1024 ** 2).toFixed(1)} MB`; return `${(number / 1024 ** 3).toFixed(1)} GB`; } function formatCount(number) { return new Intl.NumberFormat("zh-CN").format(Number(number || 0)); }