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
2 changes: 1 addition & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ npmPreapprovedPackages:
- "@transloadit/viewer@0.0.8"
- "@transloadit/zod@5.0.1"
- "@transloadit/node@5.0.1"
- "@transloadit/convex@0.5.0"
- "@transloadit/convex@0.6.0"
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ Changing the code invalidates existing album access. Without a code, entering a
Cloud tests accept `E2E_WEDDING_UPLOAD_CODE` for protected previews. Search indexing is disabled
separately; it is not access control.

The gallery hides items after `NEXT_PUBLIC_GALLERY_RETENTION_HOURS` (default 24).
The gallery displays the last `NEXT_PUBLIC_GALLERY_RETENTION_HOURS` (default 24), measured when
the album opens. Its fixed `createdAfter` bound is applied before pagination, so loading another
page does not spend it on hidden history or move the display cutoff. Reloading starts a new visit.
This is a display policy, not deletion or permission revocation. The Storage cleanup job below
performs deletion. Existing public R2 objects and their lifecycle configuration are untouched by
this code removal; old URLs cannot be made private retroactively.

## Demo deployment (Vercel + stable Convex)

The hosted wedding app is a Yarn workspace with an exact `@transloadit/convex@0.5.0` registry
The hosted wedding app is a Yarn workspace with an exact `@transloadit/convex@0.6.0` registry
dependency. It does not alias package imports to this repository's `src/`. Both `example:build` and
the production backend deploy check the resolved package before proceeding. Publish a component
release first, then update `example/package.json` and the lockfile to dogfood it on the live demo.
Expand Down
7 changes: 5 additions & 2 deletions example/app/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ function GalleryViewer({
}

export function Gallery({
displayTime,
storageAssets = [],
linkedAsset,
linkLoading = false,
Expand All @@ -263,6 +264,7 @@ export function Gallery({
loading = false,
loadingMore = false,
}: {
displayTime?: number
storageAssets?: StorageGalleryAsset[]
linkedAsset?: StorageGalleryAsset | null
linkLoading?: boolean
Expand All @@ -274,11 +276,12 @@ export function Gallery({
const t = useTranslations('album')
const selectedId = useGalleryLocation()
const [direction, setDirection] = useState<'next' | 'previous'>('next')
const items = loadFailed ? [] : buildStorageGalleryItems(storageAssets)
const items = loadFailed ? [] : buildStorageGalleryItems(storageAssets, { now: displayTime })
const position = items.findIndex((item) => item.id === selectedId)
// A deep-linked item outside loaded pages has no known neighbors or ordinal. Keep its live
// authorized subscription, but do not append it to the grid or invent a pagination position.
const linked = linkedAsset && !loadFailed ? buildStorageGalleryItems([linkedAsset]) : []
const linked =
linkedAsset && !loadFailed ? buildStorageGalleryItems([linkedAsset], { now: displayTime }) : []
const selected = items[position] ?? linked.find((item) => item.id === selectedId)
const navigate = (value: 'next' | 'previous') => {
// Read the current URL, not a render closure, so rapid clicks never collapse into one move.
Expand Down
29 changes: 28 additions & 1 deletion example/app/StorageGallery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { PaginationOptions } from 'convex/server'
import { ConvexError } from 'convex/values'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { AlbumIntlProvider } from '../i18n/AlbumIntlProvider'
import type { StorageGalleryAsset } from '../lib/gallery'
import { galleryRetentionMs, type StorageGalleryAsset } from '../lib/gallery'
import { StorageGallery } from './StorageGallery'

// A client whose subscriptions answer from `respond`; `update` pushes a new answer to all of them.
Expand Down Expand Up @@ -110,6 +110,33 @@ test('keeps the next-page button disabled and announces loading while a page is
expect(shown()).toBe(1)
})

test('keeps one display cutoff for query pages and rendered memories throughout the visit', () => {
const openedAt = Date.now()
const now = vi.spyOn(Date, 'now').mockReturnValue(openedAt)
const watch = vi.spyOn(client, 'watchQuery')
const first = { ...photo(1), createdAt: openedAt - galleryRetentionMs + 2000 }
const second = { ...photo(2), createdAt: openedAt - galleryRetentionMs + 1000 }
respond = ({ cursor }) =>
cursor === null
? { page: [first], isDone: false, continueCursor: 'next' }
: { page: [second], isDone: true, continueCursor: 'end' }
render(album)
expect(shown()).toBe(1)
expect(watch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ createdAfter: openedAt - galleryRetentionMs }),
expect.anything(),
)

now.mockReturnValue(openedAt + 5000)
fireEvent.click(screen.getByRole('button', { name: 'Show more memories' }))
expect(shown()).toBe(2)
expect(screen.queryByRole('button', { name: 'Show more memories' })).toBeNull()
expect(
watch.mock.calls.every(([, args]) => args?.createdAfter === openedAt - galleryRetentionMs),
).toBe(true)
})

test('refused cursors restart the album, other failures replace its photos with an alert', () => {
respond = ({ cursor }) =>
cursor === null
Expand Down
14 changes: 10 additions & 4 deletions example/app/StorageGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import { useQuery } from 'convex/react'
import { makeFunctionReference, type PaginationOptions, type PaginationResult } from 'convex/server'
import { usePaginatedQuery } from 'convex-helpers/react'
import { Component, type ReactNode } from 'react'
import type { StorageGalleryAsset } from '../lib/gallery'
import { Component, type ReactNode, useState } from 'react'
import { galleryRetentionMs, type StorageGalleryAsset } from '../lib/gallery'
import { parseGalleryMedia, useGalleryLocation } from '../lib/gallery-location'
import { Gallery } from './Gallery'

const listMediaRef = makeFunctionReference<
'query',
{ paginationOpts: PaginationOptions },
{ paginationOpts: PaginationOptions; createdAfter?: number },
PaginationResult<StorageGalleryAsset>
>('media:list')
const getMediaRef = makeFunctionReference<
Expand All @@ -22,7 +22,12 @@ const pageSize = 24

// Canonical private receipts only; the media route authorizes every request.
const StoragePages = () => {
const media = usePaginatedQuery(listMediaRef, {}, { initialNumItems: pageSize })
// Paging and rendering share one visit's clock: loading another page must not move its cutoff.
const [displayTime] = useState(Date.now)
const createdAfter = Number.isFinite(galleryRetentionMs)
? displayTime - galleryRetentionMs
: undefined
const media = usePaginatedQuery(listMediaRef, { createdAfter }, { initialNumItems: pageSize })
const selected = useGalleryLocation()
const identity = parseGalleryMedia(selected)
const listed = media.results.some(
Expand All @@ -31,6 +36,7 @@ const StoragePages = () => {
const linked = useQuery(getMediaRef, identity && !listed ? identity : 'skip')
return (
<Gallery
displayTime={displayTime}
linkedAsset={linked}
linkLoading={Boolean(identity && !listed && linked === undefined)}
storageAssets={media.results}
Expand Down
74 changes: 70 additions & 4 deletions example/convex/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ beforeEach(() => {
vi.stubEnv('CONVEX_CLOUD_URL', 'https://happy-otter-123.convex.cloud')
})

afterEach(() => vi.unstubAllEnvs())
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})

describe('private Storage uploads', () => {
test.each([0, -1, 1.5, 13])(
Expand Down Expand Up @@ -241,6 +244,43 @@ describe('private Storage uploads', () => {
})

describe('gallery and delivery authorization', () => {
test('excludes hidden history before paging without revoking retained exact versions', async () => {
const startedAt = Date.now()
const now = vi.spyOn(Date, 'now').mockReturnValue(startedAt)
const t = setup()
const guest = await admit(t, 'Alex')
const { asset: old } = await upload(guest, t, 'old')
now.mockReturnValue(startedAt + 100)
await upload(guest, t, 'boundary')
now.mockReturnValue(startedAt + 200)
const { asset: recent } = await upload(guest, t, 'recent')
now.mockReturnValue(startedAt + 300)
const { asset: newest } = await upload(guest, t, 'newest')
const createdAfter = startedAt + 100

const first = await guest.query(api.media.list, {
createdAfter,
paginationOpts: { numItems: 1, cursor: null },
})
expect(first.page.map((row: { asset: { asset_id: string } }) => row.asset.asset_id)).toEqual([
newest.asset_id,
])
const rest = await guest.query(api.media.list, {
createdAfter,
paginationOpts: { numItems: 24, cursor: first.continueCursor },
})
expect(rest.page.map((row: { asset: { asset_id: string } }) => row.asset.asset_id)).toEqual([
recent.asset_id,
])
expect(rest.isDone).toBe(true)
expect(await guest.query(api.media.get, link(old))).toMatchObject({
asset: { asset_id: old.asset_id, version_id: old.version_id },
})
expect(await guest.query(api.media.forDelivery, request(old))).toMatchObject({
asset_id: old.asset_id,
})
})

test('a shared exact-version link resolves outside the loaded page, without listing all pages', async () => {
const t = setup()
const alex = await admit(t, 'Alex')
Expand Down Expand Up @@ -272,7 +312,7 @@ describe('gallery and delivery authorization', () => {
])
})

test('joins private video renditions by source identity across pages, not by filename', async () => {
test('joins private video renditions by source identity even in an Assembly with over 500 results', async () => {
const t = setup()
const guest = await admit(t, 'Alex')
const { assemblyOptions } = await guest.action(api.wedding.createWeddingAssemblyOptions, {
Expand Down Expand Up @@ -306,7 +346,13 @@ describe('gallery and delivery authorization', () => {
fields: params.fields,
results: {
':original': [original],
video_encode: [video, other],
video_encode: [
video,
other,
...Array.from({ length: 500 }, (_, i) =>
result(`unrelated-${i}`, 'video/mp4', `unrelated-${i}`),
),
],
video_poster: [poster],
},
},
Expand Down Expand Up @@ -342,6 +388,13 @@ describe('gallery and delivery authorization', () => {
await t.mutation(components.transloadit.lib.requestStoredAssetDeletion, {
album: 'wedding-gallery',
createdBefore: Date.now() + 1,
limit: 500,
})
// The large Assembly spans two bounded deletion batches; no receipt is revoked until selected.
await t.mutation(components.transloadit.lib.requestStoredAssetDeletion, {
album: 'wedding-gallery',
createdBefore: Date.now() + 1,
limit: 500,
})
expect(await guest.query(api.media.forDelivery, request(video, 'original'))).toBeNull()
expect(await guest.query(api.media.forDelivery, request(poster, 'original'))).toBeNull()
Expand Down Expand Up @@ -449,11 +502,24 @@ describe('gallery and delivery authorization', () => {
for (const denied of [
{ ...asset, version_id: damId('otherversion') },
{ ...asset, asset_id: damId('otherasset') },
])
]) {
expect(await alex.query(api.media.forDelivery, request(denied))).toBeNull()
expect(await alex.query(api.media.get, link(denied))).toBeNull()
}
await expect(alex.query(api.media.forDelivery, request(asset, 'thumbnail'))).rejects.toThrow()
})

test('rejects non-finite display cutoffs with a fixed component error', async () => {
const t = setup()
const guest = await admit(t, 'Alex')
await expect(
guest.query(api.media.list, {
createdAfter: Number.POSITIVE_INFINITY,
paginationOpts: { numItems: 10, cursor: null },
}),
).rejects.toThrow('Invalid Storage media cutoff')
})

test('ignores receipts that are not bound to a matching server-created upload', async () => {
const t = setup()
const alex = await admit(t, 'Alex')
Expand Down
52 changes: 12 additions & 40 deletions example/convex/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { findGuest, requireGuest } from './guests'
type StoredAssetRow = Infer<typeof vStoredAssetResponse>

const vAction = v.union(v.literal('preview'), v.literal('original'), v.literal('download'))
const mediaProducers = {
album,
// Keep old private photos readable alongside direct originals, without a data migration.
originalSteps: [':original', 'images_filtered'],
renditions: { playback: 'video_encode', poster: 'video_poster' },
}

const vGalleryAsset = v.object({
id: v.string(),
Expand Down Expand Up @@ -93,15 +99,13 @@ const galleryAsset = async (
* convex-helpers uses to keep loaded pages stable in a live album.
*/
export const list = query({
args: { paginationOpts: paginationOptsValidator },
args: { paginationOpts: paginationOptsValidator, createdAfter: v.optional(v.number()) },
returns: paginationResultValidator(vGalleryAsset),
handler: async (ctx, args) => {
await requireGuest(ctx)
const result = await ctx.runQuery(components.transloadit.lib.listStoredMedia, {
album,
// Keep old private photos readable alongside direct originals, without a data migration.
originalSteps: [':original', 'images_filtered'],
renditions: { playback: 'video_encode', poster: 'video_poster' },
...mediaProducers,
createdAfter: args.createdAfter,
paginationOpts: args.paginationOpts,
})
const page = []
Expand Down Expand Up @@ -142,43 +146,11 @@ export const get = query({
handler: async (ctx, args) => {
const workspace = getStorageWorkspace()
if (!workspace || !(await findGuest(ctx))) return null
const row = await ctx.runQuery(components.transloadit.lib.getStoredAsset, {
const media = await ctx.runQuery(components.transloadit.lib.getStoredMedia, {
...mediaProducers,
workspace,
...args,
})
if (
!row ||
![':original', 'images_filtered'].includes(row.stepName) ||
!(await findBoundUpload(ctx, row))
)
return null
if (!row.asset.mime?.startsWith('video/')) return galleryAsset(ctx, row, {})
// This app signs batches of at most 12 uploads with three stored outputs per video. Reuse
// the component's bounded Assembly lookup; custom/ambiguous excess results fail closed.
const siblings = await ctx.runQuery(components.transloadit.lib.listStoredAssetsForAssembly, {
assemblyId: row.assemblyId,
limit: 500,
})
if (siblings.length === 500) return null
const sourceId = (id: StoredAssetRow['originalId']) =>
typeof id === 'string' ? id : id?.length === 1 ? id[0] : undefined
const original = sourceId(row.originalId)
const rendition = (step: string) => {
if (!original) return undefined
const matches = siblings.filter(
(item) =>
item.stepName === step &&
sourceId(item.originalId) === original &&
item.album === row.album &&
item.userId === row.userId &&
item.uploadId === row.uploadId &&
item.asset.workspace === workspace,
)
return matches.length === 1 ? matches[0] : undefined
}
return galleryAsset(ctx, row, {
playback: rendition('video_encode'),
poster: rendition('video_poster'),
})
return media ? galleryAsset(ctx, media.original, media.renditions) : null
},
})
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"dependencies": {
"@auth/core": "^0.41.3",
"@convex-dev/auth": "^0.0.95",
"@transloadit/convex": "0.5.0",
"@transloadit/convex": "0.6.0",
"@transloadit/node": "5.0.1",
"@transloadit/viewer": "0.0.8",
"@transloadit/zod": "5.0.1",
Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1890,9 +1890,9 @@ __metadata:
languageName: node
linkType: hard

"@transloadit/convex@npm:0.5.0":
version: 0.5.0
resolution: "@transloadit/convex@npm:0.5.0"
"@transloadit/convex@npm:0.6.0":
version: 0.6.0
resolution: "@transloadit/convex@npm:0.6.0"
dependencies:
"@transloadit/utils": "npm:^4.8.1"
"@transloadit/zod": "npm:5.0.1"
Expand All @@ -1902,7 +1902,7 @@ __metadata:
dependenciesMeta:
esbuild:
built: true
checksum: 10c0/cff302af49cd9cf17e7e0c74c40a073d72732e2e33d970159efbbb50885664ec70742c0449b95385dac8b7bff2c71c7ce9b4beca03ec8c16f9f120cb43ae84de
checksum: 10c0/46ca74924ff03003f019d422cca856cf7fb708b8aed8a384a899a8bf06f876c30833f178b3b6aa0f11a9ad9cb627a6abbe97a36a5346d2e4b4884fa837bccc11
languageName: node
linkType: hard

Expand Down Expand Up @@ -4847,7 +4847,7 @@ __metadata:
dependencies:
"@auth/core": "npm:^0.41.3"
"@convex-dev/auth": "npm:^0.0.95"
"@transloadit/convex": "npm:0.5.0"
"@transloadit/convex": "npm:0.6.0"
"@transloadit/node": "npm:5.0.1"
"@transloadit/viewer": "npm:0.0.8"
"@transloadit/zod": "npm:5.0.1"
Expand Down
Loading