Private files
Files that open only through an expiring link or for a signed-in reader.
A private file opens only for a signed-in reader who may read Uploads, or through a temporary link.
Mark a file or folder private in the dashboard, or in server code:
import { updateUpload } from 'ohnejs/uploads';
await updateUpload(upload.UUID, { private: true });Set UPLOADS_SECRET and every read of it also carries a temporary link:
UPLOADS_SECRET=a-long-random-valueMarking files private
In the dashboard, use the Private switch in a file's details, or Make private on a tile or a selection. Every file starts public, including the ones you uploaded before.
A folder locks everything inside it:
- Toggling a folder rewrites its whole subtree.
- A file uploaded into a private folder, or a folder created in one, is private.
- Moving into a private folder makes the moved row private, a folder with its subtree. Moving out, or renaming in place, changes nothing.
- Making a row public inside a private folder is a
422, and so is moving one in withprivate: false.
What a read carries
A private file's decorations change:
upload.url;
// -> '/uploads/press/launch.jpg?e=1700000000000&s=...'
upload.variants.thumbnail;
// -> 'https://img.example.com/.../w_320,h_320,fit_inside,f_webp,e_1700000000000/press/launch.jpg'
upload.expires;
// -> 1700000000000urlis always the API route, neverpublicURLor the backend's own.eis the expiry in epoch milliseconds andsits signature.variantscarry the same expiry as their last transform.expiressays when both stop working.- A read whose
selectleaves outprivatedecorates every file as private, so select it too.
The secret
UPLOADS_SECRET signs temporary links and image variants:
- It may list several secrets, comma-separated. The first signs and any verifies, which is how you rotate it.
- Without it, a private file's
urlis the bare API route andvariantsis absent.POST /uploads/[uuid]/linkanswers404, and ohne warns at boot while a private row exists. - Only
Copy temporary linkin the dashboard needs it.
How long a link lives
uploads.privateMaxAge sets the window, and uploads.linkMaxAge caps how far ahead a link may expire. Both take milliseconds or a string like '1h':
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', 'ohnejs/uploads'],
uploads: { privateMaxAge: '1h', linkMaxAge: '30d' },
});- A read mints links that expire at the end of the window after the one holding now, so a link lives between one and two windows.
- Every read inside one window mints the same URLs, so a browser cache keeps working.
privateMaxAgecan be at most half oflinkMaxAge. A longer one stops the boot.- A link that expires further ahead than
linkMaxAgefrom now does not open. Lowering it holds back links you already shared but does not revoke them. Rotate the secret for that. - The image service asks for the original with a variant's own expiry, so the cap reaches variants too. A variant it or a browser already holds keeps showing until it expires.
Who can open the bytes
GET /uploads/<path> streams a private file only when:
- the URL carries a valid
sfor anethat has not passed, or - the caller is signed in, holds
collection.Uploads.read, and the read'saccessscope reaches the row.
Anyone else gets the same 404 as an unknown path.
To keep some files behind a role, grant it a custom capability and scope the read in your own collections/Uploads.ts:
import { defineCollection } from 'ohnejs';
import { requireUser, userCan } from 'ohnejs/auth';
import { uploadsDefinition } from 'ohnejs/uploads';
export default defineCollection({
...uploadsDefinition,
api: {
read: {
access: async () =>
userCan(await requireUser(), 'media.private') || { where: { private: false } },
},
},
});- A caller outside the scope does not see the row, cannot open its bytes, and gets
nullwhere a media field references it. Changing, moving, replacing, or deleting it is the same404. - A write that would hide a row from its own writer, such as making it private, is a
422. - A write to a folder the caller can see applies to its whole subtree, hidden files included.
- The scope hides rows, not names, so a hidden name is still taken. Keep secrets out of file and folder names.
Temporary links
A link for someone who is not signed in lasts exactly as long as you say:
import { temporaryUploadURL } from 'ohnejs/uploads';
temporaryUploadURL(upload, '7d');
// -> { url: '/uploads/press/launch.jpg?e=1700604800000&s=...', expires: 1700604800000 }maxAgeis milliseconds or a string like'7d', counted from now, not aligned to a window.maxAgeis above zero and at mostuploads.linkMaxAge,30dunless you set it. Anything else throws.POST /uploads/[uuid]/linkwith the body{ "maxAge": "7d" }answers the same{ url, expires }to a caller who may readUploads.maxAgedefaults toprivateMaxAge, and one out of range is a400. A public file answers its plainurlwithexpires: null.- The details popup offers the same as
Copy temporary link. - Without
UPLOADS_SECRET,temporaryUploadURLthrows.
Storage and the image service
A private file is hidden at the API route alone. A backend without setPrivate keeps its object readable at publicURL, and ohne warns at boot. @ohnejs/s3 tags a private object so the bucket can refuse it. Its README has the policy. An image service fetches a private original through a link it signs with the same secret, so give it your UPLOADS_SECRET as its IMAGES_SECRET.