${escapeHtml(project.title)}
${escapeHtml(project.summary)}
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 ? "换个关键词试试。" : "无需 GitHub,项目源码、教程和附件都保存在我们自己的服务器。"} ${escapeHtml(post.authorName)} · ${post.replies?.length || 0} 条回复 · ${formatDate(post.updatedAt)}${query ? "没有找到相关项目" : "发布属于我们的第一个项目"}
${escapeHtml(post.title)}
从源码、README 到教程和附件,在自己的项目社区完整展示作品,让更多人发现和使用。
项目 hdycode-project.zip
README · 源码 · 教程 · 附件
正在上传并整理项目内容…
创建你的项目账户
填写名称、介绍和标签
上传整理好的 ZIP 项目包
补充教程、文档和附件
检查内容并正式公开项目
${escapeHtml(viewStats.date)} · hdycode 每日访问统计
${query ? "尝试搜索其他名称、作者、语言或标签。" : "注册登录后即可发布第一个项目。"}
${user ? "发布项目" : "注册并发布"}共 ${projects.length} 个项目,源码、文档和教程均托管在我们自己的平台。
${escapeHtml(project.summary)}
文件较多,完整目录请通过 API 读取。
` : ""}${escapeHtml(comment.content).replaceAll("\n", "
")}
${escapeHtml(release.changelog || "暂无更新说明")}
下载该版本 · ${formatBytes(release.size)} ↓${project.source ? "源码已准备好。确认项目内容无误后即可公开发布。" : "请先在右侧上传项目源码,再进行公开发布。"}
${project.source ? `` : ``}项目已出现在公开项目列表中。后续上传新版本会立即更新当前代码,并保留历史版本。
查看项目列表${user ? "只有项目作者可以更新源码和内容。" : "登录后可以发布你自己的项目。"}
${user ? "" : `登录`}${escapeHtml(project.summary)}
${escapeHtml(forumExcerpt(post.content))}
发起一个话题,与社区成员交流。
发布帖子分享进展、提出问题、交流经验,或者随便聊聊正在做的事情。
可以分享项目进度、寻求帮助、发布教程或与其他成员交流想法。内容支持 Markdown。
清晰的标题更容易获得回复
${escapeHtml(reply.content).replaceAll("\n", "
")}
注册后可以发布项目源码、教程和附件,集中管理自己的作品与发布记录。
登录后管理项目源码、教程和附件。游客仍然可以浏览所有公开项目。
${escapeHtml(user.email)} · ${formatDate(user.createdAt)} 加入 · 余额 ${user.coinBalance} 枚硬币
先创建项目页面,再上传源码 ZIP、教程和附件。项目内容全部保存在本地服务器,与 GitHub 无关。
创建后即可上传源码
整理项目、上传源码、完善说明,然后把项目链接分享给其他人。整个过程不依赖第三方代码平台。
${user ? "开始创建项目" : "注册并开始"} →创建项目页面,填写名称、一句话介绍和标签。清晰的介绍能帮助其他人快速了解项目用途。
将项目打包为 ZIP。不要包含依赖目录、环境变量、密钥和个人配置文件。
zip -r project.zip . -x '.git/*' 'node_modules/*' '.env*'
平台会自动读取 README 和文件目录。你还可以添加使用教程、PDF 文档、安装包或其他附件。
已发布项目无需重新创建。每次上传新版本都会更新当前源码,并保留旧版本和更新说明。
完成开发和测试后重新整理项目,更新 README,并像首次发布一样排除依赖、环境变量和密钥文件。
进入项目维护区,填写新的版本号,例如 v1.1.0,并说明新增功能、修复内容或不兼容变更。
上传新版 ZIP 后,项目页面会立即切换到最新源码。历史版本仍保留在“版本记录”中供用户下载。
返回首页,或检查提交内容后重试。
返回首页