mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
서버 코드를 Router, Service, Repository 계층으로 분리하고 오류 처리 및 파일 삭제 로직 개선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed732be999
commit
6a6aa1765b
72 files changed
+1928
-1509
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import type { Category } from "#lib/server/db/repositories/category.js";
|
||||
import type { Directory } from "#lib/server/db/repositories/directory.js";
|
||||
import type { File } from "#lib/server/db/repositories/file.js";
|
||||
|
||||
// Shapes of filesystem entries exposed by the API
|
||||
|
||||
export const toDirectoryDto = (directory: Directory) => ({
|
||||
id: directory.id,
|
||||
parent: directory.parentId,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
});
|
||||
|
||||
export const toFileDto = (file: File) => ({
|
||||
id: file.id,
|
||||
isLegacy: !!file.encContentIv,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
});
|
||||
|
||||
export const toCategoryDto = (category: Category) => ({
|
||||
id: category.id,
|
||||
parent: category.parentId,
|
||||
mekVersion: category.mekVersion,
|
||||
dek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
name: category.encName.ciphertext,
|
||||
nameIv: category.encName.iv,
|
||||
});
|
||||
|
||||
export const toFileWithCategoriesDto = (file: File & { categories: Category[] }) => ({
|
||||
...toFileDto(file),
|
||||
categories: file.categories.map(toCategoryDto),
|
||||
});
|
||||
+41
-7
@@ -1,14 +1,34 @@
|
||||
import type { RequestEvent } from "@sveltejs/kit";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize";
|
||||
import { authorize, type ClientSession, type Session } from "#lib/server/auth.js";
|
||||
import { AppError, type ErrorCode } from "#lib/server/errors.js";
|
||||
|
||||
export const createContext = (event: RequestEvent) => event;
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
export const t = initTRPC.context<Context>().create({ transformer: superjson });
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const trpcErrorCodes = {
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
CONFLICT: "CONFLICT",
|
||||
RANGE_NOT_SATISFIABLE: "BAD_REQUEST",
|
||||
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
|
||||
} as const satisfies Record<ErrorCode, TRPCError["code"]>;
|
||||
|
||||
// Converts `AppError`s thrown by services into tRPC errors
|
||||
export const publicProcedure = t.procedure.use(async ({ next }) => {
|
||||
const result = await next();
|
||||
if (!result.ok && result.error.cause instanceof AppError) {
|
||||
const { code, message } = result.error.cause;
|
||||
throw new TRPCError({ code: trpcErrorCodes[code], message, cause: result.error.cause });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.locals.session) {
|
||||
@@ -17,10 +37,24 @@ const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
||||
authedProcedure.use(async ({ ctx, next }) => {
|
||||
const session: Session = await authorize(ctx.locals, requiredPermission);
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
|
||||
const clientSessionProcedure = (
|
||||
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
||||
) =>
|
||||
authedProcedure.use(async ({ ctx, next }) => {
|
||||
const session: ClientSession = await authorize(ctx.locals, requiredPermission);
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
|
||||
export const roleProcedure = {
|
||||
any: authedProcedure.use(authorizeMiddleware("any")),
|
||||
notClient: authedProcedure.use(authorizeMiddleware("notClient")),
|
||||
anyClient: authedProcedure.use(authorizeClientMiddleware("anyClient")),
|
||||
pendingClient: authedProcedure.use(authorizeClientMiddleware("pendingClient")),
|
||||
activeClient: authedProcedure.use(authorizeClientMiddleware("activeClient")),
|
||||
any: sessionProcedure("any"),
|
||||
notClient: sessionProcedure("notClient"),
|
||||
anyClient: clientSessionProcedure("anyClient"),
|
||||
pendingClient: clientSessionProcedure("pendingClient"),
|
||||
activeClient: clientSessionProcedure("activeClient"),
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
AuthorizationError,
|
||||
authorizeInternal,
|
||||
type ClientSession,
|
||||
type SessionPermission,
|
||||
} from "#lib/server/modules/auth.js";
|
||||
import { t } from "../init.server";
|
||||
|
||||
const authorize = async (locals: App.Locals, requiredPermission: SessionPermission) => {
|
||||
try {
|
||||
return await authorizeInternal(locals, requiredPermission);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthorizationError) {
|
||||
throw new TRPCError({
|
||||
code: e.status === 403 ? "FORBIDDEN" : "INTERNAL_SERVER_ERROR",
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const authorizeMiddleware = (requiredPermission: "any" | "notClient") =>
|
||||
t.middleware(async ({ ctx, next }) => {
|
||||
const session = await authorize(ctx.locals, requiredPermission);
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
|
||||
export const authorizeClientMiddleware = (
|
||||
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
||||
) =>
|
||||
t.middleware(async ({ ctx, next }) => {
|
||||
const session = (await authorize(ctx.locals, requiredPermission)) as ClientSession;
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
+30
-89
@@ -1,11 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import argon2 from "argon2";
|
||||
import { z } from "zod";
|
||||
import { ClientRepo, SessionRepo, UserRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import { cookieOptions } from "#lib/server/modules/auth.js";
|
||||
import { generateChallenge, verifySignature, issueSessionId } from "#lib/server/modules/crypto.js";
|
||||
import { router, publicProcedure, roleProcedure } from "../init.server";
|
||||
import { cookieOptions } from "#lib/server/auth.js";
|
||||
import { AuthService } from "#lib/server/services/index.js";
|
||||
import { router, publicProcedure, roleProcedure } from "#trpc/init.server.js";
|
||||
import { Base64Schema, IdSchema } from "#trpc/schemas.js";
|
||||
|
||||
const authRouter = router({
|
||||
login: publicProcedure
|
||||
@@ -16,18 +13,17 @@ const authRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await UserRepo.getUserByEmail(input.email);
|
||||
if (!user || !(await argon2.verify(user.password, input.password))) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
|
||||
}
|
||||
|
||||
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
|
||||
await SessionRepo.createSession(user.id, sessionId, ctx.locals.ip, ctx.locals.userAgent);
|
||||
const { sessionIdSigned } = await AuthService.login(
|
||||
input.email,
|
||||
input.password,
|
||||
ctx.locals.ip,
|
||||
ctx.locals.userAgent,
|
||||
);
|
||||
ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions);
|
||||
}),
|
||||
|
||||
logout: roleProcedure["any"].mutation(async ({ ctx }) => {
|
||||
await SessionRepo.deleteSession(ctx.session.sessionId);
|
||||
await AuthService.logout(ctx.session.sessionId);
|
||||
ctx.cookies.delete("sessionId", cookieOptions);
|
||||
}),
|
||||
|
||||
@@ -39,103 +35,48 @@ const authRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.oldPassword === input.newPassword) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Same passwords" });
|
||||
} else if (input.newPassword.length < 8) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Too short password" });
|
||||
}
|
||||
|
||||
const user = await UserRepo.getUser(ctx.session.userId);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
|
||||
} else if (!(await argon2.verify(user.password, input.oldPassword))) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid password" });
|
||||
}
|
||||
|
||||
await UserRepo.setUserPassword(ctx.session.userId, await argon2.hash(input.newPassword));
|
||||
await SessionRepo.deleteAllOtherSessions(ctx.session.userId, ctx.session.sessionId);
|
||||
await AuthService.changePassword(
|
||||
ctx.session.userId,
|
||||
ctx.session.sessionId,
|
||||
input.oldPassword,
|
||||
input.newPassword,
|
||||
);
|
||||
}),
|
||||
|
||||
upgrade: roleProcedure["notClient"]
|
||||
.input(
|
||||
z.object({
|
||||
encPubKey: z.base64().nonempty(),
|
||||
sigPubKey: z.base64().nonempty(),
|
||||
encPubKey: Base64Schema,
|
||||
sigPubKey: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const client = await ClientRepo.getClientByPubKeys(input.encPubKey, input.sigPubKey);
|
||||
const userClient = client
|
||||
? await ClientRepo.getUserClient(ctx.session.userId, client.id)
|
||||
: undefined;
|
||||
if (!client) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid public key(s)" });
|
||||
} else if (!userClient || userClient.state === "challenging") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Unregistered client" });
|
||||
}
|
||||
|
||||
const { answer, challenge } = await generateChallenge(32, input.encPubKey);
|
||||
const { id } = await SessionRepo.registerSessionUpgradeChallenge(
|
||||
return await AuthService.requestSessionUpgrade(
|
||||
ctx.session.userId,
|
||||
ctx.session.sessionId,
|
||||
client.id,
|
||||
answer.toString("base64"),
|
||||
ctx.locals.ip,
|
||||
new Date(Date.now() + env.challenge.sessionUpgradeExp),
|
||||
input.encPubKey,
|
||||
input.sigPubKey,
|
||||
);
|
||||
|
||||
return { id, challenge: challenge.toString("base64") };
|
||||
}),
|
||||
|
||||
verifyUpgrade: roleProcedure["notClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
answerSig: z.base64().nonempty(),
|
||||
id: IdSchema,
|
||||
answerSig: Base64Schema,
|
||||
force: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const challenge = await SessionRepo.consumeSessionUpgradeChallenge(
|
||||
input.id,
|
||||
await AuthService.verifySessionUpgrade(
|
||||
ctx.session.userId,
|
||||
ctx.session.sessionId,
|
||||
ctx.locals.ip,
|
||||
input.id,
|
||||
input.answerSig,
|
||||
input.force,
|
||||
);
|
||||
if (!challenge) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" });
|
||||
}
|
||||
|
||||
const client = await ClientRepo.getClient(challenge.clientId);
|
||||
if (!client) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" });
|
||||
} else if (
|
||||
!verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid challenge answer signature",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await SessionRepo.upgradeSession(
|
||||
ctx.session.userId,
|
||||
ctx.session.sessionId,
|
||||
client.id,
|
||||
input.force,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Session not found") {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Invalid challenge answer",
|
||||
});
|
||||
} else if (!input.force && e.message === "Session already exists") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Already logged in" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+45
-146
@@ -1,8 +1,14 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { CategoryIdSchema } from "#lib/schemas/index.js";
|
||||
import { CategoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { CategoryService } from "#lib/server/services/index.js";
|
||||
import { toCategoryDto, toFileDto } from "#trpc/dto.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import {
|
||||
Base64Schema,
|
||||
CategoryIdSchema,
|
||||
EncryptedNameSchema,
|
||||
IdSchema,
|
||||
toCiphertext,
|
||||
} from "#trpc/schemas.js";
|
||||
|
||||
const categoryRouter = router({
|
||||
get: roleProcedure["activeClient"]
|
||||
@@ -13,53 +19,15 @@ const categoryRouter = router({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const category =
|
||||
input.id !== "root"
|
||||
? await CategoryRepo.getCategory(ctx.session.userId, input.id)
|
||||
: undefined;
|
||||
if (category === null) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
|
||||
}
|
||||
|
||||
const [categories, files] = await Promise.all([
|
||||
CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id),
|
||||
input.id !== "root"
|
||||
? FileRepo.getAllFilesByCategory(ctx.session.userId, input.id, input.recurse)
|
||||
: undefined,
|
||||
]);
|
||||
const { category, subCategories, files } = await CategoryService.getCategory(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
input.recurse,
|
||||
);
|
||||
return {
|
||||
metadata: category && {
|
||||
parent: category.parentId,
|
||||
mekVersion: category.mekVersion,
|
||||
dek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
name: category.encName.ciphertext,
|
||||
nameIv: category.encName.iv,
|
||||
},
|
||||
subCategories: categories.map((category) => ({
|
||||
id: category.id,
|
||||
mekVersion: category.mekVersion,
|
||||
dek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
name: category.encName.ciphertext,
|
||||
nameIv: category.encName.iv,
|
||||
})),
|
||||
files: files?.map((file) => ({
|
||||
id: file.id,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
isRecursive: file.isRecursive,
|
||||
})),
|
||||
metadata: category && toCategoryDto(category),
|
||||
subCategories: subCategories.map(toCategoryDto),
|
||||
files: files?.map((file) => ({ ...toFileDto(file), isRecursive: file.isRecursive })),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -67,139 +35,70 @@ const categoryRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
parent: CategoryIdSchema,
|
||||
mekVersion: z.int().positive(),
|
||||
dek: z.base64().nonempty(),
|
||||
mekVersion: IdSchema,
|
||||
dek: Base64Schema,
|
||||
dekVersion: z.date(),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" });
|
||||
}
|
||||
|
||||
try {
|
||||
await CategoryRepo.registerCategory({
|
||||
parentId: input.parent,
|
||||
userId: ctx.session.userId,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
encName: { ciphertext: input.name, iv: input.nameIv },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Inactive MEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
} else if (e.message === "Category not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent category id" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await CategoryService.createCategory({
|
||||
userId: ctx.session.userId,
|
||||
parentId: input.parent,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
encName: toCiphertext(input.name, input.nameIv),
|
||||
});
|
||||
}),
|
||||
|
||||
rename: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
dekVersion: z.date(),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await CategoryRepo.setCategoryEncName(ctx.session.userId, input.id, input.dekVersion, {
|
||||
ciphertext: input.name,
|
||||
iv: input.nameIv,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Category not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await CategoryService.renameCategory(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
input.dekVersion,
|
||||
toCiphertext(input.name, input.nameIv),
|
||||
);
|
||||
}),
|
||||
|
||||
delete: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await CategoryRepo.unregisterCategory(ctx.session.userId, input.id);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Category not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await CategoryService.deleteCategory(ctx.session.userId, input.id);
|
||||
}),
|
||||
|
||||
addFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
file: z.int().positive(),
|
||||
id: IdSchema,
|
||||
file: IdSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [category, file] = await Promise.all([
|
||||
CategoryRepo.getCategory(ctx.session.userId, input.id),
|
||||
FileRepo.getFile(ctx.session.userId, input.file),
|
||||
]);
|
||||
if (!category) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
|
||||
} else if (!file) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
}
|
||||
|
||||
try {
|
||||
await FileRepo.addFileToCategory(input.file, input.id);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File already added to category") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File already added" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await CategoryService.addFileToCategory(ctx.session.userId, input.id, input.file);
|
||||
}),
|
||||
|
||||
removeFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
file: z.int().positive(),
|
||||
id: IdSchema,
|
||||
file: IdSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [category, file] = await Promise.all([
|
||||
CategoryRepo.getCategory(ctx.session.userId, input.id),
|
||||
FileRepo.getFile(ctx.session.userId, input.file),
|
||||
]);
|
||||
if (!category) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
|
||||
} else if (!file) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
}
|
||||
|
||||
try {
|
||||
await FileRepo.removeFileFromCategory(input.file, input.id);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File not found in category") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File not added" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await CategoryService.removeFileFromCategory(ctx.session.userId, input.id, input.file);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+16
-73
@@ -1,96 +1,39 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { ClientRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import { verifyPubKey, verifySignature, generateChallenge } from "#lib/server/modules/crypto.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const createUserClientChallenge = async (
|
||||
ip: string,
|
||||
userId: number,
|
||||
clientId: number,
|
||||
encPubKey: string,
|
||||
) => {
|
||||
const { answer, challenge } = await generateChallenge(32, encPubKey);
|
||||
const { id } = await ClientRepo.registerUserClientChallenge(
|
||||
userId,
|
||||
clientId,
|
||||
answer.toString("base64"),
|
||||
ip,
|
||||
new Date(Date.now() + env.challenge.userClientExp),
|
||||
);
|
||||
|
||||
return { id, challenge: challenge.toString("base64") };
|
||||
};
|
||||
import { ClientService } from "#lib/server/services/index.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { Base64Schema, IdSchema } from "#trpc/schemas.js";
|
||||
|
||||
const clientRouter = router({
|
||||
register: roleProcedure["notClient"]
|
||||
.input(
|
||||
z.object({
|
||||
encPubKey: z.base64().nonempty(),
|
||||
sigPubKey: z.base64().nonempty(),
|
||||
encPubKey: Base64Schema,
|
||||
sigPubKey: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId } = ctx.session;
|
||||
const { encPubKey, sigPubKey } = input;
|
||||
const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey);
|
||||
if (client) {
|
||||
try {
|
||||
await ClientRepo.createUserClient(userId, client.id);
|
||||
return await createUserClientChallenge(ctx.locals.ip, userId, client.id, encPubKey);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "User client already exists") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Client already registered" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
if (encPubKey === sigPubKey) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Same public keys" });
|
||||
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid public key(s)" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: clientId } = await ClientRepo.createClient(encPubKey, sigPubKey, userId);
|
||||
return await createUserClientChallenge(ctx.locals.ip, userId, clientId, encPubKey);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Public key(s) already used" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return await ClientService.registerClient(
|
||||
ctx.session.userId,
|
||||
ctx.locals.ip,
|
||||
input.encPubKey,
|
||||
input.sigPubKey,
|
||||
);
|
||||
}),
|
||||
|
||||
verify: roleProcedure["notClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
answerSig: z.base64().nonempty(),
|
||||
id: IdSchema,
|
||||
answerSig: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const challenge = await ClientRepo.consumeUserClientChallenge(
|
||||
input.id,
|
||||
await ClientService.verifyClient(
|
||||
ctx.session.userId,
|
||||
ctx.locals.ip,
|
||||
input.id,
|
||||
input.answerSig,
|
||||
);
|
||||
if (!challenge) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" });
|
||||
}
|
||||
|
||||
const client = await ClientRepo.getClient(challenge.clientId);
|
||||
if (!client) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" });
|
||||
} else if (
|
||||
!verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey)
|
||||
) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer signature" });
|
||||
}
|
||||
|
||||
await ClientRepo.setUserClientStateToPending(ctx.session.userId, client.id);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+42
-112
@@ -1,9 +1,14 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { DirectoryIdSchema } from "#lib/schemas/index.js";
|
||||
import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { safeUnlink } from "#lib/server/modules/filesystem.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { DirectoryService } from "#lib/server/services/index.js";
|
||||
import { toDirectoryDto, toFileDto } from "#trpc/dto.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import {
|
||||
Base64Schema,
|
||||
DirectoryIdSchema,
|
||||
EncryptedNameSchema,
|
||||
IdSchema,
|
||||
toCiphertext,
|
||||
} from "#trpc/schemas.js";
|
||||
|
||||
const directoryRouter = router({
|
||||
get: roleProcedure["activeClient"]
|
||||
@@ -13,51 +18,14 @@ const directoryRouter = router({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const directory =
|
||||
input.id !== "root"
|
||||
? await DirectoryRepo.getDirectory(ctx.session.userId, input.id)
|
||||
: undefined;
|
||||
if (directory === null) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
}
|
||||
|
||||
const [directories, files] = await Promise.all([
|
||||
DirectoryRepo.getAllDirectoriesByParent(ctx.session.userId, input.id),
|
||||
FileRepo.getAllFilesByParent(ctx.session.userId, input.id),
|
||||
]);
|
||||
const { directory, subDirectories, files } = await DirectoryService.getDirectory(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
);
|
||||
return {
|
||||
metadata: directory && {
|
||||
parent: directory.parentId,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
},
|
||||
subDirectories: directories.map((directory) => ({
|
||||
id: directory.id,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
})),
|
||||
metadata: directory && toDirectoryDto(directory),
|
||||
subDirectories: subDirectories.map(toDirectoryDto),
|
||||
files: files.map(toFileDto),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -65,90 +33,52 @@ const directoryRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
parent: DirectoryIdSchema,
|
||||
mekVersion: z.int().positive(),
|
||||
dek: z.base64().nonempty(),
|
||||
mekVersion: IdSchema,
|
||||
dek: Base64Schema,
|
||||
dekVersion: z.date(),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" });
|
||||
}
|
||||
|
||||
try {
|
||||
await DirectoryRepo.registerDirectory({
|
||||
parentId: input.parent,
|
||||
userId: ctx.session.userId,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
encName: { ciphertext: input.name, iv: input.nameIv },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Inactive MEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
} else if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await DirectoryService.createDirectory({
|
||||
userId: ctx.session.userId,
|
||||
parentId: input.parent,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
encName: toCiphertext(input.name, input.nameIv),
|
||||
});
|
||||
}),
|
||||
|
||||
rename: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
dekVersion: z.date(),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await DirectoryRepo.setDirectoryEncName(ctx.session.userId, input.id, input.dekVersion, {
|
||||
ciphertext: input.name,
|
||||
iv: input.nameIv,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await DirectoryService.renameDirectory(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
input.dekVersion,
|
||||
toCiphertext(input.name, input.nameIv),
|
||||
);
|
||||
}),
|
||||
|
||||
delete: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id);
|
||||
return {
|
||||
deletedFiles: files.map((file) => {
|
||||
safeUnlink(file.path); // Intended
|
||||
safeUnlink(file.thumbnailPath); // Intended
|
||||
return file.id;
|
||||
}),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const { deletedFileIds } = await DirectoryService.deleteDirectory(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
);
|
||||
return { deletedFiles: deletedFileIds };
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,123 +1,43 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { DirectoryService, FileService } from "#lib/server/services/index.js";
|
||||
import { toDirectoryDto, toFileDto } from "#trpc/dto.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { IdSchema } from "#trpc/schemas.js";
|
||||
|
||||
const IdInputSchema = z.object({ id: IdSchema });
|
||||
|
||||
const favoritesRouter = router({
|
||||
get: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
const [directories, files] = await Promise.all([
|
||||
DirectoryRepo.getAllFavoriteDirectories(ctx.session.userId),
|
||||
FileRepo.getAllFavoriteFiles(ctx.session.userId),
|
||||
DirectoryService.getFavoriteDirectories(ctx.session.userId),
|
||||
FileService.getFavoriteFiles(ctx.session.userId),
|
||||
]);
|
||||
return {
|
||||
directories: directories.map((directory) => ({
|
||||
id: directory.id,
|
||||
parent: directory.parentId,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
})),
|
||||
directories: directories.map(toDirectoryDto),
|
||||
files: files.map(toFileDto),
|
||||
};
|
||||
}),
|
||||
|
||||
addDirectory: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.input(IdInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, true);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
} else if (e.message === "Directory already favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, true);
|
||||
}),
|
||||
|
||||
removeDirectory: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.input(IdInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, false);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
} else if (e.message === "Directory not favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, false);
|
||||
}),
|
||||
|
||||
addFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setFileFavorite(ctx.session.userId, input.id, true);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "File already favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
addFile: roleProcedure["activeClient"].input(IdInputSchema).mutation(async ({ ctx, input }) => {
|
||||
await FileService.setFileFavorite(ctx.session.userId, input.id, true);
|
||||
}),
|
||||
|
||||
removeFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.input(IdInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setFileFavorite(ctx.session.userId, input.id, false);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "File not favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await FileService.setFileFavorite(ctx.session.userId, input.id, false);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+28
-119
@@ -1,97 +1,44 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { FileRepo, MediaRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { safeUnlink } from "#lib/server/modules/filesystem.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { FileService } from "#lib/server/services/index.js";
|
||||
import { toFileDto, toFileWithCategoriesDto } from "#trpc/dto.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { Base64Schema, EncryptedNameSchema, IdSchema, toCiphertext } from "#trpc/schemas.js";
|
||||
|
||||
const fileRouter = router({
|
||||
get: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const file = await FileRepo.getFile(ctx.session.userId, input.id);
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
}
|
||||
|
||||
const categories = await FileRepo.getAllFileCategories(input.id);
|
||||
return {
|
||||
isLegacy: !!file.encContentIv,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
categories: categories.map((category) => ({
|
||||
id: category.id,
|
||||
parent: category.parentId,
|
||||
mekVersion: category.mekVersion,
|
||||
dek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
name: category.encName.ciphertext,
|
||||
nameIv: category.encName.iv,
|
||||
})),
|
||||
};
|
||||
return toFileWithCategoriesDto(await FileService.getFile(ctx.session.userId, input.id));
|
||||
}),
|
||||
|
||||
bulkGet: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.number().positive().array(),
|
||||
ids: IdSchema.array(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids);
|
||||
return files.map((file) => ({
|
||||
id: file.id,
|
||||
isLegacy: !!file.encContentIv,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
categories: file.categories.map((category) => ({
|
||||
id: category.id,
|
||||
parent: category.parentId,
|
||||
mekVersion: category.mekVersion,
|
||||
dek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
name: category.encName.ciphertext,
|
||||
nameIv: category.encName.iv,
|
||||
})),
|
||||
}));
|
||||
const files = await FileService.getFiles(ctx.session.userId, input.ids);
|
||||
return files.map(toFileWithCategoriesDto);
|
||||
}),
|
||||
|
||||
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
return await FileRepo.getAllFileIds(ctx.session.userId);
|
||||
return await FileService.getAllFileIds(ctx.session.userId);
|
||||
}),
|
||||
|
||||
listByHash: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
hskVersion: z.int().positive(),
|
||||
contentHmac: z.base64().nonempty(),
|
||||
hskVersion: IdSchema,
|
||||
contentHmac: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await FileRepo.getAllFileIdsByContentHmac(
|
||||
return await FileService.getFileIdsByContentHmac(
|
||||
ctx.session.userId,
|
||||
input.hskVersion,
|
||||
input.contentHmac,
|
||||
@@ -99,84 +46,46 @@ const fileRouter = router({
|
||||
}),
|
||||
|
||||
listWithoutThumbnail: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
const files = await FileRepo.getFilesWithoutThumbnail(ctx.session.userId);
|
||||
return files.map((file) => ({
|
||||
id: file.id,
|
||||
isLegacy: !!file.encContentIv,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
}));
|
||||
const files = await FileService.getFilesWithoutThumbnail(ctx.session.userId);
|
||||
return files.map(toFileDto);
|
||||
}),
|
||||
|
||||
rename: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
dekVersion: z.date(),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setFileEncName(ctx.session.userId, input.id, input.dekVersion, {
|
||||
ciphertext: input.name,
|
||||
iv: input.nameIv,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await FileService.renameFile(
|
||||
ctx.session.userId,
|
||||
input.id,
|
||||
input.dekVersion,
|
||||
toCiphertext(input.name, input.nameIv),
|
||||
);
|
||||
}),
|
||||
|
||||
delete: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { path, thumbnailPath } = await FileRepo.unregisterFile(ctx.session.userId, input.id);
|
||||
safeUnlink(path); // Intended
|
||||
safeUnlink(thumbnailPath); // Intended
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await FileService.deleteFile(ctx.session.userId, input.id);
|
||||
}),
|
||||
|
||||
thumbnail: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
id: IdSchema,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const thumbnail = await MediaRepo.getFileThumbnail(ctx.session.userId, input.id);
|
||||
if (!thumbnail) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" });
|
||||
}
|
||||
|
||||
return { updatedAt: thumbnail.updatedAt };
|
||||
const { updatedAt } = await FileService.getFileThumbnail(ctx.session.userId, input.id);
|
||||
return { updatedAt };
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+12
-19
@@ -1,11 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { HskRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { KeyService } from "#lib/server/services/index.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { Base64Schema, IdSchema } from "#trpc/schemas.js";
|
||||
|
||||
const hskRouter = router({
|
||||
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
const hsks = await HskRepo.getAllValidHsks(ctx.session.userId);
|
||||
const hsks = await KeyService.getHsks(ctx.session.userId);
|
||||
return hsks.map(({ version, state, mekVersion, encHsk }) => ({
|
||||
version,
|
||||
state,
|
||||
@@ -17,24 +17,17 @@ const hskRouter = router({
|
||||
registerInitial: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
mekVersion: z.int().positive(),
|
||||
hsk: z.base64().nonempty(),
|
||||
mekVersion: IdSchema,
|
||||
hsk: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await HskRepo.registerInitialHsk(
|
||||
ctx.session.userId,
|
||||
ctx.session.clientId,
|
||||
input.mekVersion,
|
||||
input.hsk,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "HSK already registered") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Initial HSK already registered" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await KeyService.registerInitialHsk(
|
||||
ctx.session.userId,
|
||||
ctx.session.clientId,
|
||||
input.mekVersion,
|
||||
input.hsk,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+12
-41
@@ -1,31 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { ClientRepo, MekRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import { verifySignature } from "#lib/server/modules/crypto.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const verifyClientEncMekSig = async (
|
||||
userId: number,
|
||||
clientId: number,
|
||||
version: number,
|
||||
encMek: string,
|
||||
encMekSig: string,
|
||||
) => {
|
||||
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
|
||||
}
|
||||
|
||||
const data = JSON.stringify({ version, key: encMek });
|
||||
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
|
||||
};
|
||||
import { KeyService } from "#lib/server/services/index.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { Base64Schema } from "#trpc/schemas.js";
|
||||
|
||||
const mekRouter = router({
|
||||
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
const clientMeks = await MekRepo.getAllValidClientMeks(
|
||||
ctx.session.userId,
|
||||
ctx.session.clientId,
|
||||
);
|
||||
const clientMeks = await KeyService.getClientMeks(ctx.session.userId, ctx.session.clientId);
|
||||
return clientMeks.map(({ version, state, encMek, encMekSig }) => ({
|
||||
version,
|
||||
state,
|
||||
@@ -37,26 +17,17 @@ const mekRouter = router({
|
||||
registerInitial: roleProcedure["pendingClient"]
|
||||
.input(
|
||||
z.object({
|
||||
mek: z.base64().nonempty(),
|
||||
mekSig: z.base64().nonempty(),
|
||||
mek: Base64Schema,
|
||||
mekSig: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId, clientId } = ctx.session;
|
||||
const { mek, mekSig } = input;
|
||||
if (!(await verifyClientEncMekSig(userId, clientId, 1, mek, mekSig))) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid signature" });
|
||||
}
|
||||
|
||||
try {
|
||||
await MekRepo.registerInitialMek(userId, clientId, mek, mekSig);
|
||||
await ClientRepo.setUserClientStateToActive(userId, clientId);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "MEK already registered") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Initial MEK already registered" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await KeyService.registerInitialMek(
|
||||
ctx.session.userId,
|
||||
ctx.session.clientId,
|
||||
input.mek,
|
||||
input.mekSig,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+14
-44
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { DirectoryIdSchema } from "#lib/schemas/index.js";
|
||||
import { DirectoryRepo, FileRepo } from "#lib/server/db/index.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { SearchService } from "#lib/server/services/index.js";
|
||||
import { toDirectoryDto, toFileDto } from "#trpc/dto.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import { DirectoryIdSchema, IdSchema } from "#trpc/schemas.js";
|
||||
|
||||
const searchRouter = router({
|
||||
search: roleProcedure["activeClient"]
|
||||
@@ -9,51 +10,20 @@ const searchRouter = router({
|
||||
z.object({
|
||||
ancestor: DirectoryIdSchema.default("root"),
|
||||
inFavorites: z.boolean().default(false),
|
||||
includeCategories: z.number().positive().array().default([]),
|
||||
excludeCategories: z.number().positive().array().default([]),
|
||||
includeCategories: IdSchema.array().default([]),
|
||||
excludeCategories: IdSchema.array().default([]),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [directories, files] = await Promise.all([
|
||||
input.includeCategories.length === 0 && input.excludeCategories.length === 0
|
||||
? DirectoryRepo.searchDirectories(ctx.session.userId, {
|
||||
parentId: input.ancestor,
|
||||
inFavorites: input.inFavorites,
|
||||
})
|
||||
: [],
|
||||
FileRepo.searchFiles(ctx.session.userId, {
|
||||
parentId: input.ancestor,
|
||||
inFavorites: input.inFavorites,
|
||||
includeCategoryIds: input.includeCategories,
|
||||
excludeCategoryIds: input.excludeCategories,
|
||||
}),
|
||||
]);
|
||||
const { directories, files } = await SearchService.search(ctx.session.userId, {
|
||||
ancestorId: input.ancestor,
|
||||
inFavorites: input.inFavorites,
|
||||
includeCategoryIds: input.includeCategories,
|
||||
excludeCategoryIds: input.excludeCategories,
|
||||
});
|
||||
return {
|
||||
directories: directories.map((directory) => ({
|
||||
id: directory.id,
|
||||
parent: directory.parentId,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
})),
|
||||
directories: directories.map(toDirectoryDto),
|
||||
files: files.map(toFileDto),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
+50
-205
@@ -1,206 +1,87 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import { mkdir, open } from "fs/promises";
|
||||
import mime from "mime";
|
||||
import { dirname } from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
import { DirectoryIdSchema } from "#lib/schemas/index.js";
|
||||
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import db from "#lib/server/db/kysely.js";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import { safeUnlink } from "#lib/server/modules/filesystem.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
|
||||
|
||||
const sessionLocks = new Set<string>();
|
||||
|
||||
const reserveUploadPath = async (path: string) => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const file = await open(path, "wx", 0o600);
|
||||
await file.close();
|
||||
};
|
||||
|
||||
const generateFileUploadSession = async (userId: number) => {
|
||||
const id = uuidv4();
|
||||
const path = `${env.libraryPath}/${userId}/${uuidv4()}`;
|
||||
await reserveUploadPath(path);
|
||||
return { id, path };
|
||||
};
|
||||
|
||||
const generateThumbnailUploadSession = async (userId: number) => {
|
||||
const id = uuidv4();
|
||||
const path = `${env.thumbnailsPath}/${userId}/${id}`;
|
||||
await reserveUploadPath(path);
|
||||
return { id, path };
|
||||
};
|
||||
import { UploadService } from "#lib/server/services/index.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
import {
|
||||
Base64Schema,
|
||||
DirectoryIdSchema,
|
||||
EncryptedNameSchema,
|
||||
IdSchema,
|
||||
toCiphertext,
|
||||
} from "#trpc/schemas.js";
|
||||
|
||||
const uploadRouter = router({
|
||||
startFileUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
chunks: z.int().positive(),
|
||||
chunks: IdSchema,
|
||||
parent: DirectoryIdSchema,
|
||||
mekVersion: z.int().positive(),
|
||||
dek: z.base64().nonempty(),
|
||||
mekVersion: IdSchema,
|
||||
dek: Base64Schema,
|
||||
dekVersion: z.date(),
|
||||
hskVersion: z.int().positive().optional(),
|
||||
hskVersion: IdSchema.optional(),
|
||||
contentType: z
|
||||
.string()
|
||||
.trim()
|
||||
.nonempty()
|
||||
.refine((value) => mime.getExtension(value) !== null),
|
||||
name: z.base64().nonempty(),
|
||||
nameIv: z.base64().nonempty(),
|
||||
createdAt: z.base64().nonempty().optional(),
|
||||
createdAtIv: z.base64().nonempty().optional(),
|
||||
lastModifiedAt: z.base64().nonempty(),
|
||||
lastModifiedAtIv: z.base64().nonempty(),
|
||||
...EncryptedNameSchema,
|
||||
createdAt: Base64Schema.optional(),
|
||||
createdAtIv: Base64Schema.optional(),
|
||||
lastModifiedAt: Base64Schema,
|
||||
lastModifiedAtIv: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" });
|
||||
}
|
||||
|
||||
const { id, path } = await generateFileUploadSession(ctx.session.userId);
|
||||
|
||||
try {
|
||||
await UploadRepo.createFileUploadSession({
|
||||
id,
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: input.chunks,
|
||||
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||
parentId: input.parent,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
hskVersion: input.hskVersion ?? null,
|
||||
contentType: input.contentType,
|
||||
encName: { ciphertext: input.name, iv: input.nameIv },
|
||||
encCreatedAt:
|
||||
input.createdAt && input.createdAtIv
|
||||
? { ciphertext: input.createdAt, iv: input.createdAtIv }
|
||||
: null,
|
||||
encLastModifiedAt: { ciphertext: input.lastModifiedAt, iv: input.lastModifiedAtIv },
|
||||
});
|
||||
return { uploadId: id };
|
||||
} catch (e) {
|
||||
await safeUnlink(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Inactive MEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid MEK version" });
|
||||
} else if (e.message === "Inactive HSK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid HSK version" });
|
||||
} else if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return await UploadService.startFileUpload({
|
||||
userId: ctx.session.userId,
|
||||
totalChunks: input.chunks,
|
||||
parentId: input.parent,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
dekVersion: input.dekVersion,
|
||||
hskVersion: input.hskVersion ?? null,
|
||||
contentType: input.contentType,
|
||||
encName: toCiphertext(input.name, input.nameIv),
|
||||
encCreatedAt:
|
||||
input.createdAt && input.createdAtIv
|
||||
? toCiphertext(input.createdAt, input.createdAtIv)
|
||||
: null,
|
||||
encLastModifiedAt: toCiphertext(input.lastModifiedAt, input.lastModifiedAtIv),
|
||||
});
|
||||
}),
|
||||
|
||||
completeFileUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
uploadId: z.uuidv4(),
|
||||
contentHmac: z.base64().nonempty().optional(),
|
||||
encContentHash: z.base64().nonempty(),
|
||||
contentHmac: Base64Schema.optional(),
|
||||
encContentHash: Base64Schema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { uploadId } = input;
|
||||
if (sessionLocks.has(uploadId)) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" });
|
||||
} else {
|
||||
sessionLocks.add(uploadId);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||
if (session?.type !== "file") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||
} else if (
|
||||
(session.hskVersion && !input.contentHmac) ||
|
||||
(!session.hskVersion && input.contentHmac)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" });
|
||||
} else if (session.uploadedChunks < session.totalChunks) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||
}
|
||||
|
||||
const hashStream = createHash("sha256");
|
||||
|
||||
for await (const chunk of createReadStream(session.path)) {
|
||||
hashStream.update(chunk);
|
||||
}
|
||||
|
||||
const hash = hashStream.digest("base64");
|
||||
if (hash !== input.encContentHash) {
|
||||
await UploadRepo.deleteUploadSession(db, uploadId);
|
||||
await safeUnlink(session.path);
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Uploaded file corrupted" });
|
||||
}
|
||||
|
||||
const fileId = await db.transaction().execute(async (trx) => {
|
||||
const { id: fileId } = await FileRepo.registerFile(trx, {
|
||||
...session,
|
||||
userId: ctx.session.userId,
|
||||
path: session.path,
|
||||
contentHmac: input.contentHmac ?? null,
|
||||
encContentHash: hash,
|
||||
encContentIv: null,
|
||||
});
|
||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||
return fileId;
|
||||
});
|
||||
|
||||
return { file: fileId };
|
||||
} finally {
|
||||
sessionLocks.delete(uploadId);
|
||||
}
|
||||
const { fileId } = await UploadService.completeFileUpload(
|
||||
ctx.session.userId,
|
||||
input.uploadId,
|
||||
input.contentHmac ?? null,
|
||||
input.encContentHash,
|
||||
);
|
||||
return { file: fileId };
|
||||
}),
|
||||
|
||||
startFileThumbnailUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
file: z.int().positive(),
|
||||
file: IdSchema,
|
||||
dekVersion: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, path } = await generateThumbnailUploadSession(ctx.session.userId);
|
||||
|
||||
try {
|
||||
await UploadRepo.createThumbnailUploadSession({
|
||||
id,
|
||||
type: "thumbnail",
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: 1, // Up to 4 MiB
|
||||
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||
fileId: input.file,
|
||||
dekVersion: input.dekVersion,
|
||||
});
|
||||
return { uploadId: id };
|
||||
} catch (e) {
|
||||
await safeUnlink(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return await UploadService.startFileThumbnailUpload(
|
||||
ctx.session.userId,
|
||||
input.file,
|
||||
input.dekVersion,
|
||||
);
|
||||
}),
|
||||
|
||||
completeFileThumbnailUpload: roleProcedure["activeClient"]
|
||||
@@ -210,43 +91,7 @@ const uploadRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { uploadId } = input;
|
||||
if (sessionLocks.has(uploadId)) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" });
|
||||
} else {
|
||||
sessionLocks.add(uploadId);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||
if (session?.type !== "thumbnail") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||
} else if (session.uploadedChunks < session.totalChunks) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||
}
|
||||
|
||||
const oldThumbnailPath = await db.transaction().execute(async (trx) => {
|
||||
const oldPath = await MediaRepo.updateFileThumbnail(
|
||||
trx,
|
||||
ctx.session.userId,
|
||||
session.fileId,
|
||||
session.dekVersion,
|
||||
session.path,
|
||||
null,
|
||||
);
|
||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||
return oldPath;
|
||||
});
|
||||
await safeUnlink(oldThumbnailPath);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
|
||||
// DEK rotated after this upload started
|
||||
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
sessionLocks.delete(uploadId);
|
||||
}
|
||||
await UploadService.completeFileThumbnailUpload(ctx.session.userId, input.uploadId);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { UserRepo } from "#lib/server/db/index.js";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
import { UserService } from "#lib/server/services/index.js";
|
||||
import { router, roleProcedure } from "#trpc/init.server.js";
|
||||
|
||||
const userRouter = router({
|
||||
get: roleProcedure["any"].query(async ({ ctx }) => {
|
||||
const user = await UserRepo.getUser(ctx.session.userId);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
|
||||
}
|
||||
|
||||
return { email: user.email, nickname: user.nickname };
|
||||
const { email, nickname } = await UserService.getUser(ctx.session.userId);
|
||||
return { email, nickname };
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const IdSchema = z.int().positive();
|
||||
export const DirectoryIdSchema = z.union([z.literal("root"), IdSchema]);
|
||||
export const CategoryIdSchema = z.union([z.literal("root"), IdSchema]);
|
||||
export const Base64Schema = z.base64().nonempty();
|
||||
|
||||
export const EncryptedNameSchema = {
|
||||
name: Base64Schema,
|
||||
nameIv: Base64Schema,
|
||||
};
|
||||
|
||||
export const toCiphertext = (ciphertext: string, iv: string) => ({ ciphertext, iv });
|
||||
Reference in new issue
Block a user