Skip to content

15. Sharing and counting downloads

A download function that counts and returns the link, share links that open with their files, and the operator's view of the storage.

Shelf can hold files; this chapter makes them travel. Members will see how often an asset’s files were downloaded, a stranger holding a share link can download without an account, and the operator can see what it all costs.

Counting downloads

A plain download link tells backd nothing: the browser fetches the file from the storage. To count, the app asks a function for the link, and the function counts. That is the whole trick: a function is called with POST and answers JSON, so it can’t stream a file itself. It returns the link, and the app opens it.

examples/config/shelf/main/_functions/download/index.ts

// download: counts a download and returns the link the app opens (chapter 15).
// A function is called with POST and answers JSON, so it can't stream the file:
// it hands over the link. Two identities do two jobs:
//   - ctx.db, the caller: asking for the link is reading the asset, so the
//     asset's `read` rule decides who gets one (an asset they can't read is a 404);
//   - ctx.admin.db, the function: the count is its own, in a field the rules
//     refuse to clients.
import { relay } from "../lib/relay.ts";
import type { Context } from "../lib/types.ts";

export default async function handler(ctx: Context) {
  const { document_id, field, file_id } = ctx.input as { document_id: string; field: "file" | "attachments"; file_id?: string };

  let link;
  try {
    link = await ctx.db("main").collection("assets").files(document_id, field).link(file_id);
  } catch (err) {
    relay(err, ctx.error);
  }

  // Increment, conditional on the version read; a clash with another download retries.
  const assets = ctx.admin.db("main").collection("assets");
  for (let attempt = 0; attempt < 5; attempt++) {
    const asset = await assets.get(document_id);
    try {
      await assets.patch(document_id, { downloads: ((asset.downloads as number | undefined) ?? 0) + 1 }, { ifMatch: asset._meta.version });
      break;
    } catch (err) {
      if ((err as { code?: string }).code !== "version_mismatch" || attempt === 4) relay(err, ctx.error);
    }
  }
  return { url: link!.url, expires_at: link!.expires_at };
}

Two identities again. The link is asked as the caller (ctx.db): getting a link is reading the asset, so the asset’s read rule decides who gets one, and one you can’t read is a 404. The count is written as the function (ctx.admin.db), in a field the rules refuse to clients, with ifMatch so two downloads at once don’t lose a count (the loop retries on a version clash).

examples/config/shelf/main/_functions/download/function.yaml

# Counts a download and returns its link (chapter 15). admin: true because the
# count is written through ctx.admin.db; the link is asked as the caller, so
# the asset's read rule still decides who may download.
admin: true
invoke: "user != nil"
rate_limit:
  per: user
  limit: 120
  window: 1m

The input is validated, the app calls it from the gallery’s download buttons, and then opens the link:

const { url } = await main.fn('download', { document_id: asset.id, field: file.field, file_id: file.id })
window.location.assign(url)

Fetch the files, build and restart:

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/download/function.yaml main/_functions/download/index.ts main/_functions/download/index.test.ts main/_functions/download/input.schema.json; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done
docker compose run --rm functions-build && docker compose restart backd

Download from the gallery: the card says 1 downloads. (Open the Gallery, since it is the one counted: My assets downloads are the owner’s own and aren’t.)

The hole, and closing it

downloads is a field of schema.json, so until the rules say otherwise a member can write it: an owner can give their own asset a thousand downloads.

curl -s -X PATCH "http://localhost:8080/v1/shelf/main/assets/$ASSET" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/merge-patch+json' -d '{"downloads": 1000}'   # 200

The same two lines as for published_at in chapter 5 close it. Edit the rules: of assets/collection.yaml again; this is the finished file’s rule:

examples/config/shelf/main/assets/collection.yaml

  create: >
    user != nil && data.published_at == nil && data.downloads == nil
  update: >
    user != nil && document._meta.owner == user.id
    && !('published_at' in changed())
    && !('downloads' in changed())
  delete: user != nil && document._meta.owner == user.id
docker compose restart backd

Repeat the PATCH: 403. An owner can no longer set their own count, nor create an asset that starts with one, and the download function keeps counting because it writes through ctx.admin.db.

Chapter 5’s share-open answers a stranger with a small projection of the asset, never the whole document. A shared asset’s files should come with it. The function makes the links itself (ctx.admin.db), after the token and the expiry were checked: the share link is the permission, so no sign-in is asked and no read rule runs.

examples/config/shelf/main/_functions/share-open/index.ts

  // Chapter 15: the files of a shared asset come with links that work for
  // whoever holds the share link, until they expire. The share is the
  // permission, so the links are made as the function (ctx.admin.db), after the
  // token and expiry checks above; they carry names and sizes, never ids.
  const files: { name: string; size: number; type: string; url: string; expires_at: string }[] = [];
  for (const field of ["file", "attachments"] as const) {
    const held = asset[field];
    for (const f of (Array.isArray(held) ? held : held ? [held] : []) as FileDetails[]) {
      const link = await db.collection("assets").files(asset.id, field).link(f.id);
      files.push({ name: f.name, size: f.size, type: f.type, url: link.url, expires_at: link.expires_at });
    }
  }
  return { title, kind, url, body, tags, published_at, files };

Links carry the name, size and type, never ids. They last as long as presigned_ttl (five minutes) — long enough to click, and a stranger can ask again while the share link lives. Fetch, build, restart, then share an asset that has a file, and open the link in a private window:

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/share-open/index.ts main/_functions/share-open/index.test.ts; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done

The operator’s view

Admin now shows the Storage card, from GET /_admin/storage: how the storage is set up (the keys by name only), whether the keys are set and the bucket answers, the totals of bytes and files that documents reference, who holds the most, and what waits to be deleted. The same from the command line:

docker compose exec backd /backd storage usage --realm shelf

Two operations to know before this has real data:

  • Backups. A MongoDB backup doesn’t include files. Back the bucket up at the provider (versioning, replication), and after restoring a database run backd storage reconcile --realm shelf: it reports the objects no document references, and with --delete removes exactly those. It never touches anything younger than 24 hours or with an upload still in flight.
  • Security. Links work for whoever holds them until they expire, even if the rules change meanwhile, so keep presigned_ttl short on sensitive fields and read Security of files.

You should see

  • Each download from the gallery raises the count on the card; the unrelated asset’s count is unchanged.
  • A PATCH of downloads answers 200 before the rule and 403 after it, and a create that names one 403.
  • A share link opened by a signed-out visitor lists the asset’s files, and a click downloads one.
  • Admin → Storage shows the bytes and files in use, and backd storage usage --realm shelf prints the same.
  • deno test passes for download and share-open.

That is the whole of Shelf: a team asset library with members, rules, invitations, functions, email, webhooks, files and an operator’s console, all of it from configuration and a few small functions. The last stop is Running it if you haven’t been through it, and then the documentation for everything these chapters only touched.

Edit this page · Last updated 08 Oct 2026