Add file storage#
This recipe adds private attachments to the browser authentication application in recipes/add-session-authentication. Complete that recipe first: these actions use zyn.users, zyn.auth.signed_in, and action.auth.user.
The application owns attachment records and authorization. Zyn owns immutable blob bytes, metadata, and backend deletion.
1. Configure private storage#
In zyn.zon, keep the existing storage limits and replace the development and testing profile values with these private profiles:
// zyn.zon: storage profiles
.development = .{
.service = "development_files",
.disk = .{ .root = ".zyn/storage", .public = false },
},
.testing = .{
.service = "testing_files",
.disk = .{ .temporary = true, .public = false },
},
The scaffold's disk profiles enable public URLs. Disable that delivery path before storing private files; protecting an application route does not protect an independently public blob URL. Production needs its own stable service and durable disk or S3 configuration; see framework/storage. Do not rename a service which already owns blobs without migrating its stored references.
2. Create the ownership table#
zyn g table attachments
Add the ownership columns to the generated table declaration, retaining its UUID primary key, timestamps, and update trigger. The table declaration becomes:
CREATE TABLE public."attachments" (
id uuid NOT NULL DEFAULT uuidv7() PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
owner_id uuid NOT NULL REFERENCES zyn.users(id),
blob_id uuid NOT NULL UNIQUE REFERENCES zyn.blobs(id)
);
Run zyn db migrate and commit the migration and refreshed database artifacts. The foreign key prevents purging a blob while an attachment still references it.
3. Add upload, download, and removal actions#
The example accepts a raw body of at most 64 KiB, uses a fixed filename and media type, and delivers downloads as attachments rather than inline content:
// src/actions/attachments.zig
const std = @import("std");
const zyn = @import("zyn");
const Attachment = struct { id: []const u8, blob_id: []const u8 };
fn valid_id(id: []const u8) bool {
if (id.len != 36) return false;
for (id, 0..) |char, i| {
if (i == 8 or i == 13 or i == 18 or i == 23) {
if (char != '-') return false;
} else if (!std.ascii.isHex(char)) return false;
}
return true;
}
pub fn create(action: *zyn.Action) !zyn.Response {
const contents = action.body() orelse return .empty(.{ .status = .bad_request });
if (contents.len == 0 or contents.len > 64 * 1024) {
return .empty(.{ .status = .unprocessable_entity });
}
const blob = try action.storage.write(contents, .{
.filename = "attachment.bin",
.media_type = "application/octet-stream",
});
const attachment = (try action.db.one(
struct { id: []const u8 },
"INSERT INTO attachments (owner_id, blob_id) VALUES ($1, $2::uuid) RETURNING id::text AS id",
.{ action.auth.user.?.id, blob.id },
)) orelse return error.AttachmentNotCreated;
return action.json(attachment, .{ .status = .created });
}
pub fn show(action: *zyn.Action) !zyn.Response {
try action.headers.set("Cache-Control", "private, no-store");
const id = action.param("id");
if (!valid_id(id)) {
return .empty(.{ .status = .bad_request });
}
const attachment = (try action.db.one(
Attachment,
"SELECT id::text AS id, blob_id::text AS blob_id FROM attachments WHERE id = $1::uuid AND owner_id = $2",
.{ id, action.auth.user.?.id },
)) orelse return .empty(.{ .status = .not_found });
const blob = (try action.storage.get(attachment.blob_id)) orelse
return error.AttachmentBlobMissing;
return action.storage.serve(blob, .{});
}
pub fn remove(action: *zyn.Action) !zyn.Response {
const id = action.param("id");
if (!valid_id(id)) {
return .empty(.{ .status = .bad_request });
}
var tx = try action.db.begin();
defer tx.cancel();
const attachment = (try tx.one(
Attachment,
"DELETE FROM attachments WHERE id = $1::uuid AND owner_id = $2 RETURNING id::text AS id, blob_id::text AS blob_id",
.{ id, action.auth.user.?.id },
)) orelse return .empty(.{ .status = .not_found });
const blob = (try action.storage.get(attachment.blob_id)) orelse
return error.AttachmentBlobMissing;
try tx.storage.purge(blob);
try tx.commit();
return .empty(.{ .status = .no_content });
}
Both lookup and deletion constrain owner_id; knowing an attachment ID is not permission to access it. A different owner receives the same 404 as an absent attachment. Set the private, no-store cache policy before serving bytes so it also applies to conditional 304 responses.
Byte publication and attachment insertion are not atomic. If insertion fails after write, an unreferenced blob can remain. Reconcile unreferenced blobs before retrying uncertain operations; do not assume every failed response means no blob or attachment was committed. Replacement creates a new blob, then changes the application reference. Use storage.writer for generated content which should not be fully buffered; see framework/storage.
4. Register the protected routes#
Keep the authentication recipe's mounted routes. Add:
// src/routes.zig: imports
const attachments = @import("actions/attachments.zig");
// src/routes.zig: table entries
.post("/attachments", attachments.create, &.{.guard(zyn.auth.signed_in)}),
.get("/attachments/:id", attachments.show, &.{.guard(zyn.auth.signed_in)}),
.delete("/attachments/:id", attachments.remove, &.{.guard(zyn.auth.signed_in)}),
Keep existing routes and CSRF protection. Zyn loads auth state before guards evaluate it.
5. Verify the ownership and lifecycle#
zyn routes
zyn check
zyn dev --http
Sign in through /auth/sign-in and confirm the emailed magic link, or register, verify your email, and sign in with a password. Use your browser's authenticated session to upload through an application form, or export its _zyn_auth cookie to a local curl cookie jar named cookies.txt. Treat that file as a credential; do not commit or share it. Then run:
printf 'Example attachment\n' > attachment.bin
curl -i -b cookies.txt http://localhost:7532/attachments \
-H 'Content-Type: application/octet-stream' \
-H 'Sec-Fetch-Site: same-origin' \
--data-binary @attachment.bin
The upload returns 201 with a UUID string id. Substitute that ID below:
curl -i -b cookies.txt http://localhost:7532/attachments/0194f8c0-0000-7000-8000-000000000001
curl -i -b cookies.txt -X DELETE http://localhost:7532/attachments/0194f8c0-0000-7000-8000-000000000001 \
-H 'Sec-Fetch-Site: same-origin'
curl -i -b cookies.txt http://localhost:7532/attachments/0194f8c0-0000-7000-8000-000000000001
Expect the original bytes and attachment disposition, then 204, then 404. Without a session, HTML requests redirect to sign-in and JSON requests return 401; another signed-in user receives 404 for the same attachment. The purge job deletes backend bytes after the transaction commits; zyn dev runs its worker. Remove the local cookie jar and uploaded sample after testing. Use the server's reported port if it differs.
Managed tests should also cover size limits, reference-protected purge, and transaction rollback. They have isolated storage and can perform queued purge jobs synchronously.