# The submission form

> Turn on /submit/ — the two-step form that fetches a URL, validates the listing on the server and hands the submitter a signed status link.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/form/

---

`/submit/` takes a listing in two steps — the site's address, then the details the server read
from it — and stores a pending row with a signed status link. `features.submissions: true` in
`site.config.ts` switches it on; it needs a D1 database and the two admin secrets, both of which
[Go live](https://zerodirs.com/docs/deploy/go-live/) provides.

> **Free edition**
>
> The free edition has no Worker and no D1, so it ships without `/submit/`, the status pages and
> the review queue behind them.

## Setup

1. Switch the form on.

   ```ts title="site.config.ts"
   features: { submissions: true },
   ```

   ```sh title="Terminal"
   pnpm check:config
   ```

   `site.config.ts OK — <your site name> (https://your-domain.com)`.

2. Give it a database and the two secrets.

   ```sh title="Terminal"
   pnpm cf:setup
   ```

   It creates the D1 database, applies the migrations, deploys, and sets `ADMIN_SECRET` and
   `TOKEN_SECRET` on the Worker. Already done? `pnpm cf:secrets` lists both names.

3. Try it on a fresh clone.

   ```sh title="Terminal"
   pnpm dev
   ```

   First it writes `.env` and `.dev.vars` from their examples and applies the migrations to the
   local database, one `dev:` line each. Open `http://localhost:4321/submit/`, paste an address,
   press Continue, fill in step 2 and press "Submit for review". The browser lands on
   `/submit/status/<token>/?new=1` under the heading "In the review queue". With accounts on,
   `/submit/` first sends you to `/login/` — [Submitter accounts](https://zerodirs.com/docs/submissions/accounts/).

4. Read what it wrote.

   ```sh title="Terminal"
   pnpm exec wrangler d1 execute DB --local --command "SELECT name, status, tier, payment_status FROM listings ORDER BY created_at DESC LIMIT 1"
   ```

   One row: `pending`, the free tier, `payment_status` `n/a`. `email_outbox` holds a `submitted`
   row and an `admin-new-submission` row. Sign in at `/admin/login/` with `ADMIN_SECRET` from
   `.dev.vars`: the queue lists the listing with Approve and Reject.

## Verify

| What to check | Where | You should see |
| --- | --- | --- |
| The form | `https://<your domain>/submit/` | step 1: an address field and Continue. Signed out with accounts on: a 302 to `/login/?next=%2Fsubmit%2F` |
| A free submission | after "Submit for review" | a 303 to `/submit/status/<token>/?new=1`, "In the review queue", the banner "Thanks — we have your submission." |
| The row | `listings` | `status` `pending`; `payment_status` `n/a` on a free plan, `unpaid` on a priced one |
| The queue | `https://<your domain>/admin/` | the "Waiting for review" tile counts it; its row offers Approve |
| A bad link | `/submit/status/not-a-real-token/` | 404 and "This link no longer works" |

## How it works

**Step 1 is a URL.** Both steps are server-rendered forms at one address, with no client-side
JavaScript. Step 1 posts the address to `submit.prefill`: the Worker fetches the page, reads its
title, description and icons, and renders step 2 filled in. A timeout, an oversized page or a
site that is down still reaches step 2, empty, under a note ending "Please fill the fields in
yourself." Only two answers stop at step 1: "Enter a full URL, including https://." and "That
address is not a public website." [Security](https://zerodirs.com/docs/run/security/#the-metadata-fetcher) has the
fences around that fetch.

**Step 2 is the listing**: its fields, the email — or the account's address when accounts are
on — the plan when more than one is offered, and a consent box. A logo can be uploaded; without
one, the icons found in step 1 are tried in order. The plans are the enabled tiers; priced ones
appear only while `features.payments` is on.

**Validation runs on the server, in a fixed order**, and each outcome renders differently:

1. **Honeypot.** A hidden field named `company`, outside the tab order. Filled in, nothing is
   written, queued or fetched, and the visitor gets a 303 to `/submit/?sent=1` — the same
   "Thanks — we have your submission." banner a real submission shows. It is on both steps; on
   step 1 it stops `submit.prefill` being an open fetch-this-URL endpoint.
2. **Secrets.** No `TOKEN_SECRET` means no status link, so no row.
3. **Schema.** Messages appear next to their fields, and the values come back with them.
4. **Duplicate.** One host, one live listing. Already pending: a link to that submission's
   status page. Already listed: a link to the listing. A rejected row does not block a retry.
5. **Rate limit.** `limits.submissionsPerEmailPerDay` per address over a rolling 24 hours.
6. **Upload.** The logo goes to R2 best-effort; a rejected file costs the logo, never the row.
7. **Insert.** One `listings` row, `status` `pending`, `source` `submit`.

A free plan is redirected to its status page and its receipt is queued at once. A priced plan is
redirected to `/api/checkout/<id>/?t=<token>` and hears nothing until the payment lands —
[Plans and payments](https://zerodirs.com/docs/submissions/payments/) continues there. You are notified either way, at
`admin.email` or `site.contactEmail`.

**The status link** is `/submit/status/<token>/`. The token is
`<id>.<purpose>.<expiry>.<signature>`, an HMAC-SHA256 under `TOKEN_SECRET`. Purpose and expiry
sit inside the signature, so a status link cannot become a checkout link and its expiry cannot be
pushed out. Status links last 90 days, checkout links 24 hours. There is no token table, so a
link cannot be revoked early. A bad token and a deleted listing produce the same 404 page, so
the route cannot be used to probe ids.

The page shows the listing — name, address, plan, dates, payment state, a rejection reason — and
nothing about the person: no email address, no admin note. It is `noindex` and `no-store`, and
`/submit/` is disallowed in `robots.txt`. `?new=1` adds the thank-you banner with "It works for
90 days."; `?paid=1` adds "Payment received." A pending free submission also gets its wait
estimate and, with paid plans on, a "Skip the queue" section —
[The review queue](https://zerodirs.com/docs/submissions/queue/).

## When it is off or degraded

| Configuration | What a visitor sees | What `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `features.submissions: false` | no Submit button in the header or footer, no mention on `/about/`, `/terms/` or `/privacy/` | nothing | set it to `true` |
| `TOKEN_SECRET` unset | step 2 answers "Submissions are temporarily unavailable. Please try again later."; every status link is the 404 page | error: "Missing secret TOKEN_SECRET … Status links, checkout links and the admin cookie cannot be signed." | `pnpm cf:secrets TOKEN_SECRET`; locally, `.dev.vars` |
| `TOKEN_SECRET` still the placeholder from `.dev.vars.example` | everything works, and every link is forgeable | error: "TOKEN_SECRET is still the value from .dev.vars.example…" | `pnpm cf:setup`, which replaces it |
| no `MEDIA` bucket bound | listings arrive without a logo | nothing | `pnpm cf:setup` creates the bucket |
| `providers.email: 'console'` | no receipt arrives; it is logged as `email(console): to=… subject=…` | warning: `providers.email is "console": submitters get no mail, it is only logged.` | [Email](https://zerodirs.com/docs/submissions/email/) |
| `features.payments` on, Stripe secrets missing | priced plans are offered; checkout answers 503 "Payment is temporarily unavailable…" | an error per missing key, ending "Paid tiers cannot complete a checkout." | [Plans and payments](https://zerodirs.com/docs/submissions/payments/) |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.submissions` | `false` | the form, the header button, the footer link |
| `features.honeypot` | `true` | whether the hidden field is checked; it is rendered either way |
| `limits.submissionsPerEmailPerDay` | `3` | submissions per address in a rolling 24 hours |
| `limits.logoMaxBytes` | `524288` | the cap on an uploaded or fetched logo |
| `limits.descriptionMaxChars` | `2000` | the description, and the textarea's `maxlength` |
| `limits.unpaidTtlDays` | `7` | how long an unpaid priced submission survives before the hourly job deletes it |
| `limits.screenshotMaxBytes` | `2097152` | declared for screenshots; not read by the form |

Which plans are offered comes from [tiers](https://zerodirs.com/docs/configure/reference/#tiers); the notification
address from [admin](https://zerodirs.com/docs/configure/reference/#admin). Reference:
[features](https://zerodirs.com/docs/configure/reference/#features), [limits](https://zerodirs.com/docs/configure/reference/#limits).

## Files

- site.config.ts `features`, `limits`, `tiers`
- src/
  - pages/submit/
    - index.astro both steps, and the redirects after a POST
    - status/
      - [token].astro verifies the token, then reads the row
  - templates/submit/
    - SubmitPage.astro the form, the honeypot, the plan cards
    - StatusPage.astro the receipt page
  - actions/submit.ts `submit.prefill` and `submit.create`, glue only
  - server/
    - submit.ts the validation order and the insert
    - token.ts status and checkout tokens
    - meta-fetcher.ts the step-1 fetch and its SSRF list
    - media.ts logo upload and remote logo fetch
    - secrets.ts `configGaps()`, the report `/admin/` renders
    - email/templates/submitted.ts the receipt
- tests/e2e/submit.spec.ts the flow, the honeypot and the bad token, in a browser

## Related

- [Submitter accounts](https://zerodirs.com/docs/submissions/accounts/) — Sign-in by emailed link or Google, and the address that replaces the email field.
- [Plans and payments](https://zerodirs.com/docs/submissions/payments/) — Checkout, the webhook and the receipt for a priced plan.
- [The review queue](https://zerodirs.com/docs/submissions/queue/) — The wait a free submission is told, and the daily digest.
- [Security](https://zerodirs.com/docs/run/security/) — The metadata fetcher's fences and the abuse controls in the flow.
