diff --git a/api/.env.example b/api/.env.example index b248b8170..e1a152c41 100644 --- a/api/.env.example +++ b/api/.env.example @@ -22,4 +22,11 @@ AT_BRIDGE_RETENTION_MAX_BATCHES_PER_RUN=8 AT_BRIDGE_RETENTION_AT_POSTS_DAYS=30 AT_BRIDGE_RETENTION_AT_RECORDS_DAYS=14 AT_BRIDGE_RETENTION_INACTIVE_RECORDS_DAYS=3 -AT_BRIDGE_RETENTION_DRY_RUN=false \ No newline at end of file +AT_BRIDGE_RETENTION_DRY_RUN=false +# --- Safe Browsing (link preview malicious-URL guard) ---------------------------- +# Optional Google Safe Browsing v5alpha1 API key. When unset, link previews are +# fetched without the threat-intel check (open). Preferred name; SAFE_BROWSING_API_KEY +# is also accepted as a fallback for parity with ActivityPods backend / Fedify sidecar. +GOOGLE_SAFE_BROWSING_API_KEY= +# Set to 1 to block (rather than allow) URLs when the Safe Browsing call errors. +SAFE_BROWSING_FAIL_CLOSED=0 diff --git a/api/bun.lock b/api/bun.lock index ab41c1514..e71fb389b 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -8,7 +8,7 @@ "@elysiajs/cors": "^1.2.0", "@elysiajs/jwt": "^1.2.0", "@sinclair/typebox": "^0.34.14", - "drizzle-orm": "^0.38.4", + "drizzle-orm": "^0.45.2", "drizzle-typebox": "^0.2.1", "elysia": "^1.2.10", "ky": "^1.7.4", @@ -19,7 +19,7 @@ "@types/bun": "^1.2.0", "@types/luxon": "^3.4.2", "@types/pg": "^8.11.11", - "drizzle-kit": "^0.30.2", + "drizzle-kit": "^0.31.10", "prettier-plugin-tailwindcss": "^0.6.11", "typescript": "^5.7.3", }, @@ -39,7 +39,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, ""], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, ""], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.14", "", {}, ""], @@ -59,19 +109,17 @@ "cookie": ["cookie@1.0.2", "", {}, ""], - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, ""], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-kit": ["drizzle-kit@0.30.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": "bin.cjs" }, ""], - - "drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "expo-sqlite", "knex", "kysely", "mysql2", "postgres", "react", "sql.js", "sqlite3"] }, ""], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "drizzle-typebox": ["drizzle-typebox@0.2.1", "", { "peerDependencies": { "@sinclair/typebox": ">=0.34.8", "drizzle-orm": ">=0.36.0" } }, ""], "elysia": ["elysia@1.2.10", "", { "dependencies": { "@sinclair/typebox": "^0.34.13", "cookie": "^1.0.2", "memoirist": "^0.2.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" } }, ""], - "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/linux-x64": "0.19.12" }, "bin": "bin/esbuild" }, ""], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, ""], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "get-tsconfig": ["get-tsconfig@4.9.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, ""], @@ -83,8 +131,6 @@ "memoirist": ["memoirist@0.2.0", "", {}, ""], - "ms": ["ms@2.1.3", "", {}, ""], - "obuf": ["obuf@1.1.2", "", {}, ""], "openapi-types": ["openapi-types@12.1.3", "", {}, ""], @@ -129,6 +175,8 @@ "split2": ["split2@4.2.0", "", {}, ""], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, ""], "undici-types": ["undici-types@5.26.5", "", {}, ""], @@ -139,6 +187,8 @@ "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, ""], + "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, ""], "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, ""], @@ -148,5 +198,57 @@ "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, ""], "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, ""], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], } } diff --git a/api/drizzle/0015_media_attachments.sql b/api/drizzle/0015_media_attachments.sql new file mode 100644 index 000000000..cd3a070a8 --- /dev/null +++ b/api/drizzle/0015_media_attachments.sql @@ -0,0 +1,67 @@ +-- Durable media attachment lifecycle for pod-first uploads. +-- Source binaries remain in the user's ActivityPods pod; this table stores +-- Memory-local upload state, ActivityPub attachment metadata, and processed +-- media references when they become available. + +CREATE EXTENSION IF NOT EXISTS pgcrypto;--> statement-breakpoint + +ALTER TABLE "posts" + ADD COLUMN IF NOT EXISTS "client_post_key" varchar(128), + ADD COLUMN IF NOT EXISTS "client_post_request_hash" varchar(64);--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "posts_author_client_key_unique_idx" + ON "posts" ("author_id", "client_post_key") + WHERE "client_post_key" IS NOT NULL;--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "media_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" integer NOT NULL, + "post_id" integer, + "state" text DEFAULT 'uploading' NOT NULL, + "kind" text NOT NULL, + "source_url" text, + "source_media_type" varchar(120) NOT NULL, + "source_size" integer NOT NULL, + "original_filename" text, + "alt_text" text, + "focus_x" integer, + "focus_y" integer, + "blurhash" varchar(128), + "width" integer, + "height" integer, + "duration_ms" integer, + "preview_url" text, + "thumbnail_url" text, + "canonical_url" text, + "gateway_url" text, + "filebase_cid" varchar(256), + "digest_multibase" varchar(256), + "error_code" varchar(64), + "error_message" text, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "media_attachments_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action, + CONSTRAINT "media_attachments_post_id_posts_id_fk" + FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action, + CONSTRAINT "media_attachments_state_check" + CHECK ("state" IN ('uploading', 'uploaded', 'processing', 'ready', 'failed', 'expired', 'deleted')), + CONSTRAINT "media_attachments_kind_check" + CHECK ("kind" IN ('image', 'gif', 'video', 'audio', 'unknown')), + CONSTRAINT "media_attachments_size_check" + CHECK ("source_size" > 0), + CONSTRAINT "media_attachments_focus_x_check" + CHECK ("focus_x" IS NULL OR ("focus_x" >= -1000000 AND "focus_x" <= 1000000)), + CONSTRAINT "media_attachments_focus_y_check" + CHECK ("focus_y" IS NULL OR ("focus_y" >= -1000000 AND "focus_y" <= 1000000)) +);--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "media_attachments_user_state_idx" + ON "media_attachments" ("user_id", "state");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "media_attachments_user_post_idx" + ON "media_attachments" ("user_id", "post_id");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "media_attachments_expires_at_idx" + ON "media_attachments" ("expires_at"); \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index b32859eea..2608d8d29 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1777896060000, "tag": "0014_chat_pod_commit_projection", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1777896120000, + "tag": "0015_media_attachments", + "breakpoints": true } ] } diff --git a/api/package-lock.json b/api/package-lock.json index 96c9f22fc..cf2c826af 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,7 +9,7 @@ "@elysiajs/cors": "^1.2.0", "@elysiajs/jwt": "^1.2.0", "@sinclair/typebox": "^0.34.14", - "drizzle-orm": "^0.38.4", + "drizzle-orm": "^0.45.2", "drizzle-typebox": "^0.2.1", "elysia": "^1.2.10", "ky": "^1.7.4", @@ -20,23 +20,37 @@ "@types/bun": "^1.2.0", "@types/luxon": "^3.4.2", "@types/pg": "^8.11.11", - "drizzle-kit": "^0.30.2", - "prettier-plugin-tailwindcss": "^0.6.11" + "drizzle-kit": "^0.31.10", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript": "^5.7.3" }, "peerDependencies": { "typescript": "^5.7.3" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "dev": true, "license": "Apache-2.0" }, "node_modules/@elysiajs/cors": { - "version": "1.2.0", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@elysiajs/cors/-/cors-1.4.2.tgz", + "integrity": "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ==", "license": "MIT", "peerDependencies": { - "elysia": ">= 1.2.0" + "elysia": ">= 1.4.0" } }, "node_modules/@elysiajs/jwt": { @@ -476,9 +490,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -489,13 +503,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -506,13 +520,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -523,13 +537,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -540,13 +554,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -557,13 +571,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -574,13 +588,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -591,13 +605,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -608,13 +622,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -625,13 +639,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -642,13 +656,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -659,13 +673,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -676,13 +690,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -693,13 +707,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -710,13 +724,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -727,13 +741,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -744,11 +758,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -759,13 +775,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -776,13 +809,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -793,13 +843,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -810,13 +877,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -827,13 +894,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -844,13 +911,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -861,16 +928,43 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@sinclair/typebox": { - "version": "0.34.14", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "license": "MIT" }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT", + "peer": true + }, "node_modules/@types/bun": { "version": "1.2.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bun-types": "1.2.0" @@ -922,16 +1016,24 @@ } }, "node_modules/cookie": { - "version": "1.0.2", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/debug": { - "version": "4.4.0", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -945,21 +1047,25 @@ } }, "node_modules/drizzle-kit": { - "version": "0.30.2", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", - "esbuild-register": "^3.5.0" + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "node_modules/drizzle-orm": { - "version": "0.38.4", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -970,24 +1076,24 @@ "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", + "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", - "@types/react": ">=18", "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", + "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", - "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, @@ -1031,10 +1137,10 @@ "@types/pg": { "optional": true }, - "@types/react": { + "@types/sql.js": { "optional": true }, - "@types/sql.js": { + "@upstash/redis": { "optional": true }, "@vercel/postgres": { @@ -1052,6 +1158,9 @@ "expo-sqlite": { "optional": true }, + "gel": { + "optional": true + }, "knex": { "optional": true }, @@ -1070,9 +1179,6 @@ "prisma": { "optional": true }, - "react": { - "optional": true - }, "sql.js": { "optional": true }, @@ -1090,21 +1196,26 @@ } }, "node_modules/elysia": { - "version": "1.2.10", + "version": "1.4.28", + "resolved": "https://registry.npmjs.org/elysia/-/elysia-1.4.28.tgz", + "integrity": "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.13", - "cookie": "^1.0.2", - "memoirist": "^0.2.0", - "openapi-types": "^12.1.3" + "cookie": "^1.1.1", + "exact-mirror": "^0.2.7", + "fast-decode-uri-component": "^1.0.1", + "memoirist": "^0.4.0" }, "peerDependencies": { - "@sinclair/typebox": ">= 0.34.0", + "@sinclair/typebox": ">= 0.34.0 < 1", + "@types/bun": ">= 1.2.0", + "exact-mirror": ">= 0.0.9", + "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "peerDependenciesMeta": { - "openapi-types": { + "@types/bun": { "optional": true }, "typescript": { @@ -1113,7 +1224,9 @@ } }, "node_modules/esbuild": { - "version": "0.19.12", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1121,43 +1234,89 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "dev": true, + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/exact-mirror": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/exact-mirror/-/exact-mirror-0.2.7.tgz", + "integrity": "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg==", + "license": "MIT", + "peerDependencies": { + "@sinclair/typebox": "^0.34.15" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + } + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", "license": "MIT", + "peer": true, "dependencies": { - "debug": "^4.3.4" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" }, - "peerDependencies": { - "esbuild": ">=0.12 <1" + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/get-tsconfig": { @@ -1171,6 +1330,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/jose": { "version": "4.15.9", "license": "MIT", @@ -1196,13 +1376,15 @@ } }, "node_modules/memoirist": { - "version": "0.2.0", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/memoirist/-/memoirist-0.4.0.tgz", + "integrity": "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==", "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/obuf": { "version": "1.1.2", @@ -1211,7 +1393,8 @@ }, "node_modules/openapi-types": { "version": "12.1.3", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pg": { "version": "8.13.1", @@ -1506,16 +1689,569 @@ "node": ">= 10.x" } }, - "node_modules/typescript": { - "version": "5.7.3", - "license": "Apache-2.0", + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=14.17" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/undici-types": { diff --git a/api/package.json b/api/package.json index 5676eee82..da7396a7c 100644 --- a/api/package.json +++ b/api/package.json @@ -18,7 +18,7 @@ "@types/bun": "^1.2.0", "@types/luxon": "^3.4.2", "@types/pg": "^8.11.11", - "drizzle-kit": "^0.30.2", + "drizzle-kit": "^0.31.10", "prettier-plugin-tailwindcss": "^0.6.11", "typescript": "^5.7.3" }, @@ -29,7 +29,7 @@ "@elysiajs/cors": "^1.2.0", "@elysiajs/jwt": "^1.2.0", "@sinclair/typebox": "^0.34.14", - "drizzle-orm": "^0.38.4", + "drizzle-orm": "^0.45.2", "drizzle-typebox": "^0.2.1", "elysia": "^1.2.10", "ky": "^1.7.4", diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 645e42b14..7e3168eaf 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,5 +1,5 @@ import { relations, eq, sql } from 'drizzle-orm' -import { boolean, text, serial, pgTable as table, timestamp, integer, pgView, varchar, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core' +import { boolean, text, serial, pgTable as table, timestamp, integer, pgView, varchar, jsonb, index, uniqueIndex, uuid, check } from 'drizzle-orm/pg-core' export const users = table('users', { id: serial().primaryKey(), @@ -46,7 +46,50 @@ export const posts = table('posts', { postType: text('post_type', { enum: ['note', 'article'] }).notNull().default('note'), name: text('name'), summary: text('summary'), -}) + clientPostKey: varchar('client_post_key', { length: 128 }), + clientPostRequestHash: varchar('client_post_request_hash', { length: 64 }), +}, t => [ + uniqueIndex('posts_author_client_key_unique_idx').on(t.authorId, t.clientPostKey).where(sql`${t.clientPostKey} IS NOT NULL`), +]) + +export const mediaAttachments = table('media_attachments', { + id: uuid('id').defaultRandom().primaryKey(), + userId: integer('user_id').notNull().references(() => users.id), + postId: integer('post_id').references(() => posts.id), + state: text('state', { enum: ['uploading', 'uploaded', 'processing', 'ready', 'failed', 'expired', 'deleted'] }).notNull().default('uploading'), + kind: text('kind', { enum: ['image', 'gif', 'video', 'audio', 'unknown'] }).notNull(), + sourceUrl: text('source_url'), + sourceMediaType: varchar('source_media_type', { length: 120 }).notNull(), + sourceSize: integer('source_size').notNull(), + originalFilename: text('original_filename'), + altText: text('alt_text'), + focusX: integer('focus_x'), + focusY: integer('focus_y'), + blurhash: varchar('blurhash', { length: 128 }), + width: integer('width'), + height: integer('height'), + durationMs: integer('duration_ms'), + previewUrl: text('preview_url'), + thumbnailUrl: text('thumbnail_url'), + canonicalUrl: text('canonical_url'), + gatewayUrl: text('gateway_url'), + filebaseCid: varchar('filebase_cid', { length: 256 }), + digestMultibase: varchar('digest_multibase', { length: 256 }), + errorCode: varchar('error_code', { length: 64 }), + errorMessage: text('error_message'), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, t => [ + index('media_attachments_user_state_idx').on(t.userId, t.state), + index('media_attachments_user_post_idx').on(t.userId, t.postId), + index('media_attachments_expires_at_idx').on(t.expiresAt), + check('media_attachments_state_check', sql`${t.state} IN ('uploading', 'uploaded', 'processing', 'ready', 'failed', 'expired', 'deleted')`), + check('media_attachments_kind_check', sql`${t.kind} IN ('image', 'gif', 'video', 'audio', 'unknown')`), + check('media_attachments_size_check', sql`${t.sourceSize} > 0`), + check('media_attachments_focus_x_check', sql`${t.focusX} IS NULL OR (${t.focusX} >= -1000000 AND ${t.focusX} <= 1000000)`), + check('media_attachments_focus_y_check', sql`${t.focusY} IS NULL OR (${t.focusY} >= -1000000 AND ${t.focusY} <= 1000000)`), +]) export const postsView = pgView('posts_view', { id: serial().primaryKey(), diff --git a/api/src/i18n.ts b/api/src/i18n.ts index 6e2310bcf..210c8171b 100644 --- a/api/src/i18n.ts +++ b/api/src/i18n.ts @@ -8,6 +8,16 @@ const defaultLocale: ApiLocale = 'en' const messages: Record> = { en: { 'common.mustBeSignedIn': 'You must be signed in to do that', + 'media.upload.multipartRequired': 'Media uploads must use multipart form data', + 'media.upload.invalidBody': 'Could not read the uploaded media', + 'media.upload.fileRequired': 'Choose an image or video file to upload', + 'media.upload.tooLarge': 'That media file is too large', + 'media.upload.unsupportedType': 'That media type is not supported', + 'media.upload.failed': 'Could not upload media to your pod', + 'media.attachments.tooMany': 'You can attach up to 8 media files', + 'media.attachments.invalid': 'One of those media attachments is invalid', + 'media.attachments.notFound': 'Media attachment not found', + 'media.attachments.notAttachable': 'That media attachment is not ready to post', 'common.userNotFound': 'User not found', 'auth.alreadyLoggedIn': "You're already logged in", 'auth.endpointBadStatus': "Endpoint didn't respond with a 200 status code", @@ -59,6 +69,9 @@ const messages: Record> = { 'activitypods.webhooks.payloadTooLarge': 'Payload too large', 'activitypods.webhooks.invalidPayload': 'Invalid notification payload', 'posts.createFailed': 'Error while creating the post', + 'posts.contentOrAttachmentRequired': 'Add text or media before posting', + 'posts.idempotencyKeyInvalid': 'That idempotency key is not valid', + 'posts.idempotencyKeyConflict': 'That idempotency key was already used for a different post', 'conversations.noMessagesYet': 'No messages yet', 'conversations.unknown': 'Unknown', 'conversations.fetchListFailed': 'Failed to fetch conversations', @@ -70,6 +83,16 @@ const messages: Record> = { }, es: { 'common.mustBeSignedIn': 'Debes iniciar sesión para hacer eso', + 'media.upload.multipartRequired': 'Las subidas de medios deben usar datos de formulario multipart', + 'media.upload.invalidBody': 'No se pudo leer el archivo multimedia subido', + 'media.upload.fileRequired': 'Elige una imagen o video para subir', + 'media.upload.tooLarge': 'Ese archivo multimedia es demasiado grande', + 'media.upload.unsupportedType': 'Ese tipo de medio no es compatible', + 'media.upload.failed': 'No se pudo subir el contenido multimedia a tu pod', + 'media.attachments.tooMany': 'Puedes adjuntar hasta 8 archivos multimedia', + 'media.attachments.invalid': 'Uno de esos adjuntos multimedia no es válido', + 'media.attachments.notFound': 'No se encontró el adjunto multimedia', + 'media.attachments.notAttachable': 'Ese adjunto multimedia no está listo para publicarse', 'common.userNotFound': 'Usuario no encontrado', 'auth.alreadyLoggedIn': 'Ya has iniciado sesión', 'auth.endpointBadStatus': 'El endpoint no respondió con un código 200', @@ -121,6 +144,9 @@ const messages: Record> = { 'activitypods.webhooks.payloadTooLarge': 'La carga útil es demasiado grande', 'activitypods.webhooks.invalidPayload': 'La carga útil de la notificación no es válida', 'posts.createFailed': 'Se produjo un error al crear la publicación', + 'posts.contentOrAttachmentRequired': 'Agrega texto o contenido multimedia antes de publicar', + 'posts.idempotencyKeyInvalid': 'Esa clave de idempotencia no es válida', + 'posts.idempotencyKeyConflict': 'Esa clave de idempotencia ya se usó para otra publicación', 'conversations.noMessagesYet': 'Todavía no hay mensajes', 'conversations.unknown': 'Desconocido', 'conversations.fetchListFailed': 'No se pudieron obtener las conversaciones', diff --git a/api/src/index.ts b/api/src/index.ts index 6945e2c3c..5bd13d380 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,6 +1,6 @@ import { Elysia } from 'elysia' import { _createPost, _selectUsers } from './types' -import { postsPlugin, authPlugin, oidcAuthPlugin, oidcClientPlugin, setupPlugin, atBridgePlugin, followPlugin, replyPlugin, actorMetadataPlugin, profilePlugin, mastodonApiPlugin, conversationsPlugin, activityPodsAppPublicPlugin, activityPodsNotificationsPlugin, chatPlugin, linkPreviewPlugin, bookmarksPlugin } from './routes' +import { postsPlugin, authPlugin, oidcAuthPlugin, oidcClientPlugin, setupPlugin, atBridgePlugin, followPlugin, replyPlugin, actorMetadataPlugin, profilePlugin, mastodonApiPlugin, conversationsPlugin, activityPodsAppPublicPlugin, activityPodsNotificationsPlugin, chatPlugin, linkPreviewPlugin, bookmarksPlugin, mediaUploadsPlugin, mediaSidecarCallbackPlugin } from './routes' import atBridgeWebhookPlugin from './routes/atBridgeWebhook' import { xrpcFeedPlugin } from './routes/atBridge' import apBridgeWebhookPlugin from './routes/apBridgeWebhook' @@ -31,6 +31,7 @@ const publicRoutes = new Elysia({ aot: false }) .use(activityPodsAppPublicPlugin) .use(mastodonApiPlugin) .use(apBridgeWebhookPlugin) + .use(mediaSidecarCallbackPlugin) .use(xrpcFeedPlugin) const protectedRoutes = new Elysia({ aot: false }) @@ -73,6 +74,7 @@ const protectedRoutes = new Elysia({ aot: false }) .use(bookmarksPlugin) .use(activityPodsNotificationsPlugin) .use(linkPreviewPlugin) + .use(mediaUploadsPlugin) .use(chatPlugin) export const app = new Elysia({ aot: false }) diff --git a/api/src/postPayload.test.ts b/api/src/postPayload.test.ts index 927a80534..b12106ac6 100644 --- a/api/src/postPayload.test.ts +++ b/api/src/postPayload.test.ts @@ -74,4 +74,58 @@ describe('buildOutboxPost', () => { }, ]) }) + + it('adds uploaded media attachments to notes and removes duplicates', () => { + const result = buildOutboxPost({ + user: mockUser, + content: 'Uploaded media https://cdn.example/photo.jpg', + isPublic: true, + postType: 'note', + attachments: [ + { + type: 'Image', + mediaType: 'image/png', + url: 'https://pods.example/alice/data/semapps/file/uploaded.png', + name: ' Uploaded image ', + }, + { + type: 'Image', + mediaType: 'image/png', + url: 'https://pods.example/alice/data/semapps/file/uploaded.png', + }, + ], + }) + + expect(result.attachment).toEqual([ + { + type: 'Image', + mediaType: 'image/png', + url: 'https://pods.example/alice/data/semapps/file/uploaded.png', + name: 'Uploaded image', + }, + { + type: 'Image', + mediaType: 'image/jpeg', + url: 'https://cdn.example/photo.jpg', + }, + ]) + }) + + it('rejects unsupported explicit attachment media types', () => { + const result = buildOutboxPost({ + user: mockUser, + content: 'No supported uploads', + isPublic: true, + postType: 'note', + attachments: [ + { + type: 'Image', + mediaType: 'image/svg+xml', + url: 'https://pods.example/alice/data/semapps/file/vector.svg', + }, + ], + }) + + expect(result.attachment).toBeUndefined() + }) }) diff --git a/api/src/postPayload.ts b/api/src/postPayload.ts index 564019684..c9db8e6f5 100644 --- a/api/src/postPayload.ts +++ b/api/src/postPayload.ts @@ -1,5 +1,5 @@ import type User from './decorater/User' -import type { NoteCreateRequest } from './types' +import type { MediaAttachmentInput, NoteCreateRequest } from './types' import { mergeHashtags, toActivityPubHashtagTags } from './utils/hashtags' import { FEP_C16B_CONTEXT, looksLikeMfm, renderMfmToHtml } from './utils/mfm' @@ -13,8 +13,25 @@ export interface BuildOutboxPostInput { postType?: MemoryPostType name?: string | null summary?: string | null + attachments?: MediaAttachmentInput[] | null } +const MAX_MEDIA_ATTACHMENTS = 8 + +const MEDIA_EXTENSION_MIME_TYPES: Record = { + avif: 'image/avif', + gif: 'image/gif', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + mov: 'video/quicktime', + mp4: 'video/mp4', + png: 'image/png', + webm: 'video/webm', + webp: 'image/webp', +} + +const SUPPORTED_ATTACHMENT_MEDIA_TYPES = /^(image\/(avif|gif|jpeg|png|webp)|video\/(mp4|quicktime|webm))$/ + function normalizeOptionalText(value: string | null | undefined): string | null { if (typeof value !== 'string') return null const normalized = value.trim() @@ -40,6 +57,89 @@ function plainTextToHtmlParagraphs(value: string): string { .join('') } +function extractMediaAttachments(content: string): NonNullable | undefined { + const attachments: NonNullable = [] + const seen = new Set() + const urlPattern = /https?:\/\/[^\s<>()"']+/gi + const matches = content.match(urlPattern) || [] + + for (const rawUrl of matches) { + if (attachments.length >= MAX_MEDIA_ATTACHMENTS) break + + const normalized = normalizeMediaUrl(rawUrl) + if (!normalized || seen.has(normalized.url)) continue + + seen.add(normalized.url) + attachments.push({ + type: normalized.mediaType.startsWith('video/') ? 'Video' : 'Image', + mediaType: normalized.mediaType, + url: normalized.url, + }) + } + + return attachments.length > 0 ? attachments : undefined +} + +function mergeMediaAttachments( + content: string, + explicitAttachments: MediaAttachmentInput[] | null | undefined +): NonNullable | undefined { + const merged: NonNullable = [] + const seen = new Set() + + for (const attachment of explicitAttachments || []) { + const normalized = normalizeExplicitMediaAttachment(attachment) + if (!normalized || seen.has(normalized.url)) continue + seen.add(normalized.url) + merged.push(normalized) + } + + for (const attachment of extractMediaAttachments(content) || []) { + if (seen.has(attachment.url)) continue + seen.add(attachment.url) + merged.push(attachment) + } + + return merged.length > 0 ? merged.slice(0, MAX_MEDIA_ATTACHMENTS) : undefined +} + +function normalizeExplicitMediaAttachment(attachment: MediaAttachmentInput): NonNullable[number] | null { + if (!attachment || typeof attachment !== 'object') return null + const mediaType = typeof attachment.mediaType === 'string' ? attachment.mediaType.trim().toLowerCase() : '' + if (!SUPPORTED_ATTACHMENT_MEDIA_TYPES.test(mediaType)) return null + + try { + const parsed = new URL(attachment.url) + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) return null + return { + type: mediaType.startsWith('video/') ? 'Video' : 'Image', + mediaType, + url: parsed.toString(), + ...(typeof attachment.name === 'string' && attachment.name.trim() ? { name: attachment.name.trim().slice(0, 160) } : {}), + } + } catch { + return null + } +} + +function normalizeMediaUrl(rawUrl: string): { url: string; mediaType: string } | null { + try { + const parsed = new URL(rawUrl.replace(/[.,;:!?]+$/g, '')) + if (parsed.username || parsed.password) return null + + const extension = parsed.pathname.split('/').pop()?.split('.').pop()?.toLowerCase() + if (!extension) return null + + const mediaType = MEDIA_EXTENSION_MIME_TYPES[extension] + if (!mediaType) return null + + parsed.hash = '' + return { url: parsed.toString(), mediaType } + } catch { + return null + } +} + export function buildOutboxPost({ user, content, @@ -48,6 +148,7 @@ export function buildOutboxPost({ postType = 'note', name, summary, + attachments, }: BuildOutboxPostInput): NoteCreateRequest { const addressats = [`${user.endpoint}/${user.userName}/followers`] if (isPublic) addressats.push('https://www.w3.org/ns/activitystreams#Public') @@ -57,6 +158,7 @@ export function buildOutboxPost({ const hasMfm = looksLikeMfm(content) const normalizedHashtags = mergeHashtags(content, hashtags) const activityPubTags = toActivityPubHashtagTags(normalizedHashtags, user.endpoint) + const mediaAttachments = mergeMediaAttachments(content, attachments) if (postType === 'article') { const post: NoteCreateRequest = { @@ -68,6 +170,7 @@ export function buildOutboxPost({ content, to: addressats, ...(activityPubTags.length > 0 ? { tag: activityPubTags } : {}), + ...(mediaAttachments ? { attachment: mediaAttachments } : {}), source: { content, mediaType: hasMfm ? 'text/x.misskeymarkdown' : 'text/markdown', @@ -90,6 +193,7 @@ export function buildOutboxPost({ content: renderedContent, to: addressats, ...(activityPubTags.length > 0 ? { tag: activityPubTags } : {}), + ...(mediaAttachments ? { attachment: mediaAttachments } : {}), ...(hasMfm && { htmlMfm: true, source: { content, mediaType: 'text/x.misskeymarkdown' }, diff --git a/api/src/routes/activityPodsNotifications.ts b/api/src/routes/activityPodsNotifications.ts index eea028699..e82439e77 100644 --- a/api/src/routes/activityPodsNotifications.ts +++ b/api/src/routes/activityPodsNotifications.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import setupPlugin from './setup' import { ensureMemoryInboxWebhook, @@ -14,10 +15,7 @@ import { localeFromHeaders, translate } from '../i18n' const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notifications' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true, - }) + .guard(signedInGuard) .get('/activitypods/notifications/status', async ({ set, user, headers }: any) => { const locale = localeFromHeaders(headers) const dbUser = await getUserById(user.userId) @@ -39,7 +37,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 502: t.String(), }, - detail: 'Return Memory app registration and webhook status for the current ActivityPods user', + detail: { description: 'Return Memory app registration and webhook status for the current ActivityPods user' }, }) .post('/activitypods/notifications/bootstrap', async ({ set, user, headers }: any) => { const locale = localeFromHeaders(headers) @@ -70,7 +68,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 502: t.String(), }, - detail: 'Create the Memory inbox webhook after the app has been authorized on the user pod', + detail: { description: 'Create the Memory inbox webhook after the app has been authorized on the user pod' }, }) .get('/activitypods/notifications', async ({ set, user, headers }: any) => { const locale = localeFromHeaders(headers) @@ -93,7 +91,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 500: t.String(), }, - detail: 'List recent ActivityPods notification deliveries captured by Memory', + detail: { description: 'List recent ActivityPods notification deliveries captured by Memory' }, }) .get('/activitypods/notifications/grouped', async ({ set, user, headers, query }: any) => { const locale = localeFromHeaders(headers) @@ -136,7 +134,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 500: t.String(), }, - detail: 'List grouped notifications (Mastodon-style grouping for boosts/likes)', + detail: { description: 'List grouped notifications (Mastodon-style grouping for boosts/likes)' }, }) .patch('/activitypods/notifications/:id/read', async ({ set, user, params, headers }: any) => { const locale = localeFromHeaders(headers) @@ -166,7 +164,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 400: t.String(), 404: t.String(), }, - detail: 'Mark a single notification as read', + detail: { description: 'Mark a single notification as read' }, }) .post('/activitypods/notifications/groups/read', async ({ set, user, headers, body }: any) => { const locale = localeFromHeaders(headers) @@ -191,7 +189,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 500: t.String(), }, - detail: 'Mark a grouped notification set as read', + detail: { description: 'Mark a grouped notification set as read' }, }) .post('/activitypods/notifications/read-all', async ({ set, user, headers }: any) => { const locale = localeFromHeaders(headers) @@ -215,7 +213,7 @@ const activityPodsNotificationsPlugin = new Elysia({ name: 'activitypods-notific 404: t.String(), 500: t.String(), }, - detail: 'Mark all notifications as read', + detail: { description: 'Mark all notifications as read' }, }) export default activityPodsNotificationsPlugin diff --git a/api/src/routes/actorMetadata.ts b/api/src/routes/actorMetadata.ts index b7ed3ed64..a0b9dca63 100644 --- a/api/src/routes/actorMetadata.ts +++ b/api/src/routes/actorMetadata.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import ActivityPod from '../services/ActivityPod' import setupPlugin from './setup' import { localeFromHeaders, translate } from '../i18n' @@ -14,26 +15,23 @@ const isAbsoluteHttpsUrl = (value: string): boolean => { const actorMetadataPlugin = new Elysia({ name: 'actorMetadata' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) .post( '/actor-metadata/verify', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) if (!user?.endpoint || !user?.userName) { - return error(401, translate(locale, 'common.mustBeSignedIn')) + return status(401, translate(locale, 'common.mustBeSignedIn')) } if (body.actorUri && !isAbsoluteHttpsUrl(body.actorUri)) { - return error(400, translate(locale, 'metadata.actorUriHttps')) + return status(400, translate(locale, 'metadata.actorUriHttps')) } try { return await ActivityPod.verifyActorMetadata(user, body.actorUri) } catch (e) { console.error('Error while verifying actor metadata:', e) - return error(502, translate(locale, 'metadata.verifyActorFailed')) + return status(502, translate(locale, 'metadata.verifyActorFailed')) } }, { @@ -46,28 +44,28 @@ const actorMetadataPlugin = new Elysia({ name: 'actorMetadata' }) 401: t.String(), 502: t.String() }, - detail: 'Verify rel=me links for the authenticated ActivityPub actor metadata' + detail: { description: 'Verify rel=me links for the authenticated ActivityPub actor metadata' } } ) .post( '/actor-metadata/verify-link', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) if (!user?.endpoint || !user?.userName) { - return error(401, translate(locale, 'common.mustBeSignedIn')) + return status(401, translate(locale, 'common.mustBeSignedIn')) } if (!isAbsoluteHttpsUrl(body.href)) { - return error(400, translate(locale, 'metadata.hrefHttps')) + return status(400, translate(locale, 'metadata.hrefHttps')) } if (body.actorUri && !isAbsoluteHttpsUrl(body.actorUri)) { - return error(400, translate(locale, 'metadata.actorUriHttps')) + return status(400, translate(locale, 'metadata.actorUriHttps')) } try { return await ActivityPod.verifyRelMeLink(user, body.href, body.actorUri) } catch (e) { console.error('Error while verifying rel=me link:', e) - return error(502, translate(locale, 'metadata.verifyRelMeFailed')) + return status(502, translate(locale, 'metadata.verifyRelMeFailed')) } }, { @@ -81,7 +79,7 @@ const actorMetadataPlugin = new Elysia({ name: 'actorMetadata' }) 401: t.String(), 502: t.String() }, - detail: 'Verify a single rel=me link against the authenticated ActivityPub actor' + detail: { description: 'Verify a single rel=me link against the authenticated ActivityPub actor' } } ) diff --git a/api/src/routes/apBridgeWebhook.ts b/api/src/routes/apBridgeWebhook.ts index 990a365e6..a6ddb8003 100644 --- a/api/src/routes/apBridgeWebhook.ts +++ b/api/src/routes/apBridgeWebhook.ts @@ -57,7 +57,7 @@ const apBridgeWebhookPlugin = new Elysia({ name: 'ap-bridge-webhook', prefix: '/ }, { body: t.Any(), - detail: 'Receives AP relay activities from the fedify sidecar for feed inclusion', + detail: { description: 'Receives AP relay activities from the fedify sidecar for feed inclusion' }, }, ) diff --git a/api/src/routes/atBridge.ts b/api/src/routes/atBridge.ts index c37567579..75bf0215f 100644 --- a/api/src/routes/atBridge.ts +++ b/api/src/routes/atBridge.ts @@ -19,6 +19,7 @@ */ import Elysia, { t } from 'elysia' +import { signedIn, signedInGuard } from './elysiaCompat' import { db } from '../db/client' import { atPosts, atIdentities, atFirehoseCursors, unifiedFeedView, atRecords, unifiedFeedCandidatesView, apRemotePosts, apActorCache } from '../db/atBridgeSchema' import { desc, eq, and, sql, ilike, or, gt, inArray, type SQL } from 'drizzle-orm' @@ -48,6 +49,7 @@ import { type RepostRecordInput, } from '../utils/repostGroups' import ActivityPod from '../services/ActivityPod' +import { BlueskyAppViewClient } from '../services/BlueskyAppViewClient' type UnifiedFeedRow = { id: number @@ -431,8 +433,7 @@ async function resolveViewerModerationState( } } -const BSKY_PUBLIC_API = 'https://public.api.bsky.app/xrpc' -const BSKY_PROFILE_BATCH_SIZE = 25 +const blueskyAppViewClient = BlueskyAppViewClient.fromEnv(process.env, console) /** * For AT rows whose author_name is still a raw DID (handle not yet resolved), @@ -443,78 +444,66 @@ async function resolveAndCacheHandles(dids: string[]): Promise() if (dids.length === 0) return resolved - // Process in batches of BSKY_PROFILE_BATCH_SIZE (API limit is 25). - for (let i = 0; i < dids.length; i += BSKY_PROFILE_BATCH_SIZE) { - const batch = dids.slice(i, i + BSKY_PROFILE_BATCH_SIZE) - const params = batch.map(d => `actors[]=${encodeURIComponent(d)}`).join('&') - try { - const resp = await fetch(`${BSKY_PUBLIC_API}/app.bsky.actor.getProfiles?${params}`, { - signal: AbortSignal.timeout(4000), - headers: { 'Accept': 'application/json' }, - }) - if (!resp.ok) { - console.warn('[AT Bridge] getProfiles returned', resp.status, 'for batch', batch.slice(0, 3)) - continue - } - const data = await resp.json() as { - profiles?: Array<{ - did: string - handle: string - displayName?: string - avatar?: string - banner?: string - followersCount?: number - followsCount?: number - postsCount?: number - }> - } - const profiles = data?.profiles ?? [] - for (const profile of profiles) { - if (!profile.did || !profile.handle) continue - resolved.set(profile.did, profile.handle) - } - // Upsert resolved handles + profile data into at_identities cache (fire-and-forget; don't block response). - const upsertValues = profiles - .filter(p => p.did && p.handle) - .map(p => ({ - did: p.did, - handle: p.handle, - displayName: p.displayName ?? null, - avatarUrl: p.avatar ?? null, - bannerUrl: p.banner ?? null, - followersCount: typeof p.followersCount === 'number' ? p.followersCount : null, - followsCount: typeof p.followsCount === 'number' ? p.followsCount : null, - postsCount: typeof p.postsCount === 'number' ? p.postsCount : null, - isActive: true, - resolvedAt: new Date(), - updatedAt: new Date(), - })) - if (upsertValues.length > 0) { - db.insert(atIdentities) - .values(upsertValues) - .onConflictDoUpdate({ - target: atIdentities.did, - set: { - handle: sql`EXCLUDED.handle`, - displayName: sql`EXCLUDED.display_name`, - avatarUrl: sql`EXCLUDED.avatar_url`, - bannerUrl: sql`EXCLUDED.banner_url`, - followersCount: sql`EXCLUDED.followers_count`, - followsCount: sql`EXCLUDED.follows_count`, - postsCount: sql`EXCLUDED.posts_count`, - resolvedAt: new Date(), - updatedAt: new Date(), - }, - }) - .catch(err => console.warn('[AT Bridge] Failed to cache resolved handles:', err)) - } - } catch (err) { - console.warn('[AT Bridge] resolveAndCacheHandles batch failed:', err) + try { + const profiles = await blueskyAppViewClient.getProfiles(dids) + for (const profile of profiles) resolved.set(profile.did, profile.handle) + + const upsertValues = profiles.map(profile => ({ + did: profile.did, + handle: profile.handle, + displayName: profile.displayName ?? null, + avatarUrl: profile.avatar ?? null, + bannerUrl: profile.banner ?? null, + followersCount: typeof profile.followersCount === 'number' ? profile.followersCount : null, + followsCount: typeof profile.followsCount === 'number' ? profile.followsCount : null, + postsCount: typeof profile.postsCount === 'number' ? profile.postsCount : null, + isActive: true, + resolvedAt: new Date(), + updatedAt: new Date(), + })) + + if (upsertValues.length > 0) { + db.insert(atIdentities) + .values(upsertValues) + .onConflictDoUpdate({ + target: atIdentities.did, + set: { + handle: sql`EXCLUDED.handle`, + displayName: sql`EXCLUDED.display_name`, + avatarUrl: sql`EXCLUDED.avatar_url`, + bannerUrl: sql`EXCLUDED.banner_url`, + followersCount: sql`EXCLUDED.followers_count`, + followsCount: sql`EXCLUDED.follows_count`, + postsCount: sql`EXCLUDED.posts_count`, + resolvedAt: new Date(), + updatedAt: new Date(), + }, + }) + .catch(err => console.warn('[AT Bridge] Failed to cache resolved handles:', err)) } + } catch (err) { + console.warn('[AT Bridge] resolveAndCacheHandles failed:', err) } return resolved } +function canonicalFeedDedupeKey(row: UnifiedFeedRow): string { + const uri = feedRowUri(row) + return uri ?? `${row.source}:${row.id}` +} + +function dedupeFeedRows(rows: T[]): T[] { + const byKey = new Map() + for (const row of rows) { + const key = canonicalFeedDedupeKey(row) + const existing = byKey.get(key) + if (!existing || feedSortTimestamp(row) > feedSortTimestamp(existing)) { + byKey.set(key, row) + } + } + return [...byKey.values()] +} + function hasEmbedsMedia(embeds: unknown): boolean { if (!embeds) return false if (Array.isArray(embeds)) return embeds.length > 0 @@ -1649,7 +1638,7 @@ const subscribeBody = t.Object({ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) .use(setupPlugin) - .guard({ as: 'scoped', isSignedIn: true }) + .guard(signedInGuard) // ------------------------------------------------------------------------- // GET /at/feed — Unified feed (AT + ActivityPods) @@ -1678,13 +1667,13 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) ? (() => { const d = new Date(since); return isNaN(d.getTime()) ? null : d })() : null - const candidateRows = await queryFeedCandidates({ + const candidateRows = dedupeFeedRows(await queryFeedCandidates({ fetchLimit, source: source && source !== 'all' ? source : null, hashtag: hashtag?.trim().length ? hashtag : null, sinceDate, viewerIds: extractCurrentUserIds(user), - }) + })) const queryDuration = Date.now() - queryStartTime console.log('[AT Bridge /feed] Query executed', { duration: queryDuration, rows: candidateRows.length }) @@ -1806,17 +1795,21 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { query: feedQuery, - detail: 'Returns a unified feed of ActivityPods and AT Protocol posts', - isSignedIn: true, + detail: { description: 'Returns a unified feed of ActivityPods and AT Protocol posts' }, + ...signedIn, }, ) .get( '/thread', - async ({ query: { rootUri, limit, cursor }, error, user }) => { + async ({ query: { rootUri, limit, cursor }, set, user }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const normalizedRootUri = normalizeThreadUri(rootUri) if (!normalizedRootUri) { - return error(400, 'Invalid rootUri') + return status(400, 'Invalid rootUri') } const offset = decodeCursor(cursor) @@ -1897,13 +1890,13 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) return response } catch (err) { console.error('[AT Bridge] Failed to fetch thread context:', err) - return error(500, 'Failed to fetch thread context') + return status(500, 'Failed to fetch thread context') } }, { query: threadQuery, - detail: 'Returns paginated thread context for a root URI', - isSignedIn: true, + detail: { description: 'Returns paginated thread context for a root URI' }, + ...signedIn, response: { 200: t.Object({ rootUri: t.String(), @@ -1968,10 +1961,14 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) .post( '/reposts', - async ({ body, user, error }) => { + async ({ body, user, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const targetUri = normalizeRepostTarget(body) if (!targetUri) { - return error(400, 'Choose a valid ActivityPub object URL or AT URI to repost') + return status(400, 'Choose a valid ActivityPub object URL or AT URI to repost') } const actorId = normalizeString(user.atprotoDid) ?? user.getWebId() @@ -2027,8 +2024,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { body: repostBody, - detail: 'Create a canonical repost/boost for a feed object', - isSignedIn: true, + detail: { description: 'Create a canonical repost/boost for a feed object' }, + ...signedIn, response: { 200: t.Object({ ok: t.Boolean(), @@ -2044,10 +2041,14 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) .post( '/reposts/remove', - async ({ body, user, error }) => { + async ({ body, user, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const targetUri = normalizeRepostTarget(body) if (!targetUri) { - return error(400, 'Choose a valid ActivityPub object URL or AT URI to remove from reposts') + return status(400, 'Choose a valid ActivityPub object URL or AT URI to remove from reposts') } const actorId = normalizeString(user.atprotoDid) ?? user.getWebId() @@ -2092,8 +2093,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { body: repostBody, - detail: 'Remove the current viewer canonical repost/boost for a feed object', - isSignedIn: true, + detail: { description: 'Remove the current viewer canonical repost/boost for a feed object' }, + ...signedIn, response: { 200: t.Object({ ok: t.Boolean(), @@ -2108,10 +2109,14 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) .post( '/moderation/author', - async ({ body, user, error }) => { + async ({ body, user, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const subject = deriveModerationSubject(body) if (!subject) { - return error(400, 'Unable to determine moderation subject') + return status(400, 'Unable to determine moderation subject') } try { @@ -2124,7 +2129,7 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) } } catch (err) { console.error('[AT Bridge] Failed to create viewer moderation decision:', err) - return error(502, 'Failed to create viewer moderation decision') + return status(502, 'Failed to create viewer moderation decision') } }, { @@ -2134,8 +2139,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) authorWebId: t.String({ minLength: 1, maxLength: 2048 }), atUri: t.Optional(t.Union([t.String({ minLength: 1, maxLength: 3072 }), t.Null()])), }), - detail: 'Create a viewer-specific block or mute for a feed author', - isSignedIn: true, + detail: { description: 'Create a viewer-specific block or mute for a feed author' }, + ...signedIn, response: { 200: t.Object({ ok: t.Boolean(), @@ -2151,10 +2156,14 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) .post( '/feed/viewed', - async ({ body, user, error }) => { + async ({ body, user, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const objectIds = [...new Set([body.objectId, ...(body.objectIds ?? [])].filter((value): value is string => !!value && value.trim().length > 0))] if (objectIds.length === 0) { - return error(400, 'objectId or objectIds is required') + return status(400, 'objectId or objectIds is required') } try { @@ -2162,13 +2171,13 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) return { ok: true, recorded: objectIds.length } } catch (err) { console.error('[AT Bridge] Failed to record viewed feed objects:', err) - return error(502, 'Failed to record viewed feed objects') + return status(502, 'Failed to record viewed feed objects') } }, { body: feedViewedBody, - detail: 'Record viewed objects for the signed-in user', - isSignedIn: true, + detail: { description: 'Record viewed objects for the signed-in user' }, + ...signedIn, response: { 200: t.Object({ ok: t.Boolean(), @@ -2244,8 +2253,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { query: paginationQuery, - detail: 'Returns AT Protocol posts from the federated firehose', - isSignedIn: true, + detail: { description: 'Returns AT Protocol posts from the federated firehose' }, + ...signedIn, }, ) @@ -2307,8 +2316,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { query: recordsQuery, - detail: 'Returns raw records for supported Bluesky and standard.site lexicons', - isSignedIn: true, + detail: { description: 'Returns raw records for supported Bluesky and standard.site lexicons' }, + ...signedIn, }, ) @@ -2340,8 +2349,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) }, { query: paginationQuery, - detail: 'Returns cached AT Protocol identities', - isSignedIn: true, + detail: { description: 'Returns cached AT Protocol identities' }, + ...signedIn, }, ) @@ -2383,8 +2392,8 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) } }, { - detail: 'Returns firehose ingestion health and cursor status', - isSignedIn: true, + detail: { description: 'Returns firehose ingestion health and cursor status' }, + ...signedIn, }, ) @@ -2393,17 +2402,21 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) // ------------------------------------------------------------------------- .post( '/subscribe', - async ({ body, error }) => { + async ({ body, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const { sourceId, url, sourceType } = body // Validate URL format try { const parsed = new URL(url) if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { - return error(400, 'Source URL must use ws:// or wss:// protocol') + return status(400, 'Source URL must use ws:// or wss:// protocol') } } catch { - return error(400, 'Invalid source URL format') + return status(400, 'Invalid source URL format') } try { @@ -2425,22 +2438,24 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) return { success: true, - message: `Registered AT firehose source: ${sourceId}`, + message: `Stored AT source metadata for UI visibility: ${sourceId}`, + warning: 'Memory app does not own AT source runtime. Configure firehose sources in fedify-sidecar.', sourceId, } } catch (err) { console.error('[AT Bridge] Failed to register AT source:', err) - return error(500, 'Failed to register AT firehose source') + return status(500, 'Failed to register AT firehose source') } }, { body: subscribeBody, - detail: 'Register a new AT Protocol firehose source', - isSignedIn: true, + detail: { description: 'Register a new AT Protocol firehose source' }, + ...signedIn, response: { 200: t.Object({ success: t.Boolean(), message: t.String(), + warning: t.String(), sourceId: t.String(), }), 400: t.String(), @@ -2469,7 +2484,11 @@ export const xrpcFeedPlugin = new Elysia({ name: 'xrpc-feed', prefix: '/xrpc' }) .use(setupPlugin) .get( '/app.bsky.feed.getFeedSkeleton', - async ({ query: { feed, limit, cursor }, error: elysiaError }) => { + async ({ query: { feed, limit, cursor }, set }) => { + const elysiaError = (code: number, body: unknown) => { + set.status = code + return body + } // Determine mode from the feed AT URI's rkey. const feedRkey = (() => { if (!feed) return 'memory-unified' @@ -2490,12 +2509,12 @@ export const xrpcFeedPlugin = new Elysia({ name: 'xrpc-feed', prefix: '/xrpc' }) const fetchLimit = Math.min(300, Math.max(60, normalizedLimit * 6)) try { - const candidateRows = await queryFeedCandidates({ + const candidateRows = dedupeFeedRows(await queryFeedCandidates({ fetchLimit, source: null, hashtag: null, sinceDate: null, - }) + })) const cursorScopedRows = keysetCursor !== null ? candidateRows.filter(row => isBeforeFeedCursor(row, keysetCursor)) @@ -2531,6 +2550,6 @@ export const xrpcFeedPlugin = new Elysia({ name: 'xrpc-feed', prefix: '/xrpc' }) limit: t.Optional(t.Integer({ default: 30, minimum: 1, maximum: 100 })), cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })), }), - detail: 'AT Protocol feed generator skeleton — returns an ordered list of post AT URIs for Bluesky clients', + detail: { description: 'AT Protocol feed generator skeleton — returns an ordered list of post AT URIs for Bluesky clients' }, }, ) diff --git a/api/src/routes/atBridgeWebhook.ts b/api/src/routes/atBridgeWebhook.ts index 0da202899..a381d32d2 100644 --- a/api/src/routes/atBridgeWebhook.ts +++ b/api/src/routes/atBridgeWebhook.ts @@ -141,7 +141,7 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ state: t.Optional(t.String()), })), ]), - detail: 'Receive trusted bridge events (legacy ingress + canonical intents) from the federation pipeline', + detail: { description: 'Receive trusted bridge events (legacy ingress + canonical intents) from the federation pipeline' }, response: { 200: t.Object({ processed: t.Number(), @@ -165,7 +165,7 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ bridgeConfigured: !!process.env.FIREHOSE_BRIDGE_SECRET, }), { - detail: 'AT Protocol bridge webhook health check', + detail: { description: 'AT Protocol bridge webhook health check' }, }, ) diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 363ea5fc4..2411722e2 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -38,12 +38,16 @@ const authPlugin = new Elysia({name: 'auth'}) .use(setupPlugin) .post( '/signin', - async ({ body, jwt, headers, error }) => { + async ({ body, jwt, headers, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const locale = localeFromHeaders(headers) const auth = headers.auth // check if user is already logged in if (auth && isCurrentSessionToken(await jwt.verify(auth))) { - return error(204, translate(locale, 'auth.alreadyLoggedIn')) + return status(204, translate(locale, 'auth.alreadyLoggedIn')) } const { username, password, providerEndpoint } = body @@ -54,12 +58,12 @@ const authPlugin = new Elysia({name: 'auth'}) providerResponse = await ActivityPod.signIn(providerEndpoint, username, password) } catch (e) { console.error('Error while logging in to endpoint: ', e) - return error(400, translate(locale, 'auth.endpointBadStatus')) + return status(400, translate(locale, 'auth.endpointBadStatus')) } // check if the endpoint returned a token if (providerResponse.token === undefined) { - return error(400, translate(locale, 'auth.endpointNoToken')) + return status(400, translate(locale, 'auth.endpointNoToken')) } else { let dbUser: SelectUsers[] = [] // the endpoint returned like expected now check if the user is already in the database @@ -88,7 +92,7 @@ const authPlugin = new Elysia({name: 'auth'}) } } catch (e) { console.error('Error while checking if user is in the database: ', e) - return error(500, translate(locale, 'auth.userCheckFailed')) + return status(500, translate(locale, 'auth.userCheckFailed')) } const linkedUser = await maybeSyncAtprotoIdentity( dbUser[0], @@ -114,7 +118,7 @@ const authPlugin = new Elysia({name: 'auth'}) 400: t.String(), 500: t.String() }, - detail: 'Logs in a with a pod provider and sets an auth cookie for the user' + detail: { description: 'Logs in a with a pod provider and sets an auth cookie for the user' } } ) .get( @@ -125,12 +129,16 @@ const authPlugin = new Elysia({name: 'auth'}) return translate(locale, 'auth.loggedOut') }, { - detail: 'Removes the auth cookie' + detail: { description: 'Removes the auth cookie' } } ) .post( '/signup', - async ({ body, error, headers, jwt }) => { + async ({ body, headers, jwt, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const locale = localeFromHeaders(headers) const auth = headers.auth // check if user is already logged in @@ -145,7 +153,7 @@ const authPlugin = new Elysia({name: 'auth'}) let userResponse: SelectUsers[] = [] if (providerResponse.token === undefined) { - return error(400, translate(locale, 'auth.providerNoToken')) + return status(400, translate(locale, 'auth.providerNoToken')) } else { // the provider created a new user, so we need to create a new user in the database try { @@ -168,7 +176,7 @@ const authPlugin = new Elysia({name: 'auth'}) } } catch (e) { console.error('Error while checking if user is in the database: ', e) - return error(500, translate(locale, 'auth.userCheckFailed')) + return status(500, translate(locale, 'auth.userCheckFailed')) } const linkedUser = await maybeSyncAtprotoIdentity( userResponse[0], @@ -187,14 +195,14 @@ const authPlugin = new Elysia({name: 'auth'}) if (e.name === 'HTTPError') { const errorJson = await e.response.json() console.error('Error while signing up the user', errorJson) - return error(errorJson.code, errorJson.message) + return status(errorJson.code, errorJson.message) } console.error('Error while signing up the user', e) - return error(400, translate(locale, 'auth.providerError')) + return status(400, translate(locale, 'auth.providerError')) } }, { - detail: 'Signs up a new user', + detail: { description: 'Signs up a new user' }, body: signUpBody, response: { 200: signinResponse, diff --git a/api/src/routes/bookmarks.ts b/api/src/routes/bookmarks.ts index 4c0ddb4ef..ecb6ff406 100644 --- a/api/src/routes/bookmarks.ts +++ b/api/src/routes/bookmarks.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import { and, eq } from 'drizzle-orm' import { db } from '../db/client' import { bookmarks } from '../db/schema' @@ -12,18 +13,15 @@ function makePostId(source: string, atUri?: string | null, objectUri?: string | const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) // ------------------------------------------------------------------ // GET /bookmarks — list all bookmarks for the authenticated user // ------------------------------------------------------------------ .get( '/bookmarks', - async ({ user, error }: any) => { + async ({ user, status }: any) => { const userId: number = user.userId - if (!userId) return error(401, 'Unauthorized') + if (!userId) return status(401, 'Unauthorized') const rows = await db .select() .from(bookmarks) @@ -48,7 +46,7 @@ const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) })), 401: t.String(), }, - detail: 'List all bookmarks for the authenticated user', + detail: { description: 'List all bookmarks for the authenticated user' }, } ) // ------------------------------------------------------------------ @@ -56,16 +54,16 @@ const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) // ------------------------------------------------------------------ .post( '/bookmarks', - async ({ body, user, error }: any) => { + async ({ body, user, status }: any) => { const userId: number = user.userId - if (!userId) return error(401, 'Unauthorized') + if (!userId) return status(401, 'Unauthorized') const { source, atUri, objectUri } = body as { source: string atUri?: string | null objectUri?: string | null } if (!atUri && !objectUri) { - return error(400, 'One of atUri or objectUri is required') + return status(400, 'One of atUri or objectUri is required') } // Check for duplicate — one bookmark per user per URI. const existing = await db @@ -102,7 +100,7 @@ const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) 400: t.String(), 401: t.String(), }, - detail: 'Bookmark a post', + detail: { description: 'Bookmark a post' }, } ) // ------------------------------------------------------------------ @@ -110,15 +108,15 @@ const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) // ------------------------------------------------------------------ .delete( '/bookmarks', - async ({ body, user, error }: any) => { + async ({ body, user, status }: any) => { const userId: number = user.userId - if (!userId) return error(401, 'Unauthorized') + if (!userId) return status(401, 'Unauthorized') const { atUri, objectUri } = body as { atUri?: string | null objectUri?: string | null } if (!atUri && !objectUri) { - return error(400, 'One of atUri or objectUri is required') + return status(400, 'One of atUri or objectUri is required') } await db .delete(bookmarks) @@ -142,7 +140,7 @@ const bookmarksPlugin = new Elysia({ name: 'bookmarks' }) 400: t.String(), 401: t.String(), }, - detail: 'Remove a bookmark', + detail: { description: 'Remove a bookmark' }, } ) diff --git a/api/src/routes/chat/chatRoutes.ts b/api/src/routes/chat/chatRoutes.ts index cf3c31a9a..e6e98cb95 100644 --- a/api/src/routes/chat/chatRoutes.ts +++ b/api/src/routes/chat/chatRoutes.ts @@ -13,6 +13,7 @@ */ import Elysia, { t } from 'elysia' +import { signedInGuard } from '../elysiaCompat' import { createHash, randomUUID } from 'node:crypto' import { eq, and, desc, lt, sql, ilike } from 'drizzle-orm' import { db } from '../../db/client' @@ -319,7 +320,7 @@ async function persistCommittedDmProjection(args: { export const chatRoutes = new Elysia() .use(setupPlugin) - .guard({ as: 'scoped', isSignedIn: true }) + .guard(signedInGuard) // --------------------------------------------------------------------------- // GET /chat/listConvos @@ -664,7 +665,7 @@ export const chatRoutes = new Elysia() return { error: error.message } } console.error('[chatRoutes] Pod DM commit failed', { - error: error instanceof Error ? error.message : String(error), + status: error instanceof Error ? error.message : String(error), actor: actorUri, convoId, }) diff --git a/api/src/routes/conversations.ts b/api/src/routes/conversations.ts index 1472a8871..e612d48f3 100644 --- a/api/src/routes/conversations.ts +++ b/api/src/routes/conversations.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedIn, signedInGuard } from './elysiaCompat' import { conversations, conversationMembers, messages, users } from '../db/schema' import { db } from '../db/client' import setupPlugin from './setup' @@ -18,10 +19,7 @@ export interface ConversationPreview { const conversationsRoutes = new Elysia({ name: 'conversations' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) .get( '/conversations', async ({ user, headers }) => { @@ -103,8 +101,8 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) } }, { - detail: 'Get all conversations for the current user', - isSignedIn: true, + detail: { description: 'Get all conversations for the current user' }, + ...signedIn, response: { 200: t.Array(t.Any()), }, @@ -112,7 +110,11 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) ) .get( '/conversations/:id', - async ({ params: { id }, user, headers, error }) => { + async ({ params: { id }, user, headers, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const locale = localeFromHeaders(headers) try { const conv = await db @@ -122,7 +124,7 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) .limit(1) if (!conv.length || conv[0].userId !== user.userId) { - return error(404, translate(locale, 'conversations.notFound')) + return status(404, translate(locale, 'conversations.notFound')) } const msgs = await db @@ -141,17 +143,21 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) return { conversation: conv[0], messages: msgs } } catch (e) { console.error('Error fetching conversation:', e) - return error(500, translate(locale, 'conversations.fetchFailed')) + return status(500, translate(locale, 'conversations.fetchFailed')) } }, { - detail: 'Get a specific conversation with all messages', - isSignedIn: true, + detail: { description: 'Get a specific conversation with all messages' }, + ...signedIn, } ) .post( '/conversations', - async ({ body, user, headers, error }) => { + async ({ body, user, headers, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const locale = localeFromHeaders(headers) const { type, name, memberIds } = body as { type: 'dm' | 'group' @@ -182,7 +188,7 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) return newConv } catch (e) { console.error('Error creating conversation:', e) - return error(500, translate(locale, 'conversations.createFailed')) + return status(500, translate(locale, 'conversations.createFailed')) } }, { @@ -191,13 +197,17 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) name: t.Optional(t.String()), memberIds: t.Array(t.Number()), }), - detail: 'Create a new conversation', - isSignedIn: true, + detail: { description: 'Create a new conversation' }, + ...signedIn, } ) .post( '/conversations/:id/messages', - async ({ params: { id }, body, user, headers, error }) => { + async ({ params: { id }, body, user, headers, set }) => { + const status = (code: number, message: string) => { + set.status = code + return message + } const locale = localeFromHeaders(headers) const { content } = body as { content: string } @@ -217,7 +227,7 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) .limit(1) if (!member.length) { - return error(403, translate(locale, 'conversations.notMember')) + return status(403, translate(locale, 'conversations.notMember')) } // Create message @@ -245,15 +255,15 @@ const conversationsRoutes = new Elysia({ name: 'conversations' }) } } catch (e) { console.error('Error sending message:', e) - return error(500, translate(locale, 'conversations.sendFailed')) + return status(500, translate(locale, 'conversations.sendFailed')) } }, { body: t.Object({ content: t.String({ minLength: 1 }), }), - detail: 'Send a message in a conversation', - isSignedIn: true, + detail: { description: 'Send a message in a conversation' }, + ...signedIn, } ) diff --git a/api/src/routes/elysiaCompat.ts b/api/src/routes/elysiaCompat.ts new file mode 100644 index 000000000..2d8453b03 --- /dev/null +++ b/api/src/routes/elysiaCompat.ts @@ -0,0 +1,3 @@ +export const signedIn = { isSignedIn: true } as const + +export const signedInGuard = { as: 'scoped', ...signedIn, } as any diff --git a/api/src/routes/follow.ts b/api/src/routes/follow.ts index 1a0041a5b..0a2c9e52a 100644 --- a/api/src/routes/follow.ts +++ b/api/src/routes/follow.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import ActivityPod from '../services/ActivityPod' import setupPlugin from './setup' import { localeFromHeaders, translate } from '../i18n' @@ -14,22 +15,19 @@ const isAbsoluteHttpsUrl = (value: string): boolean => { const followPlugin = new Elysia({ name: 'follow' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) .post( '/follows/resolve', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) if (!isAbsoluteHttpsUrl(body.objectUri)) { - return error(400, translate(locale, 'follow.objectUriHttps')) + return status(400, translate(locale, 'follow.objectUriHttps')) } try { return await ActivityPod.resolveFollowTarget(user, body.objectUri) } catch (e) { console.error('Error while resolving follow target:', e) - return error(502, translate(locale, 'follow.resolveFailed')) + return status(502, translate(locale, 'follow.resolveFailed')) } }, { @@ -42,22 +40,22 @@ const followPlugin = new Elysia({ name: 'follow' }) 401: t.String(), 502: t.String() }, - detail: 'Resolve whether an ActivityPub object is followable and where the Follow will be delivered' + detail: { description: 'Resolve whether an ActivityPub object is followable and where the Follow will be delivered' } } ) .post( '/follows', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) const { objectUri } = body if (!isAbsoluteHttpsUrl(objectUri)) { - return error(400, translate(locale, 'follow.objectUriHttps')) + return status(400, translate(locale, 'follow.objectUriHttps')) } try { await ActivityPod.followObject(user, objectUri) } catch (e) { console.error('Error while following object:', e) - return error(502, translate(locale, 'follow.failed')) + return status(502, translate(locale, 'follow.failed')) } return { followed: true } }, @@ -69,7 +67,7 @@ const followPlugin = new Elysia({ name: 'follow' }) 401: t.String(), 502: t.String() }, - detail: 'Follow a followable ActivityPub object via FEP-efda resolution' + detail: { description: 'Follow a followable ActivityPub object via FEP-efda resolution' } } ) diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 7f93e9987..6766b4ec0 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -16,3 +16,5 @@ export { default as apBridgeWebhookPlugin } from './apBridgeWebhook' export { default as chatPlugin } from './chat' export { default as linkPreviewPlugin } from './linkPreview' export { default as bookmarksPlugin } from './bookmarks' +export { default as mediaUploadsPlugin } from './mediaUploads' +export { default as mediaSidecarCallbackPlugin } from './mediaSidecarCallback' diff --git a/api/src/routes/linkPreview.ts b/api/src/routes/linkPreview.ts index a46be5453..a535d66f4 100644 --- a/api/src/routes/linkPreview.ts +++ b/api/src/routes/linkPreview.ts @@ -1,16 +1,20 @@ import Elysia, { t } from 'elysia' +import { signedIn } from './elysiaCompat' import { fetchLinkPreview } from '../services/LinkPreviewService' const linkPreviewPlugin = new Elysia({ name: 'link-preview' }) .get( '/link-preview', - async ({ query, error }) => { + async ({ query, set }) => { try { return await fetchLinkPreview(query.url) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to fetch link preview' - const status = /url|http\(s\)|private|local/i.test(message) ? 400 : 502 - return error(status, message) + const responseStatus = /url|http\(s\)|private|local|numeric|safe browsing|credentials|dns|redirect|too many/i.test(message) + ? 400 + : 502 + set.status = responseStatus + return message } }, { @@ -30,8 +34,8 @@ const linkPreviewPlugin = new Elysia({ name: 'link-preview' }) 400: t.String(), 502: t.String(), }, - detail: 'Fetches Open Graph metadata for a URL to power link previews', - isSignedIn: true, + detail: { description: 'Fetches Open Graph metadata for a URL to power link previews' }, + ...signedIn, }, ) diff --git a/api/src/routes/mastodonApi.ts b/api/src/routes/mastodonApi.ts index 7a37ae972..0499603b4 100644 --- a/api/src/routes/mastodonApi.ts +++ b/api/src/routes/mastodonApi.ts @@ -82,7 +82,7 @@ const mastodonApiPlugin = new Elysia({ name: 'mastodonApi' }) 401: t.Object({ error: t.String() }), 502: t.Object({ error: t.String() }) }, - detail: 'Mastodon-compatible credential account response' + detail: { description: 'Mastodon-compatible credential account response' } } ) .patch( @@ -128,7 +128,7 @@ const mastodonApiPlugin = new Elysia({ name: 'mastodonApi' }) 422: t.Object({ error: t.String() }), 502: t.Object({ error: t.String() }) }, - detail: 'Mastodon-compatible account credential update' + detail: { description: 'Mastodon-compatible account credential update' } } ) .get( @@ -154,7 +154,7 @@ const mastodonApiPlugin = new Elysia({ name: 'mastodonApi' }) 401: t.Object({ error: t.String() }), 502: t.Object({ error: t.String() }) }, - detail: 'Mastodon-compatible profile response' + detail: { description: 'Mastodon-compatible profile response' } } ) .patch( @@ -200,7 +200,7 @@ const mastodonApiPlugin = new Elysia({ name: 'mastodonApi' }) 422: t.Object({ error: t.String() }), 502: t.Object({ error: t.String() }) }, - detail: 'Mastodon-compatible profile update' + detail: { description: 'Mastodon-compatible profile update' } } ) diff --git a/api/src/routes/mediaSidecarCallback.ts b/api/src/routes/mediaSidecarCallback.ts new file mode 100644 index 000000000..defc5cf6a --- /dev/null +++ b/api/src/routes/mediaSidecarCallback.ts @@ -0,0 +1,155 @@ +/** + * Media Pipeline Sidecar — Processed Asset Callback + * + * Receives the `ActivityPodsCallbackPayload` that the media-pipeline-sidecar + * delivers after it has finished processing an uploaded media asset. The + * sidecar already retries with exponential back-off (up to 4 attempts, 10 s + * timeout) and treats HTTP 409 as success, so this endpoint is designed to + * be idempotent. + * + * Matching is done by `sourceUrl` (the pod LDP URL produced by the raw upload + * and stored in `media_attachments.source_url`). The sidecar carries this + * value through from the ActivityPods LDP-created event. + * + * Authentication: + * Bearer token in the `Authorization` header, compared with + * `MEMORY_SIDECAR_CALLBACK_TOKEN` using a constant-time comparison to + * prevent timing-oracle attacks. + * + * Configuration (env vars): + * MEMORY_SIDECAR_CALLBACK_TOKEN — shared secret; if unset the endpoint + * returns 503 so mis-configuration is + * visible rather than silently open. + */ + +import Elysia, { t } from 'elysia' +import { timingSafeEqual } from 'node:crypto' +import { updateMediaAttachmentProcessed } from '../services/MediaAttachments' + +// CanonicalAsset.duration is produced by ffprobe's format.duration which is +// in fractional seconds. Memory stores durationMs (integer milliseconds). +function parseDurationMs(value: unknown): number | null { + if (value === undefined || value === null || value === '') return null + const seconds = Number(value) + if (!Number.isFinite(seconds) || seconds < 0) return null + return Math.round(seconds * 1000) +} + +function extractBearer(authorizationHeader: string | undefined): string | null { + if (!authorizationHeader) return null + const match = authorizationHeader.match(/^Bearer (.+)$/) + return match ? match[1] : null +} + +function safeCompare(a: string, b: string): boolean { + // Constant-time length-aware comparison: pad the shorter string so both + // buffers are the same byte length before calling timingSafeEqual. + const aBuf = new Uint8Array(Buffer.from(a, 'utf8')) + const bBuf = new Uint8Array(Buffer.from(b, 'utf8')) + if (aBuf.byteLength !== bBuf.byteLength) { + // Still run a constant-time comparison against itself to avoid short- + // circuit timing leaks, then return false. + timingSafeEqual(aBuf, aBuf) + return false + } + return timingSafeEqual(aBuf, bBuf) +} + +const mediaSidecarCallbackPlugin = new Elysia({ + name: 'media-sidecar-callback', + prefix: '/media/sidecar', +}) + .post( + '/callback', + async ({ body, headers, set }) => { + // ── Authentication ────────────────────────────────────────────────────── + const expectedToken = process.env.MEMORY_SIDECAR_CALLBACK_TOKEN + if (!expectedToken) { + console.error('[MediaSidecarCallback] MEMORY_SIDECAR_CALLBACK_TOKEN is not configured') + set.status = 503 + return { received: false, error: 'callback_not_configured' } + } + + const providedToken = extractBearer(headers['authorization']) + if (!providedToken || !safeCompare(providedToken, expectedToken)) { + set.status = 401 + return { received: false, error: 'unauthorized' } + } + + // ── Extract fields from the CanonicalAsset ─────────────────────────── + const { asset } = body + const sourceUrls: string[] = Array.isArray(asset.sourceUrls) ? asset.sourceUrls : [] + + if (sourceUrls.length === 0) { + // No source URL means we cannot match to a media_attachments row. + // Return 200 so the sidecar does not endlessly retry for assets that + // were not originated from a Memory upload. + return { received: true, matched: false, reason: 'no_source_urls' } + } + + const processedUpdate = { + canonicalUrl: asset.canonicalUrl as string, + gatewayUrl: (asset.gatewayUrl as string | undefined) ?? null, + filebaseCid: (asset.cid as string | undefined) ?? null, + digestMultibase: (asset.digestMultibase as string | undefined) ?? null, + width: typeof asset.width === 'number' ? asset.width : null, + height: typeof asset.height === 'number' ? asset.height : null, + durationMs: parseDurationMs(asset.duration), + blurhash: (asset.blurhash as string | undefined) ?? null, + thumbnailUrl: (asset.variants?.thumbnail as string | undefined) ?? null, + previewUrl: (asset.variants?.preview as string | undefined) ?? null, + } + + // Try each sourceUrl in order; first match wins. + for (const sourceUrl of sourceUrls) { + const outcome = await updateMediaAttachmentProcessed(sourceUrl, processedUpdate) + + if (outcome === 'updated') { + return { received: true, matched: true, outcome } + } + + if (outcome === 'already_ready') { + // Idempotent re-delivery — sidecar expects 409 as a success signal. + set.status = 409 + return { received: true, matched: true, outcome } + } + + // outcome === 'not_found' — try the next sourceUrl + } + + // None of the sourceUrls matched an active media_attachments row. + // Return 200 (not 404) so the sidecar does not burn retries on assets + // that were processed outside of the Memory upload flow. + return { received: true, matched: false, reason: 'no_matching_attachment' } + }, + { + body: t.Object({ + asset: t.Object({ + assetId: t.Optional(t.String()), + canonicalUrl: t.String({ minLength: 1 }), + gatewayUrl: t.Optional(t.String()), + cid: t.Optional(t.String()), + digestMultibase: t.Optional(t.String()), + sourceUrls: t.Optional(t.Array(t.String())), + width: t.Optional(t.Number()), + height: t.Optional(t.Number()), + duration: t.Optional(t.Union([t.Number(), t.String()])), + blurhash: t.Optional(t.String()), + variants: t.Optional( + t.Object({ + original: t.Optional(t.String()), + thumbnail: t.Optional(t.String()), + preview: t.Optional(t.String()), + }, { additionalProperties: true }), + ), + }, { additionalProperties: true }), + signals: t.Optional(t.Any()), + bindings: t.Optional(t.Any()), + }), + detail: { + description: 'Internal: receives processed media asset results from the media-pipeline-sidecar', + }, + }, + ) + +export default mediaSidecarCallbackPlugin diff --git a/api/src/routes/mediaUploads.ts b/api/src/routes/mediaUploads.ts new file mode 100644 index 000000000..f953f09a2 --- /dev/null +++ b/api/src/routes/mediaUploads.ts @@ -0,0 +1,171 @@ +import Elysia from 'elysia' +import { signedIn } from './elysiaCompat' +import setupPlugin from './setup' +import ActivityPod from '../services/ActivityPod' +import { localeFromHeaders, translate } from '../i18n' +import { + buildPrivateMediaSlug, + chooseMediaType, + cleanupExpiredMediaAttachments, + createUploadingMediaAttachment, + deleteOwnedUnattachedMediaAttachment, + getOwnedMediaAttachment, + isMediaAttachmentId, + markMediaAttachmentFailed, + markMediaAttachmentUploaded, + normalizeMediaType, + sanitizeAltText, + sniffMediaType, + SUPPORTED_ATTACHMENT_MEDIA_TYPES, + toActivityPubMediaAttachment, + toPublicMediaAttachment, +} from '../services/MediaAttachments' + +const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +const MAX_UPLOAD_BYTES = parseUploadLimit(process.env.MEMORY_MEDIA_UPLOAD_MAX_BYTES) + +const mediaUploadsPlugin = new Elysia({ name: 'media-uploads' }) + .use(setupPlugin) + .post( + '/media/uploads', + async ({ request, set, user, headers }) => { + const locale = localeFromHeaders(headers) + const contentType = request.headers.get('content-type') || '' + if (!contentType.toLowerCase().includes('multipart/form-data')) { + set.status = 415 + return { error: translate(locale, 'media.upload.multipartRequired') } + } + + let formData: FormData + try { + formData = await request.formData() + } catch { + set.status = 400 + return { error: translate(locale, 'media.upload.invalidBody') } + } + + const file = formData.get('file') + if (!(file instanceof File)) { + set.status = 400 + return { error: translate(locale, 'media.upload.fileRequired') } + } + + if (!user?.token) { + set.status = 401 + return { error: translate(locale, 'common.mustBeSignedIn') } + } + + if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) { + set.status = 413 + return { error: translate(locale, 'media.upload.tooLarge') } + } + + const bytes = await file.arrayBuffer() + const declaredMediaType = normalizeMediaType(file.type) + const sniffedMediaType = sniffMediaType(new Uint8Array(bytes)) + const mediaType = chooseMediaType(declaredMediaType, sniffedMediaType) + if (!mediaType || !SUPPORTED_ATTACHMENT_MEDIA_TYPES.test(mediaType)) { + set.status = 415 + return { error: translate(locale, 'media.upload.unsupportedType') } + } + + await cleanupExpiredMediaAttachments(user.userId) + + const attachmentRow = await createUploadingMediaAttachment({ + userId: user.userId, + mediaType, + size: file.size, + originalFilename: file.name, + altText: sanitizeAltText(formData.get('altText')), + }) + + let uploaded: Awaited> + try { + const uploadFile = new File([bytes], file.name || 'media', { type: mediaType }) + uploaded = await ActivityPod.uploadMedia(user, uploadFile, buildPrivateMediaSlug(mediaType)) + } catch (error) { + const upstreamStatus = getUpstreamStatus(error) + console.error('[mediaUploads] ActivityPods media upload failed:', error) + await markMediaAttachmentFailed(attachmentRow.id, 'POD_UPLOAD_FAILED', 'Could not upload media to the user pod') + set.status = upstreamStatus === 401 || upstreamStatus === 403 ? 401 : 502 + return { error: translate(locale, 'media.upload.failed') } + } + + const updatedAttachment = await markMediaAttachmentUploaded(attachmentRow.id, uploaded.url) + const media = toPublicMediaAttachment(updatedAttachment) + + return { + id: updatedAttachment.id, + state: updatedAttachment.state, + url: uploaded.url, + mediaType: uploaded.mediaType, + size: uploaded.size, + media, + attachment: { + id: updatedAttachment.id, + ...toActivityPubMediaAttachment(updatedAttachment), + previewUrl: media.previewUrl || uploaded.url, + state: updatedAttachment.state, + }, + } + }, + { + detail: { description: 'Uploads image or video media into the signed-in user pod' }, + ...signedIn, + } + ) + .get( + '/media/uploads/:id', + async ({ params, set, user, headers }) => { + const locale = localeFromHeaders(headers) + if (!isMediaAttachmentId(params.id)) { + set.status = 400 + return { error: translate(locale, 'media.attachments.invalid') } + } + await cleanupExpiredMediaAttachments(user.userId) + const attachment = await getOwnedMediaAttachment(user.userId, params.id) + if (!attachment) { + set.status = 404 + return { error: translate(locale, 'media.attachments.notFound') } + } + return { media: toPublicMediaAttachment(attachment) } + }, + { + detail: { description: 'Returns upload lifecycle state for a media attachment' }, + ...signedIn, + } + ) + .delete( + '/media/uploads/:id', + async ({ params, set, user, headers }) => { + const locale = localeFromHeaders(headers) + if (!isMediaAttachmentId(params.id)) { + set.status = 400 + return { error: translate(locale, 'media.attachments.invalid') } + } + const attachment = await deleteOwnedUnattachedMediaAttachment(user.userId, params.id) + if (!attachment) { + set.status = 404 + return { error: translate(locale, 'media.attachments.notFound') } + } + return { media: toPublicMediaAttachment(attachment) } + }, + { + detail: { description: 'Marks an unattached media upload deleted' }, + ...signedIn, + } + ) + +function parseUploadLimit(value: string | undefined): number { + if (!value) return DEFAULT_MAX_UPLOAD_BYTES + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES +} + +function getUpstreamStatus(error: unknown): number | null { + if (!error || typeof error !== 'object') return null + const response = (error as { response?: { status?: unknown } }).response + return typeof response?.status === 'number' ? response.status : null +} + +export default mediaUploadsPlugin diff --git a/api/src/routes/posts.ts b/api/src/routes/posts.ts index 2a7d2279f..d2194364a 100644 --- a/api/src/routes/posts.ts +++ b/api/src/routes/posts.ts @@ -1,15 +1,25 @@ import Elysia, { t } from 'elysia' +import { signedIn } from './elysiaCompat' import { posts, postsView } from '../db/schema' import ActivityPod from '../services/ActivityPod' import { _createPost, selectQueryObject, type SelectPost } from '../types' import type { NoteCreateRequest } from '../types' import { db } from '../db/client' import setupPlugin from './setup' -import { ilike, or, sql } from 'drizzle-orm' +import { and, eq, ilike, or, sql } from 'drizzle-orm' import { localeFromHeaders, translate } from '../i18n' import { buildOutboxPost } from '../postPayload' import { deriveArticleCanonicalUrl } from '../articleShare' import { mergeHashtags, normalizeHashtag } from '../utils/hashtags' +import { + hashPostRequest, + markMediaAttachmentsAttached, + MediaAttachmentError, + normalizeIdempotencyKey, + normalizeMediaAttachmentIds, + resolveAttachableMediaAttachments, + toActivityPubMediaAttachment, +} from '../services/MediaAttachments' const postsRoutes = new Elysia({ name: 'posts' }) .use(setupPlugin) @@ -17,9 +27,56 @@ const postsRoutes = new Elysia({ name: 'posts' }) '/posts', async ({ set, body, user, headers }) => { const locale = localeFromHeaders(headers) - const { content, hashtags, isPublic, postType = 'note', name, summary } = body + const { content, hashtags, isPublic, postType = 'note', name, summary, attachments, attachmentIds = [] } = body + if (content.trim().length === 0 && (!attachments || attachments.length === 0) && attachmentIds.length === 0) { + set.status = 400 + return translate(locale, 'posts.contentOrAttachmentRequired') + } const normalizedHashtags = mergeHashtags(content, hashtags) + let idempotencyKey: string | null = null + let idempotencyRequestHash: string | null = null + const normalizedAttachmentIds = normalizeMediaAttachmentIds(attachmentIds) + let durableAttachments: Awaited> = [] + + try { + idempotencyKey = normalizeIdempotencyKey(headers['idempotency-key'] ?? body.idempotencyKey) + if (idempotencyKey) { + idempotencyRequestHash = hashPostRequest({ + content, + hashtags: normalizedHashtags, + isPublic, + postType, + name: name ?? null, + summary: summary ?? null, + attachmentIds: normalizedAttachmentIds, + attachments: attachments ?? [], + }) + + const existingPost = await findIdempotentPost(user.userId, idempotencyKey) + if (existingPost) { + if (existingPost.clientPostRequestHash !== idempotencyRequestHash) { + set.status = 409 + return translate(locale, 'posts.idempotencyKeyConflict') + } + return toSelectPost(existingPost, user) + } + } + + durableAttachments = await resolveAttachableMediaAttachments(user.userId, normalizedAttachmentIds) + } catch (error) { + if (error instanceof MediaAttachmentError) { + set.status = error.status as any + return translate(locale, error.translationKey) + } + throw error + } + + const outboxAttachments = [ + ...durableAttachments.map(toActivityPubMediaAttachment), + ...(attachments ?? []), + ] + const post: NoteCreateRequest = buildOutboxPost({ user, content, @@ -28,6 +85,7 @@ const postsRoutes = new Elysia({ name: 'posts' }) postType, name, summary, + attachments: outboxAttachments, }) let objectUri: string | null = null @@ -41,6 +99,17 @@ const postsRoutes = new Elysia({ name: 'posts' }) const created = await ActivityPod.createPost(user, post) objectUri = created.objectUri canonicalUrl = postType === 'article' ? deriveArticleCanonicalUrl(objectUri) : null + + // Structured audit record written BEFORE the DB insert so that the + // objectUri is durably logged even if the insert fails or the process + // crashes. This lets an operator reconcile split-brain cases where the + // post exists in the pod but not in Memory's database. + console.info('[posts] pod-post-created', { + userId: user.userId, + objectUri, + idempotencyKey: idempotencyKey ?? null, + }) + // insert the post into the database const newPosts = await db .insert(posts) @@ -53,9 +122,14 @@ const postsRoutes = new Elysia({ name: 'posts' }) canonicalUrl, postType, name: name ?? null, - summary: summary ?? null + summary: summary ?? null, + clientPostKey: idempotencyKey, + clientPostRequestHash: idempotencyRequestHash, }) .returning() + if (normalizedAttachmentIds.length > 0) { + await markMediaAttachmentsAttached(user.userId, normalizedAttachmentIds, newPosts[0].id) + } newPost = { id: newPosts[0].id, content, @@ -75,7 +149,26 @@ const postsRoutes = new Elysia({ name: 'posts' }) } } } catch (e) { - console.error('Error while creating the post: ', e) + if (idempotencyKey && isUniqueViolation(e)) { + const existingPost = await findIdempotentPost(user.userId, idempotencyKey) + if (existingPost?.clientPostRequestHash === idempotencyRequestHash) { + return toSelectPost(existingPost, user) + } + } + // If we already created the pod post (objectUri is set) but the local + // DB insert failed, log a structured reconciliation record so an + // operator can manually re-insert the row. The audit log line written + // before the insert (pod-post-created) also persists the objectUri. + if (objectUri) { + console.error('[posts] pod-post-db-failure', { + userId: user.userId, + objectUri, + idempotencyKey: idempotencyKey ?? null, + error: e instanceof Error ? e.message : String(e), + }) + } else { + console.error('Error while creating the post: ', e) + } set.status = 500 return translate(locale, 'posts.createFailed') } @@ -84,15 +177,23 @@ const postsRoutes = new Elysia({ name: 'posts' }) }, { body: t.Object({ - content: t.String({ minLength: 1 }), + content: t.String({ minLength: 0 }), hashtags: t.Optional(t.Array(t.String({ minLength: 1, maxLength: 65 }), { maxItems: 50 })), isPublic: t.Boolean(), postType: t.Optional(t.Union([t.Literal('note'), t.Literal('article')], { default: 'note' })), name: t.Optional(t.String({ minLength: 1, maxLength: 160 })), - summary: t.Optional(t.String({ minLength: 1, maxLength: 500 })) + summary: t.Optional(t.String({ minLength: 1, maxLength: 500 })), + attachments: t.Optional(t.Array(t.Object({ + type: t.Union([t.Literal('Image'), t.Literal('Video')]), + mediaType: t.String({ minLength: 3, maxLength: 120, pattern: '^(image/(avif|gif|jpeg|png|webp)|video/(mp4|quicktime|webm))$' }), + url: t.String({ minLength: 1, maxLength: 2048 }), + name: t.Optional(t.String({ minLength: 1, maxLength: 160 })) + }), { maxItems: 8 })), + attachmentIds: t.Optional(t.Array(t.String({ minLength: 36, maxLength: 36 }), { maxItems: 8 })), + idempotencyKey: t.Optional(t.String({ minLength: 1, maxLength: 128 })) }), - detail: 'Creates a new post', - isSignedIn: true + detail: { description: 'Creates a new post' }, + ...signedIn, } ) .get( @@ -136,8 +237,8 @@ const postsRoutes = new Elysia({ name: 'posts' }) return postsQuery }, { - detail: 'Returns all public posts', - isSignedIn: true, + detail: { description: 'Returns all public posts' }, + ...signedIn, query: selectQueryObject, response: { 200: t.Array(t.Any()) @@ -145,4 +246,38 @@ const postsRoutes = new Elysia({ name: 'posts' }) } ) +async function findIdempotentPost(userId: number, idempotencyKey: string): Promise { + const [row] = await db + .select() + .from(posts) + .where(and(eq(posts.authorId, userId), eq(posts.clientPostKey, idempotencyKey))) + .limit(1) + return row ?? null +} + +function toSelectPost(row: typeof posts.$inferSelect, user: any): SelectPost { + return { + id: row.id, + content: row.content, + hashtags: row.hashtags, + isPublic: row.isPublic, + postType: row.postType, + name: row.name ?? null, + summary: row.summary ?? null, + authorId: user.userId, + objectUri: row.objectUri ?? null, + canonicalUrl: row.canonicalUrl ?? null, + createdAt: row.createdAt?.toString() || '', + author: { + id: user.userId, + name: user.userName, + webId: user.getWebId() + } + } +} + +function isUniqueViolation(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as { code?: string }).code === '23505') +} + export default postsRoutes diff --git a/api/src/routes/profile.ts b/api/src/routes/profile.ts index f761901e9..a16663da3 100644 --- a/api/src/routes/profile.ts +++ b/api/src/routes/profile.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import ActivityPod from '../services/ActivityPod' import setupPlugin from './setup' import { applyLocaleHeaders, localeFromHeaders, translate } from '../i18n' @@ -8,10 +9,7 @@ import { normalizeProfileDiscovery, ProfileDiscoveryValidationError } from '../p const profilePlugin = new Elysia({ name: 'profile' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) .get( '/profile', async ({ set, user, headers }: any) => { @@ -35,7 +33,7 @@ const profilePlugin = new Elysia({ name: 'profile' }) 401: t.String(), 502: t.String() }, - detail: 'Fetch the authenticated ActivityPub actor profile' + detail: { description: 'Fetch the authenticated ActivityPub actor profile' } } ) .put( @@ -107,7 +105,7 @@ const profilePlugin = new Elysia({ name: 'profile' }) 401: t.String(), 502: t.String() }, - detail: 'Update the authenticated ActivityPub actor profile' + detail: { description: 'Update the authenticated ActivityPub actor profile' } } ) diff --git a/api/src/routes/reply.ts b/api/src/routes/reply.ts index 841149c7c..35ef2ab19 100644 --- a/api/src/routes/reply.ts +++ b/api/src/routes/reply.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' import { eq } from 'drizzle-orm' import ActivityPod from '../services/ActivityPod' import { db } from '../db/client' @@ -23,22 +24,19 @@ const isAbsoluteHttpsUrl = (value: string): boolean => { const replyPlugin = new Elysia({ name: 'reply' }) .use(setupPlugin) - .guard({ - as: 'scoped', - isSignedIn: true - }) + .guard(signedInGuard) .post( '/replies/resolve', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) if (!isAbsoluteHttpsUrl(body.objectUri)) { - return error(400, translate(locale, 'reply.objectUriHttps')) + return status(400, translate(locale, 'reply.objectUriHttps')) } try { return await ActivityPod.resolveReplyPolicy(user, body.objectUri) } catch (e) { console.error('Error while resolving reply policy:', e) - return error(502, translate(locale, 'reply.resolveFailed')) + return status(502, translate(locale, 'reply.resolveFailed')) } }, { @@ -51,21 +49,21 @@ const replyPlugin = new Elysia({ name: 'reply' }) 401: t.String(), 502: t.String() }, - detail: 'Resolve ActivityPub reply policy for an object' + detail: { description: 'Resolve ActivityPub reply policy for an object' } } ) .post( '/replies', - async ({ body, user, headers, error }: any) => { + async ({ body, user, headers, status }: any) => { const locale = localeFromHeaders(headers) if (!isAbsoluteHttpsUrl(body.objectUri)) { - return error(400, translate(locale, 'reply.objectUriHttps')) + return status(400, translate(locale, 'reply.objectUriHttps')) } // Strip C0/C1 control characters before forwarding // eslint-disable-next-line no-control-regex const content = (body.content as string).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '').trim() if (content.length === 0) { - return error(400, translate(locale, 'reply.contentEmpty')) + return status(400, translate(locale, 'reply.contentEmpty')) } try { const replyResult = await ActivityPod.replyToObject(user, body.objectUri, content, body.isPublic ?? true) @@ -101,7 +99,7 @@ const replyPlugin = new Elysia({ name: 'reply' }) return replyResult } catch (e) { console.error('Error while submitting reply:', e) - return error(502, translate(locale, 'reply.submitFailed')) + return status(502, translate(locale, 'reply.submitFailed')) } }, { @@ -117,7 +115,7 @@ const replyPlugin = new Elysia({ name: 'reply' }) 401: t.String(), 502: t.String() }, - detail: 'Submit an ActivityPub reply, using pending approval automatically when required' + detail: { description: 'Submit an ActivityPub reply, using pending approval automatically when required' } } ) diff --git a/api/src/routes/setup.ts b/api/src/routes/setup.ts index 3ba39fffe..ff8a1a643 100644 --- a/api/src/routes/setup.ts +++ b/api/src/routes/setup.ts @@ -19,7 +19,7 @@ const setupPlugin = new Elysia({ name: 'setup' }) applyLocaleHeaders(set, locale) return { - error: (status: number, message: string) => { + status: (status: number, message: string) => { set.status = status as any return message } diff --git a/api/src/services/ActivityPod.ts b/api/src/services/ActivityPod.ts index af7f8a95f..e84ef37a5 100644 --- a/api/src/services/ActivityPod.ts +++ b/api/src/services/ActivityPod.ts @@ -205,6 +205,35 @@ export interface ConversationBackfillResult { posts: Record[] } +export interface UploadedPodMedia { + url: string + mediaType: string + size: number +} + + // --------------------------------------------------------------------------- + // Process-level profile cache + // + // Caches the authenticated user's own profile for a short TTL so that repeated + // requests within the same Bun process (e.g. multiple route handlers in one + // session burst) do not each make a full pod roundtrip. + // + // Constraints: + // - Only profile data (non-sensitive) is stored; tokens are never cached. + // - Cache key = WebID URI (per-user). + // - updateProfile() must invalidate the entry so writes are never masked. + // - Pod remains source of truth; this is a soft read-through cache only. + // --------------------------------------------------------------------------- + + const PROFILE_CACHE_TTL_MS = 5 * 60 * 1_000 // 5 minutes + + interface ProfileCacheEntry { + profile: Record + expiresAt: number + } + + const profileCache = new Map() + export default abstract class ActivityPod { static async signIn(endpoint: string, username: string, password: string) { const response: PodProviderSignInResponse = await ky @@ -241,6 +270,30 @@ export default abstract class ActivityPod { } } + static async uploadMedia(user: User, file: File, slug: string): Promise { + const uploadContainerUrl = `${user.endpoint.replace(/\/$/, '')}/${encodeURIComponent(user.userName)}/data/semapps/file` + const response = await ky.post(uploadContainerUrl, { + headers: { + Authorization: `Bearer ${user.token}`, + 'Content-Type': file.type, + Slug: slug, + }, + body: file, + timeout: DEFAULT_TIMEOUT, + retry: 0, + }) + const location = response.headers.get('Location') || response.headers.get('location') + if (!location) { + throw new Error('ActivityPods media upload did not return a Location header') + } + + return { + url: new URL(location, uploadContainerUrl).toString(), + mediaType: file.type, + size: file.size, + } + } + static async announceObject(user: User, objectUri: string) { const actorUri = user.getWebId() const response = await ky @@ -269,8 +322,14 @@ export default abstract class ActivityPod { } static async getProfile(user: User) { - return ky - .get(user.getWebId(), { + const webId = user.getWebId() + const cached = profileCache.get(webId) + if (cached && cached.expiresAt > Date.now()) { + return cached.profile + } + + const profile = await ky + .get(webId, { headers: { Authorization: `Bearer ${user.token}`, Accept: 'application/ld+json, application/json;q=0.9' @@ -278,10 +337,16 @@ export default abstract class ActivityPod { retry: SAFE_RETRY, timeout: DEFAULT_TIMEOUT }) - .json() + .json>() + + profileCache.set(webId, { profile, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS }) + return profile } static async updateProfile(user: User, actor: Record) { + // Invalidate cache before the write so the next getProfile() fetches fresh data. + profileCache.delete(user.getWebId()) + const response = await ky.put(user.getWebId(), { headers: { Authorization: `Bearer ${user.token}`, diff --git a/api/src/services/ApRemoteIngestionService.ts b/api/src/services/ApRemoteIngestionService.ts index dd08d160c..09cd004f2 100644 --- a/api/src/services/ApRemoteIngestionService.ts +++ b/api/src/services/ApRemoteIngestionService.ts @@ -22,6 +22,8 @@ import { db } from '../db/client' import { apRemotePosts, atRecords, apActorCache } from '../db/atBridgeSchema' import { eq, and, isNull, isNotNull } from 'drizzle-orm' import crypto from 'crypto' +import { secureFetch } from '../utils/secureFetch' +import { isPublicHttpUrl } from '../utils/urlGuards' // --------------------------------------------------------------------------- // Constants @@ -271,11 +273,23 @@ async function cacheApActorIfStale(actorUri: string): Promise { return } - const resp = await fetch(actorUri, { - headers: { Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }, - signal: AbortSignal.timeout(5000), - }) - if (!resp.ok) return + const resp = await (async () => { + try { + const { response } = await secureFetch(actorUri, { + headers: { Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }, + timeoutMs: 5000, + // AP actor URIs are federation identifiers; we still run the + // full URL-hygiene pipeline (sanitize + DNS resolve + Safe + // Browsing) but allow callers to suppress SB if a future + // private/internal AP cluster is introduced. + }) + return response + } catch { + // Guard rejection or transport failure — actor cache is best-effort. + return null + } + })() + if (!resp || !resp.ok) return const actor = await resp.json() as Record @@ -294,10 +308,13 @@ async function cacheApActorIfStale(actorUri: string): Promise { const iconObj = actor['icon'] const imageObj = actor['image'] function extractMediaUrl(obj: unknown): string | null { - if (typeof obj === 'string') return isHttpsUrl(obj) ? obj : null + // Reject non-public URLs (private IPs, loopback, javascript:, file:, etc.) + // before storing — the browser will later request these directly so we + // must not let federated actors point clients at internal hosts. + if (typeof obj === 'string') return isPublicHttpUrl(obj) ? obj : null if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) { const url = (obj as Record)['url'] - if (typeof url === 'string' && isHttpsUrl(url)) return url + if (typeof url === 'string' && isPublicHttpUrl(url)) return url } return null } diff --git a/api/src/services/BlueskyAppViewClient.test.ts b/api/src/services/BlueskyAppViewClient.test.ts new file mode 100644 index 000000000..a9551c94c --- /dev/null +++ b/api/src/services/BlueskyAppViewClient.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test' +import { BlueskyAppViewClient } from './BlueskyAppViewClient' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('BlueskyAppViewClient', () => { + it('rejects unsafe AppView origins', () => { + expect(() => new BlueskyAppViewClient({ publicOrigin: 'http://public.api.bsky.app' })).toThrow(/https/) + expect(() => new BlueskyAppViewClient({ publicOrigin: 'https://example.com' })).toThrow(/not allowed/) + expect(() => new BlueskyAppViewClient({ publicOrigin: 'https://user:pass@public.api.bsky.app' })).toThrow(/credentials/) + expect(() => new BlueskyAppViewClient({ publicOrigin: 'https://public.api.bsky.app/xrpc' })).toThrow(/path/) + }) + + it('deduplicates DIDs, batches profile requests, and returns profile views', async () => { + const requestedUrls: string[] = [] + const client = new BlueskyAppViewClient({ + batchSize: 1, + fetchImpl: (async (url: string | URL | Request) => { + requestedUrls.push(String(url)) + return jsonResponse({ + profiles: [{ did: requestedUrls.length === 1 ? 'did:plc:one' : 'did:plc:two', handle: `h${requestedUrls.length}.bsky.social` }], + }) + }) as typeof fetch, + }) + + const profiles = await client.getProfiles(['did:plc:one', 'did:plc:one', 'did:plc:two']) + + expect(requestedUrls).toHaveLength(2) + expect(profiles.map(profile => profile.did)).toEqual(['did:plc:one', 'did:plc:two']) + }) + + it('retries transient AppView failures with bounded attempts', async () => { + let calls = 0 + const client = new BlueskyAppViewClient({ + maxRetries: 1, + fetchImpl: (async () => { + calls += 1 + if (calls === 1) return jsonResponse({ error: 'busy' }, 503) + return jsonResponse({ profiles: [{ did: 'did:plc:one', handle: 'one.bsky.social' }] }) + }) as typeof fetch, + }) + + const profiles = await client.getProfiles(['did:plc:one']) + + expect(calls).toBe(2) + expect(profiles[0]?.handle).toBe('one.bsky.social') + }) +}) diff --git a/api/src/services/BlueskyAppViewClient.ts b/api/src/services/BlueskyAppViewClient.ts new file mode 100644 index 000000000..70ef39f54 --- /dev/null +++ b/api/src/services/BlueskyAppViewClient.ts @@ -0,0 +1,174 @@ +const DEFAULT_PUBLIC_APPVIEW_ORIGIN = 'https://public.api.bsky.app' +const DEFAULT_PRIVATE_APPVIEW_ORIGIN = 'https://api.bsky.app' +const DEFAULT_REQUEST_TIMEOUT_MS = 4000 +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_BATCH_SIZE = 25 + +const ALLOWED_PUBLIC_HOSTS = new Set(['public.api.bsky.app', 'api.bsky.app']) + +export interface BlueskyProfileView { + did: string + handle: string + displayName?: string + avatar?: string + banner?: string + followersCount?: number + followsCount?: number + postsCount?: number +} + +export interface BlueskyAppViewClientOptions { + publicOrigin?: string + requestTimeoutMs?: number + maxRetries?: number + batchSize?: number + fetchImpl?: typeof fetch +} + +export interface BlueskyAppViewClientLogger { + warn(message: string, meta?: Record): void +} + +const NOOP_LOGGER: BlueskyAppViewClientLogger = { + warn: () => undefined, +} + +function validateAllowedOrigin(raw: string | undefined, fallback: string): string { + const value = raw?.trim() || fallback + let url: URL + try { + url = new URL(value) + } catch (err) { + throw new Error(`Invalid Bluesky AppView origin: ${err instanceof Error ? err.message : String(err)}`) + } + + if (url.protocol !== 'https:') throw new Error('Bluesky AppView origin must use https://') + if (url.username || url.password) throw new Error('Bluesky AppView origin must not include credentials') + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error('Bluesky AppView origin must not include path, query, or fragment') + } + if (!ALLOWED_PUBLIC_HOSTS.has(url.hostname)) { + throw new Error(`Bluesky AppView host is not allowed: ${url.hostname}`) + } + + return url.origin +} + +function parsePositiveInteger(raw: string | undefined, fallback: number, min: number, max: number): number { + if (!raw) return fallback + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +function uniqueDids(dids: string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const did of dids) { + const trimmed = did.trim() + if (!trimmed.startsWith('did:') || seen.has(trimmed)) continue + seen.add(trimmed) + result.push(trimmed) + } + return result +} + +function backoffDelayMs(attempt: number): number { + return Math.min(1500, 200 * (2 ** attempt) + Math.floor(Math.random() * 75)) +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 +} + +export class BlueskyAppViewClient { + private readonly publicXrpcBase: string + private readonly requestTimeoutMs: number + private readonly maxRetries: number + private readonly batchSize: number + private readonly fetchImpl: typeof fetch + + constructor( + options: BlueskyAppViewClientOptions = {}, + private readonly logger: BlueskyAppViewClientLogger = NOOP_LOGGER, + ) { + const origin = validateAllowedOrigin(options.publicOrigin, DEFAULT_PUBLIC_APPVIEW_ORIGIN) + this.publicXrpcBase = `${origin}/xrpc` + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES + this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE + this.fetchImpl = options.fetchImpl ?? fetch + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env, logger?: BlueskyAppViewClientLogger): BlueskyAppViewClient { + return new BlueskyAppViewClient({ + publicOrigin: env.BSKY_PUBLIC_APPVIEW_ORIGIN || env.BSKY_APPVIEW_ORIGIN || DEFAULT_PUBLIC_APPVIEW_ORIGIN, + requestTimeoutMs: parsePositiveInteger(env.BSKY_APPVIEW_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS, 500, 15000), + maxRetries: parsePositiveInteger(env.BSKY_APPVIEW_MAX_RETRIES, DEFAULT_MAX_RETRIES, 0, 5), + batchSize: parsePositiveInteger(env.BSKY_APPVIEW_PROFILE_BATCH_SIZE, DEFAULT_BATCH_SIZE, 1, 25), + }, logger) + } + + static defaultPrivateOrigin(): string { + return DEFAULT_PRIVATE_APPVIEW_ORIGIN + } + + async getProfiles(dids: string[]): Promise { + const actors = uniqueDids(dids) + if (actors.length === 0) return [] + + const profiles: BlueskyProfileView[] = [] + for (let i = 0; i < actors.length; i += this.batchSize) { + const batch = actors.slice(i, i + this.batchSize) + const params = batch.map(did => `actors[]=${encodeURIComponent(did)}`).join('&') + const data = await this.getJson<{ profiles?: BlueskyProfileView[] }>(`/app.bsky.actor.getProfiles?${params}`) + for (const profile of data.profiles ?? []) { + if (profile?.did && profile.handle) profiles.push(profile) + } + } + return profiles + } + + private async getJson(pathAndQuery: string): Promise { + const url = `${this.publicXrpcBase}${pathAndQuery}` + let lastError: unknown = null + + for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs) + try { + const response = await this.fetchImpl(url, { + method: 'GET', + signal: controller.signal, + headers: { Accept: 'application/json' }, + }) + clearTimeout(timeout) + + if (!response.ok) { + if (attempt < this.maxRetries && isRetryableStatus(response.status)) { + await delay(backoffDelayMs(attempt)) + continue + } + throw new Error(`Bluesky AppView request failed with HTTP ${response.status}`) + } + + return await response.json() as T + } catch (err) { + clearTimeout(timeout) + lastError = err + if (attempt >= this.maxRetries) break + await delay(backoffDelayMs(attempt)) + } + } + + this.logger.warn('Bluesky AppView request exhausted retries', { + path: pathAndQuery.split('?')[0], + error: lastError instanceof Error ? lastError.message : String(lastError), + }) + throw lastError instanceof Error ? lastError : new Error('Bluesky AppView request failed') + } +} diff --git a/api/src/services/LinkPreviewService.test.ts b/api/src/services/LinkPreviewService.test.ts new file mode 100644 index 000000000..ba53e8126 --- /dev/null +++ b/api/src/services/LinkPreviewService.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { sanitizeHttpUrl } from './LinkPreviewService' + +describe('sanitizeHttpUrl', () => { + test('accepts ordinary http(s) URLs', () => { + expect(sanitizeHttpUrl('https://example.com/page')).toBe('https://example.com/page') + expect(sanitizeHttpUrl('http://example.com/')).toBe('http://example.com/') + }) + + test('rejects empty / non-string / malformed input', () => { + expect(() => sanitizeHttpUrl('')).toThrow() + expect(() => sanitizeHttpUrl(' ')).toThrow() + expect(() => sanitizeHttpUrl('not-a-url')).toThrow() + // @ts-expect-error invalid type + expect(() => sanitizeHttpUrl(undefined)).toThrow() + }) + + test('rejects non-http(s) schemes', () => { + expect(() => sanitizeHttpUrl('javascript:alert(1)')).toThrow(/http\(s\)/) + expect(() => sanitizeHttpUrl('file:///etc/passwd')).toThrow(/http\(s\)/) + expect(() => sanitizeHttpUrl('ftp://example.com/')).toThrow(/http\(s\)/) + expect(() => sanitizeHttpUrl('data:text/html, + + diff --git a/frontend/src/components/BottomNav.vue b/frontend/src/components/BottomNav.vue index 69dea51be..e3277f2a7 100644 --- a/frontend/src/components/BottomNav.vue +++ b/frontend/src/components/BottomNav.vue @@ -3,6 +3,8 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from '@/i18n' import { useNotificationsStore } from '@/stores/notificationsStore' +import AppIcon from '@/components/AppIcon.vue' +import type { IconName } from '@/components/AppIcon.types' const route = useRoute() const router = useRouter() @@ -10,16 +12,16 @@ const { t } = useI18n() const notificationsStore = useNotificationsStore() const hiddenRoutes = new Set(['signin', 'signup', 'welcome', 'experience']) -// Hide bottom nav entirely on dashboard routes (they have their own sidebar) const show = computed(() => !hiddenRoutes.has(String(route.name)) && !route.path.startsWith('/dashboard')) -interface NavItem { name: string; route: string; label: string } +interface NavItem { name: string; route: string; label: string; icon: IconName } + const items = computed(() => [ - { name: 'home', route: '/', label: t('nav.home') }, - { name: 'explore', route: '/explore', label: t('nav.explore') }, - { name: 'messages', route: '/messages', label: t('nav.messages') }, - { name: 'notifications', route: '/notifications', label: t('nav.notifications') }, - { name: 'dashboard', route: '/dashboard', label: 'Dashboard' }, + { name: 'home', route: '/', label: t('nav.home'), icon: 'home' }, + { name: 'explore', route: '/explore', label: t('nav.explore'), icon: 'explore' }, + { name: 'messages', route: '/messages', label: t('nav.messages'), icon: 'messages' }, + { name: 'notifications', route: '/notifications', label: t('nav.notifications'), icon: 'notifications' }, + { name: 'dashboard', route: '/dashboard', label: 'Dashboard', icon: 'dashboard' }, ]) function isActive(item: NavItem): boolean { @@ -47,39 +49,19 @@ function navigate(item: NavItem) { :aria-label="item.label" @click="navigate(item)" > - - - - - - - - - - - - - - - - - + + {{ notificationsStore.totalUnreadCount > 99 ? '99+' : notificationsStore.totalUnreadCount }} - - - - - - - - - diff --git a/frontend/src/components/ControlBar.vue b/frontend/src/components/ControlBar.vue index 8d9eaa083..a76905a35 100644 --- a/frontend/src/components/ControlBar.vue +++ b/frontend/src/components/ControlBar.vue @@ -2,6 +2,7 @@ import { ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import MemoryButton from '@/components/MemoryButton.vue' +import AppIcon from '@/components/AppIcon.vue' // External const route = useRoute() @@ -23,8 +24,8 @@ function getHeader() {