social bookmarking for atproto

[backend] Implement updates and deletes for records

hexmani.ac dc871e71 3f48e46d

verified
+14 -30
.husky/pre-commit
··· 6 6 # SPDX-License-Identifier: AGPL-3.0-only 7 7 # 8 8 9 - # Collect staged files 10 - STAGED=$(git diff --cached --name-only) 11 - 12 - # Track exit status 13 - FAILED=0 9 + test() { 10 + cd backend || return 1 11 + pnpm run fmt 12 + pnpm run lint 13 + cd ../frontend || return 1 14 + pnpm run fmt 15 + cd ../lexicons || return 1 16 + pnpm run generate 17 + pnpm run prepublish 18 + } 14 19 15 20 echo "Testing code for any errors before committing..." 16 - 17 - run_cmds() { 18 - desc=$1 19 - shift 20 - cd "$desc" || FAILED=1 && return 21 - echo "Running pre-commit checks for $desc..." 22 - if ! "$@"; then 23 - echo "ERROR: Pre-commit checks for $desc failed. Aborting commit." 24 - FAILED=1 25 - fi 26 - cd .. 21 + test 22 + if [ $? -ne 0 ]; then { 23 + echo "Linting failed. Commit aborted." 24 + exit 1 27 25 } 28 - 29 - echo "$STAGED" | grep -q "^backend/" && run_cmds "backend" pnpm run fmt && run_cmds "backend" pnpm run lint --fix 30 - git update-index --again 31 - 32 - echo "$STAGED" | grep -q "^frontend/" && run_cmds "frontend" pnpm run fmt 33 - git update-index --again 34 - 35 - echo "$STAGED" | grep -q "^lexicons/" && run_cmds "lexicons" pnpm run generate && run_cmds "lexicons" pnpm run prepublish 36 - git update-index --again 37 - 38 - # If any failed, block commit 39 - if [ $FAILED -ne 0 ]; then 40 - echo "Commit aborted due to failed checks. Please fix your code before committing." 41 - exit 1 42 26 fi 43 27 44 28 echo "All relevant checks passed. Proceeding with commit."
+80 -20
backend/src/network/commit.ts
··· 18 18 import { validateClip, validateProfile, validateTag } from "./validator.js"; 19 19 import { convertDidToString } from "./converters.js"; 20 20 import { hashString } from "../hasher.js"; 21 - import { eq } from "drizzle-orm"; 21 + import { and, eq } from "drizzle-orm"; 22 + import type { TagRef } from "../api/types.js"; 22 23 23 24 const db = Database.getInstance().getDb(); 24 25 ··· 30 31 export async function handleClip( 31 32 event: CommitEvent<`social.clippr.${string}`>, 32 33 ): Promise<void> { 33 - if (event.commit.operation !== "create") { 34 - Logger.warn( 35 - `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 36 - ); 34 + if (event.commit.operation === "delete") { 35 + await db 36 + .delete(clipsTable) 37 + .where( 38 + and( 39 + eq(clipsTable.did, event.did), 40 + eq(clipsTable.recordKey, event.commit.rkey), 41 + ), 42 + ); 43 + Logger.verbose(`Deleted clip: ${event.did}/${event.commit.rkey}`, event); 37 44 return; 38 - } // We currently do not handle these. 45 + } 39 46 40 47 if (event.commit.record.$type !== "social.clippr.feed.clip") { 41 48 Logger.verbose( ··· 76 83 return; 77 84 } 78 85 79 - if (!(await validateClip(record))) { 86 + if (!(await validateClip(record))) return; 87 + 88 + if (event.commit.operation === "update") { 89 + await db 90 + .update(clipsTable) 91 + .set({ 92 + did: convertDidToString(event.did), 93 + cid: event.commit.cid, 94 + timestamp: convertMicroToDate(event.time_us), 95 + recordKey: event.commit.rkey, 96 + createdAt: new Date(record.createdAt), 97 + indexedAt: new Date(), 98 + url: record.url, 99 + title: record.title, 100 + description: record.description, 101 + tags: record.tags as TagRef[] | undefined, 102 + notes: record.notes, 103 + unlisted: record.unlisted, 104 + unread: record.unread, 105 + languages: record.languages, 106 + }) 107 + .where( 108 + and( 109 + eq(clipsTable.did, event.did), 110 + eq(clipsTable.recordKey, event.commit.rkey), 111 + ), 112 + ); 113 + Logger.verbose(`Updated clip: ${event.did}/${event.commit.rkey}`, event); 80 114 return; 81 115 } 82 116 ··· 104 138 export async function handleTag( 105 139 event: CommitEvent<`social.clippr.${string}`>, 106 140 ): Promise<void> { 107 - if (event.commit.operation !== "create") { 108 - Logger.warn( 109 - `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 110 - ); 141 + if (event.commit.operation === "delete") { 142 + await db 143 + .delete(tagsTable) 144 + .where( 145 + and( 146 + eq(tagsTable.did, event.did), 147 + eq(tagsTable.recordKey, event.commit.rkey), 148 + ), 149 + ); 150 + Logger.verbose(`Deleted tag: ${event.did}/${event.commit.rkey}`, event); 111 151 return; 112 - } // We currently do not handle these. 152 + } 113 153 114 154 if (event.commit.record.$type !== "social.clippr.feed.tag") { 115 155 Logger.verbose( ··· 148 188 return; 149 189 } 150 190 191 + if (event.commit.operation === "update") { 192 + await db 193 + .update(tagsTable) 194 + .set({ 195 + timestamp: convertMicroToDate(event.time_us), 196 + did: convertDidToString(event.did), 197 + cid: event.commit.cid, 198 + recordKey: event.commit.rkey, 199 + name: record.name, 200 + description: record.description, 201 + color: record.color, 202 + createdAt: new Date(record.createdAt), 203 + indexedAt: new Date(), 204 + }) 205 + .where( 206 + and( 207 + eq(tagsTable.did, event.did), 208 + eq(tagsTable.recordKey, event.commit.rkey), 209 + ), 210 + ); 211 + Logger.verbose(`Updated tag: ${event.did}/${event.commit.rkey}`, event); 212 + return; 213 + } 214 + 151 215 await db.insert(tagsTable).values({ 152 216 timestamp: convertMicroToDate(event.time_us), 153 217 did: convertDidToString(event.did), ··· 167 231 event: CommitEvent<`social.clippr.${string}`>, 168 232 ): Promise<void> { 169 233 if (event.commit.operation === "delete") { 170 - Logger.warn( 171 - `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 172 - ); 234 + await db.delete(usersTable).where(eq(usersTable.did, event.did)); 235 + Logger.verbose(`Deleted profile: ${event.did}`, event); 173 236 return; 174 - } // We currently do not handle deletes. 237 + } 175 238 176 239 if (event.commit.record.$type !== "social.clippr.actor.profile") { 177 240 Logger.verbose( ··· 257 320 avatar: record.avatar?.ref.$link, 258 321 description: record.description, 259 322 }) 260 - .where(eq(usersTable.did, convertDidToString(event.did))) 261 - .execute(); 262 - 323 + .where(eq(usersTable.did, convertDidToString(event.did))); 263 324 Logger.verbose(`Updated profile: ${convertDidToString(event.did)}`, event); 264 - 265 325 return; 266 326 } 267 327
+9 -7
frontend/README.md
··· 4 4 5 5 ## development 6 6 7 - If you are testing the frontend in conjunction with the AppView, you might want to change the following: 7 + If you are testing the frontend in conjunction with the AppView, you might want to change the 8 + following: 8 9 9 - * OAuth automatically adapts to whether the frontend is built or in dev mode. 10 - * ``VITE_CLIPPR_APPVIEW`` is set to the defaults for both production and development, however, if you are hosting the 11 - appview from another location, you will need to change this. 10 + - OAuth automatically adapts to whether the frontend is built or in dev mode. 11 + - `VITE_CLIPPR_APPVIEW` is set to the defaults for both production and development, however, if you 12 + are hosting the appview from another location, you will need to change this. 12 13 13 14 ```shell 14 15 pnpm install ··· 17 18 18 19 ## deployment 19 20 20 - If you plan to deploy the frontend and use another AppView or to add/remove OAuth scopes, you will have to modify 21 - ``public/oauth/client-metadata.json`` and the ``VITE_CLIPPR_APPVIEW`` environment variable. There are plans to add a way 22 - to change what AppView DID the frontend proxies its requests to inside the frontend, but not before launch. 21 + If you plan to deploy the frontend and use another AppView or to add/remove OAuth scopes, you will 22 + have to modify `public/oauth/client-metadata.json` and the `VITE_CLIPPR_APPVIEW` environment 23 + variable. There are plans to add a way to change what AppView DID the frontend proxies its requests 24 + to inside the frontend, but not before launch. 23 25 24 26 ```shell 25 27 pnpm run build
+6 -1
frontend/src/components/profileEditor.tsx
··· 146 146 accept=".jpg,.jpeg,.png,image/jpeg,image/png" 147 147 onChange={() => uploadBlob()} 148 148 /> 149 - <img class="profile-picture" src={avatarPreview()} alt="The user's uploaded avatar." hidden={avatarPreview() === ""} /> 149 + <img 150 + class="profile-picture" 151 + src={avatarPreview()} 152 + alt="The user's uploaded avatar." 153 + hidden={avatarPreview() === ""} 154 + /> 150 155 <label for="displayName">display name</label> 151 156 <input 152 157 type="text"
+1 -8
frontend/src/components/profileWidget.tsx
··· 4 4 * SPDX-License-Identifier: AGPL-3.0-only 5 5 */ 6 6 7 - import { 8 - createResource, 9 - Match, 10 - Show, 11 - splitProps, 12 - Switch, 13 - } from "solid-js"; 7 + import { createResource, Match, Show, splitProps, Switch } from "solid-js"; 14 8 import { agent } from "./loginForm.tsx"; 15 9 import { fetchProfile } from "../utils/profile.ts"; 16 10 ··· 21 15 const ProfileWidget = (props: ProfileProps) => { 22 16 const [local] = splitProps(props, ["actor"]); 23 17 const actor = () => local.actor ?? agent.session.info.sub; 24 - 25 18 26 19 const [profile] = createResource(actor, fetchProfile); 27 20
+6 -6
frontend/src/styles/index.css
··· 13 13 :root { 14 14 --bg: #222 !important; 15 15 --fg: #fff !important; 16 - --controls-bg: #2B2A33 !important; 17 - --controls-bg-hover: #52525E !important; 18 - --controls-border: #8F8F9D !important; 16 + --controls-bg: #2b2a33 !important; 17 + --controls-bg-hover: #52525e !important; 18 + --controls-border: #8f8f9d !important; 19 19 } 20 20 } 21 21 ··· 23 23 :root { 24 24 --bg: #fff !important; 25 25 --fg: #222 !important; 26 - --controls-bg: #E9E9ED !important; 27 - --controls-bg-hover: #D0D0D7 !important; 28 - --controls-border: #8F8F9D !important; 26 + --controls-bg: #e9e9ed !important; 27 + --controls-bg-hover: #d0d0d7 !important; 28 + --controls-border: #8f8f9d !important; 29 29 } 30 30 } 31 31
+1 -1
frontend/src/types.ts
··· 16 16 avatar: string; 17 17 description: string; 18 18 createdAt: string; 19 - } 19 + };
+19
frontend/src/utils/client.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { ServiceProxyOptions } from "@atcute/client"; 8 + 9 + // Converts the AppView environment variable into options for the client's server proxy options. 10 + export const createServiceProxy = (): ServiceProxyOptions | undefined => { 11 + const appviewUrl = import.meta.env.VITE_CLIPPR_APPVIEW; 12 + if (appviewUrl.includes("localhost:")) return undefined; // TODO: You can't do PDS proxying if you're testing locally!!! 13 + let sanitizedUrl = appviewUrl.replace(/^(https?:\/\/)/, "did:web:"); 14 + 15 + return { 16 + did: sanitizedUrl as `did:${string}:${string}`, 17 + serviceId: "#clippr_appview", 18 + }; 19 + };
-6
lexicons/lib/lexicons/index.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 export * as SocialClipprActorDefs from "./types/social/clippr/actor/defs.js"; 8 2 export * as SocialClipprActorGetPreferences from "./types/social/clippr/actor/getPreferences.js"; 9 3 export * as SocialClipprActorGetProfile from "./types/social/clippr/actor/getProfile.js";
-6
lexicons/lib/lexicons/types/social/clippr/actor/defs.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3
-6
lexicons/lib/lexicons/types/social/clippr/actor/getPreferences.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/getProfile.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/profile.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/putPreferences.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/searchClips.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/searchProfiles.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/actor/searchTags.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/clip.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/defs.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import * as SocialClipprActorDefs from "../actor/defs.js";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getClips.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getProfileClips.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getProfileTags.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getTagClips.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getTagList.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/getTags.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";
-6
lexicons/lib/lexicons/types/social/clippr/feed/tag.ts
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 1 import type {} from "@atcute/lexicons"; 8 2 import * as v from "@atcute/lexicons/validations"; 9 3 import type {} from "@atcute/lexicons/ambient";