LogoZonai
zonai.dev

Photo Rules

Controlling who can upload, view, and delete photos.

The _photos table backs the photo HTTP API. It is a framework-managed table, but unlike the other internal tables its built-in rules are marked overridable — you can replace them with your own.

There is no dedicated PhotoRules base class. You write the same TableRules and RowRules you would for any collection, typed against the framework's PhotosTable and PhotoEntry, and pass the photos schema getter to the constructor.

Creating Photo Rules#

Add both files under rulesPath — a collection-rules file and a row-rules file, exactly as you would for one of your own tables. Registering only one leaves the other at its built-in default.

import 'package:zonai_schema/zonai_schema.dart';

PhotoTableRules main() => PhotoTableRules();

final class PhotoTableRules extends TableRules<PhotosTable, PhotoEntry> {
  PhotoTableRules() : super(photos);

  @override
  Future<bool> canCreate(Jwt? jwt) async => jwt != null;

  @override
  Future<bool> canView(Jwt? jwt) async => true;
}
import 'package:zonai_schema/zonai_schema.dart';

PhotoRowRules main() => PhotoRowRules();

final class PhotoRowRules extends RowRules<PhotosTable, PhotoEntry> {
  PhotoRowRules() : super(photos);

  @override
  Future<bool> canView(Jwt? jwt, PhotoEntry row) async => true;

  @override
  Future<bool> canDelete(Jwt? jwt, PhotoEntry row) async {
    if (jwt?.admin.isAdmin ?? false) return true;
    return jwt?.userId == row.ownerId;
  }
}

Compare jwt.userId against row.ownerId directly — both are Ids. Comparing jwt.userId to row.ownerId.value compares an Id to a String, which is always false, and would silently deny every owner.

Endpoints and the rules they run#

Each request runs the collection rule first, then the row rule when a row is involved.

Photo API operationTable checkRow check
POST /imgcanCreatecanCreate
GET /img/:idcanViewcanView
PATCH /img/:idcanUpdatecanUpdate
DELETE /img/:idcanDeletecanDelete

The _photos table is reached through this API, not through the generic /db/* routes.

The PhotoEntry row#

Row rules receive a PhotoEntry, exported from package:zonai_schema/zonai_schema.dart:

FieldPurpose
row.idPhoto ID (PhotoId)
row.ownerId The authenticated user at upload time, as an Id
row.ownerTableThe auth collection that user came from
row.tableThe app collection the photo is attached to
row.pathRelative path under the configured images directory
row.extension Normalized file extension (jpg, png, …)
row.createdAtUpload time

Built-in defaults#

Without override files, the built-in rules apply:

Collection rules — create, view, update and delete are all allowed, for every caller including unauthenticated ones.

Row rules:

MethodDefault
canViewAllowed for everyone
canCreateRequires a JWT (jwt != null)
canUpdateOwner (jwt.userId == row.ownerId) or admin
canDeleteOwner or admin

Note that your own TableRules/RowRules do not inherit these permissive defaults — the public base classes deny by default for non-admins, so once you add an override file you are responsible for every method it exposes.

Only _photos can be overridden this way. Registering rules for any other framework table (_jwt, _log, _rate_limit, _auth_challenges, …) fails when the rules load with Table rules already registered for <table>.

After adding or changing these files, recompile (zonai compile, or press c while zonai serve is running) and restart serve.

File Constraints#

File size and MIME type constraints are enforced before rules run — they are configured in AppConfig.photos. Your canCreate rule only sees uploads that have already passed the size and type checks.

photos: PhotosConfig(
  maxBytes: 5 * 1024 * 1024,
  allowedMimeTypes: [ImageMimeType.jpeg, ImageMimeType.png],
),