Merge branch 'dev' into add-file-thumbnail

This commit is contained in:
static
2025-07-07 00:43:41 +09:00
14 changed files with 1057 additions and 1379 deletions

View File

@@ -16,50 +16,50 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.4", "@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.12", "@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.2.11", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.15.2", "@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7", "@types/node-schedule": "^2.1.7",
"@types/pg": "^8.11.10", "@types/pg": "^8.15.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.21",
"axios": "^1.7.9", "axios": "^1.10.0",
"dexie": "^4.0.10", "dexie": "^4.0.11",
"eslint": "^9.17.0", "eslint": "^9.30.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.26.0", "exifreader": "^4.31.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^15.14.0", "globals": "^16.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.10.1", "kysely-ctl": "^0.13.1",
"lru-cache": "^11.1.0", "lru-cache": "^11.1.0",
"mime": "^4.0.6", "mime": "^4.0.7",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"prettier": "^3.4.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.3.2", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.13",
"svelte": "^5.19.1", "svelte": "^5.35.2",
"svelte-check": "^4.1.3", "svelte-check": "^4.2.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.19.1", "typescript-eslint": "^8.35.1",
"unplugin-icons": "^0.22.0", "unplugin-icons": "^22.1.0",
"vite": "^5.4.11" "vite": "^5.4.19"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.1.1", "@fastify/busboy": "^3.1.1",
"argon2": "^0.41.1", "argon2": "^0.43.0",
"kysely": "^0.27.5", "kysely": "^0.28.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.13.1", "pg": "^8.16.3",
"uuid": "^11.0.4", "uuid": "^11.1.0",
"zod": "^3.24.1" "zod": "^3.25.74"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",

