Fix recorder finalize path and add invite-gated login
这个提交包含在:
@@ -20,6 +20,7 @@ export const ENV = {
|
||||
appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "",
|
||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE ?? "CA2026",
|
||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||
adminUsernames: parseList(process.env.ADMIN_USERNAMES),
|
||||
|
||||
15
server/db.ts
15
server/db.ts
@@ -82,7 +82,16 @@ export async function getUserByUsername(username: string) {
|
||||
return userResult.length > 0 ? userResult[0] : undefined;
|
||||
}
|
||||
|
||||
export async function createUsernameAccount(username: string): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
|
||||
export function isValidRegistrationInvite(inviteCode?: string | null) {
|
||||
const expected = ENV.registrationInviteCode.trim();
|
||||
if (!expected) return true;
|
||||
return (inviteCode ?? "").trim() === expected;
|
||||
}
|
||||
|
||||
export async function createUsernameAccount(
|
||||
username: string,
|
||||
inviteCode?: string,
|
||||
): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
@@ -97,6 +106,10 @@ export async function createUsernameAccount(username: string): Promise<{ user: t
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidRegistrationInvite(inviteCode)) {
|
||||
throw new Error("新用户注册需要正确的邀请码");
|
||||
}
|
||||
|
||||
// Create new user with username as openId
|
||||
const openId = `username_${username}_${Date.now()}`;
|
||||
await db.insert(users).values({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import { ENV } from "./_core/env";
|
||||
import { sdk } from "./_core/sdk";
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
@@ -114,6 +116,68 @@ describe("auth.loginWithUsername input validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginWithUsername invite flow", () => {
|
||||
const originalInviteCode = ENV.registrationInviteCode;
|
||||
|
||||
beforeEach(() => {
|
||||
ENV.registrationInviteCode = "CA2026";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ENV.registrationInviteCode = originalInviteCode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows existing users to log in without an invite code", async () => {
|
||||
const existingUser = createTestUser({ name: "ExistingPlayer", openId: "existing-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(existingUser);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: existingUser,
|
||||
isNew: false,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "ExistingPlayer" });
|
||||
|
||||
expect(result.isNew).toBe(false);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("ExistingPlayer", undefined);
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
|
||||
it("rejects new users without the correct invite code", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount");
|
||||
|
||||
await expect(caller.auth.loginWithUsername({ username: "NewPlayer" })).rejects.toThrow("新用户注册需要正确的邀请码");
|
||||
expect(createUsernameAccountSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows new users with the correct invite code", async () => {
|
||||
const newUser = createTestUser({ name: "NewPlayer", openId: "new-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: newUser,
|
||||
isNew: true,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "NewPlayer", inviteCode: "CA2026" });
|
||||
|
||||
expect(result.isNew).toBe(true);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("NewPlayer", "CA2026");
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== PROFILE TESTS =====
|
||||
|
||||
describe("profile.stats", () => {
|
||||
|
||||
73
server/mediaService.test.ts
普通文件
73
server/mediaService.test.ts
普通文件
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV } from "./_core/env";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
|
||||
const originalMediaServiceUrl = ENV.mediaServiceUrl;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
ENV.mediaServiceUrl = originalMediaServiceUrl;
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getRemoteMediaSession", () => {
|
||||
it("falls back to /media-prefixed routes when the root route returns 404", async () => {
|
||||
ENV.mediaServiceUrl = "http://127.0.0.1:8081";
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: vi.fn().mockResolvedValue("404 page not found\n"),
|
||||
statusText: "Not Found",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "1",
|
||||
title: "demo",
|
||||
archiveStatus: "idle",
|
||||
playback: {
|
||||
ready: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const session = await getRemoteMediaSession("session-1");
|
||||
|
||||
expect(session.id).toBe("session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, "http://127.0.0.1:8081/sessions/session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:8081/media/sessions/session-1");
|
||||
});
|
||||
|
||||
it("uses the configured /media base URL directly when already present", async () => {
|
||||
ENV.mediaServiceUrl = "http://media:8081/media";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
id: "session-2",
|
||||
userId: "2",
|
||||
title: "demo",
|
||||
archiveStatus: "processing",
|
||||
playback: {
|
||||
ready: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const session = await getRemoteMediaSession("session-2");
|
||||
|
||||
expect(session.id).toBe("session-2");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://media:8081/media/sessions/session-2");
|
||||
});
|
||||
});
|
||||
@@ -23,12 +23,30 @@ function getMediaBaseUrl() {
|
||||
return ENV.mediaServiceUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function getRemoteMediaSession(sessionId: string) {
|
||||
const response = await fetch(`${getMediaBaseUrl()}/sessions/${sessionId}`);
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => response.statusText);
|
||||
throw new Error(`Media service request failed (${response.status}): ${message}`);
|
||||
function getMediaCandidateUrls(path: string) {
|
||||
const baseUrl = getMediaBaseUrl();
|
||||
if (baseUrl.endsWith("/media")) {
|
||||
return [`${baseUrl}${path}`];
|
||||
}
|
||||
const payload = await response.json() as { session: RemoteMediaSession };
|
||||
return payload.session;
|
||||
return [`${baseUrl}${path}`, `${baseUrl}/media${path}`];
|
||||
}
|
||||
|
||||
export async function getRemoteMediaSession(sessionId: string) {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const url of getMediaCandidateUrls(`/sessions/${encodeURIComponent(sessionId)}`)) {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const payload = await response.json() as { session: RemoteMediaSession };
|
||||
return payload.session;
|
||||
}
|
||||
|
||||
const message = await response.text().catch(() => response.statusText);
|
||||
lastError = new Error(`Media service request failed (${response.status}): ${message}`);
|
||||
if (response.status !== 404) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Media service request failed");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { adminProcedure, publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
@@ -48,11 +49,20 @@ export const appRouter = router({
|
||||
|
||||
// Username-based login
|
||||
loginWithUsername: publicProcedure
|
||||
.input(z.object({ username: z.string().min(1).max(64) }))
|
||||
.input(z.object({
|
||||
username: z.string().trim().min(1).max(64),
|
||||
inviteCode: z.string().trim().max(64).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, isNew } = await db.createUsernameAccount(input.username);
|
||||
const username = input.username.trim();
|
||||
const existingUser = await db.getUserByUsername(username);
|
||||
if (!existingUser && !db.isValidRegistrationInvite(input.inviteCode)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "新用户注册需要正确的邀请码" });
|
||||
}
|
||||
|
||||
const { user, isNew } = await db.createUsernameAccount(username, input.inviteCode);
|
||||
const sessionToken = await sdk.createSessionToken(user.openId, {
|
||||
name: user.name || input.username,
|
||||
name: user.name || username,
|
||||
expiresInMs: ONE_YEAR_MS,
|
||||
});
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
|
||||
在新工单中引用
屏蔽一个用户