forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 老师端用户前端托管(标准前后端分离):
|
||||
* GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退
|
||||
* GET /database/api/login-info — 登录页配置(org slug / dev 开关)
|
||||
* GET /app/dev-login{,-teacher} — DEV ONLY 一键登录(管理员 / 普通老师)
|
||||
*
|
||||
* 服务端不再渲染老师端页面;页面由独立前端工程 hub/filelib-web 产出。
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
|
||||
export interface TeacherAppConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
/** 飞书 OAuth 链接按 silo org slug 构造。 */
|
||||
readonly siloOrganizationSlug: string;
|
||||
/** DEV ONLY(双重门禁,见 database/plugin.ts):一键登录端点与按钮同进同退。 */
|
||||
readonly allowDevLoginBypass: boolean;
|
||||
}
|
||||
|
||||
export async function registerTeacherApp(
|
||||
app: FastifyInstance,
|
||||
config: TeacherAppConfig,
|
||||
): Promise<void> {
|
||||
// 登录页配置(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
|
||||
app.get("/database/api/login-info", async () => ({
|
||||
orgSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
}));
|
||||
|
||||
// 标准分离:静态托管 SPA 构建产物;非文件路径回退 index.html 交给前端。
|
||||
const distDir = path.resolve(import.meta.dirname, "../../../filelib-web/dist");
|
||||
if (fs.existsSync(path.join(distDir, "index.html"))) {
|
||||
await app.register(fastifyStatic, { root: distDir, prefix: "/app/", decorateReply: true });
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith("/app")) {
|
||||
return reply.sendFile("index.html", distDir);
|
||||
}
|
||||
return reply.status(404).send({ error: { code: "not_found", message: "not found" } });
|
||||
});
|
||||
} else {
|
||||
app.log.warn({ distDir }, "filelib-web dist not found; build it with `npm run build --prefix filelib-web`");
|
||||
app.get("/app", async (_request, reply) =>
|
||||
reply
|
||||
.status(503)
|
||||
.type("text/plain")
|
||||
.send("filelib-web 未构建。请先运行 npm run build --prefix hub/filelib-web"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.allowDevLoginBypass) return;
|
||||
registerDevLogins(app, config);
|
||||
}
|
||||
|
||||
/** DEV ONLY:普通老师一键登录端点(双重门禁见 plugin.ts)。
|
||||
* 老师端不提供管理员登录 —— 管理员从 /database/admin 进。 */
|
||||
function registerDevLogins(app: FastifyInstance, config: TeacherAppConfig): void {
|
||||
app.get("/app/dev-login-teacher", async (_request, reply) => {
|
||||
const prisma = config.prisma;
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (organization === null) {
|
||||
return reply.status(404).send({ error: { code: "no_org", message: "no active organization" } });
|
||||
}
|
||||
|
||||
let membership = await prisma.organizationMembership.findFirst({
|
||||
where: { organizationId: organization.id, role: "MEMBER", revokedAt: null },
|
||||
select: { userId: true, organizationId: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
const teacher = await prisma.user.upsert({
|
||||
where: { feishuOpenId: "ou_dev_teacher" },
|
||||
update: {},
|
||||
create: { feishuOpenId: "ou_dev_teacher", displayName: "测试老师" },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: organization.id, userId: teacher.id, role: "MEMBER" },
|
||||
});
|
||||
membership = { userId: teacher.id, organizationId: organization.id };
|
||||
}
|
||||
|
||||
const connection = await prisma.organizationFeishuApplicationConnection.findFirst({
|
||||
where: { organizationId: membership.organizationId, status: "ACTIVE" },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (connection === null) {
|
||||
return reply.status(404).send({ error: { code: "no_connection", message: "no active Feishu connection for org" } });
|
||||
}
|
||||
let identity = await prisma.feishuUserIdentity.findFirst({
|
||||
where: { userId: membership.userId, connectionId: connection.id },
|
||||
select: { id: true, connectionId: true },
|
||||
});
|
||||
if (identity === null) {
|
||||
identity = await prisma.feishuUserIdentity.create({
|
||||
data: { connectionId: connection.id, userId: membership.userId, openId: "ou_dev_teacher" },
|
||||
select: { id: true, connectionId: true },
|
||||
});
|
||||
}
|
||||
|
||||
setSessionCookie(reply, config.sessionSecret, membership.userId, identity.id, identity.connectionId, connection.organizationId);
|
||||
reply.log.warn({ userId: membership.userId }, "DEV teacher-app login bypass (regular teacher) used");
|
||||
return reply.redirect("/app");
|
||||
});
|
||||
}
|
||||
|
||||
function setSessionCookie(
|
||||
reply: { setCookie: (name: string, value: string, opts: Record<string, unknown>) => void },
|
||||
secret: string,
|
||||
userId: string,
|
||||
feishuIdentityId: string,
|
||||
feishuConnectionId: string,
|
||||
feishuOrganizationId: string,
|
||||
): void {
|
||||
const token = signSession({ userId, feishuIdentityId, feishuConnectionId, feishuOrganizationId }, secret);
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user