diff options
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/lib/attachments.ts | 5 | ||||
| -rw-r--r-- | packages/trpc/models/users.ts | 102 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 75 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 10 |
4 files changed, 191 insertions, 1 deletions
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 25d9be94..fb9e2079 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -17,6 +17,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { [AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent", [AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset", [AssetTypes.USER_UPLOADED]: "userUploaded", + [AssetTypes.AVATAR]: "avatar", [AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets [AssetTypes.UNKNOWN]: "bannerImage", }; @@ -36,6 +37,7 @@ export function mapSchemaAssetTypeToDB( bookmarkAsset: AssetTypes.BOOKMARK_ASSET, linkHtmlContent: AssetTypes.LINK_HTML_CONTENT, userUploaded: AssetTypes.USER_UPLOADED, + avatar: AssetTypes.AVATAR, unknown: AssetTypes.UNKNOWN, }; return map[assetType]; @@ -52,6 +54,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) { bookmarkAsset: "Bookmark Asset", linkHtmlContent: "HTML Content", userUploaded: "User Uploaded File", + avatar: "Avatar", unknown: "Unknown", }; return map[type]; @@ -68,6 +71,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; @@ -84,6 +88,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index 4c7272b1..0653349b 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { assets, + AssetTypes, bookmarkLinks, bookmarkLists, bookmarks, @@ -17,7 +18,7 @@ import { users, verificationTokens, } from "@karakeep/db/schema"; -import { deleteUserAssets } from "@karakeep/shared/assetdb"; +import { deleteAsset, deleteUserAssets } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { zResetPasswordSchema, @@ -491,6 +492,104 @@ export class User { .where(eq(users.id, this.user.id)); } + async updateAvatar(assetId: string | null): Promise<void> { + const previousImage = this.user.image ?? null; + const [asset, previousAsset] = await Promise.all([ + assetId + ? this.ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, assetId), eq(assets.userId, this.user.id)), + columns: { + id: true, + bookmarkId: true, + contentType: true, + assetType: true, + }, + }) + : Promise.resolve(null), + previousImage && previousImage !== assetId + ? this.ctx.db.query.assets.findFirst({ + where: and( + eq(assets.id, previousImage), + eq(assets.userId, this.user.id), + ), + columns: { + id: true, + bookmarkId: true, + }, + }) + : Promise.resolve(null), + ]); + + if (assetId) { + if (!asset) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Avatar asset not found", + }); + } + + if (asset.bookmarkId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset must not be attached to a bookmark", + }); + } + + if (asset.contentType && !asset.contentType.startsWith("image/")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset must be an image", + }); + } + + if ( + asset.assetType !== AssetTypes.AVATAR && + asset.assetType !== AssetTypes.UNKNOWN + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset type is not supported", + }); + } + + if (asset.assetType !== AssetTypes.AVATAR) { + await this.ctx.db + .update(assets) + .set({ assetType: AssetTypes.AVATAR }) + .where(eq(assets.id, asset.id)); + } + } + if (previousImage === assetId) { + return; + } + + await this.ctx.db.transaction(async (tx) => { + await tx + .update(users) + .set({ image: assetId }) + .where(eq(users.id, this.user.id)); + + if (!previousImage || previousImage === assetId) { + return; + } + + if (previousAsset && !previousAsset.bookmarkId) { + await tx.delete(assets).where(eq(assets.id, previousAsset.id)); + } + }); + + this.user.image = assetId; + + if (!previousImage || previousImage === assetId) { + return; + } + + await deleteAsset({ + userId: this.user.id, + assetId: previousImage, + }).catch(() => ({})); + } + async getStats(): Promise<z.infer<typeof zUserStatsResponseSchema>> { const userObj = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.user.id), @@ -770,6 +869,7 @@ export class User { id: this.user.id, name: this.user.name, email: this.user.email, + image: this.user.image, localUser: this.user.password !== null, }; } diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index 27d6c2d6..21f05f5b 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -942,6 +942,81 @@ describe("User Routes", () => { }); }); + describe("Update Avatar", () => { + test<CustomTestContext>("updateAvatar - promotes unknown asset", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Avatar Reject", + email: "avatar-reject@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id, user.email, user.role || "user"); + + await db.insert(assets).values({ + id: "avatar-asset-2", + assetType: AssetTypes.UNKNOWN, + userId: user.id, + contentType: "image/png", + size: 12, + fileName: "avatar.png", + bookmarkId: null, + }); + + await caller.users.updateAvatar({ assetId: "avatar-asset-2" }); + + const updatedAsset = await db + .select() + .from(assets) + .where(eq(assets.id, "avatar-asset-2")) + .then((rows) => rows[0]); + + expect(updatedAsset?.assetType).toBe(AssetTypes.AVATAR); + }); + + test<CustomTestContext>("updateAvatar - deletes avatar asset", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Avatar Delete", + email: "avatar-delete@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id, user.email, user.role || "user"); + + await db.insert(assets).values({ + id: "avatar-asset-3", + assetType: AssetTypes.UNKNOWN, + userId: user.id, + contentType: "image/png", + size: 12, + fileName: "avatar.png", + bookmarkId: null, + }); + + await caller.users.updateAvatar({ assetId: "avatar-asset-3" }); + await caller.users.updateAvatar({ assetId: null }); + + const updatedUser = await db + .select() + .from(users) + .where(eq(users.id, user.id)) + .then((rows) => rows[0]); + const remainingAsset = await db + .select() + .from(assets) + .where(eq(assets.id, "avatar-asset-3")) + .then((rows) => rows[0]); + + expect(updatedUser?.image).toBeNull(); + expect(remainingAsset).toBeUndefined(); + }); + }); + describe("Who Am I", () => { test<CustomTestContext>("whoami - returns user info", async ({ db, diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index d3bc06d9..dbfbbc3c 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -148,6 +148,16 @@ export const usersAppRouter = router({ const user = await User.fromCtx(ctx); await user.updateSettings(input); }), + updateAvatar: authedProcedure + .input( + z.object({ + assetId: z.string().nullable(), + }), + ) + .mutation(async ({ input, ctx }) => { + const user = await User.fromCtx(ctx); + await user.updateAvatar(input.assetId); + }), verifyEmail: publicProcedure .use( createRateLimitMiddleware({ |
