Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ app/test-results/*
# Lighthouse CI results
.lighthouseci/
tmp/

# Brainstorming visual companion
.superpowers/
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
182 changes: 133 additions & 49 deletions convex/attachments.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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">,
Expand All @@ -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))
Expand All @@ -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);
Expand All @@ -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<void> => {
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(),
});
},
});
Loading
Loading