From d13f6cbccc0e13ad8540fe5d988b916dcfa59933 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Thu, 14 May 2026 22:04:34 -0700 Subject: [PATCH 1/3] send it --- .env.example | 10 ++ .gitignore | 3 + bun.lock | 3 + convex/_generated/api.d.ts | 8 + convex/attachments.ts | 182 +++++++++++++++------ convex/dev.ts | 116 +++++++++++++ convex/http.ts | 4 +- convex/lib/bucket.ts | 141 ++++++++++++++++ convex/migrations/bucketBackfill.ts | 242 ++++++++++++++++++++++++++++ convex/schema.ts | 40 ++++- convex/siteActions.ts | 39 ++--- convex/siteImages.ts | 206 +++++++++++++++++++++++ convex/siteInternals.ts | 14 +- convex/sites.ts | 67 +++++++- convex/sitesHttp.ts | 51 +++++- package.json | 7 +- scripts/bucket-smoke.mjs | 63 ++++++++ scripts/bucket.test.mjs | 196 ++++++++++++++++++++++ scripts/sync-convex-env.sh | 56 +++++++ scripts/sync-convex-turnkey-env.sh | 52 ------ server.ts | 44 ++++- src/components/Attachments.tsx | 70 ++++---- src/components/sites/SiteImages.tsx | 180 +++++++++++++++++++++ src/pages/SiteDetail.tsx | 40 ++++- src/pages/Sites.tsx | 11 +- 25 files changed, 1647 insertions(+), 198 deletions(-) create mode 100644 convex/dev.ts create mode 100644 convex/lib/bucket.ts create mode 100644 convex/migrations/bucketBackfill.ts create mode 100644 convex/siteImages.ts create mode 100644 scripts/bucket-smoke.mjs create mode 100644 scripts/bucket.test.mjs create mode 100755 scripts/sync-convex-env.sh delete mode 100755 scripts/sync-convex-turnkey-env.sh create mode 100644 src/components/sites/SiteImages.tsx diff --git a/.env.example b/.env.example index c2ae5bb..008a3eb 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,16 @@ VITE_SITE_BASE_DOMAIN=boop.ad # Should match VITE_WEBVH_DOMAIN. # WEBVH_DOMAIN=your-domain.example.com +# Railway Storage Bucket (S3-compatible). +# Provision a Bucket service in Railway, then copy these from its Variables tab. +# Must also be set in Convex (`npx convex env set BOOP_BUCKET_NAME …` etc.) +# and on the Railway app service (link via `${{Bucket.BUCKET}}` references). +# BOOP_BUCKET_NAME= +# BOOP_BUCKET_ACCESS_KEY_ID= +# BOOP_BUCKET_SECRET_ACCESS_KEY= +# BOOP_BUCKET_ENDPOINT=https://storage.railway.app +# BOOP_BUCKET_REGION=auto + # PostHog Analytics (optional — analytics are disabled if not set) # Get your key at https://posthog.com — free tier available. # VITE_POSTHOG_KEY=phc_your_project_api_key diff --git a/.gitignore b/.gitignore index 0311a5f..ada4740 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ app/test-results/* # Lighthouse CI results .lighthouseci/ tmp/ + +# Brainstorming visual companion +.superpowers/ diff --git a/bun.lock b/bun.lock index 37220be..0f75a83 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", + "aws4fetch": "^1.0.20", "capacitor-native-biometric": "^4.2.2", "convex": "^1.31.6", "idb": "^8.0.3", @@ -724,6 +725,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], "b58": ["b58@4.0.3", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-VDtdiomm0ywbL8YzgevOZ9pcx6LuOZ3d9qYTPDcYUPf7dRYNA8wvK6epYy0FKMWIM5uaDwd3kWt1x+1S9scB1Q=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 03b42d3..7757b5b 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,6 +25,7 @@ import type * as categoriesHttp from "../categoriesHttp.js"; import type * as cloudflare from "../cloudflare.js"; import type * as comments from "../comments.js"; import type * as crons from "../crons.js"; +import type * as dev from "../dev.js"; import type * as didCreation from "../didCreation.js"; import type * as didLogs from "../didLogs.js"; import type * as didLogsHttp from "../didLogsHttp.js"; @@ -36,6 +37,7 @@ import type * as items from "../items.js"; import type * as itemsHttp from "../itemsHttp.js"; import type * as lib_auth from "../lib/auth.js"; import type * as lib_authUser from "../lib/authUser.js"; +import type * as lib_bucket from "../lib/bucket.js"; import type * as lib_httpResponses from "../lib/httpResponses.js"; import type * as lib_jwt from "../lib/jwt.js"; import type * as lib_observability from "../lib/observability.js"; @@ -44,6 +46,7 @@ import type * as lib_turnkeyClient from "../lib/turnkeyClient.js"; import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as lists from "../lists.js"; import type * as listsHttp from "../listsHttp.js"; +import type * as migrations_bucketBackfill from "../migrations/bucketBackfill.js"; import type * as notificationActions from "../notificationActions.js"; import type * as notifications from "../notifications.js"; import type * as originals from "../originals.js"; @@ -53,6 +56,7 @@ import type * as publication from "../publication.js"; import type * as rateLimits from "../rateLimits.js"; import type * as referrals from "../referrals.js"; import type * as siteActions from "../siteActions.js"; +import type * as siteImages from "../siteImages.js"; import type * as siteInternals from "../siteInternals.js"; import type * as sites from "../sites.js"; import type * as sitesHttp from "../sitesHttp.js"; @@ -87,6 +91,7 @@ declare const fullApi: ApiFromModules<{ cloudflare: typeof cloudflare; comments: typeof comments; crons: typeof crons; + dev: typeof dev; didCreation: typeof didCreation; didLogs: typeof didLogs; didLogsHttp: typeof didLogsHttp; @@ -98,6 +103,7 @@ declare const fullApi: ApiFromModules<{ itemsHttp: typeof itemsHttp; "lib/auth": typeof lib_auth; "lib/authUser": typeof lib_authUser; + "lib/bucket": typeof lib_bucket; "lib/httpResponses": typeof lib_httpResponses; "lib/jwt": typeof lib_jwt; "lib/observability": typeof lib_observability; @@ -106,6 +112,7 @@ declare const fullApi: ApiFromModules<{ "lib/turnkeySigner": typeof lib_turnkeySigner; lists: typeof lists; listsHttp: typeof listsHttp; + "migrations/bucketBackfill": typeof migrations_bucketBackfill; notificationActions: typeof notificationActions; notifications: typeof notifications; originals: typeof originals; @@ -115,6 +122,7 @@ declare const fullApi: ApiFromModules<{ rateLimits: typeof rateLimits; referrals: typeof referrals; siteActions: typeof siteActions; + siteImages: typeof siteImages; siteInternals: typeof siteInternals; sites: typeof sites; sitesHttp: typeof sitesHttp; diff --git a/convex/attachments.ts b/convex/attachments.ts index 9b2471f..ff62156 100644 --- a/convex/attachments.ts +++ b/convex/attachments.ts @@ -1,15 +1,42 @@ /** - * File attachments for list items. + * File attachments for list items — stored in Railway Bucket. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { + action, + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { + bucketKey as makeBucketKey, + deleteObject, + presignGet, + presignPut, +} from "./lib/bucket"; + +const EXT_BY_CONTENT_TYPE: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "application/pdf": "pdf", + "text/plain": "txt", + "application/json": "json", +}; + +const ALLOWED_CONTENT_TYPES = new Set(Object.keys(EXT_BY_CONTENT_TYPE)); +const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; + +function extensionFor(contentType: string): string { + return EXT_BY_CONTENT_TYPE[contentType] ?? "bin"; +} -/** - * Helper to check if a user can edit a list. - */ async function canUserEditList( ctx: MutationCtx | QueryCtx, listId: Id<"lists">, @@ -24,7 +51,6 @@ async function canUserEditList( if (dids.includes(list.ownerDid)) return true; - // Published lists are editable by anyone const pub = await ctx.db .query("publications") .withIndex("by_list", (q) => q.eq("listId", listId)) @@ -33,37 +59,56 @@ async function canUserEditList( return pub?.status === "active"; } -/** - * Generate an upload URL for a file attachment. - */ -export const generateUploadUrl = mutation({ +export const generateUploadUrl = action({ args: { itemId: v.id("items"), userDid: v.string(), legacyDid: v.optional(v.string()), + contentType: v.string(), + byteLength: v.number(), }, - handler: async (ctx, args) => { - const item = await ctx.db.get(args.itemId); - if (!item) throw new Error("Item not found"); + handler: async ( + ctx, + args + ): Promise<{ uploadUrl: string; bucketKey: string }> => { + if (!ALLOWED_CONTENT_TYPES.has(args.contentType)) { + throw new Error(`Unsupported file type: ${args.contentType}`); + } + if (args.byteLength <= 0 || args.byteLength > MAX_ATTACHMENT_BYTES) { + throw new Error("File is empty or exceeds the 10 MB limit."); + } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); - if (!canEdit) { + const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { + itemId: args.itemId, + userDid: args.userDid, + legacyDid: args.legacyDid, + }); + if (!owned) { throw new Error("Not authorized to add attachments to this item"); } - return await ctx.storage.generateUploadUrl(); + const key = makeBucketKey( + "attachments", + args.itemId, + `${crypto.randomUUID()}.${extensionFor(args.contentType)}` + ); + const uploadUrl = await presignPut(key, { + contentType: args.contentType, + expiresSec: 600, + }); + return { uploadUrl, bucketKey: key }; }, }); -/** - * Add an attachment to an item after upload. - */ export const addAttachment = mutation({ args: { itemId: v.id("items"), - storageId: v.id("_storage"), userDid: v.string(), legacyDid: v.optional(v.string()), + bucketKey: v.string(), + contentType: v.string(), + size: v.number(), + sha256: v.string(), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -74,58 +119,97 @@ export const addAttachment = mutation({ throw new Error("Not authorized to add attachments to this item"); } - const currentAttachments = item.attachments ?? []; + const current = item.attachments ?? []; await ctx.db.patch(args.itemId, { - attachments: [...currentAttachments, args.storageId], + attachments: [ + ...current, + { + key: args.bucketKey, + contentType: args.contentType, + size: args.size, + sha256: args.sha256, + }, + ], updatedAt: Date.now(), }); }, }); -/** - * Remove an attachment from an item. - */ -export const removeAttachment = mutation({ +export const removeAttachment = action({ args: { itemId: v.id("items"), - storageId: v.id("_storage"), + bucketKey: v.string(), userDid: v.string(), legacyDid: v.optional(v.string()), }, - handler: async (ctx, args) => { - const item = await ctx.db.get(args.itemId); - if (!item) throw new Error("Item not found"); - - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); - if (!canEdit) { + handler: async (ctx, args): Promise => { + const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { + itemId: args.itemId, + userDid: args.userDid, + legacyDid: args.legacyDid, + }); + if (!owned) { throw new Error("Not authorized to remove attachments from this item"); } - const currentAttachments = item.attachments ?? []; - await ctx.db.patch(args.itemId, { - attachments: currentAttachments.filter((id) => id !== args.storageId), - updatedAt: Date.now(), + await deleteObject(args.bucketKey); + await ctx.runMutation(internal.attachments.dropAttachment, { + itemId: args.itemId, + bucketKey: args.bucketKey, }); - - // Delete the file from storage - await ctx.storage.delete(args.storageId); }, }); -/** - * Get attachment URLs for an item. - */ export const getAttachmentUrls = query({ args: { itemId: v.id("items") }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item || !item.attachments) return []; - const urls: { storageId: Id<"_storage">; url: string | null }[] = []; - for (const storageId of item.attachments) { - const url = await ctx.storage.getUrl(storageId); - urls.push({ storageId, url }); - } - return urls; + // Legacy v.id("_storage") entries (un-migrated) are filtered out; + // the bucketBackfill migration converts them to object form. + const objects = item.attachments.filter( + (entry): entry is { key: string; contentType: string; size: number; sha256: string } => + typeof entry === "object" + ); + + return await Promise.all( + objects.map(async (entry) => ({ + key: entry.key, + contentType: entry.contentType, + size: entry.size, + url: await presignGet(entry.key, { expiresSec: 600 }), + })) + ); + }, +}); + +export const assertItemEditable = internalQuery({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) return null; + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + return canEdit ? { itemId: item._id } : null; + }, +}); + +export const dropAttachment = internalMutation({ + args: { itemId: v.id("items"), bucketKey: v.string() }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) return; + const current = item.attachments ?? []; + const remaining = current.filter( + (entry) => typeof entry !== "object" || entry.key !== args.bucketKey + ); + await ctx.db.patch(args.itemId, { + attachments: remaining, + updatedAt: Date.now(), + }); }, }); diff --git a/convex/dev.ts b/convex/dev.ts new file mode 100644 index 0000000..abccb21 --- /dev/null +++ b/convex/dev.ts @@ -0,0 +1,116 @@ +/** + * Dev-only helpers — run via `npx convex run` to patch state from the CLI. + * Not exposed as `api.*`; only callable via the dashboard / `convex run`. + */ + +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export const grantSubscription = internalMutation({ + args: { + email: v.optional(v.string()), + did: v.optional(v.string()), + plan: v.optional(v.union(v.literal("pro"), v.literal("team"))), + durationDays: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (!args.email && !args.did) { + throw new Error("Provide either `email` or `did`."); + } + + const user = args.email + ? await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", args.email!)) + .first() + : await ctx.db + .query("users") + .withIndex("by_did", (q) => q.eq("did", args.did!)) + .first(); + + if (!user) { + throw new Error( + `User not found for ${args.email ? `email=${args.email}` : `did=${args.did}`}` + ); + } + + const now = Date.now(); + const plan = args.plan ?? "pro"; + const periodEnd = now + (args.durationDays ?? 365) * ONE_DAY_MS; + + const existing = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + plan, + status: "active", + currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: false, + updatedAt: now, + }); + return { + action: "updated" as const, + subscriptionId: existing._id, + userId: user._id, + plan, + periodEnd, + }; + } + + const subscriptionId = await ctx.db.insert("subscriptions", { + userId: user._id, + stripeCustomerId: `dev_${user._id}`, + plan, + status: "active", + currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: false, + createdAt: now, + updatedAt: now, + }); + return { + action: "created" as const, + subscriptionId, + userId: user._id, + plan, + periodEnd, + }; + }, +}); + +export const revokeSubscription = internalMutation({ + args: { + email: v.optional(v.string()), + did: v.optional(v.string()), + }, + handler: async (ctx, args) => { + if (!args.email && !args.did) { + throw new Error("Provide either `email` or `did`."); + } + + const user = args.email + ? await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", args.email!)) + .first() + : await ctx.db + .query("users") + .withIndex("by_did", (q) => q.eq("did", args.did!)) + .first(); + + if (!user) throw new Error("User not found"); + + const sub = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + if (!sub) return { action: "noop" as const }; + + await ctx.db.patch(sub._id, { status: "canceled", updatedAt: Date.now() }); + return { action: "canceled" as const, subscriptionId: sub._id }; + }, +}); diff --git a/convex/http.ts b/convex/http.ts index db0df13..829c449 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -28,7 +28,7 @@ import { assignItem as assignItemHttp, unassignItem as unassignItemHttp, getItem import { heartbeat as presenceHeartbeatHttp, listPresence as listPresenceHttp } from "./presenceHttp"; import { getListActivity as getListActivityHttp } from "./activityHttp"; import { stripeWebhook, createCheckout, createPortal, getSubscription } from "./billingHttp"; -import { resolveSiteHost } from "./sitesHttp"; +import { resolveSiteHost, resolveSiteImage } from "./sitesHttp"; const RATE_LIMITS = { initiate: { windowMs: 60000, maxAttempts: 5 }, verify: { windowMs: 60000, maxAttempts: 5 }, @@ -407,6 +407,8 @@ http.route({ path: "/api/billing/subscription", method: "OPTIONS", handler: cors // --- Hosted site endpoints --- http.route({ path: "/api/sites/resolve-host", method: "GET", handler: resolveSiteHost }); http.route({ path: "/api/sites/resolve-host", method: "OPTIONS", handler: resolveSiteHost }); +http.route({ path: "/api/sites/resolve-image", method: "GET", handler: resolveSiteImage }); +http.route({ path: "/api/sites/resolve-image", method: "OPTIONS", handler: resolveSiteImage }); // ============================================================================ diff --git a/convex/lib/bucket.ts b/convex/lib/bucket.ts new file mode 100644 index 0000000..a86eb82 --- /dev/null +++ b/convex/lib/bucket.ts @@ -0,0 +1,141 @@ +import { AwsClient } from "aws4fetch"; + +type BucketConfig = { + name: string; + accessKeyId: string; + secretAccessKey: string; + endpoint: string; + region: string; +}; + +function readConfig(): BucketConfig { + const name = process.env.BOOP_BUCKET_NAME; + const accessKeyId = process.env.BOOP_BUCKET_ACCESS_KEY_ID; + const secretAccessKey = process.env.BOOP_BUCKET_SECRET_ACCESS_KEY; + const endpoint = process.env.BOOP_BUCKET_ENDPOINT ?? "https://storage.railway.app"; + const region = process.env.BOOP_BUCKET_REGION ?? "auto"; + if (!name) throw new Error("BOOP_BUCKET_NAME is not set"); + if (!accessKeyId) throw new Error("BOOP_BUCKET_ACCESS_KEY_ID is not set"); + if (!secretAccessKey) throw new Error("BOOP_BUCKET_SECRET_ACCESS_KEY is not set"); + return { name, accessKeyId, secretAccessKey, endpoint, region }; +} + +function client(cfg: BucketConfig = readConfig()) { + return { + aws: new AwsClient({ + accessKeyId: cfg.accessKeyId, + secretAccessKey: cfg.secretAccessKey, + service: "s3", + region: cfg.region, + }), + cfg, + }; +} + +function objectUrl(cfg: BucketConfig, key: string): string { + const host = new URL(cfg.endpoint).host; + const encoded = key.split("/").map(encodeURIComponent).join("/"); + return `https://${cfg.name}.${host}/${encoded}`; +} + +export async function presignPut( + key: string, + options: { contentType?: string; expiresSec?: number } = {} +): Promise { + const { aws, cfg } = client(); + const url = new URL(objectUrl(cfg, key)); + url.searchParams.set("X-Amz-Expires", String(options.expiresSec ?? 3600)); + const headers: Record = {}; + if (options.contentType) headers["content-type"] = options.contentType; + const signed = await aws.sign(url.toString(), { + method: "PUT", + headers, + aws: { signQuery: true }, + }); + return signed.url; +} + +export async function presignGet( + key: string, + options: { expiresSec?: number } = {} +): Promise { + const { aws, cfg } = client(); + const url = new URL(objectUrl(cfg, key)); + url.searchParams.set("X-Amz-Expires", String(options.expiresSec ?? 3600)); + const signed = await aws.sign(url.toString(), { + method: "GET", + aws: { signQuery: true }, + }); + return signed.url; +} + +export type HeadResult = { + exists: boolean; + contentLength?: number; + contentType?: string; + etag?: string; +}; + +export async function headObject(key: string): Promise { + const { aws, cfg } = client(); + const res = await aws.fetch(objectUrl(cfg, key), { method: "HEAD" }); + if (res.status === 404) return { exists: false }; + if (!res.ok) throw new Error(`HEAD ${key} failed: ${res.status}`); + return { + exists: true, + contentLength: Number(res.headers.get("content-length") ?? 0) || undefined, + contentType: res.headers.get("content-type") ?? undefined, + etag: res.headers.get("etag")?.replace(/^"|"$/g, "") ?? undefined, + }; +} + +export async function deleteObject(key: string): Promise { + const { aws, cfg } = client(); + const res = await aws.fetch(objectUrl(cfg, key), { method: "DELETE" }); + if (!res.ok && res.status !== 404) { + throw new Error(`DELETE ${key} failed: ${res.status}`); + } +} + +function byteLengthOf(body: BodyInit): number { + if (typeof body === "string") return new TextEncoder().encode(body).byteLength; + if (body instanceof ArrayBuffer) return body.byteLength; + if (ArrayBuffer.isView(body)) return body.byteLength; + if (body instanceof Blob) return body.size; + throw new Error("Unsupported body type for bucket putObject"); +} + +export async function putObject( + key: string, + body: BodyInit, + options: { contentType?: string } = {} +): Promise { + const { aws, cfg } = client(); + const headers: Record = { + "content-length": String(byteLengthOf(body)), + }; + if (options.contentType) headers["content-type"] = options.contentType; + const res = await aws.fetch(objectUrl(cfg, key), { + method: "PUT", + body, + headers, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PUT ${key} failed: ${res.status} ${text}`); + } +} + +export async function getObjectBody(key: string): Promise { + const { aws, cfg } = client(); + const res = await aws.fetch(objectUrl(cfg, key), { method: "GET" }); + if (!res.ok) throw new Error(`GET ${key} failed: ${res.status}`); + return await res.blob(); +} + +export function bucketKey(...parts: string[]): string { + return parts + .map((p) => p.replace(/^\/+|\/+$/g, "")) + .filter(Boolean) + .join("/"); +} diff --git a/convex/migrations/bucketBackfill.ts b/convex/migrations/bucketBackfill.ts new file mode 100644 index 0000000..1e5513d --- /dev/null +++ b/convex/migrations/bucketBackfill.ts @@ -0,0 +1,242 @@ +/** + * One-shot migration: copy Convex storage blobs to Railway Buckets. + * + * Run after deploying this branch: + * npx convex run migrations/bucketBackfill:runAll + * npx convex run --prod migrations/bucketBackfill:runAll + * + * Idempotent — rows already pointing at a bucketKey are skipped. + * The legacy `storageId` fields and the Convex storage blobs are left in + * place; a follow-up cleanup commit (post-verification) deletes them. + */ + +import { v } from "convex/values"; +import { + internalAction, + internalMutation, + internalQuery, +} from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { bucketKey as makeBucketKey, putObject } from "../lib/bucket"; + +const EXT_BY_CONTENT_TYPE: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/avif": "avif", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", + "application/pdf": "pdf", + "text/plain": "txt", + "application/json": "json", + "text/html": "html", + "text/html; charset=utf-8": "html", +}; + +function extensionFor(contentType: string): string { + return EXT_BY_CONTENT_TYPE[contentType] ?? "bin"; +} + +function attachmentKey(itemId: Id<"items">, sha256: string, contentType: string): string { + return makeBucketKey("attachments", itemId, `${sha256}.${extensionFor(contentType)}`); +} + +function siteFileKey(fileId: Id<"siteFiles">): string { + return makeBucketKey("siteFiles", `${fileId}.html`); +} + +async function blobToArrayBuffer(blob: Blob): Promise { + return await blob.arrayBuffer(); +} + +export const runAll = internalAction({ + args: {}, + handler: async (ctx): Promise<{ siteFiles: number; attachments: number }> => { + const sites: { migrated: number } = await ctx.runAction( + internal.migrations.bucketBackfill.backfillSiteFiles, + {} + ); + const attachments: { migrated: number } = await ctx.runAction( + internal.migrations.bucketBackfill.backfillAttachments, + {} + ); + console.log( + `[bucketBackfill] complete — ${sites.migrated} siteFiles, ${attachments.migrated} item attachments migrated` + ); + return { + siteFiles: sites.migrated, + attachments: attachments.migrated, + }; + }, +}); + +// --- siteFiles --- + +export const listUnmigratedSiteFiles = internalQuery({ + args: {}, + handler: async (ctx) => { + const all = await ctx.db.query("siteFiles").collect(); + return all + .filter((row) => !row.bucketKey && row.storageId) + .map((row) => ({ + _id: row._id, + storageId: row.storageId!, + contentType: row.contentType, + sha256: row.sha256, + byteLength: row.byteLength, + })); + }, +}); + +export const setSiteFileBucketKey = internalMutation({ + args: { fileId: v.id("siteFiles"), bucketKey: v.string() }, + handler: async (ctx, args) => { + await ctx.db.patch(args.fileId, { bucketKey: args.bucketKey }); + }, +}); + +export const backfillSiteFiles = internalAction({ + args: {}, + handler: async (ctx): Promise<{ migrated: number; skipped: number }> => { + const rows: Array<{ + _id: Id<"siteFiles">; + storageId: Id<"_storage">; + contentType: string; + sha256: string; + byteLength: number; + }> = await ctx.runQuery( + internal.migrations.bucketBackfill.listUnmigratedSiteFiles, + {} + ); + + let migrated = 0; + let skipped = 0; + for (const row of rows) { + const blob = await ctx.storage.get(row.storageId); + if (!blob) { + console.warn(`[bucketBackfill] siteFile ${row._id} storage blob missing`); + skipped += 1; + continue; + } + const buffer = await blobToArrayBuffer(blob); + const key = siteFileKey(row._id); + await putObject(key, buffer, { contentType: row.contentType }); + await ctx.runMutation(internal.migrations.bucketBackfill.setSiteFileBucketKey, { + fileId: row._id, + bucketKey: key, + }); + migrated += 1; + } + return { migrated, skipped }; + }, +}); + +// --- item attachments --- + +type AttachmentObject = { + key: string; + contentType: string; + size: number; + sha256: string; +}; + +export const listItemsWithLegacyAttachments = internalQuery({ + args: {}, + handler: async (ctx) => { + const all = await ctx.db.query("items").collect(); + return all + .filter((row) => + (row.attachments ?? []).some((entry) => typeof entry === "string") + ) + .map((row) => ({ + _id: row._id, + attachments: row.attachments ?? [], + })); + }, +}); + +export const setItemAttachments = internalMutation({ + args: { + itemId: v.id("items"), + attachments: v.array( + v.object({ + key: v.string(), + contentType: v.string(), + size: v.number(), + sha256: v.string(), + }) + ), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.itemId, { + attachments: args.attachments, + updatedAt: Date.now(), + }); + }, +}); + +export const backfillAttachments = internalAction({ + args: {}, + handler: async (ctx): Promise<{ migrated: number; skipped: number }> => { + const rows: Array<{ + _id: Id<"items">; + attachments: Array | AttachmentObject>; + }> = await ctx.runQuery( + internal.migrations.bucketBackfill.listItemsWithLegacyAttachments, + {} + ); + + let migrated = 0; + let skipped = 0; + for (const item of rows) { + const converted: AttachmentObject[] = []; + let touched = false; + for (const entry of item.attachments) { + if (typeof entry !== "string") { + converted.push(entry); + continue; + } + const storageId = entry as Id<"_storage">; + const blob = await ctx.storage.get(storageId); + if (!blob) { + console.warn( + `[bucketBackfill] item ${item._id} storage blob ${storageId} missing` + ); + skipped += 1; + continue; + } + const buffer = await blobToArrayBuffer(blob); + const meta = await ctx.storage.getMetadata(storageId); + const contentType = meta?.contentType ?? "application/octet-stream"; + const size = meta?.size ?? buffer.byteLength; + const sha256 = meta?.sha256 ?? (await sha256Hex(buffer)); + const key = attachmentKey(item._id, sha256, contentType); + await putObject(key, buffer, { contentType }); + converted.push({ key, contentType, size, sha256 }); + touched = true; + migrated += 1; + } + if (touched) { + await ctx.runMutation( + internal.migrations.bucketBackfill.setItemAttachments, + { + itemId: item._id, + attachments: converted, + } + ); + } + } + return { migrated, skipped }; + }, +}); + +async function sha256Hex(buffer: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/convex/schema.ts b/convex/schema.ts index 27d9e6c..3d0feb0 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -148,8 +148,17 @@ export default defineSchema({ parentId: v.optional(v.id("items")), // Optional assignee DID for Mission Control workflows assigneeDid: v.optional(v.string()), - // Attachments - stored file IDs - attachments: v.optional(v.array(v.id("_storage"))), + // Attachments — Railway Bucket objects. Legacy `v.id("_storage")` entries + // exist only until the bucketBackfill migration runs once on this deploy. + attachments: v.optional(v.array(v.union( + v.id("_storage"), + v.object({ + key: v.string(), + contentType: v.string(), + size: v.number(), + sha256: v.string(), + }), + ))), // VC proofs for item actions (Phase 6 - Provenance Chain) vcProofs: v.optional(v.array(v.object({ type: v.string(), // e.g., "ItemCreation", "ItemCompletion" @@ -292,8 +301,11 @@ export default defineSchema({ // Single-file hosted sites. These are public, shareable HTML drops with // portable did:webvh identity. Kept separate from todo/list publication. + // `storageId` is the legacy Convex-storage pointer, kept optional only until + // the bucketBackfill migration runs once on this deploy. siteFiles: defineTable({ - storageId: v.id("_storage"), + storageId: v.optional(v.id("_storage")), + bucketKey: v.optional(v.string()), contentType: v.string(), sha256: v.string(), byteLength: v.number(), @@ -370,6 +382,28 @@ export default defineSchema({ }) .index("by_site", ["siteId"]), + // Image assets associated with a site. Served at https:///_assets/ + // and stored in Railway Bucket under "site-images//". + // Filename is unique per site; re-uploads overwrite. + siteImages: defineTable({ + siteId: v.id("sites"), + fileName: v.string(), + bucketKey: v.string(), + contentType: v.string(), + byteLength: v.number(), + sha256: v.string(), + kind: v.optional(v.union( + v.literal("favicon"), + v.literal("og"), + v.literal("avatar"), + v.literal("asset"), + )), + createdAt: v.number(), + updatedAt: v.optional(v.number()), + }) + .index("by_site", ["siteId"]) + .index("by_site_filename", ["siteId", "fileName"]), + // Comments table - threaded discussions on items comments: defineTable({ itemId: v.id("items"), diff --git a/convex/siteActions.ts b/convex/siteActions.ts index 93b8ddf..9cd79ea 100644 --- a/convex/siteActions.ts +++ b/convex/siteActions.ts @@ -5,6 +5,7 @@ import { internal, api } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { createCustomHostname, getCustomHostname } from "./cloudflare"; +import { getObjectBody, headObject } from "./lib/bucket"; import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { sha512 } from "@noble/hashes/sha2.js"; import { concatBytes, bytesToHex } from "@noble/hashes/utils.js"; @@ -211,7 +212,7 @@ function normalizeCustomHostname(hostname: string): string { export const createSiteFromUpload = action({ args: { ownerDid: v.string(), - storageId: v.id("_storage"), + bucketKey: v.string(), }, handler: async (ctx, args): Promise<{ siteId: string; hostname: string; url: string; did: string; scid: string }> => { configureEd25519Sha512(); @@ -222,15 +223,15 @@ export const createSiteFromUpload = action({ throw new Error("SITE_KEY_ENCRYPTION_SECRET is not configured"); } - const blob = await ctx.storage.get(args.storageId); - if (!blob) { + const head = await headObject(args.bucketKey); + if (!head.exists) { throw new Error("That uploaded file disappeared. Try dropping it again."); } + const blob = await getObjectBody(args.bucketKey); const content = validateHtmlPayload(await blob.text()); const byteLength = new TextEncoder().encode(content).byteLength; - const metadata = await ctx.storage.getMetadata(args.storageId); - const sha256 = metadata?.sha256 ?? createHash("sha256").update(content).digest("hex"); + const sha256 = createHash("sha256").update(content).digest("hex"); let hostname = ""; for (let attempt = 0; attempt < 25; attempt += 1) { @@ -277,7 +278,7 @@ export const createSiteFromUpload = action({ const createdAt = Date.now(); const record = await ctx.runMutation(internal.siteInternals.createSiteRecord, { ownerDid: args.ownerDid, - storageId: args.storageId, + bucketKey: args.bucketKey, contentType: "text/html; charset=utf-8", sha256, byteLength, @@ -533,7 +534,7 @@ export const replaceSiteFile = action({ args: { ownerDid: v.string(), siteId: v.id("sites"), - storageId: v.id("_storage"), + bucketKey: v.string(), }, handler: async (ctx, args): Promise<{ fileId: string }> => { // Ownership check via the existing query. @@ -543,26 +544,20 @@ export const replaceSiteFile = action({ }); if (!owned) throw new Error("Site not found"); - const url = await ctx.storage.getUrl(args.storageId); - if (!url) throw new Error("Uploaded file not found."); - - const response = await fetch(url); - if (!response.ok) { - throw new Error("Could not read the uploaded file."); - } - const buffer = await response.arrayBuffer(); - const byteLength = buffer.byteLength; - if (byteLength === 0) { + const head = await headObject(args.bucketKey); + if (!head.exists) throw new Error("Uploaded file not found."); + if (!head.contentLength || head.contentLength === 0) { throw new Error("The uploaded file was empty."); } - if (byteLength > MAX_REPLACE_HTML_BYTES) { + if (head.contentLength > MAX_REPLACE_HTML_BYTES) { throw new Error("That file is bigger than the 2 MB limit."); } - const contentType = - response.headers.get("content-type") ?? "text/html; charset=utf-8"; + const blob = await getObjectBody(args.bucketKey); + const buffer = await blob.arrayBuffer(); + const byteLength = buffer.byteLength; + const contentType = head.contentType ?? "text/html; charset=utf-8"; - // SHA-256 hex const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); const sha256 = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) @@ -572,7 +567,7 @@ export const replaceSiteFile = action({ internal.siteInternals.replaceSiteFileRecord, { siteId: args.siteId, - storageId: args.storageId, + bucketKey: args.bucketKey, contentType, sha256, byteLength, diff --git a/convex/siteImages.ts b/convex/siteImages.ts new file mode 100644 index 0000000..7c08f62 --- /dev/null +++ b/convex/siteImages.ts @@ -0,0 +1,206 @@ +import { v } from "convex/values"; +import { + action, + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { + bucketKey as makeBucketKey, + deleteObject, + presignPut, +} from "./lib/bucket"; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const ALLOWED_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "image/x-icon", + "image/vnd.microsoft.icon", + "image/avif", +]); + +function sanitizeFileName(fileName: string): string { + const cleaned = fileName + .trim() + .replace(/\\/g, "/") + .split("/") + .pop()! + .replace(/[^A-Za-z0-9._\-]/g, "_") + .replace(/^\.+/, ""); + if (!cleaned) throw new Error("Filename is empty."); + if (cleaned.length > 128) throw new Error("Filename is too long."); + return cleaned; +} + +function imageKindOrAsset(value: string | undefined) { + if (value === "favicon" || value === "og" || value === "avatar" || value === "asset") { + return value; + } + return "asset" as const; +} + +export const generateSiteImageUploadUrl = action({ + args: { + ownerDid: v.string(), + siteId: v.id("sites"), + fileName: v.string(), + contentType: v.string(), + byteLength: v.number(), + }, + handler: async ( + ctx, + args + ): Promise<{ uploadUrl: string; bucketKey: string; fileName: string }> => { + if (!ALLOWED_TYPES.has(args.contentType)) { + throw new Error(`Unsupported image type: ${args.contentType}`); + } + if (args.byteLength <= 0 || args.byteLength > MAX_IMAGE_BYTES) { + throw new Error("Image is empty or exceeds the 10 MB limit."); + } + + const owned = await ctx.runQuery(internal.siteImages.assertOwnsSite, { + siteId: args.siteId, + ownerDid: args.ownerDid, + }); + if (!owned) throw new Error("Site not found"); + + const fileName = sanitizeFileName(args.fileName); + const key = makeBucketKey("site-images", args.siteId, fileName); + const uploadUrl = await presignPut(key, { + contentType: args.contentType, + expiresSec: 600, + }); + return { uploadUrl, bucketKey: key, fileName }; + }, +}); + +export const addSiteImage = mutation({ + args: { + ownerDid: v.string(), + siteId: v.id("sites"), + fileName: v.string(), + bucketKey: v.string(), + contentType: v.string(), + byteLength: v.number(), + sha256: v.string(), + kind: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ imageId: Id<"siteImages"> }> => { + const site = await ctx.db.get(args.siteId); + if (!site || site.ownerDid !== args.ownerDid) { + throw new Error("Site not found"); + } + + const fileName = sanitizeFileName(args.fileName); + const now = Date.now(); + const existing = await ctx.db + .query("siteImages") + .withIndex("by_site_filename", (q) => + q.eq("siteId", args.siteId).eq("fileName", fileName) + ) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + bucketKey: args.bucketKey, + contentType: args.contentType, + byteLength: args.byteLength, + sha256: args.sha256, + kind: imageKindOrAsset(args.kind), + updatedAt: now, + }); + return { imageId: existing._id }; + } + + const imageId = await ctx.db.insert("siteImages", { + siteId: args.siteId, + fileName, + bucketKey: args.bucketKey, + contentType: args.contentType, + byteLength: args.byteLength, + sha256: args.sha256, + kind: imageKindOrAsset(args.kind), + createdAt: now, + }); + return { imageId }; + }, +}); + +export const listSiteImages = query({ + args: { ownerDid: v.string(), siteId: v.id("sites") }, + handler: async (ctx, args) => { + const site = await ctx.db.get(args.siteId); + if (!site || site.ownerDid !== args.ownerDid) return []; + + return await ctx.db + .query("siteImages") + .withIndex("by_site", (q) => q.eq("siteId", args.siteId)) + .order("desc") + .collect(); + }, +}); + +export const removeSiteImage = action({ + args: { + ownerDid: v.string(), + imageId: v.id("siteImages"), + }, + handler: async (ctx, args): Promise => { + const image = await ctx.runQuery(internal.siteImages.getOwnedImage, { + imageId: args.imageId, + ownerDid: args.ownerDid, + }); + if (!image) throw new Error("Image not found"); + + await deleteObject(image.bucketKey); + await ctx.runMutation(internal.siteImages.deleteImageRow, { + imageId: args.imageId, + }); + }, +}); + +export const assertOwnsSite = internalQuery({ + args: { siteId: v.id("sites"), ownerDid: v.string() }, + handler: async (ctx, args) => { + const site = await ctx.db.get(args.siteId); + if (!site || site.ownerDid !== args.ownerDid) return null; + return { siteId: site._id }; + }, +}); + +export const getOwnedImage = internalQuery({ + args: { imageId: v.id("siteImages"), ownerDid: v.string() }, + handler: async (ctx, args) => { + const image = await ctx.db.get(args.imageId); + if (!image) return null; + const site = await ctx.db.get(image.siteId); + if (!site || site.ownerDid !== args.ownerDid) return null; + return image; + }, +}); + +export const deleteImageRow = internalMutation({ + args: { imageId: v.id("siteImages") }, + handler: async (ctx, args) => { + await ctx.db.delete(args.imageId); + }, +}); + +export const resolvePublicImage = internalQuery({ + args: { siteId: v.id("sites"), fileName: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("siteImages") + .withIndex("by_site_filename", (q) => + q.eq("siteId", args.siteId).eq("fileName", args.fileName) + ) + .first(); + }, +}); diff --git a/convex/siteInternals.ts b/convex/siteInternals.ts index 8a6100a..75c1f56 100644 --- a/convex/siteInternals.ts +++ b/convex/siteInternals.ts @@ -15,7 +15,7 @@ export const isHostnameAvailable = internalQuery({ export const createSiteRecord = internalMutation({ args: { ownerDid: v.string(), - storageId: v.id("_storage"), + bucketKey: v.string(), contentType: v.string(), sha256: v.string(), byteLength: v.number(), @@ -43,7 +43,7 @@ export const createSiteRecord = internalMutation({ } const fileId = await ctx.db.insert("siteFiles", { - storageId: args.storageId, + bucketKey: args.bucketKey, contentType: args.contentType, sha256: args.sha256, byteLength: args.byteLength, @@ -92,12 +92,6 @@ export const createSiteRecord = internalMutation({ }, }); -export const deleteUploadedFile = internalMutation({ - args: { storageId: v.id("_storage") }, - handler: async (ctx, args) => { - await ctx.storage.delete(args.storageId); - }, -}); export const getSiteIdentityForUpdate = internalQuery({ args: { @@ -336,7 +330,7 @@ export const clearHostnameErrors = internalMutation({ export const replaceSiteFileRecord = internalMutation({ args: { siteId: v.id("sites"), - storageId: v.id("_storage"), + bucketKey: v.string(), contentType: v.string(), sha256: v.string(), byteLength: v.number(), @@ -347,7 +341,7 @@ export const replaceSiteFileRecord = internalMutation({ if (!site) throw new Error("Site not found"); const newFileId = await ctx.db.insert("siteFiles", { - storageId: args.storageId, + bucketKey: args.bucketKey, contentType: args.contentType, sha256: args.sha256, byteLength: args.byteLength, diff --git a/convex/sites.ts b/convex/sites.ts index 9e3c6d8..7904b11 100644 --- a/convex/sites.ts +++ b/convex/sites.ts @@ -1,10 +1,32 @@ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { action, internalQuery, query } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { + bucketKey as makeBucketKey, + presignGet, + presignPut, +} from "./lib/bucket"; -export const generateSiteUploadUrl = mutation({ +const UPLOAD_EXPIRY_SEC = 600; +const PREVIEW_EXPIRY_SEC = 300; +const SITE_HTML_CONTENT_TYPE = "text/html; charset=utf-8"; + +function newSiteBucketKey(): string { + return makeBucketKey("siteFiles", `${crypto.randomUUID()}.html`); +} + +export const generateSiteUploadUrl = action({ args: { ownerDid: v.string() }, - handler: async (ctx, _args) => { - return await ctx.storage.generateUploadUrl(); + handler: async ( + _ctx, + _args + ): Promise<{ uploadUrl: string; bucketKey: string }> => { + const key = newSiteBucketKey(); + const uploadUrl = await presignPut(key, { + contentType: SITE_HTML_CONTENT_TYPE, + expiresSec: UPLOAD_EXPIRY_SEC, + }); + return { uploadUrl, bucketKey: key }; }, }); @@ -55,11 +77,19 @@ export const getSite = query({ const primaryHostname = site.primaryHostnameId != null ? await ctx.db.get(site.primaryHostnameId) : null; - const storageUrl = file ? await ctx.storage.getUrl(file.storageId) : null; return { ...site, - file: file ? { ...file, storageUrl } : null, + file: file + ? { + _id: file._id, + contentType: file.contentType, + sha256: file.sha256, + byteLength: file.byteLength, + bucketKey: file.bucketKey ?? null, + createdAt: file.createdAt, + } + : null, publicKeyMultibase: key?.publicKeyMultibase ?? null, hostnames, primaryHostname, @@ -71,6 +101,29 @@ export const getSite = query({ }, }); +export const getSitePreviewUrl = action({ + args: { siteId: v.id("sites"), ownerDid: v.string() }, + handler: async (ctx, args): Promise => { + const site = await ctx.runQuery(internal.sites.getSiteFileBucketKey, { + siteId: args.siteId, + ownerDid: args.ownerDid, + }); + if (!site?.bucketKey) return null; + return await presignGet(site.bucketKey, { expiresSec: PREVIEW_EXPIRY_SEC }); + }, +}); + +export const getSiteFileBucketKey = internalQuery({ + args: { siteId: v.id("sites"), ownerDid: v.string() }, + handler: async (ctx, args) => { + const site = await ctx.db.get(args.siteId); + if (!site || site.ownerDid !== args.ownerDid) return null; + const file = await ctx.db.get(site.fileId); + if (!file) return null; + return { bucketKey: file.bucketKey ?? null }; + }, +}); + export const getPublicSiteByHostname = query({ args: { hostname: v.string() }, handler: async (ctx, args) => { @@ -98,10 +151,10 @@ export const getPublicSiteByHostname = query({ hostname, primaryHostname, file: { - storageId: file.storageId, contentType: file.contentType, sha256: file.sha256, byteLength: file.byteLength, + bucketKey: file.bucketKey ?? null, }, didLogJsonl: didLogEntries .sort((a, b) => a.signedAt - b.signedAt) diff --git a/convex/sitesHttp.ts b/convex/sitesHttp.ts index d899e48..9ab5098 100644 --- a/convex/sitesHttp.ts +++ b/convex/sitesHttp.ts @@ -1,5 +1,6 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; +import { presignGet } from "./lib/bucket"; function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -59,11 +60,12 @@ export const resolveSiteHost = httpAction(async (ctx, request) => { }); } - const blob = await ctx.storage.get(record.file.storageId); - if (!blob) { + if (!record.file.bucketKey) { return json({ status: "missing", hostname }, 404); } + const bucketUrl = await presignGet(record.file.bucketKey, { expiresSec: 300 }); + return json({ status: "active", hostname, @@ -71,9 +73,50 @@ export const resolveSiteHost = httpAction(async (ctx, request) => { did: record.site.did, scid: record.site.scid, primaryHostname: record.primaryHostname?.hostname ?? hostname, - html: await blob.text(), + bucketUrl, contentType: record.file.contentType, sha256: record.file.sha256, didLogJsonl: record.didLogJsonl, }); }); + +export const resolveSiteImage = httpAction(async (ctx, request) => { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + }); + } + + const url = new URL(request.url); + const hostname = normalizedHostname(url.searchParams.get("hostname")); + const fileName = url.searchParams.get("fileName"); + if (!hostname || !fileName) { + return json({ status: "error", error: "hostname and fileName are required" }, 400); + } + + const record = await ctx.runQuery(api.sites.getPublicSiteByHostname, { hostname }); + if (!record) return json({ status: "missing" }, 404); + if (record.hostname.status !== "active") { + return json({ status: "pending" }, 404); + } + + const image = await ctx.runQuery(internal.siteImages.resolvePublicImage, { + siteId: record.site._id, + fileName, + }); + if (!image) return json({ status: "missing" }, 404); + + const presigned = await presignGet(image.bucketKey, { expiresSec: 300 }); + return json({ + status: "active", + contentType: image.contentType, + byteLength: image.byteLength, + sha256: image.sha256, + url: presigned, + }); +}); diff --git a/package.json b/package.json index cfaecaa..546a1d2 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,8 @@ "start": "serve dist -s", "start:sites": "bun server.ts", "mission-control:validate-observability": "node scripts/validate-mission-control-observability.mjs", - "env:dev": "bash -c 'export $(grep -v \"^#\" .env.local | grep -E \"^(TURNKEY_|JWT_SECRET|WEBVH_DOMAIN)\" | xargs) && for k in TURNKEY_API_PUBLIC_KEY TURNKEY_API_PRIVATE_KEY TURNKEY_ORGANIZATION_ID JWT_SECRET WEBVH_DOMAIN; do npx convex env set \"$k\" \"${!k}\"; done'", - "env:prod": "bash -c 'export $(grep -v \"^#\" .env.local | grep -E \"^(TURNKEY_|JWT_SECRET|WEBVH_DOMAIN)\" | xargs) && for k in TURNKEY_API_PUBLIC_KEY TURNKEY_API_PRIVATE_KEY TURNKEY_ORGANIZATION_ID JWT_SECRET WEBVH_DOMAIN; do npx convex env set --prod \"$k\" \"${!k}\"; done'", - "env:turnkey:dev": "bash ./scripts/sync-convex-turnkey-env.sh .env.local dev", - "env:turnkey:prod": "bash ./scripts/sync-convex-turnkey-env.sh .env.local prod", + "env:dev": "bash ./scripts/sync-convex-env.sh .env.local dev", + "env:prod": "bash ./scripts/sync-convex-env.sh .env.local prod", "cap:sync": "npx cap sync", "cap:build": "npm run build && npx cap sync", "generate:assets": "node scripts/generate-icons.mjs && npx capacitor-assets generate --iconBackgroundColor '#fafaf7' --iconBackgroundColorDark '#fafaf7' --splashBackgroundColor '#fafaf7' --splashBackgroundColorDark '#0c0b10' --ios --android && node scripts/generate-icons.mjs --android-native-only", @@ -40,6 +38,7 @@ "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", + "aws4fetch": "^1.0.20", "capacitor-native-biometric": "^4.2.2", "convex": "^1.31.6", "idb": "^8.0.3", diff --git a/scripts/bucket-smoke.mjs b/scripts/bucket-smoke.mjs new file mode 100644 index 0000000..7ebde7e --- /dev/null +++ b/scripts/bucket-smoke.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env bun +/** + * Round-trip smoke test for Railway Bucket credentials. + * Reads BOOP_BUCKET_* from .env.local (bun auto-loads it). + * + * Usage: bun scripts/bucket-smoke.mjs + */ +import { + bucketKey, + deleteObject, + getObjectBody, + headObject, + presignGet, + presignPut, + putObject, +} from "../convex/lib/bucket.ts"; + +const key = bucketKey("_smoke", `${Date.now()}-${crypto.randomUUID()}.txt`); +const body = `hello bucket — ${new Date().toISOString()}`; +const bodyBytes = new TextEncoder().encode(body).length; + +console.log("→ PUT", key); +await putObject(key, body, { contentType: "text/plain" }); + +console.log("→ HEAD", key); +const head = await headObject(key); +console.log(" ", head); +if (!head.exists) throw new Error("HEAD reports object does not exist after PUT"); +if (head.contentLength !== bodyBytes) { + throw new Error(`content-length mismatch: ${head.contentLength} vs ${bodyBytes}`); +} + +console.log("→ GET", key); +const blob = await getObjectBody(key); +const text = await blob.text(); +if (text !== body) throw new Error(`body mismatch: ${JSON.stringify(text)}`); + +console.log("→ presignGet", key); +const getUrl = await presignGet(key, { expiresSec: 60 }); +const presignedRes = await fetch(getUrl); +if (!presignedRes.ok) { + throw new Error(`presigned GET failed: ${presignedRes.status}`); +} +const presignedText = await presignedRes.text(); +if (presignedText !== body) { + throw new Error(`presigned GET body mismatch: ${JSON.stringify(presignedText)}`); +} + +console.log("→ presignPut (no upload)", key); +const putUrl = await presignPut(`${key}.presigned`, { + contentType: "text/plain", + expiresSec: 60, +}); +if (!new URL(putUrl).searchParams.get("X-Amz-Signature")) { + throw new Error("presignPut returned unsigned URL"); +} + +console.log("→ DELETE", key); +await deleteObject(key); +const headAfter = await headObject(key); +if (headAfter.exists) throw new Error("object still exists after DELETE"); + +console.log("✓ Bucket smoke test passed"); diff --git a/scripts/bucket.test.mjs b/scripts/bucket.test.mjs new file mode 100644 index 0000000..2420d7d --- /dev/null +++ b/scripts/bucket.test.mjs @@ -0,0 +1,196 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/bucket-test"; + +async function loadBucketModule(env = {}) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/lib/bucket.ts"], + outfile: `${outdir}/bucket.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + for (const k of [ + "BOOP_BUCKET_NAME", + "BOOP_BUCKET_ACCESS_KEY_ID", + "BOOP_BUCKET_SECRET_ACCESS_KEY", + "BOOP_BUCKET_ENDPOINT", + "BOOP_BUCKET_REGION", + ]) { + delete process.env[k]; + } + for (const [k, v] of Object.entries(env)) process.env[k] = v; + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/bucket.mjs`).href}?t=${Date.now()}` + ); +} + +const ENV = { + BOOP_BUCKET_NAME: "my-bucket-abc", + BOOP_BUCKET_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + BOOP_BUCKET_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + BOOP_BUCKET_ENDPOINT: "https://storage.railway.app", + BOOP_BUCKET_REGION: "auto", +}; + +function stubFetch(handler) { + const calls = []; + globalThis.fetch = async (input, init = {}) => { + const url = typeof input === "string" ? input : input.url; + const method = + init.method ?? (typeof input !== "string" ? input.method : "GET"); + calls.push({ url, method, init }); + return handler(input, init, calls.length); + }; + return calls; +} + +test("presignPut returns a virtual-hosted URL with signature params", async () => { + const m = await loadBucketModule(ENV); + const url = await m.presignPut("sites/abc/index.html", { + contentType: "text/html", + }); + const u = new URL(url); + assert.equal(u.host, "my-bucket-abc.storage.railway.app"); + assert.equal(u.pathname, "/sites/abc/index.html"); + assert.ok(u.searchParams.get("X-Amz-Algorithm")?.startsWith("AWS4")); + assert.ok(u.searchParams.get("X-Amz-Signature")); + assert.equal(u.searchParams.get("X-Amz-Expires"), "3600"); +}); + +test("presignGet returns a virtual-hosted URL with custom expiry", async () => { + const m = await loadBucketModule(ENV); + const url = await m.presignGet("attachments/xyz.png", { expiresSec: 600 }); + const u = new URL(url); + assert.equal(u.host, "my-bucket-abc.storage.railway.app"); + assert.equal(u.pathname, "/attachments/xyz.png"); + assert.equal(u.searchParams.get("X-Amz-Expires"), "600"); + assert.ok(u.searchParams.get("X-Amz-Signature")); +}); + +test("headObject returns metadata on 200", async () => { + const m = await loadBucketModule(ENV); + stubFetch( + async () => + new Response(null, { + status: 200, + headers: { + "content-length": "12345", + "content-type": "text/html", + etag: '"abc123"', + }, + }) + ); + const result = await m.headObject("sites/abc/index.html"); + assert.equal(result.exists, true); + assert.equal(result.contentLength, 12345); + assert.equal(result.contentType, "text/html"); + assert.equal(result.etag, "abc123"); +}); + +test("headObject returns exists: false on 404", async () => { + const m = await loadBucketModule(ENV); + stubFetch(async () => new Response(null, { status: 404 })); + const result = await m.headObject("missing"); + assert.equal(result.exists, false); +}); + +test("headObject hits the correct virtual-hosted URL with HEAD", async () => { + const m = await loadBucketModule(ENV); + const calls = stubFetch(async () => new Response(null, { status: 200 })); + await m.headObject("sites/abc/index.html"); + assert.equal(calls.length, 1); + const u = new URL(calls[0].url); + assert.equal(u.host, "my-bucket-abc.storage.railway.app"); + assert.equal(u.pathname, "/sites/abc/index.html"); + assert.equal(calls[0].method, "HEAD"); +}); + +test("deleteObject succeeds on 204", async () => { + const m = await loadBucketModule(ENV); + const calls = stubFetch(async () => new Response(null, { status: 204 })); + await m.deleteObject("attachments/foo.png"); + assert.equal(calls[0].method, "DELETE"); +}); + +test("deleteObject does not throw on 404", async () => { + const m = await loadBucketModule(ENV); + stubFetch(async () => new Response(null, { status: 404 })); + await m.deleteObject("missing"); +}); + +test("deleteObject surfaces non-404 errors", async () => { + const m = await loadBucketModule(ENV); + stubFetch(async () => new Response("forbidden", { status: 403 })); + await assert.rejects(() => m.deleteObject("locked"), /DELETE locked failed: 403/); +}); + +test("putObject signs and PUTs with content-type", async () => { + const m = await loadBucketModule(ENV); + const calls = stubFetch(async () => new Response(null, { status: 200 })); + await m.putObject("sites/abc/index.html", "", { + contentType: "text/html", + }); + assert.equal(calls[0].method, "PUT"); + const u = new URL(calls[0].url); + assert.equal(u.host, "my-bucket-abc.storage.railway.app"); + assert.equal(u.pathname, "/sites/abc/index.html"); +}); + +test("missing BOOP_BUCKET_NAME throws a clear error", async () => { + const m = await loadBucketModule({ + ...ENV, + BOOP_BUCKET_NAME: undefined, + }); + delete process.env.BOOP_BUCKET_NAME; + await assert.rejects(() => m.presignPut("k"), /BOOP_BUCKET_NAME is not set/); +}); + +test("missing BOOP_BUCKET_ACCESS_KEY_ID throws a clear error", async () => { + const m = await loadBucketModule({ + ...ENV, + BOOP_BUCKET_ACCESS_KEY_ID: undefined, + }); + delete process.env.BOOP_BUCKET_ACCESS_KEY_ID; + await assert.rejects( + () => m.presignPut("k"), + /BOOP_BUCKET_ACCESS_KEY_ID is not set/ + ); +}); + +test("missing BOOP_BUCKET_SECRET_ACCESS_KEY throws a clear error", async () => { + const m = await loadBucketModule({ + ...ENV, + BOOP_BUCKET_SECRET_ACCESS_KEY: undefined, + }); + delete process.env.BOOP_BUCKET_SECRET_ACCESS_KEY; + await assert.rejects( + () => m.presignPut("k"), + /BOOP_BUCKET_SECRET_ACCESS_KEY is not set/ + ); +}); + +test("bucketKey joins parts safely and strips slashes", async () => { + const m = await loadBucketModule(ENV); + assert.equal(m.bucketKey("sites", "abc", "index.html"), "sites/abc/index.html"); + assert.equal( + m.bucketKey("/sites/", "/abc/", "index.html"), + "sites/abc/index.html" + ); + assert.equal(m.bucketKey("", "abc", ""), "abc"); +}); + +test("object keys with special characters are percent-encoded", async () => { + const m = await loadBucketModule(ENV); + const url = await m.presignGet("sites/abc/hello world.html"); + const u = new URL(url); + assert.equal(u.pathname, "/sites/abc/hello%20world.html"); +}); diff --git a/scripts/sync-convex-env.sh b/scripts/sync-convex-env.sh new file mode 100755 index 0000000..de4385e --- /dev/null +++ b/scripts/sync-convex-env.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Push every server-side var from a dotenv file into Convex. +# Skips VITE_* (client-only), but mirrors VITE_X -> X if X isn't set. +# +# Usage: sync-convex-env.sh [] [] +set -euo pipefail + +ENV_FILE="${1:-.env.local}" +TARGET="${2:-dev}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Error: env file not found: $ENV_FILE" >&2 + exit 1 +fi +if [[ "$TARGET" != "dev" && "$TARGET" != "prod" ]]; then + echo "Error: target must be 'dev' or 'prod', got: $TARGET" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +count=0 +while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + key="${line%%=*}" + key="${key#export }" + key="${key// /}" + [[ -z "$key" || ! "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] && continue + # CONVEX_* identifies the deployment target itself — never push into Convex. + [[ "$key" == CONVEX_* ]] && continue + + # Mirror VITE_X -> X if X isn't already in the file. + if [[ "$key" == VITE_* ]]; then + bare="${key#VITE_}" + if grep -qE "^[[:space:]]*(export[[:space:]]+)?${bare}=" "$ENV_FILE"; then + continue + fi + key="$bare" + val="${!key:-}" + else + val="${!key:-}" + fi + [[ -z "$val" ]] && continue + + if [[ "$TARGET" == "prod" ]]; then + npx convex env set --prod "$key" "$val" + else + npx convex env set "$key" "$val" + fi + count=$((count + 1)) +done < "$ENV_FILE" + +echo "Done: synced $count var(s) to Convex ($TARGET)" diff --git a/scripts/sync-convex-turnkey-env.sh b/scripts/sync-convex-turnkey-env.sh deleted file mode 100755 index e557d24..0000000 --- a/scripts/sync-convex-turnkey-env.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ENV_FILE="${1:-.env.local}" -TARGET="${2:-dev}" - -if [[ ! -f "$ENV_FILE" ]]; then - echo "Error: env file not found: $ENV_FILE" >&2 - exit 1 -fi - -# shellcheck disable=SC2046 -export $(grep -v '^#' "$ENV_FILE" | grep -E '^(VITE_)?(TURNKEY_API_PUBLIC_KEY|TURNKEY_API_PRIVATE_KEY|TURNKEY_ORGANIZATION_ID|JWT_SECRET|WEBVH_DOMAIN)=' | xargs) - -required_vars=( - TURNKEY_API_PUBLIC_KEY - TURNKEY_API_PRIVATE_KEY - TURNKEY_ORGANIZATION_ID - JWT_SECRET - WEBVH_DOMAIN -) - -for key in "${required_vars[@]}"; do - vite_key="VITE_${key}" - if [[ -n "${!key:-}" ]]; then - continue - fi - if [[ -n "${!vite_key:-}" ]]; then - export "$key=${!vite_key}" - continue - fi - if [[ -z "${!key:-}" ]]; then - echo "Error: missing required env var in $ENV_FILE: $key (or $vite_key)" >&2 - exit 1 - fi -done - -if [[ "$TARGET" != "dev" && "$TARGET" != "prod" ]]; then - echo "Error: target must be 'dev' or 'prod', got: $TARGET" >&2 - exit 1 -fi - -for key in "${required_vars[@]}"; do - convex_key="${key#VITE_}" - if [[ "$TARGET" == "prod" ]]; then - npx convex env set --prod "$convex_key" "${!key}" - else - npx convex env set "$convex_key" "${!key}" - fi -done - -echo "Done: synced Turnkey env vars to Convex ($TARGET)" diff --git a/server.ts b/server.ts index 532576a..8409c1f 100644 --- a/server.ts +++ b/server.ts @@ -100,12 +100,31 @@ async function resolveHostedSite(hostname: string) { status: "active" | "missing" | "pending" | "redirect" | "error"; hostname: string; location?: string; - html?: string; + bucketUrl?: string; contentType?: string; didLogJsonl?: string; }>; } +async function resolveSiteImageUrl( + hostname: string, + fileName: string +): Promise<{ status: "active" | "missing" | "pending"; url?: string; contentType?: string }> { + const convexHttpUrl = process.env.CONVEX_HTTP_URL || process.env.VITE_CONVEX_HTTP_URL; + if (!convexHttpUrl) return { status: "missing" }; + + const response = await fetch( + `${convexHttpUrl}/api/sites/resolve-image?hostname=${encodeURIComponent(hostname)}&fileName=${encodeURIComponent(fileName)}` + ); + if (response.status === 404) return { status: "missing" }; + if (!response.ok) throw new Error(`Image resolve failed with ${response.status}`); + return response.json() as Promise<{ + status: "active" | "missing" | "pending"; + url?: string; + contentType?: string; + }>; +} + function missingSitePage(hostname: string): Response { return new Response( `Not found on boop