2280
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -178,7 +178,7 @@ export const registerUserClientChallenge = async (
allowedIp: string, allowedIp: string,
expiresAt: Date, expiresAt: Date,
) => { ) => {
await db const { id } = await db
.insertInto("user_client_challenge") .insertInto("user_client_challenge")
.values({ .values({
user_id: userId, user_id: userId,
@@ -187,19 +187,25 @@ export const registerUserClientChallenge = async (
allowed_ip: allowedIp, allowed_ip: allowedIp,
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .returning("id")
.executeTakeFirstOrThrow();
return { id };
}; };
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => { export const consumeUserClientChallenge = async (
challengeId: number,
userId: number,
ip: string,
) => {
const challenge = await db const challenge = await db
.deleteFrom("user_client_challenge") .deleteFrom("user_client_challenge")
.where("id", "=", challengeId)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("answer", "=", answer)
.where("allowed_ip", "=", ip) .where("allowed_ip", "=", ip)
.where("expires_at", ">", new Date()) .where("expires_at", ">", new Date())
.returning("client_id") .returning(["client_id", "answer"])
.executeTakeFirst(); .executeTakeFirst();
return challenge ? { clientId: challenge.client_id } : null; return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null;
}; };
export const cleanupExpiredUserClientChallenges = async () => { export const cleanupExpiredUserClientChallenges = async () => {

View File

@@ -94,7 +94,7 @@ export const registerSessionUpgradeChallenge = async (
expiresAt: Date, expiresAt: Date,
) => { ) => {
try { try {
await db const { id } = await db
.insertInto("session_upgrade_challenge") .insertInto("session_upgrade_challenge")
.values({ .values({
session_id: sessionId, session_id: sessionId,
@@ -103,7 +103,9 @@ export const registerSessionUpgradeChallenge = async (
allowed_ip: allowedIp, allowed_ip: allowedIp,
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .returning("id")
.executeTakeFirstOrThrow();
return { id };
} catch (e) { } catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") { if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("Challenge already registered"); throw new IntegrityError("Challenge already registered");
@@ -113,19 +115,19 @@ export const registerSessionUpgradeChallenge = async (
}; };
export const consumeSessionUpgradeChallenge = async ( export const consumeSessionUpgradeChallenge = async (
challengeId: number,
sessionId: string, sessionId: string,
answer: string,
ip: string, ip: string,
) => { ) => {
const challenge = await db const challenge = await db
.deleteFrom("session_upgrade_challenge") .deleteFrom("session_upgrade_challenge")
.where("id", "=", challengeId)
.where("session_id", "=", sessionId) .where("session_id", "=", sessionId)
.where("answer", "=", answer)
.where("allowed_ip", "=", ip) .where("allowed_ip", "=", ip)
.where("expires_at", ">", new Date()) .where("expires_at", ">", new Date())
.returning("client_id") .returning(["client_id", "answer"])
.executeTakeFirst(); .executeTakeFirst();
return challenge ? { clientId: challenge.client_id } : null; return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null;
}; };
export const cleanupExpiredSessionUpgradeChallenges = async () => { export const cleanupExpiredSessionUpgradeChallenges = async () => {

View File

@@ -19,12 +19,13 @@ export const sessionUpgradeRequest = z.object({
export type SessionUpgradeRequest = z.infer<typeof sessionUpgradeRequest>; export type SessionUpgradeRequest = z.infer<typeof sessionUpgradeRequest>;
export const sessionUpgradeResponse = z.object({ export const sessionUpgradeResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(), challenge: z.string().base64().nonempty(),
}); });
export type SessionUpgradeResponse = z.infer<typeof sessionUpgradeResponse>; export type SessionUpgradeResponse = z.infer<typeof sessionUpgradeResponse>;
export const sessionUpgradeVerifyRequest = z.object({ export const sessionUpgradeVerifyRequest = z.object({
answer: z.string().base64().nonempty(), id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(), answerSig: z.string().base64().nonempty(),
}); });
export type SessionUpgradeVerifyRequest = z.infer<typeof sessionUpgradeVerifyRequest>; export type SessionUpgradeVerifyRequest = z.infer<typeof sessionUpgradeVerifyRequest>;

View File

@@ -17,12 +17,13 @@ export const clientRegisterRequest = z.object({
export type ClientRegisterRequest = z.infer<typeof clientRegisterRequest>; export type ClientRegisterRequest = z.infer<typeof clientRegisterRequest>;
export const clientRegisterResponse = z.object({ export const clientRegisterResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(), challenge: z.string().base64().nonempty(),
}); });
export type ClientRegisterResponse = z.infer<typeof clientRegisterResponse>; export type ClientRegisterResponse = z.infer<typeof clientRegisterResponse>;
export const clientRegisterVerifyRequest = z.object({ export const clientRegisterVerifyRequest = z.object({
answer: z.string().base64().nonempty(), id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(), answerSig: z.string().base64().nonempty(),
}); });
export type ClientRegisterVerifyRequest = z.infer<typeof clientRegisterVerifyRequest>; export type ClientRegisterVerifyRequest = z.infer<typeof clientRegisterVerifyRequest>;

View File

@@ -81,7 +81,7 @@ export const createSessionUpgradeChallenge = async (
} }
const { answer, challenge } = await generateChallenge(32, encPubKey); const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerSessionUpgradeChallenge( const { id } = await registerSessionUpgradeChallenge(
sessionId, sessionId,
client.id, client.id,
answer.toString("base64"), answer.toString("base64"),
@@ -89,16 +89,16 @@ export const createSessionUpgradeChallenge = async (
new Date(Date.now() + env.challenge.sessionUpgradeExp), new Date(Date.now() + env.challenge.sessionUpgradeExp),
); );
return { challenge: challenge.toString("base64") }; return { id, challenge: challenge.toString("base64") };
}; };
export const verifySessionUpgradeChallenge = async ( export const verifySessionUpgradeChallenge = async (
sessionId: string, sessionId: string,
ip: string, ip: string,
answer: string, challengeId: number,
answerSig: string, answerSig: string,
) => { ) => {
const challenge = await consumeSessionUpgradeChallenge(sessionId, answer, ip); const challenge = await consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
if (!challenge) { if (!challenge) {
error(403, "Invalid challenge answer"); error(403, "Invalid challenge answer");
} }
@@ -106,7 +106,9 @@ export const verifySessionUpgradeChallenge = async (
const client = await getClient(challenge.clientId); const client = await getClient(challenge.clientId);
if (!client) { if (!client) {
error(500, "Invalid challenge answer"); error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { } else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature"); error(403, "Invalid challenge answer signature");
} }

View File

@@ -34,8 +34,14 @@ const createUserClientChallenge = async (
encPubKey: string, encPubKey: string,
) => { ) => {
const { answer, challenge } = await generateChallenge(32, encPubKey); const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt()); const { id } = await registerUserClientChallenge(
return challenge.toString("base64"); userId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return { id, challenge: challenge.toString("base64") };
}; };
export const registerUserClient = async ( export const registerUserClient = async (
@@ -48,7 +54,7 @@ export const registerUserClient = async (
if (client) { if (client) {
try { try {
await createUserClient(userId, client.id); await createUserClient(userId, client.id);
return { challenge: await createUserClientChallenge(ip, userId, client.id, encPubKey) }; return await createUserClientChallenge(ip, userId, client.id, encPubKey);
} catch (e) { } catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") { if (e instanceof IntegrityError && e.message === "User client already exists") {
error(409, "Client already registered"); error(409, "Client already registered");
@@ -64,7 +70,7 @@ export const registerUserClient = async (
try { try {
const { id: clientId } = await createClient(encPubKey, sigPubKey, userId); const { id: clientId } = await createClient(encPubKey, sigPubKey, userId);
return { challenge: await createUserClientChallenge(ip, userId, clientId, encPubKey) }; return await createUserClientChallenge(ip, userId, clientId, encPubKey);
} catch (e) { } catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") { if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
error(409, "Public key(s) already used"); error(409, "Public key(s) already used");
@@ -77,10 +83,10 @@ export const registerUserClient = async (
export const verifyUserClient = async ( export const verifyUserClient = async (
userId: number, userId: number,
ip: string, ip: string,
answer: string, challengeId: number,
answerSig: string, answerSig: string,
) => { ) => {
const challenge = await consumeUserClientChallenge(userId, answer, ip); const challenge = await consumeUserClientChallenge(challengeId, userId, ip);
if (!challenge) { if (!challenge) {
error(403, "Invalid challenge answer"); error(403, "Invalid challenge answer");
} }
@@ -88,7 +94,9 @@ export const verifyUserClient = async (
const client = await getClient(challenge.clientId); const client = await getClient(challenge.clientId);
if (!client) { if (!client) {
error(500, "Invalid challenge answer"); error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { } else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature"); error(403, "Invalid challenge answer signature");
} }

View File

@@ -18,12 +18,12 @@ export const requestSessionUpgrade = async (
}); });
if (!res.ok) return false; if (!res.ok) return false;
const { challenge }: SessionUpgradeResponse = await res.json(); const { id, challenge }: SessionUpgradeResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", { res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
answer: encodeToBase64(answer), id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
}); });
return res.ok; return res.ok;

View File

@@ -27,12 +27,12 @@ export const requestClientRegistration = async (
}); });
if (!res.ok) return false; if (!res.ok) return false;
const { challenge }: ClientRegisterResponse = await res.json(); const { id, challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", { res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
answer: encodeToBase64(answer), id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
}); });
return res.ok; return res.ok;

View File

@@ -15,12 +15,12 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data; const { encPubKey, sigPubKey } = zodRes.data;
const { challenge } = await createSessionUpgradeChallenge( const { id, challenge } = await createSessionUpgradeChallenge(
sessionId, sessionId,
userId, userId,
locals.ip, locals.ip,
encPubKey, encPubKey,
sigPubKey, sigPubKey,
); );
return json(sessionUpgradeResponse.parse({ challenge } satisfies SessionUpgradeResponse)); return json(sessionUpgradeResponse.parse({ id, challenge } satisfies SessionUpgradeResponse));
}; };

View File

@@ -9,8 +9,8 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const zodRes = sessionUpgradeVerifyRequest.safeParse(await request.json()); const zodRes = sessionUpgradeVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { answer, answerSig } = zodRes.data; const { id, answerSig } = zodRes.data;
await verifySessionUpgradeChallenge(sessionId, locals.ip, answer, answerSig); await verifySessionUpgradeChallenge(sessionId, locals.ip, id, answerSig);
return text("Session upgraded", { headers: { "Content-Type": "text/plain" } }); return text("Session upgraded", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -15,6 +15,6 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data; const { encPubKey, sigPubKey } = zodRes.data;
const { challenge } = await registerUserClient(userId, locals.ip, encPubKey, sigPubKey); const { id, challenge } = await registerUserClient(userId, locals.ip, encPubKey, sigPubKey);
return json(clientRegisterResponse.parse({ challenge } satisfies ClientRegisterResponse)); return json(clientRegisterResponse.parse({ id, challenge } satisfies ClientRegisterResponse));
}; };

View File

@@ -9,8 +9,8 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const zodRes = clientRegisterVerifyRequest.safeParse(await request.json()); const zodRes = clientRegisterVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { answer, answerSig } = zodRes.data; const { id, answerSig } = zodRes.data;
await verifyUserClient(userId, locals.ip, answer, answerSig); await verifyUserClient(userId, locals.ip, id, answerSig);
return text("Client verified", { headers: { "Content-Type": "text/plain" } }); return text("Client verified", { headers: { "Content-Type": "text/plain" } });
}; };