aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/lib/attachments.ts5
-rw-r--r--packages/trpc/models/users.ts102
-rw-r--r--packages/trpc/routers/users.test.ts75
-rw-r--r--packages/trpc/routers/users.ts10
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({