Nothing here yet.

${hostname} does not exist on boop, or it is still waking up. Check the link and try again.

`, @@ -116,8 +135,26 @@ function missingSitePage(hostname: string): Response { ); } +async function serveHostedSiteImage( + hostname: string, + fileName: string +): Promise { + const image = await resolveSiteImageUrl(hostname, fileName); + if (image.status === "active" && image.url) { + return Response.redirect(image.url, 302); + } + return missingSitePage(hostname); +} + async function serveHostedSite(request: Request, hostname: string): Promise { const url = new URL(request.url); + + if (url.pathname.startsWith("/_assets/")) { + const fileName = decodeURIComponent(url.pathname.slice("/_assets/".length)); + if (!fileName || fileName.includes("/")) return missingSitePage(hostname); + return serveHostedSiteImage(hostname, fileName); + } + const site = await resolveHostedSite(hostname); if (!site || site.status === "missing" || site.status === "error") { @@ -145,7 +182,10 @@ async function serveHostedSite(request: Request, hostname: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + interface AttachmentsProps { itemId: Id<"items">; userDid: string; @@ -38,16 +45,15 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments const fileInputRef = useRef(null); const [uploadingCount, setUploadingCount] = useState(0); const [uploadError, setUploadError] = useState(null); - const [deletingId, setDeletingId] = useState | null>(null); - const [failedPreviewIds, setFailedPreviewIds] = useState>({}); + const [deletingKey, setDeletingKey] = useState(null); + const [failedPreviewKeys, setFailedPreviewKeys] = useState>({}); // Fetch attachment URLs const attachments = useQuery(api.attachments.getAttachmentUrls, { itemId }); - // Mutations - const generateUploadUrl = useMutation(api.attachments.generateUploadUrl); + const generateUploadUrl = useAction(api.attachments.generateUploadUrl); const addAttachment = useMutation(api.attachments.addAttachment); - const removeAttachment = useMutation(api.attachments.removeAttachment); + const removeAttachment = useAction(api.attachments.removeAttachment); const handleUploadClick = () => { if (!canEdit) return; @@ -56,32 +62,36 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments }; const uploadSingleFile = async (file: File) => { - // Step 1: Generate upload URL - const uploadUrl = await generateUploadUrl({ + const buffer = await file.arrayBuffer(); + const sha256 = await sha256Hex(buffer); + const contentType = file.type || "application/octet-stream"; + + const { uploadUrl, bucketKey } = await generateUploadUrl({ itemId, userDid, legacyDid, + contentType, + byteLength: file.size, }); - // Step 2: Upload to Convex storage const response = await fetch(uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type || "application/octet-stream" }, - body: file, + method: "PUT", + headers: { "Content-Type": contentType }, + body: buffer, }); if (!response.ok) { throw new Error("Upload failed"); } - const { storageId } = await response.json(); - - // Step 3: Add attachment to item await addAttachment({ itemId, - storageId, userDid, legacyDid, + bucketKey, + contentType, + size: file.size, + sha256, }); }; @@ -144,23 +154,23 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments } }; - const handleDelete = async (storageId: Id<"_storage">) => { + const handleDelete = async (bucketKey: string) => { if (!canEdit) return; haptic("medium"); - setDeletingId(storageId); + setDeletingKey(bucketKey); try { await removeAttachment({ itemId, - storageId, + bucketKey, userDid, legacyDid, }); - setFailedPreviewIds((prev) => { - if (!prev[String(storageId)]) return prev; + setFailedPreviewKeys((prev) => { + if (!prev[bucketKey]) return prev; const clone = { ...prev }; - delete clone[String(storageId)]; + delete clone[bucketKey]; return clone; }); haptic("success"); @@ -168,7 +178,7 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments console.error("Failed to remove attachment:", err); haptic("error"); } finally { - setDeletingId(null); + setDeletingKey(null); } }; @@ -187,9 +197,9 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments {/* Attachment grid */} {attachments && attachments.length > 0 && (
- {attachments.map(({ storageId, url }) => ( + {attachments.map(({ key, url }) => (
{url ? ( @@ -200,13 +210,13 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments className="block w-full h-full" title="Open attachment" > - {!failedPreviewIds[String(storageId)] ? ( + {!failedPreviewKeys[key] ? ( Attachment { - setFailedPreviewIds((prev) => ({ ...prev, [String(storageId)]: true })); + setFailedPreviewKeys((prev) => ({ ...prev, [key]: true })); }} /> ) : ( @@ -228,11 +238,11 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments {/* Delete button */} {canEdit && ( + { + const file = event.target.files?.[0]; + if (file) handleUpload(file); + }} + /> +
+ + {sorted.length === 0 ? ( +

+ No images yet. +

+ ) : ( +
    + {sorted.map((image) => { + const href = hostname + ? `https://${hostname}/_assets/${image.fileName}` + : ""; + return ( +
  • +
    + + {image.fileName} + +

    + {image.contentType} · {formatBytes(image.byteLength)} +

    +
    + + /_assets/{image.fileName} + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index 89bbf72..261edc0 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -1,12 +1,13 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useAction, useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useToast } from "../hooks/useToast"; import { useSettings } from "../hooks/useSettings"; import { ConnectDomainModal } from "../components/sites/ConnectDomainModal"; +import { SiteImages } from "../components/sites/SiteImages"; export function SiteDetail() { const { siteId } = useParams(); @@ -15,10 +16,12 @@ export function SiteDetail() { const { haptic } = useSettings(); const [showDomainModal, setShowDomainModal] = useState(false); const [showIdentity, setShowIdentity] = useState(false); - const generateUploadUrl = useMutation(api.sites.generateSiteUploadUrl); + const generateUploadUrl = useAction(api.sites.generateSiteUploadUrl); const replaceSiteFile = useAction(api.siteActions.replaceSiteFile); + const getPreviewUrl = useAction(api.sites.getSitePreviewUrl); const fileInputRef = useRef(null); const [replacing, setReplacing] = useState(false); + const [previewSrc, setPreviewSrc] = useState(""); const site = useQuery( api.sites.getSite, @@ -27,7 +30,25 @@ export function SiteDetail() { const hostname = site?.primaryHostname?.hostname ?? ""; const url = hostname ? `https://${hostname}` : ""; - const previewSrc = useMemo(() => site?.file?.storageUrl ?? "", [site?.file?.storageUrl]); + + useEffect(() => { + let cancelled = false; + if (!did || !site?._id || !site.file?.bucketKey) { + setPreviewSrc(""); + return; + } + (async () => { + try { + const url = await getPreviewUrl({ siteId: site._id, ownerDid: did }); + if (!cancelled) setPreviewSrc(url ?? ""); + } catch { + if (!cancelled) setPreviewSrc(""); + } + })(); + return () => { + cancelled = true; + }; + }, [did, site?._id, site?.file?.bucketKey, getPreviewUrl]); const copyLink = async () => { if (!url) return; @@ -45,17 +66,16 @@ export function SiteDetail() { setReplacing(true); haptic("medium"); try { - const uploadUrl = await generateUploadUrl({ ownerDid: did }); + const { uploadUrl, bucketKey } = await generateUploadUrl({ ownerDid: did }); const uploadResponse = await fetch(uploadUrl, { - method: "POST", + method: "PUT", headers: { "Content-Type": "text/html; charset=utf-8" }, body: file, }); if (!uploadResponse.ok) { throw new Error("The file upload did not land. Try again."); } - const { storageId } = await uploadResponse.json(); - await replaceSiteFile({ ownerDid: did, siteId: site._id, storageId }); + await replaceSiteFile({ ownerDid: did, siteId: site._id, bucketKey }); addToast("Site updated."); haptic("success"); } catch (err) { @@ -143,6 +163,10 @@ export function SiteDetail() { /> + {did && ( + + )} +
- No images yet. + No files yet.

) : (
    - {sorted.map((image) => { + {sorted.map((asset) => { const href = hostname - ? `https://${hostname}/_assets/${image.fileName}` + ? `https://${hostname}/_assets/${asset.fileName}` : ""; return (
  • + + {assetIcon(asset.contentType)} +
    - {image.fileName} + {asset.fileName}

    - {image.contentType} · {formatBytes(image.byteLength)} + {asset.contentType} · {formatBytes(asset.byteLength)}

    - /_assets/{image.fileName} + /_assets/{asset.fileName}
{did && ( - + )}
From 252fcd550dd9f8e967fcff211fb91b6bf3d8c463 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 15 May 2026 00:27:46 -0700 Subject: [PATCH 3/3] chore(sites): bucket CORS setup script + .env hint Browser uploads via presigned PUT need CORS on the Railway Bucket (allowed any origin, PUT/GET/HEAD). Adds a one-shot script to apply the CORSConfiguration and corrects the .env.example endpoint hint to match Railway's actual hostname pattern (t3.storageapi.dev). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 2 +- scripts/bucket-set-cors.mjs | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 scripts/bucket-set-cors.mjs diff --git a/.env.example b/.env.example index 008a3eb..7614800 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,7 @@ VITE_SITE_BASE_DOMAIN=boop.ad # BOOP_BUCKET_NAME= # BOOP_BUCKET_ACCESS_KEY_ID= # BOOP_BUCKET_SECRET_ACCESS_KEY= -# BOOP_BUCKET_ENDPOINT=https://storage.railway.app +# BOOP_BUCKET_ENDPOINT=https://t3.storageapi.dev # copy from your bucket's ENDPOINT variable # BOOP_BUCKET_REGION=auto # PostHog Analytics (optional — analytics are disabled if not set) diff --git a/scripts/bucket-set-cors.mjs b/scripts/bucket-set-cors.mjs new file mode 100644 index 0000000..3a8619f --- /dev/null +++ b/scripts/bucket-set-cors.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env bun +/** + * Configure CORS on the Railway Bucket so the browser can PUT/GET directly + * via presigned URLs. Required once per bucket, per environment. + * + * Allows: any origin (since presigned URLs already gate access), PUT/GET/HEAD, + * common headers, and a 3000s preflight cache. + * + * Usage: bun scripts/bucket-set-cors.mjs + */ +import { AwsClient } from "aws4fetch"; + +const name = process.env.BOOP_BUCKET_NAME; +const accessKeyId = process.env.BOOP_BUCKET_ACCESS_KEY_ID; +const secretAccessKey = process.env.BOOP_BUCKET_SECRET_ACCESS_KEY; +const endpoint = process.env.BOOP_BUCKET_ENDPOINT ?? "https://storage.railway.app"; +const region = process.env.BOOP_BUCKET_REGION ?? "auto"; + +if (!name || !accessKeyId || !secretAccessKey) { + throw new Error("BOOP_BUCKET_* env vars must be set"); +} + +const aws = new AwsClient({ accessKeyId, secretAccessKey, service: "s3", region }); + +const host = new URL(endpoint).host; +const corsXml = ` + + + * + PUT + GET + HEAD + * + ETag + 3000 + +`; + +const url = `https://${name}.${host}/?cors`; +const md5 = await md5Base64(corsXml); + +console.log("→ PUT", url); +const res = await aws.fetch(url, { + method: "PUT", + headers: { + "content-type": "application/xml", + "content-md5": md5, + }, + body: corsXml, +}); +const text = await res.text(); +console.log(`status: ${res.status}`); +if (text) console.log(text); +if (!res.ok) process.exit(1); + +console.log("→ GET", url); +const getRes = await aws.fetch(url, { method: "GET" }); +console.log(`status: ${getRes.status}`); +console.log(await getRes.text()); + +async function md5Base64(s) { + const hash = await crypto.subtle.digest("MD5", new TextEncoder().encode(s)).catch(() => null); + if (hash) { + return Buffer.from(new Uint8Array(hash)).toString("base64"); + } + // Fallback if SubtleCrypto doesn't expose MD5 in this runtime. + const { createHash } = await import("node:crypto"); + return createHash("md5").update(s).digest("base64"); +}