mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
This commit is contained in:
+4
-4
@@ -390,10 +390,10 @@ export async function getCachedUserMediaStorage(): Promise<
|
||||
|
||||
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }] },
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
images: { select: { url: true } },
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
@@ -448,8 +448,8 @@ export async function getCachedUserMediaStorage(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (comment.imageUrl) {
|
||||
const keyParts = comment.imageUrl.split('/');
|
||||
for (const image of comment.images) {
|
||||
const keyParts = image.url.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `images/${filename}`;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SAFE_IMAGE_PROXY_PATH } from '@/lib/image-upload-validation';
|
||||
|
||||
/**
|
||||
* How many images a single comment (or reply) may carry. Pasting a batch of
|
||||
* screenshots is the normal case, so the cap is there to bound the upload
|
||||
* burst and the row width, not to make the feature scarce.
|
||||
*/
|
||||
export const MAX_COMMENT_IMAGES = 5;
|
||||
|
||||
export type CommentImageUrlsResult = { urls: string[] } | { error: string };
|
||||
|
||||
/**
|
||||
* Normalize whatever a client sent for a comment's images into an ordered,
|
||||
* de-duplicated list of upload URLs.
|
||||
*
|
||||
* Accepts the legacy single `imageUrl` alongside the `imageUrls` list so an
|
||||
* older client keeps working. Returns a message rather than throwing, because
|
||||
* every caller turns it straight into a 400.
|
||||
*/
|
||||
export function parseCommentImageUrls(input: {
|
||||
imageUrl?: unknown;
|
||||
imageUrls?: unknown;
|
||||
}): CommentImageUrlsResult {
|
||||
const { imageUrl, imageUrls } = input;
|
||||
|
||||
let raw: unknown[];
|
||||
if (imageUrls !== undefined && imageUrls !== null) {
|
||||
if (!Array.isArray(imageUrls)) {
|
||||
return { error: 'imageUrls must be an array of uploaded image URLs' };
|
||||
}
|
||||
raw = imageUrls;
|
||||
} else if (imageUrl !== undefined && imageUrl !== null) {
|
||||
raw = [imageUrl];
|
||||
} else {
|
||||
raw = [];
|
||||
}
|
||||
|
||||
const urls: string[] = [];
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string' || !SAFE_IMAGE_PROXY_PATH.test(value)) {
|
||||
return { error: 'Image URL must reference an uploaded image file' };
|
||||
}
|
||||
// The same file twice would trip the unique index on comment_images and
|
||||
// charge the account twice for one object, so collapse it here instead.
|
||||
if (!urls.includes(value)) urls.push(value);
|
||||
}
|
||||
|
||||
if (urls.length > MAX_COMMENT_IMAGES) {
|
||||
return { error: `A comment can have at most ${MAX_COMMENT_IMAGES} images` };
|
||||
}
|
||||
|
||||
return { urls };
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/** The only shape an image URL may take once it has been through our upload API. */
|
||||
export const SAFE_IMAGE_PROXY_PATH =
|
||||
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
||||
+9
-9
@@ -85,10 +85,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { videoParentId: videoId },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -105,7 +105,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
c.images.forEach((image) => urls.push(image.url));
|
||||
});
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
@@ -124,10 +124,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { video: { projectId } },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -144,7 +144,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
c.images.forEach((image) => urls.push(image.url));
|
||||
});
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
@@ -163,10 +163,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { video: { project: { workspaceId } } },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -183,7 +183,7 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||
if (c.imageUrl) urls.push(c.imageUrl);
|
||||
c.images.forEach((image) => urls.push(image.url));
|
||||
});
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
|
||||
/**
|
||||
* How long an uploaded file may sit in R2 before the row that would claim it has
|
||||
* to exist. Anything older is treated as an expired upload, so a URL cannot be
|
||||
* replayed later to attach a file the caller no longer has a right to.
|
||||
*/
|
||||
export const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
export type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
|
||||
|
||||
/**
|
||||
* Confirm an upload URL points at an object that was written just now, and
|
||||
* report its size so the caller can bill it against a quota.
|
||||
*/
|
||||
export async function isFreshAttachment(
|
||||
url: string,
|
||||
kind: 'audio' | 'image'
|
||||
): Promise<AttachmentCheck> {
|
||||
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
|
||||
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
|
||||
const filename = url.slice(prefix.length);
|
||||
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
||||
} catch {
|
||||
return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -6,13 +6,14 @@ import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
import { SAFE_IMAGE_PROXY_PATH } from '@/lib/image-upload-validation';
|
||||
|
||||
export { SAFE_IMAGE_PROXY_PATH };
|
||||
|
||||
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||
const VIDEO_PROXY_PREFIX = '/api/upload/video/';
|
||||
|
||||
export const SAFE_IMAGE_PROXY_PATH =
|
||||
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_AUDIO_PROXY_PATH =
|
||||
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_VIDEO_PROXY_PATH =
|
||||
|
||||
Reference in New Issue
Block a user