Media storage — public and private files
2026-09-28. For the frontend team (Next.js) rendering any file the API returns: avatars, post images, chat attachments, voice notes, course files, assignment submissions.
Short version: public files are plain CDN URLs you can drop into src. Private files come back
as an API URL that needs your bearer token, so you exchange it for a short-lived link first.
Everything below is detail on that one rule.
1. Two kinds of URL
Every uploaded file lives in Cloudflare R2, in one of two buckets. Which bucket is fixed per upload type on the server — you never choose it, and you never need to know the bucket. You only need to recognise the URL shape.
| Public file | Private file | |
|---|---|---|
| URL looks like | https://cdn.trymeety.com/123/photo.jpg |
https://api.trymeety.com/api/v1/media/{uuid} |
| Who can read it | Anyone with the URL | Only viewers the API authorises, checked on every fetch |
Works in <img src> / <video src> / <a href> as-is |
Yes | No — the browser sends no bearer token, so it gets 401 |
| Expires | Never | The URL in the response never expires; the link you exchange it for lasts 5 minutes |
| Cacheable | Yes (CDN, 7 days) | No — never cache the exchanged link past its expires_at |
How to tell them apart
There is no separate is_private flag in responses. The URL itself is the signal: a private
file's URL always points at the API's /media/{uuid} route. Anything else is public.
// lib/media.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL!; // e.g. https://api.trymeety.com/api/v1
const PRIVATE_MEDIA = /\/api\/v1\/media\/[0-9a-f-]{36}(\?|$)/i;
export function isPrivateMediaUrl(url: string | null | undefined): url is string {
return !!url && url.startsWith(API_BASE) && PRIVATE_MEDIA.test(url);
}
Match on the path, not the host alone: the API host also serves JSON endpoints, and in local
development API_BASE is a LAN address, not api.trymeety.com.
A private URL may carry a query string, e.g. …/media/{uuid}?conversion=thumb for a resized
preview. Keep it exactly as given when you exchange it.
2. Which fields are private
Treat this as the current list, not a contract — the regex above is what your code should rely on, so a field that moves between buckets later keeps working without a client release.
Private (need the exchange)
| Endpoint / resource | Field |
|---|---|
Chat messages (GET /chat/threads/{thread}/messages, sockets) |
attachments[].url, voice.url |
Chat shared-media gallery (GET /chat/threads/{thread}/media) |
url |
| Course lessons (author, learner and public views) | attachment_url, assignment_attachments[].url |
| Course materials | url for uploaded files (a link material's url is the external link, not private); thumbnail_url when an image material has no poster |
| Assignment submissions | file_url, submission_file_url |
Public (use directly)
Avatars, cover photos, course thumbnails and OG images, lesson and material posters, feed post images / videos / thumbnails, feed and community comment attachments, community cover / icon / gallery, community post attachments and videos, payout method logos.
Mentor verification documents (ID card, certificates) are private too, but they are served through
their own download_url endpoints — see verification-document-api.md.
3. The exchange
GET /api/v1/media/{uuid}
Authorization: Bearer {token}
Accept: application/json
200
{
"status": "success",
"message": "File link generated.",
"data": {
"url": "https://<account>.r2.cloudflarestorage.com/meety-private/481/voice-1790074948553.mp3?X-Amz-…",
"expires_at": "2026-09-28T10:05:00+00:00"
}
}
Put data.url on the element. It is a presigned R2 link: it needs no token, supports range
requests (video/audio seeking, resumable downloads) and stops working at expires_at.
Errors
| Code | Body | Meaning | What to do |
|---|---|---|---|
| 401 | { "message": "Unauthenticated." } |
No or expired token | Your normal re-login flow |
| 403 | envelope, "You do not have access to this file." |
Signed in, but not allowed — e.g. left the chat, no seat in the course | Show a locked / unavailable placeholder. Do not retry. |
| 404 | envelope, "File not found." |
The file was deleted | Show a "file removed" placeholder |
Always send Accept: application/json. Without it the route answers with a 302 straight to the
R2 link, which fetch follows and hands you the file bytes instead of the JSON.
Until the API deploy after 2026-09-28, a request without
Accept: application/jsonand without a token returns 500 instead of 401. That is what an<img src>pointing at the raw private URL shows today. It is a server bug, fixed, but the answer for the client is the same either way: exchange the URL, never render it directly.
4. Next.js integration
Recommended: a hook that resolves any media URL
Public URLs pass straight through; private ones are exchanged and cached until shortly before they expire. Components never branch on public vs private.
// lib/media.ts (continued)
type Signed = { url: string; expiresAt: number };
const cache = new Map<string, Signed>();
const inflight = new Map<string, Promise<string>>();
const REFRESH_MARGIN_MS = 30_000;
export async function resolveMediaUrl(url: string, token: string): Promise<string> {
if (!isPrivateMediaUrl(url)) return url;
const hit = cache.get(url);
if (hit && hit.expiresAt - REFRESH_MARGIN_MS > Date.now()) return hit.url;
// Twenty bubbles rendering the same attachment make one request, not twenty.
const pending = inflight.get(url);
if (pending) return pending;
const request = fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
cache: 'no-store',
})
.then(async (res) => {
const body = await res.json();
if (!res.ok) throw new MediaAccessError(res.status, body?.message);
const signed = {
url: body.data.url as string,
expiresAt: Date.parse(body.data.expires_at),
};
cache.set(url, signed);
return signed.url;
})
.finally(() => inflight.delete(url));
inflight.set(url, request);
return request;
}
export class MediaAccessError extends Error {
constructor(public status: number, message?: string) {
super(message ?? `Media request failed (${status})`);
}
}
export function forgetMediaUrl(url: string) {
cache.delete(url);
}
// hooks/useMediaUrl.ts
'use client';
import { useEffect, useState } from 'react';
import { isPrivateMediaUrl, resolveMediaUrl, MediaAccessError } from '@/lib/media';
import { useAuthToken } from '@/hooks/useAuthToken'; // however you already read the token
export function useMediaUrl(url: string | null | undefined) {
const token = useAuthToken();
const [src, setSrc] = useState<string | null>(
url && !isPrivateMediaUrl(url) ? url : null,
);
const [error, setError] = useState<MediaAccessError | null>(null);
useEffect(() => {
if (!url) return setSrc(null);
if (!isPrivateMediaUrl(url)) return setSrc(url);
if (!token) return;
let cancelled = false;
setError(null);
resolveMediaUrl(url, token)
.then((signed) => !cancelled && setSrc(signed))
.catch((e) => !cancelled && setError(e));
return () => {
cancelled = true;
};
}, [url, token]);
return { src, error, isLoading: !!url && !src && !error };
}
// components/chat/AttachmentImage.tsx
'use client';
import { useMediaUrl } from '@/hooks/useMediaUrl';
export function AttachmentImage({ url, name }: { url: string; name: string }) {
const { src, error, isLoading } = useMediaUrl(url);
if (error?.status === 403) return <LockedFile name={name} />;
if (error?.status === 404) return <RemovedFile name={name} />;
if (isLoading || !src) return <ImageSkeleton />;
return <img src={src} alt={name} loading="lazy" />;
}
next/image
-
Public files: use
next/imagenormally. Allow the CDN host once:// next.config.ts images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.trymeety.com' }], }, -
Private files: pass
unoptimized, or use a plain<img>. The Next image optimizer fetches and caches the image server-side under its own TTL; a presigned link is dead five minutes later and must not sit in that cache. If you usenext/imageanyway, also allow*.r2.cloudflarestorage.cominremotePatterns.
Audio and video
A presigned link is checked when each request starts. A voice note or video left paused longer
than the link lives fails on the next seek or resume. Handle the element's error event: drop the
cached link, resolve again, restore the position.
const { src } = useMediaUrl(voice.url);
const ref = useRef<HTMLAudioElement>(null);
async function onError() {
const el = ref.current;
if (!el || !isPrivateMediaUrl(voice.url)) return;
const at = el.currentTime;
forgetMediaUrl(voice.url);
el.src = await resolveMediaUrl(voice.url, token);
el.currentTime = at;
}
<audio ref={ref} src={src ?? undefined} onError={onError} controls />;
Downloads ("Save file")
<a href={privateUrl} download> cannot work — the browser sends no token. Resolve on click, then
navigate:
async function download(url: string, token: string) {
window.location.href = await resolveMediaUrl(url, token);
}
Resolve on click, not on render: a link resolved when the message list loaded may have expired by the time someone clicks it.
Alternative: a same-origin proxy route
If the web app keeps the API token in an httpOnly cookie, a Route Handler can do the exchange
server-side and redirect, and then private URLs work in plain src attributes with no hook:
// app/media/[uuid]/route.ts
import { cookies } from 'next/headers';
export async function GET(req: Request, { params }: { params: Promise<{ uuid: string }> }) {
const { uuid } = await params;
const token = (await cookies()).get('meetyy_token')?.value;
const query = new URL(req.url).search;
const res = await fetch(`${process.env.API_URL}/media/${uuid}${query}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
cache: 'no-store',
});
if (!res.ok) return new Response(null, { status: res.status });
const { data } = await res.json();
return Response.redirect(data.url, 302);
}
Then rewrite https://api.trymeety.com/api/v1/media/{uuid} to /media/{uuid} before rendering.
Only use this if the token is already in a cookie; do not move it out of wherever it lives today
just for this.
5. Rules of thumb
- Never render a private URL directly. Always go through
useMediaUrl/resolveMediaUrl. - Never persist the exchanged link (localStorage, IndexedDB, a service-worker cache, a message
draft). Persist the original
/media/{uuid}URL and exchange again. - Never cache an exchanged link past
expires_at. - A 403 means "not allowed", not "try again". Show a placeholder.
- Do not build URLs yourself from ids. Always use the URL the API returned — the query string may carry a conversion, and the host differs per environment.
6. Known gaps
- Admin chat moderation (
GET /admin/chat-threads/{threadId}/messages) returns chat attachment URLs that do not load: they point at the private bucket directly, and admins are not yet allowed through the media route for chat files. Being fixed server-side; nothing to do on the client until then. - Chat attachments uploaded before 2026-09-24 are still on the old local disk and have public-style URLs on the API host. They will be migrated to private storage; the regex in section 1 keeps working when they are.