Contact page: a public contact form with Turnstile, email confirmation and admin review (#329)
* Contact page: form, confirmation, admin review and API (WIP) * Contact page: docs, links, privacy, FAQ, unit tests * Contact page: end-to-end test * Contact page: layout keeps the heading's row tight, one-line previews in the admin list
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
- Mirror to GitHub / Push main to the GitHub mirrorsuccess
41 files changed+2112−41
Files (41)
- apps/diffinery/docs/accounts/notifications.md+6−1
- apps/diffinery/docs/admin/contact-form.md+124−0
- apps/diffinery/docs/admin/site-administration.md+7−2
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/reference/limits.md+2−0
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/docs/security/audit-log.md+3−0
- apps/diffinery/docs/self-hosting.md+1−1
- apps/diffinery/docs/self-hosting/architecture.md+1−0
- apps/diffinery/docs/self-hosting/configuration.md+4−4
- apps/diffinery/migrations/0043_contact.sql+41−0
- apps/diffinery/site/about.md+2−1
- apps/diffinery/site/faq.md+5−1
- apps/diffinery/site/privacy.md+3−1
- apps/diffinery/src/app.ts+2−0
- apps/diffinery/src/docs/api-meta.ts+25−0
- apps/diffinery/src/http/contact.ts+59−0
- apps/diffinery/src/lib/contact.ts+372−0
- apps/diffinery/src/lib/rate-limit.ts+4−3
- apps/diffinery/src/lib/retention.ts+13−3
- apps/diffinery/src/lib/util.ts+1−1
- apps/diffinery/src/mail/prefs.ts+1−1
- apps/diffinery/src/mail/samples.ts+7−0
- apps/diffinery/src/mail/send.ts+2−0
- apps/diffinery/src/mail/templates.ts+57−1
- apps/diffinery/src/mail/types.ts+14−0
- apps/diffinery/src/mail/web.tsx+1−1
- apps/diffinery/src/web/account.tsx+21−4
- apps/diffinery/src/web/assets.ts+2−0
- apps/diffinery/src/web/contact.tsx+595−0
- apps/diffinery/src/web/early-access.tsx+8−7
- apps/diffinery/src/web/indexnow.ts+3−1
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/src/web/seo.tsx+5−3
- apps/diffinery/src/web/site.tsx+4−0
- apps/diffinery/src/web/ui.tsx+2−1
- apps/diffinery/test/e2e/contact.test.mjs+392−0
- apps/diffinery/test/unit/contact.test.ts+316−0
- apps/diffinery/test/unit/docs.test.ts+1−1
- apps/diffinery/test/unit/retention.test.ts+1−1
- apps/diffinery/test/unit/site.test.ts+1−1
modifiedapps/diffinery/docs/accounts/notifications.md+6−1View file
| @@ -52,7 +52,7 @@ | ||
| 52 | 52 | | Watching | Everything else in repositories you watch with **All activity** | Yes | On | |
| 53 | 53 | | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only | |
| 54 | 54 | | Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On | |
| 55 | -| Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On | | |
| 55 | +| Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests and contact messages (for site administrators) | Yes | On | | |
| 56 | 56 | | Morning digest | [Today](/docs/agents/today) by email, once a day at the hour you choose | Yes | Off | |
| 57 | 57 | |
| 58 | 58 | ## Notification settings |
| @@ -229,6 +229,8 @@ | ||
| 229 | 229 | | `admin` | You are an administrator (of the organization, repository or instance). | |
| 230 | 230 | | `account` | You have a Diffinery account (account and membership changes). | |
| 231 | 231 | | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). | |
| 232 | +| `early_access` | Someone asked for [early access](/docs/accounts/early-access) with this address. | | |
| 233 | +| `contact` | Someone sent a message through the [contact form](/docs/admin/contact-form) with this address (the confirmation link). | | |
| 232 | 234 | | `digest` | You turned on the morning digest in your notification settings. | |
| 233 | 235 | |
| 234 | 236 | ## When Diffinery stops emailing you |
| @@ -268,6 +270,8 @@ | ||
| 268 | 270 | | `early_access_confirm` | `[Diffinery] Confirm your early access request` | Account | `early_access` | Someone asked for [early access](/docs/accounts/early-access) with this address: the link that confirms the request. Sent to the address typed, not to an account. | |
| 269 | 271 | | `early_access_known` | `[Diffinery] We already have your early access request`, `You're already invited` or `You already have a Diffinery account` | Account | `early_access` | Someone asked for early access again with an address whose request is already confirmed, declined or invited, or that belongs to an active account | |
| 270 | 272 | | `early_access_remove` | `[Diffinery] Remove your early access request` | Account | `early_access` | Someone asked to remove the early access request for this address: the link that deletes it | |
| 273 | +| `contact_confirm` | `[Diffinery] Confirm your message to the Diffinery team` | Account | `contact` | Someone who isn't signed in sent a message through the [contact form](/docs/admin/contact-form) with this address: the link that confirms it. Sent to the address typed. It never repeats what was typed. | | |
| 274 | +| `contact_received` | `[Diffinery] We got your message` | Account | `account` | You sent a message through the contact form while signed in: a copy, with its reference | | |
| 271 | 275 | | `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) | |
| 272 | 276 | | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up | |
| 273 | 277 | | `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. | |
| @@ -279,6 +283,7 @@ | ||
| 279 | 283 | | `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** | |
| 280 | 284 | | `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created | |
| 281 | 285 | | `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) | |
| 286 | +| `contact_message` | `[Diffinery] Contact: Press and media from Rivera Kim` | Organizations | `admin` | A [contact message](/docs/admin/contact-form) arrived, confirmed or from someone signed in (sent to every active site administrator). It holds the whole message, and its Reply-To is the sender, so replying answers them. | | |
| 282 | 287 | | `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). | |
| 283 | 288 | | `repo_access` | `[acme/website] You now have write access to acme/website` | Repositories | `account` | Your collaborator role on a repository was added, changed or removed | |
| 284 | 289 | | `repo_created` | `[acme/website] New private repository` | Repositories | `your_activity` or `admin` | You created a repository in the web UI (not when a token or script creates it), or someone created one in an organization where you are an owner or admin | |
addedapps/diffinery/docs/admin/contact-form.md+124−0View file
| @@ -1,0 +1,124 @@ | ||
| 1 | +--- | |
| 2 | +title: Contact form | |
| 3 | +description: Write to the people who run a Diffinery instance at /contact, what happens to your message and data, how the form is protected, and how site administrators read, answer, mark and delete messages. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Every Diffinery instance has a contact form at `/contact`, linked from the footer of every page. Anyone can use it, signed in or not, to write to the instance's site administrators: questions, help with an account or invitation, running your own Diffinery, feedback, partnerships, press, and privacy requests. A person reads every message and replies by email. | |
| 7 | + | |
| 8 | +On diffinery.com, the project's own instance, this is how to reach the Diffinery team: [diffinery.com/contact](https://diffinery.com/contact). | |
| 9 | + | |
| 10 | +Two things have their own, better places: | |
| 11 | + | |
| 12 | +- **A security problem:** use the private [security report form](/docs/security/disclosure) at `/security/report`. | |
| 13 | +- **A bug or an idea for Diffinery itself:** open an issue on [the Diffinery repository](https://diffinery.com/codician/diffinery) or its [GitHub mirror](https://github.com/codician-team/diffinery). | |
| 14 | + | |
| 15 | +## Send a message | |
| 16 | + | |
| 17 | +1. Open `/contact`, or click **Contact** in the footer. | |
| 18 | +2. Fill in the form. Every field is required: | |
| 19 | + | |
| 20 | + | Field | Limit | What it's for | | |
| 21 | + | --- | --- | --- | | |
| 22 | + | **Name** | up to 100 characters | How the team addresses you | | |
| 23 | + | **Email** | a valid address, up to 254 characters | Where the confirmation link and the reply go. Not shown when you're signed in: your account's address is used. | | |
| 24 | + | **Topic** | one of A question about Diffinery, My account or invitation, Running my own Diffinery, Feedback or an idea, Partnerships and integrations, Press and media, Privacy and my data, Something else | Gets your message to the right person | | |
| 25 | + | **Message** | 10 to 5,000 characters and at most 5 links, plain text | What you want to tell us | | |
| 26 | + | |
| 27 | + A link can choose the topic for you: `/contact?topic=press` opens the form with **Press and media** selected. The values are `question`, `account`, `self-hosting`, `feedback`, `partnership`, `press`, `privacy` and `other`. | |
| 28 | +3. If you aren't signed in, complete the "are you a person" check if one shows. | |
| 29 | +4. Click **Send message**. | |
| 30 | + | |
| 31 | +**If you aren't signed in**, you see **Check your inbox**. Open the email titled "Confirm your message to the Diffinery team" and click **Confirm my message**, then press **Confirm my message** on the page that opens. You see **Your message is with the team**. The link works once, for 48 hours. A message that is never confirmed is never read, and is deleted after 7 days. The confirmation email never repeats what you typed, so nobody can use the form to send someone else words through Diffinery. | |
| 32 | + | |
| 33 | +**If you're signed in**, there's nothing to confirm: you see **Message sent**, and you get a copy of your message by email ("We got your message"), with its reference. | |
| 34 | + | |
| 35 | +The team replies from their own mailbox to the address you gave, or your account's. | |
| 36 | + | |
| 37 | +## What is stored | |
| 38 | + | |
| 39 | +| What | Kept | | |
| 40 | +| --- | --- | | |
| 41 | +| Your name, address, topic and message, and the message's state | Until a site administrator deletes it; unconfirmed messages 7 days, messages marked spam 30 days after that, handled messages 365 days after they were handled | | |
| 42 | +| Your account, when you were signed in | With the message | | |
| 43 | +| A SHA-256 hash of the confirmation link's code | Until the link is used | | |
| 44 | +| One row per submission from someone not signed in, with SHA-256 hashes of the IP address and of the address typed | One day, for the daily limits only | | |
| 45 | +| A site administrator's note on the message | With the message | | |
| 46 | + | |
| 47 | +Your IP address and the address you typed are never stored in the clear for the limits, and nothing you type is written to logs. Only site administrators can read messages. To have a message deleted sooner, ask through the form (topic **Privacy and my data**). In the [data model](/docs/self-hosting/architecture), these are the `contact_messages` and `contact_attempts` tables. | |
| 48 | + | |
| 49 | +## How the form is protected | |
| 50 | + | |
| 51 | +| Protection | What it does | | |
| 52 | +| --- | --- | | |
| 53 | +| Cloudflare Turnstile | The "are you a person" check for people who aren't signed in, the same one as the [early access](/docs/accounts/early-access) and security report forms. A deployed instance without Turnstile keys (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`) takes messages only from people who are signed in; on your own machine the check is skipped. | | |
| 54 | +| Honeypot and timing | A field people never see, and a signed time stamp: a form that fills the hidden field, comes back within 3 seconds, or carries a missing or forged stamp gets the usual **Check your inbox** page, and nothing is stored or sent. A form left open more than 24 hours asks you to send it again. | | |
| 55 | +| Confirmation by email | A message from someone who isn't signed in reaches the team only after its address follows the emailed link, so nobody can write in someone else's name. | | |
| 56 | +| Per-minute limit | At most 20 sign-in attempts, security reports, early access requests and contact messages a minute from one network (`RL_AUTH`, see [Rate limits](/docs/reference/rest-api#rate-limits)). | | |
| 57 | +| Daily limits | Without signing in: at most 10 submissions from one IP address, 3 emails to one address, and 300 emails from the form in total, in 24 hours. Past the network limit you see "Too many messages from your network today"; past the other two the page looks the same and nothing is stored or sent. Signed in: 20 messages a day per person. | | |
| 58 | +| Spam | While a site administrator has a message from an address marked as spam, new messages from that address are dropped without an email; the page looks the same. | | |
| 59 | +| Strict input | Lengths are capped (longer is refused, never cut), the email address is checked, the topic must be one of the list, links are limited to 5, and control and text-direction characters are removed. Messages are shown as plain text, never as HTML or Markdown. | | |
| 60 | + | |
| 61 | +## For site administrators | |
| 62 | + | |
| 63 | +**Settings → Contact messages** (`/settings/admin/contact`) is in the **Site administration** group. The sidebar shows how many new messages are waiting. Anyone who isn't a site administrator gets `404`. | |
| 64 | + | |
| 65 | +When a message arrives (confirmed, or from someone signed in), each active site administrator gets: | |
| 66 | + | |
| 67 | +- an email, "Contact: *topic* from *name*", with the whole message. Its **Reply-To** is the sender, so replying in your mail app answers them directly. It belongs to the **Organizations** group of your [notification settings](/docs/accounts/notifications), so you can turn it off there; | |
| 68 | +- one in-app notification, "New contact messages are waiting for you", which stays until it is read however many messages arrive. | |
| 69 | + | |
| 70 | +The tabs filter by state, with counts: | |
| 71 | + | |
| 72 | +| Tab | State | Meaning | | |
| 73 | +| --- | --- | --- | | |
| 74 | +| **New** (the default) | `new` | Waiting for someone to answer | | |
| 75 | +| **Handled** | `handled` | Answered or dealt with, by whom and when | | |
| 76 | +| **Spam** | `spam` | Marked as spam; new messages from the same address are dropped | | |
| 77 | +| **Unconfirmed** | `unconfirmed` | The address hasn't followed the link yet; you don't need to act on these | | |
| 78 | +| **All** | | Every message | | |
| 79 | + | |
| 80 | +The search box finds messages whose address, name or text contains what you type. Each message shows its sender's name and address, its state, the topic, when it was sent, the start of the text, and an amber badge when the sender was signed in or an active account uses the address. Click a name to read the whole message. | |
| 81 | + | |
| 82 | +- **Reply by email** opens your mail app with a reply to the sender. Diffinery doesn't send replies itself, so the conversation stays in your mailbox. | |
| 83 | +- **Mark handled** marks the message **Handled**. **Spam** marks it **Spam**. **Reopen** puts a handled or spam message back to **New** (or **Unconfirmed**, if its address never confirmed it). Nobody is emailed. | |
| 84 | +- **Note**, on a message's page, keeps a note for the other site administrators, such as who replied and what was agreed, up to 2,000 characters. The sender never sees it. Save an empty note to clear it. | |
| 85 | +- **Delete message** removes it for good, for someone who asks for their data to be removed. | |
| 86 | + | |
| 87 | +Every change is in the [audit log](/docs/security/audit-log): `contact.create` and `contact.confirm` when a message is sent and confirmed, `contact.handle`, `contact.spam`, `contact.reopen`, `contact.note` and `contact.delete`. Entries name the message by its id; none holds what it said, and `contact.delete` doesn't hold the address. | |
| 88 | + | |
| 89 | +### From the API | |
| 90 | + | |
| 91 | +With a site administrator's token (`read` to list, `admin` to change): | |
| 92 | + | |
| 93 | +| Endpoint | What it does | | |
| 94 | +| --- | --- | | |
| 95 | +| `GET /api/v3/diffinery/admin/contact` | List messages, newest first. `state`, `q`, `per_page` (up to 500) and `page` filter and page them. | | |
| 96 | +| `GET /api/v3/diffinery/admin/contact/{id}` | One message | | |
| 97 | +| `PATCH /api/v3/diffinery/admin/contact/{id}` | `{"state": "handled"}`, `{"state": "spam"}` or `{"state": "new"}` (reopen), and or `{"note": "..."}` | | |
| 98 | +| `DELETE /api/v3/diffinery/admin/contact/{id}` | Delete it (`204`) | | |
| 99 | + | |
| 100 | +```bash | |
| 101 | +# New messages | |
| 102 | +curl -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 103 | + "https://diffinery.com/api/v3/diffinery/admin/contact?state=new" | |
| 104 | + | |
| 105 | +# Mark one handled, with a note | |
| 106 | +curl -X PATCH -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 107 | + -d '{"state": "handled", "note": "Replied with the self-hosting guide."}' \ | |
| 108 | + https://diffinery.com/api/v3/diffinery/admin/contact/<id> | |
| 109 | +``` | |
| 110 | + | |
| 111 | +Each message has `id`, `state`, `email`, `name`, `topic`, `message`, `sender` (the login of a signed-in sender, or null), `confirmed_at`, `note`, `updated_by`, `handled_at`, `created_at`, `updated_at` and `html_url`. Everyone else gets `404`. See the [API reference](/docs/api) for every field. | |
| 112 | + | |
| 113 | +## Troubleshooting | |
| 114 | + | |
| 115 | +| What you see | Why, and what to do | | |
| 116 | +| --- | --- | | |
| 117 | +| No email after **Check your inbox** | Check the spam folder. The address may have had 3 emails from the form today; try again tomorrow, or sign in and send it from your account. | | |
| 118 | +| "This link doesn't work any more" | The link was already used or is older than 48 hours. If you confirmed already, your message is with the team. Otherwise write it again. | | |
| 119 | +| "This link is incomplete" | The part after `#` was cut off. Open the link from the email in full, or copy all of it. | | |
| 120 | +| "We couldn't confirm that you're a person" | The Turnstile check failed or expired. Reload the page and try again. | | |
| 121 | +| "This form was open for a long time" | The page was open more than 24 hours. Check the fields and send it again. | | |
| 122 | +| "Too many messages from your network today" | 10 messages came from your network in 24 hours. Try again tomorrow. | | |
| 123 | +| "Keep your message to 5 links or fewer" | Messages with many links look like spam. Leave some out; you can send them once we reply. | | |
| 124 | +| The form says it takes messages only from people who are signed in | The instance has no Turnstile keys. Sign in, or ask its administrators to add them (see [Turnstile](/docs/self-hosting/configuration#turnstile)). | |
modifiedapps/diffinery/docs/admin/site-administration.md+7−2View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Site administration |
| 3 | -description: Site administrators manage people and admins, handle abuse and security reports, suspend and delete accounts, stop Actions, post status incidents, oversee imports, and check the audit and email logs. | |
| 3 | +description: Site administrators manage people, handle abuse and security reports and contact messages, suspend and delete accounts, stop Actions, post incidents, oversee imports, and check audit and email logs. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, handle private security reports, post incidents on the status page, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**. |
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | | --- | --- | --- | |
| 14 | 14 | | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links | |
| 15 | 15 | | **Early access** | `/settings/admin/early-access` | Requests from people without an invitation, to invite, decline or delete | |
| 16 | +| **Contact messages** | `/settings/admin/contact` | Messages from the contact form, to read, answer, mark handled or spam, and delete | | |
| 16 | 17 | | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve | |
| 17 | 18 | | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email | |
| 18 | 19 | | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance | |
| @@ -135,6 +136,10 @@ | ||
| 135 | 136 | |
| 136 | 137 | While the instance is invite-only, people without an invitation can ask for one at `/early-access`, and confirm their address with an emailed link. **Settings → Early access** (`/settings/admin/early-access`) lists the requests: **Confirmed** ones wait for you, with their count next to **Early access** in the sidebar and a reminder on **People and invitations**. **Invite** sends the usual instance invitation (see [Invites](#invites)), **Decline** emails nobody, and **Delete request** removes everything stored about a request. Each decision is in the audit log. See [Early access](/docs/accounts/early-access#for-site-administrators) for the whole page, the API and how the form is protected. |
| 137 | 138 | |
| 139 | +## Contact messages | |
| 140 | + | |
| 141 | +Anyone can write to the site administrators at `/contact`, linked from every page's footer. People who aren't signed in pass Turnstile and confirm their address with an emailed link first. **Settings → Contact messages** (`/settings/admin/contact`) lists the messages: **New** ones wait for you, with their count next to **Contact messages** in the sidebar, and each one emails every site administrator with the sender as Reply-To. **Reply by email** opens your mail app, **Mark handled** and **Spam** sort them, a **Note** tells the other site administrators what happened, and **Delete message** removes everything stored about one. Each change is in the audit log. See [Contact form](/docs/admin/contact-form#for-site-administrators) for the whole page, the API and how the form is protected. | |
| 142 | + | |
| 138 | 143 | ## Reports |
| 139 | 144 | |
| 140 | 145 | Anyone signed in can report a person, a repository, an issue, a pull request or a comment as abusive, from the **…** menu on it (or on a profile) and **Report**. They pick a reason (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else) and can add a note of up to 2,000 characters; "Something else" needs the note. The person reported isn't told. One person files at most 20 reports a day, and one open report per thing. |
| @@ -415,7 +420,7 @@ | ||
| 415 | 420 | |
| 416 | 421 | What it covers: |
| 417 | 422 | |
| 418 | -- **After each deploy**, the pages the deploy added, changed or removed: the home page, the product, comparison and company pages (`/features`, `/compare/github`, ...), every docs page, each of those pages' Markdown version (`/index.md`, `/compare/github.md`, `/docs/quickstart.md`, ...), `/llms.txt`, `/llms-full.txt`, and `/early-access` while the instance is invite-only. These are the pages in `/sitemap.xml` plus their Markdown versions. A deploy that changes no page's content submits nothing. | |
| 423 | +- **After each deploy**, the pages the deploy added, changed or removed: the home page, the product, comparison and company pages (`/features`, `/compare/github`, ...), every docs page, each of those pages' Markdown version (`/index.md`, `/compare/github.md`, `/docs/quickstart.md`, ...), `/llms.txt`, `/llms-full.txt`, `/contact`, and `/early-access` while the instance is invite-only. These are the pages in `/sitemap.xml` plus their Markdown versions. A deploy that changes no page's content submits nothing. | |
| 419 | 424 | - **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it). |
| 420 | 425 | - **Never** a private repository, a repository whose owner is suspended, or any page behind sign-in (settings, notifications, the API, sign-in pages). A repository is only submitted in the same check that found it public, so one made private in the meantime isn't sent, and it is forgotten. Removed site and docs pages are submitted once more, so search engines learn they are gone. |
| 421 | 426 |
modifiedapps/diffinery/docs/reference/limits.md+2−0View file
| @@ -29,6 +29,8 @@ | ||
| 29 | 29 | | Early access requests | 10 per IP address, 3 emails per address and 500 emails in total, in 24 hours | The [early access](/docs/accounts/early-access) form and its removal form together. The 11th from one network answers `Too many requests from your network today`; past the other two the page looks the same and no email is sent. | |
| 30 | 30 | | Early access links | 48 hours to confirm, 24 hours to remove, single use | Only the newest link sent to an address works. Unconfirmed requests are deleted 7 days after they were last sent. | |
| 31 | 31 | | Early access fields | name 100, company or team 120, use 1,000 characters | Longer is refused, not cut. | |
| 32 | +| Contact messages | Without signing in: 10 per IP address, 3 emails per address and 300 emails in total, in 24 hours. Signed in: 20 per person in 24 hours | The [contact form](/docs/admin/contact-form). The 11th from one network answers `Too many messages from your network today`; past the other two the page looks the same and nothing is stored or sent. | | |
| 33 | +| Contact message fields | name 100, message 10 to 5,000 characters with at most 5 links, note 2,000 characters | Longer is refused, not cut. Confirmation links work once, for 48 hours. Unconfirmed messages are deleted after 7 days, spam 30 days and handled messages 365 days after they were marked. | | |
| 32 | 34 | | Note on an access request | 500 characters | The note someone without an invite sends to site administrators. Up to 20 site administrators are emailed about each request. | |
| 33 | 35 | |
| 34 | 36 | ## Tokens |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
| @@ -409,7 +409,7 @@ | ||
| 409 | 409 | |
| 410 | 410 | | What | Limit | Counted by | |
| 411 | 411 | | --- | --- | --- | |
| 412 | -| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`), early access requests, confirmations and removals (`POST /early-access`, `/early-access/confirm`, `/early-access/remove`, `/early-access/remove/confirm`) | 20 a minute | IP address | | |
| 412 | +| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`), early access requests, confirmations and removals (`POST /early-access`, `/early-access/confirm`, `/early-access/remove`, `/early-access/remove/confirm`), contact messages and their confirmations (`POST /contact`, `/contact/confirm`) | 20 a minute | IP address | | |
| 413 | 413 | | Anything else without signing in (web pages, API, git) | 300 a minute | IP address | |
| 414 | 414 | | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account | |
| 415 | 415 | | Creating repositories, organizations, forks, issues, pull requests, releases, comments, reviews and reports, and MCP tools that change something | 60 a minute | Account | |
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
| @@ -137,6 +137,9 @@ | ||
| 137 | 137 | | `early_access.confirm` | An address confirms its [early access](/docs/accounts/early-access) request with the emailed link (the actor is `system`) | the request's id | none | |
| 138 | 138 | | `early_access.invite` | A site administrator invites an early access request (**Invite** on **Settings → Early access**, or `POST /api/v3/diffinery/admin/early-access/{id}/invite`) | the request's id | `invite` (the invitation id), `from` (the state it was in), `existing_invitation` when a pending invitation was kept, `via` (`web` or `api`) | |
| 139 | 139 | | `early_access.decline`, `early_access.restore` | A site administrator declines a request, or puts a declined one back (**Decline** or **Put back**, or `PATCH /api/v3/diffinery/admin/early-access/{id}`) | the request's id | `from` or `to` (the states), `via` | |
| 140 | +| `contact.create`, `contact.confirm` | Someone sends a message through the [contact form](/docs/admin/contact-form) (the actor is the sender when signed in, otherwise `system`), or confirms one with the emailed link (`system`) | the message's id | `signed_in` on `contact.create`; none on `contact.confirm` | | |
| 141 | +| `contact.handle`, `contact.spam`, `contact.reopen` | A site administrator marks a contact message handled or spam, or reopens it (**Mark handled**, **Spam** or **Reopen** on **Settings → Contact messages**, or `PATCH /api/v3/diffinery/admin/contact/{id}`) | the message's id | `from` and `to` (the states), `via` (`web` or `api`) | | |
| 142 | +| `contact.note`, `contact.delete` | A site administrator changes a message's note, or deletes it (**Delete message**, or `DELETE /api/v3/diffinery/admin/contact/{id}`). Neither the note, the address nor the message is recorded. | the message's id | `via`; `state` on `contact.delete` | | |
| 140 | 143 | | `early_access.delete` | A site administrator deletes a request (**Delete request**, or `DELETE /api/v3/diffinery/admin/early-access/{id}`), or someone removes their own with an emailed link (the actor is `system`). Neither the address nor the request's content is recorded. | the request's id | `state`, `via` (`web`, `api` or `self`) | |
| 141 | 144 | | `status_incident.create` | A site administrator opens an incident on the [status page](/docs/self-hosting/status-page) (**Open the incident** on **Settings → Status**, or `POST /api/v3/diffinery/admin/status/incidents`) | the incident's id | `title`, `impact`, `components`, `state`, `via` (`web` or `api`); not the update's text | |
| 142 | 145 | | `status_incident.update`, `status_incident.resolve` | A site administrator posts an update to an incident (**Post the update**, or `POST /api/v3/diffinery/admin/status/incidents/{id}/updates`); `resolve` when it resolves the incident | the incident's id | `from` and `to` (the states), `via` | |
modifiedapps/diffinery/docs/self-hosting.md+1−1View file
| @@ -55,7 +55,7 @@ | ||
| 55 | 55 | |
| 56 | 56 | - **Who can join.** An instance is invite-only by default. Set `SIGNUP=open` to let anyone create an account; see [SIGNUP](/docs/self-hosting/configuration#signup) and [Invitations](/docs/accounts/invitations). |
| 57 | 57 | - **Limits.** Change the per-account quotas and the instance's hosted job limit with the `QUOTA_*` and `HOSTED_JOBS_LIMIT` variables; see [Configuration](/docs/self-hosting/configuration#worker-variables). |
| 58 | -- **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works. | |
| 58 | +- **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, contact messages, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works. | |
| 59 | 59 | - **Your team's tools.** The CLI, the Claude Code and Codex plugins, the MCP server and the editor extensions all work with your instance. People install the CLI from it and run `diffinery setup` with its URL, which signs them in and points git, gh, the Claude Code plugins and Codex at it: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. AI apps connect to `https://git.example.com/mcp`. See [Claude Code](/docs/connect/claude-code#self-hosted-diffinery), [Codex](/docs/connect/codex#self-hosted-diffinery), [Clone in your editor](/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides). |
| 60 | 60 | - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it. |
| 61 | 61 |
modifiedapps/diffinery/docs/self-hosting/architecture.md+1−0View file
| @@ -275,6 +275,7 @@ | ||
| 275 | 275 | | `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. | |
| 276 | 276 | | `access_requests` | Requests from people waiting for an invite. | |
| 277 | 277 | | `early_access`, `early_access_attempts` | [Early access](/docs/accounts/early-access) requests from `/early-access`: the address, name, company, use and hosting given, the state (`unconfirmed`, `confirmed`, `invited`, `declined`), hashes of the confirmation and removal links' codes, and who decided; and one row per form submission with hashes of the IP address and the address typed, kept a day for the daily limits. | |
| 278 | +| `contact_messages`, `contact_attempts` | Messages from the [contact form](/docs/admin/contact-form) at `/contact`: the name, address, topic and message, the sender's account when signed in, the state (`unconfirmed`, `new`, `handled`, `spam`), a hash of the confirmation link's code, and the site administrators' note and who last changed it; and one row per submission from someone not signed in with hashes of the IP address and the address typed, kept a day for the daily limits. | | |
| 278 | 279 | | `security_reports` | Private vulnerability reports from `/security/report`: summary, details, affected area, reply-to address, the reporter when signed in, a hash of the sender's IP address (for the daily limits), and the state (`new`, `triaged`, `resolved`) and note a site administrator set. | |
| 279 | 280 | | `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. | |
| 280 | 281 | | `sessions` | Browser sessions by hash of the cookie value, with the sign-in method and passkey, user agent and expiry. | |
modifiedapps/diffinery/docs/self-hosting/configuration.md+4−4View file
| @@ -29,7 +29,7 @@ | ||
| 29 | 29 | | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). | |
| 30 | 30 | | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). | |
| 31 | 31 | | `SIGNUP` | Not set | No | `open` lets anyone create an account at `/signup`. See [SIGNUP](#signup). Any other value, or none, keeps the instance invite-only. | |
| 32 | -| `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports and [early access requests](/docs/accounts/early-access) | The site key of the Cloudflare Turnstile widget on the sign-up page, the [security report form](#security-contact) and the early access form. The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. | | |
| 32 | +| `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports, [early access requests](/docs/accounts/early-access) and [contact messages](/docs/admin/contact-form) | The site key of the Cloudflare Turnstile widget on the sign-up page, the [security report form](#security-contact), the early access form and the contact form. The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. | | |
| 33 | 33 | | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). | |
| 34 | 34 | | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES`, `QUOTA_STORAGE_BYTES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`20`), agent sandboxes open at once (`5`), repositories (`500`) and storage in bytes (`10737418240`, 10 GB). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). | |
| 35 | 35 | | `HOSTED_JOBS_LIMIT` | Not set | No | Hosted Actions jobs the whole instance runs at once (default `80`); one account runs at most half of them, accounts run by a site administrator included. Keep it under the runner container classes' `max_instances`. Past it, hosted jobs wait as queued until one finishes. See [Limits](/docs/reference/limits#actions). | |
| @@ -150,13 +150,13 @@ | ||
| 150 | 150 | |
| 151 | 151 | ### Turnstile |
| 152 | 152 | |
| 153 | -The security report form, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`): | |
| 153 | +The security report, early access and contact forms, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`): | |
| 154 | 154 | |
| 155 | 155 | 1. It looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in the account, and creates one when there is none: managed mode, for your hostname only. |
| 156 | 156 | 2. The widget's site key becomes the `TURNSTILE_SITE_KEY` variable of the deployed Worker. |
| 157 | 157 | 3. Its secret key becomes the `TURNSTILE_SECRET` secret: `scripts/ensure-secrets.sh` sets it when it's missing, and bootstrap replaces it before the deploy when it goes with another widget (the deployed `TURNSTILE_SITE_KEY` is not this widget's, for example because the widget was deleted and created again). The key goes from the Cloudflare API straight to `wrangler secret put` and is never printed. |
| 158 | 158 | |
| 159 | -The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports until you add it and deploy again. | |
| 159 | +The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports, early access requests or contact messages until you add it and deploy again. | |
| 160 | 160 | |
| 161 | 161 | To use a widget of your own instead, add `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc` and store its secret key with `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`. The deploy then creates no widget and never replaces your secret; it only fills in a missing `TURNSTILE_SECRET` when your widget is in the same Cloudflare account. If you rotate the secret key of the deploy's widget in the dashboard, run `pnpm exec wrangler secret delete TURNSTILE_SECRET -c wrangler.deploy.jsonc` and deploy again, so the deploy stores the new one. |
| 162 | 162 | |
| @@ -177,7 +177,7 @@ | ||
| 177 | 177 | | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. | |
| 178 | 178 | | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. | |
| 179 | 179 | | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. | |
| 180 | -| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", and the security report form takes reports only from people who are signed in (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. | | |
| 180 | +| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report, an early access request or a contact message without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", the security report and contact forms take reports and messages only from people who are signed in, and the early access form is closed (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. | | |
| 181 | 181 | |
| 182 | 182 | None of these secrets is rotated automatically, and `ensure-secrets.sh` never replaces one that exists. Keep a copy of `MASTER_KEY` somewhere safe: replacing it without keeping the old one as `MASTER_KEY_PREV` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out. To rotate it, follow [Rotating MASTER_KEY](#rotating-master_key). To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`. |
| 183 | 183 |
addedapps/diffinery/migrations/0043_contact.sql+41−0View file
| @@ -1,0 +1,41 @@ | ||
| 1 | +-- Contact messages (lib/contact.ts): what people send through /contact to the instance's site | |
| 2 | +-- administrators. A message from someone who isn't signed in is unconfirmed until its address follows | |
| 3 | +-- the emailed link; a signed-in person's arrives confirmed. Confirmed messages are new until a site | |
| 4 | +-- administrator marks them handled or spam. Only site administrators can read them; the audit log keeps | |
| 5 | +-- every change. Unconfirmed messages are deleted after 7 days, spam after 30, handled after 365. | |
| 6 | +CREATE TABLE IF NOT EXISTS contact_messages ( | |
| 7 | + id TEXT PRIMARY KEY, | |
| 8 | + email TEXT NOT NULL COLLATE NOCASE, -- where to reply: the address typed, or a signed-in sender's own | |
| 9 | + name TEXT NOT NULL, | |
| 10 | + topic TEXT NOT NULL, -- one of a fixed list | |
| 11 | + message TEXT NOT NULL, | |
| 12 | + account_id TEXT, -- the sender, when signed in | |
| 13 | + sender_login TEXT, | |
| 14 | + state TEXT NOT NULL DEFAULT 'unconfirmed' CHECK (state IN ('unconfirmed', 'new', 'handled', 'spam')), | |
| 15 | + confirm_hash TEXT, -- SHA-256 of the emailed confirmation token, until it is used | |
| 16 | + confirm_expires_at TEXT, | |
| 17 | + confirmed_at TEXT, | |
| 18 | + note TEXT, -- the site administrators' latest note | |
| 19 | + updated_by TEXT, -- the site administrator's login who last changed it | |
| 20 | + handled_at TEXT, -- when it was marked handled or spam | |
| 21 | + created_at TEXT NOT NULL, | |
| 22 | + updated_at TEXT NOT NULL | |
| 23 | +); | |
| 24 | +CREATE INDEX IF NOT EXISTS contact_messages_state ON contact_messages(state, created_at); | |
| 25 | +CREATE INDEX IF NOT EXISTS contact_messages_email ON contact_messages(email, state); | |
| 26 | +CREATE INDEX IF NOT EXISTS contact_messages_account ON contact_messages(account_id, created_at); | |
| 27 | +CREATE UNIQUE INDEX IF NOT EXISTS contact_messages_confirm ON contact_messages(confirm_hash); | |
| 28 | + | |
| 29 | +-- One row per form submission from someone who isn't signed in, for the daily limits only: hashes of | |
| 30 | +-- the sender's IP address and of the address typed, never either in the clear. Rows older than a day | |
| 31 | +-- are deleted. | |
| 32 | +CREATE TABLE IF NOT EXISTS contact_attempts ( | |
| 33 | + id TEXT PRIMARY KEY, | |
| 34 | + ip_hash TEXT NOT NULL, | |
| 35 | + email_hash TEXT NOT NULL, | |
| 36 | + emailed INTEGER NOT NULL DEFAULT 0, -- 1 when this submission sent an email | |
| 37 | + created_at TEXT NOT NULL | |
| 38 | +); | |
| 39 | +CREATE INDEX IF NOT EXISTS contact_attempts_ip ON contact_attempts(ip_hash, created_at); | |
| 40 | +CREATE INDEX IF NOT EXISTS contact_attempts_email ON contact_attempts(email_hash, created_at); | |
| 41 | +CREATE INDEX IF NOT EXISTS contact_attempts_at ON contact_attempts(created_at); |
modifiedapps/diffinery/site/about.md+2−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | title: About Diffinery |
| 3 | 3 | seo_title: "About Diffinery: the git forge for people and AI agents" |
| 4 | 4 | description: Why Diffinery exists, why it's open source, how it's built, how we check the claims on this site, and how to reach us. |
| 5 | -updated: 2026-10-25 | |
| 5 | +updated: 2026-10-26 | |
| 6 | 6 | --- |
| 7 | 7 | |
| 8 | 8 | Diffinery is a git forge and CI platform for teams where AI agents write a growing share of the code. Agents do the work through Git, pull requests and CI like everyone else; people see what the agents did, approve what needs them and follow the whole process. Diffinery runs entirely on Cloudflare, and it's open source under the Apache License 2.0: you deploy it to your own Cloudflare account and run it yourself. |
| @@ -40,6 +40,7 @@ | ||
| 40 | 40 | |
| 41 | 41 | ## Contact |
| 42 | 42 | |
| 43 | +- **Questions, partnerships, press and privacy requests:** the [contact form](https://diffinery.com/contact). A person reads every message and replies by email. | |
| 43 | 44 | - **Running your own instance:** [Deploy your own Diffinery](/docs/self-hosting/deploy). |
| 44 | 45 | - **Bugs, ideas and contributions:** issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery), or on [the Diffinery repository](https://diffinery.com/codician/diffinery) if you have an account there. |
| 45 | 46 | - **Security problems:** the private [report form](/security/report), as listed in [`/.well-known/security.txt`](/.well-known/security.txt). |
modifiedapps/diffinery/site/faq.md+5−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | title: Diffinery FAQ |
| 3 | 3 | seo_title: "Diffinery FAQ: open source, self-hosting, GitHub Actions, AI agents" |
| 4 | 4 | description: Short answers about Diffinery: open source and self-hosting, cost, GitHub Actions compatibility, runners, Cloudflare, AI agents, security and migration. |
| 5 | -updated: 2026-10-25 | |
| 5 | +updated: 2026-10-26 | |
| 6 | 6 | --- |
| 7 | 7 | |
| 8 | 8 | Short, direct answers to the questions people and AI assistants ask most about Diffinery. Each answer links to the page with the details. Facts about GitHub are from GitHub's own documentation, checked on October 25, 2026. |
| @@ -137,6 +137,10 @@ | ||
| 137 | 137 | |
| 138 | 138 | diffinery.com is the instance Diffinery itself is developed on, and accounts there are by invitation for the people working on it. To use Diffinery, deploy your own instance. To report a bug or contribute without an account, use issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery). |
| 139 | 139 | |
| 140 | +### How do I contact the Diffinery team? | |
| 141 | + | |
| 142 | +Use the [contact form](https://diffinery.com/contact) at diffinery.com/contact, for questions, partnerships, press or privacy requests; a person reads every message and replies by email. Report security problems privately through [the security report form](/security/report), and bugs or ideas as issues on [the Diffinery repository](https://diffinery.com/codician/diffinery). See [Contact form](/docs/admin/contact-form). | |
| 143 | + | |
| 140 | 144 | ### How do people join my instance? |
| 141 | 145 | |
| 142 | 146 | Every instance is invite-only by default: organization owners and admins invite people by email, and each invitation link creates an account with a passkey. Administrators can open sign-up to everyone with `SIGNUP=open`. See [Invitations](/docs/accounts/invitations) and [Configuration](/docs/self-hosting/configuration#signup). |
modifiedapps/diffinery/site/privacy.md+3−1View file
| @@ -46,6 +46,7 @@ | ||
| 46 | 46 | ## Forms anyone can use |
| 47 | 47 | |
| 48 | 48 | - **Early access requests** (`/early-access`): the email address you give and, if you fill them in, your name, company, what you'd use Diffinery for and where you host code today. Unconfirmed requests are deleted after 7 days; confirmed ones are kept until a site administrator deletes the request, and you can delete yours yourself at any time. To enforce daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Early access](/docs/accounts/early-access). |
| 49 | +- **Contact messages** (`/contact`): your name, the email address you give (or your account's, if you're signed in), the topic and your message, and a note our site administrators may add about how they answered. Only diffinery.com's site administrators can read messages, and we use them only to reply. If you aren't signed in, your message waits until you confirm your address with the link we email you; unconfirmed messages are deleted after 7 days, unread. Messages marked as spam are deleted after 30 days, and answered ones 365 days after we mark them handled. For daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Contact form](/docs/admin/contact-form). | |
| 49 | 50 | - **Security reports** (`/security/report`): what you write, and the email address you give for a reply if you aren't signed in. Only diffinery.com's site administrators can read reports. |
| 50 | 51 | - People who aren't signed in pass a **Cloudflare Turnstile** check on these forms, which Cloudflare runs using signals from your browser. |
| 51 | 52 | |
| @@ -79,6 +80,7 @@ | ||
| 79 | 80 | | Job logs | 90 days | |
| 80 | 81 | | Workflow artifacts | 1 to 90 days, as the workflow sets (90 by default) | |
| 81 | 82 | | Account data exports | 7 days | |
| 83 | +| Contact messages | 7 days unconfirmed; 30 days once marked spam; 365 days after they are answered | | |
| 82 | 84 | | Audit log | 2 years | |
| 83 | 85 | | Database backups | 30 days of point-in-time recovery | |
| 84 | 86 | | Username of a deleted account | Not reusable for 90 days | |
| @@ -105,4 +107,4 @@ | ||
| 105 | 107 | |
| 106 | 108 | ## Contact |
| 107 | 109 | |
| 108 | -Codician, **hi@codician.com**. Security reports: [diffinery.com/security/report](/security/report). | |
| 110 | +Codician, **hi@codician.com**, or the [contact form](/contact). Security reports: [diffinery.com/security/report](/security/report). |
modifiedapps/diffinery/src/app.ts+2−0View file
| @@ -38,6 +38,7 @@ | ||
| 38 | 38 | import { securityTxtRoutes } from "./http/security-txt.js"; |
| 39 | 39 | import { mcpRegistryAuthRoutes } from "./http/mcp-registry-auth.js"; |
| 40 | 40 | import { securityReportsApi } from "./http/security-reports.js"; |
| 41 | +import { contactApi } from "./http/contact.js"; | |
| 41 | 42 | import { earlyAccessApi } from "./http/early-access.js"; |
| 42 | 43 | import { statusApi } from "./http/status.js"; |
| 43 | 44 | import { onReviewEvent } from "./agentic/reviews.js"; |
| @@ -130,6 +131,7 @@ | ||
| 130 | 131 | app.route("/", mailApi); |
| 131 | 132 | app.route("/", securityReportsApi); |
| 132 | 133 | app.route("/", earlyAccessApi); |
| 134 | +app.route("/", contactApi); | |
| 133 | 135 | app.route("/", statusApi); |
| 134 | 136 | app.route("/", mailEvents); |
| 135 | 137 | app.route("/", registry); |
modifiedapps/diffinery/src/docs/api-meta.ts+25−0View file
| @@ -121,6 +121,10 @@ | ||
| 121 | 121 | "POST /diffinery/admin/early-access/{id}/invite": { scope: "admin" }, |
| 122 | 122 | "PATCH /diffinery/admin/early-access/{id}": { scope: "admin" }, |
| 123 | 123 | "DELETE /diffinery/admin/early-access/{id}": { scope: "admin" }, |
| 124 | + "GET /diffinery/admin/contact": { scope: "read" }, | |
| 125 | + "GET /diffinery/admin/contact/{id}": { scope: "read" }, | |
| 126 | + "PATCH /diffinery/admin/contact/{id}": { scope: "admin" }, | |
| 127 | + "DELETE /diffinery/admin/contact/{id}": { scope: "admin" }, | |
| 124 | 128 | "GET /diffinery/admin/status/incidents": { scope: "read" }, |
| 125 | 129 | "POST /diffinery/admin/status/incidents": { scope: "admin" }, |
| 126 | 130 | "POST /diffinery/admin/status/incidents/{id}/updates": { scope: "admin" }, |
| @@ -1629,6 +1633,27 @@ | ||
| 1629 | 1633 | access: "site-admin", |
| 1630 | 1634 | status: 204, |
| 1631 | 1635 | }, |
| 1636 | + "GET /diffinery/admin/contact": { | |
| 1637 | + summary: "List contact messages", | |
| 1638 | + description: | |
| 1639 | + "Messages sent through `/contact`, newest first: `id`, `state` (`unconfirmed` until the address follows the emailed link, then `new`, `handled` or `spam`), `email`, `name`, `topic` (`question`, `account`, `self-hosting`, `feedback`, `partnership`, `press`, `privacy` or `other`), `message`, `sender` (the login of a signed-in sender, or null), `confirmed_at`, `note`, `updated_by`, `handled_at`, `created_at`, `updated_at` and `html_url`. 404 for anyone but a site administrator. See [Contact form](/docs/admin/contact-form).", | |
| 1640 | + access: "site-admin", | |
| 1641 | + query: { state: ["string", "Only messages in this state: `new`, `handled`, `spam` or `unconfirmed`."], q: ["string", "Only messages whose address, name or text contains this."], per_page: ["integer", "At most this many (default 100, up to 500)."], page: ["integer", "Page number, from 1."] }, | |
| 1642 | + }, | |
| 1643 | + "GET /diffinery/admin/contact/{id}": { summary: "Get a contact message", description: "One message, as in the list. 404 when there is none, or for anyone but a site administrator.", access: "site-admin" }, | |
| 1644 | + "PATCH /diffinery/admin/contact/{id}": { | |
| 1645 | + summary: "Mark a contact message handled or spam, or note it", | |
| 1646 | + description: | |
| 1647 | + "`handled` or `spam` marks it; `new` reopens it (as `unconfirmed` if its address never confirmed it). `note` sets the note for the other site administrators (an empty one clears it; leaving it out keeps it). Nobody is emailed. Recorded in the audit log as `contact.handle`, `contact.spam`, `contact.reopen` or `contact.note`. While a message from an address is `spam`, new messages from that address are dropped without an email. 404 for anyone but a site administrator.", | |
| 1648 | + access: "site-admin", | |
| 1649 | + body: { state: ["string", "`handled`, `spam`, or `new` to reopen it."], note: ["string", "For the other site administrators, up to 2,000 characters."] }, | |
| 1650 | + }, | |
| 1651 | + "DELETE /diffinery/admin/contact/{id}": { | |
| 1652 | + summary: "Delete a contact message", | |
| 1653 | + description: "Removes the message, for someone who asks for their data to be removed. Recorded in the audit log as `contact.delete`, without the message or its address. 404 for anyone but a site administrator.", | |
| 1654 | + access: "site-admin", | |
| 1655 | + status: 204, | |
| 1656 | + }, | |
| 1632 | 1657 | "GET /diffinery/status": { |
| 1633 | 1658 | summary: "Get the instance's status", |
| 1634 | 1659 | description: |
addedapps/diffinery/src/http/contact.ts+59−0View file
| @@ -1,0 +1,59 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import type { Account } from "../lib/directory.js"; | |
| 5 | +import { ContactMessages, MESSAGE_STATES, changeMessage, deleteMessage, messageJson, type MessageState } from "../lib/contact.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Contact messages over the REST API (lib/contact.ts), for site administrators only. | |
| 10 | + * To anyone else these endpoints don't exist (404), like the messages themselves. | |
| 11 | + */ | |
| 12 | +export const contactApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); | |
| 13 | + | |
| 14 | +const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => { | |
| 15 | + const a = requireAccount(c); | |
| 16 | + if (!a.site_admin || a.kind !== "user") throw new HttpError(404, "not found"); | |
| 17 | + requireAccountScope(c, scope); | |
| 18 | + return a; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +const audit = (c: AppContext) => (e: Parameters<AppContext["var"]["dir"]["audit"]>[0]) => c.get("dir").audit(e); | |
| 22 | + | |
| 23 | +contactApi.get("/diffinery/admin/contact", async (c) => { | |
| 24 | + siteAdmin(c, "read"); | |
| 25 | + const s = c.req.query("state"); | |
| 26 | + if (s && !MESSAGE_STATES.includes(s as MessageState)) throw new HttpError(422, `state must be one of ${MESSAGE_STATES.join(", ")}`); | |
| 27 | + const n = Number(c.req.query("per_page") ?? 100); | |
| 28 | + const page = Math.max(1, Math.min(1000, Number(c.req.query("page") ?? 1) || 1)); | |
| 29 | + const limit = Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 500) : 100; | |
| 30 | + const { items } = await new ContactMessages(c.env.DB).list({ state: (s as MessageState) || undefined, q: c.req.query("q") ?? "", limit, offset: (page - 1) * limit }); | |
| 31 | + return c.json(items.map((m) => messageJson(c.env, m))); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +contactApi.get("/diffinery/admin/contact/:id", async (c) => { | |
| 35 | + siteAdmin(c, "read"); | |
| 36 | + const m = await new ContactMessages(c.env.DB).get(c.req.param("id")); | |
| 37 | + if (!m) throw new HttpError(404, "no contact message with that id"); | |
| 38 | + return c.json(messageJson(c.env, m)); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +/** Mark a message handled or spam, reopen it (`{"state": "new"}`), or set its note. Audited; nobody is emailed. */ | |
| 42 | +contactApi.patch("/diffinery/admin/contact/:id", async (c) => { | |
| 43 | + const me = siteAdmin(c, "admin"); | |
| 44 | + let b: { state?: unknown; note?: unknown }; | |
| 45 | + try { | |
| 46 | + b = (await c.req.json()) as typeof b; | |
| 47 | + } catch { | |
| 48 | + throw new HttpError(400, "request body must be JSON"); | |
| 49 | + } | |
| 50 | + if (!b || typeof b !== "object") throw new HttpError(422, 'send {"state": "handled"}, "spam" or "new", a "note", or both'); | |
| 51 | + return c.json(messageJson(c.env, await changeMessage(c.env.DB, audit(c), me, c.req.param("id"), { state: b.state, note: b.note }, "api"))); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +/** Delete a message (data removal). Audited without its content. */ | |
| 55 | +contactApi.delete("/diffinery/admin/contact/:id", async (c) => { | |
| 56 | + const me = siteAdmin(c, "admin"); | |
| 57 | + await deleteMessage(c.env.DB, audit(c), me, c.req.param("id"), "api"); | |
| 58 | + return c.body(null, 204); | |
| 59 | +}); |
addedapps/diffinery/src/lib/contact.ts+372−0View file
| @@ -1,0 +1,372 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Account } from "./directory.js"; | |
| 3 | +import { normalEmail } from "../auth/signup.js"; | |
| 4 | +import { enqueueMail } from "../mail/send.js"; | |
| 5 | +import type { MailJob } from "../mail/types.js"; | |
| 6 | +import { HttpError, newId, now, randomToken, sha256 } from "./util.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Contact messages: anyone can write to an instance's site administrators at /contact | |
| 10 | + * (web/contact.tsx), signed in or not. Someone who isn't signed in passes Cloudflare Turnstile, a | |
| 11 | + * hidden honeypot field and a signed timing stamp (the early access form's protections), and their | |
| 12 | + * message stays unconfirmed until its address follows the emailed link: so nobody can put words in | |
| 13 | + * someone else's name, and replies only ever go to an inbox that asked for them. A signed-in person's | |
| 14 | + * message arrives confirmed, from their account's address. | |
| 15 | + * | |
| 16 | + * A confirmed message is new until a site administrator marks it handled or spam, on Site | |
| 17 | + * administration → Contact messages. Each one emails the site administrators (from the sender's | |
| 18 | + * address as Reply-To, so they can answer from their mailbox) and raises one in-app notification. | |
| 19 | + * | |
| 20 | + * The confirmation email never repeats anything typed into the form, so the form can't be used to | |
| 21 | + * send someone else a message through Diffinery. An address with a message marked spam gets nothing | |
| 22 | + * through until that message is put back or deleted. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +export type MessageState = "unconfirmed" | "new" | "handled" | "spam"; | |
| 26 | +export const MESSAGE_STATES: readonly MessageState[] = ["new", "handled", "spam", "unconfirmed"]; | |
| 27 | + | |
| 28 | +/** Longest name, message and note, in characters, and the shortest message. Longer is refused, never cut. */ | |
| 29 | +export const MAX_NAME = 100; | |
| 30 | +export const MAX_MESSAGE = 5_000; | |
| 31 | +export const MIN_MESSAGE = 10; | |
| 32 | +export const MAX_NOTE = 2_000; | |
| 33 | +/** Links in one message: spam carries many, people rarely need more. */ | |
| 34 | +export const MAX_LINKS = 5; | |
| 35 | + | |
| 36 | +/** What a message is about: the only values the topic takes. */ | |
| 37 | +export const TOPICS = [ | |
| 38 | + { value: "question", label: "A question about Diffinery" }, | |
| 39 | + { value: "account", label: "My account or invitation" }, | |
| 40 | + { value: "self-hosting", label: "Running my own Diffinery" }, | |
| 41 | + { value: "feedback", label: "Feedback or an idea" }, | |
| 42 | + { value: "partnership", label: "Partnerships and integrations" }, | |
| 43 | + { value: "press", label: "Press and media" }, | |
| 44 | + { value: "privacy", label: "Privacy and my data" }, | |
| 45 | + { value: "other", label: "Something else" }, | |
| 46 | +] as const; | |
| 47 | +export type Topic = (typeof TOPICS)[number]["value"]; | |
| 48 | +export const topicLabel = (v: string): string => TOPICS.find((t) => t.value === v)?.label ?? v; | |
| 49 | +export const isTopic = (v: string): v is Topic => TOPICS.some((t) => t.value === v); | |
| 50 | + | |
| 51 | +/** How long a confirmation link works, and how long unconfirmed, spam and handled messages are kept. */ | |
| 52 | +export const CONFIRM_HOURS = 48; | |
| 53 | +export const UNCONFIRMED_DAYS = 7; | |
| 54 | +export const SPAM_DAYS = 30; | |
| 55 | +export const HANDLED_DAYS = 365; | |
| 56 | + | |
| 57 | +/** In 24 hours: submissions from one IP address, emails to one address, and emails from the form in total (people not signed in). */ | |
| 58 | +export const SUBMISSIONS_PER_IP_PER_DAY = 10; | |
| 59 | +export const EMAILS_PER_ADDRESS_PER_DAY = 3; | |
| 60 | +export const EMAILS_PER_DAY = 300; | |
| 61 | +/** In 24 hours: messages from one signed-in person. */ | |
| 62 | +export const MESSAGES_PER_ACCOUNT_PER_DAY = 20; | |
| 63 | + | |
| 64 | +export interface ContactMessage { | |
| 65 | + id: string; | |
| 66 | + email: string; | |
| 67 | + name: string; | |
| 68 | + topic: string; | |
| 69 | + message: string; | |
| 70 | + account_id: string | null; | |
| 71 | + sender_login: string | null; | |
| 72 | + state: MessageState; | |
| 73 | + confirm_hash: string | null; | |
| 74 | + confirm_expires_at: string | null; | |
| 75 | + confirmed_at: string | null; | |
| 76 | + note: string | null; | |
| 77 | + updated_by: string | null; | |
| 78 | + handled_at: string | null; | |
| 79 | + created_at: string; | |
| 80 | + updated_at: string; | |
| 81 | +} | |
| 82 | + | |
| 83 | +export interface MessageInput { | |
| 84 | + /** Null for a signed-in sender, whose account's address is used. */ | |
| 85 | + email: string | null; | |
| 86 | + name: string; | |
| 87 | + topic: Topic; | |
| 88 | + message: string; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** Control characters (except tab and newline) and bidirectional overrides, which could disguise text on the admin page. */ | |
| 92 | +// eslint-disable-next-line no-control-regex | |
| 93 | +const INVISIBLE = /[\u0000-\u0008\u000B-\u001F\u007F--]/g; | |
| 94 | + | |
| 95 | +const LINK = /\bhttps?:\/\/|\bwww\./gi; | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * Check what someone typed: their name, an email address (only when not signed in), a topic from the | |
| 99 | + * list, and the message. One-line fields lose extra spaces; the message keeps its lines. Too long is | |
| 100 | + * refused with the limit, never cut. | |
| 101 | + */ | |
| 102 | +export function validateMessage(raw: { email?: unknown; name?: unknown; topic?: unknown; message?: unknown }, signedIn: boolean): MessageInput { | |
| 103 | + const str = (v: unknown) => (typeof v === "string" ? v.replace(INVISIBLE, "") : ""); | |
| 104 | + const line = (v: unknown) => str(v).replace(/\s+/g, " ").trim(); | |
| 105 | + const name = line(raw.name); | |
| 106 | + const topic = line(raw.topic); | |
| 107 | + const message = str(raw.message) | |
| 108 | + .replace(/\r\n?/g, "\n") | |
| 109 | + .replace(/\t/g, " ") | |
| 110 | + .replace(/\n{3,}/g, "\n\n") | |
| 111 | + .trim(); | |
| 112 | + let email: string | null = null; | |
| 113 | + if (!signedIn) { | |
| 114 | + const typed = line(raw.email); | |
| 115 | + if (!typed) throw new HttpError(422, "Enter your email address, so we can reply."); | |
| 116 | + email = normalEmail(typed); | |
| 117 | + if (!email) throw new HttpError(422, "Enter a valid email address, like you@example.com."); | |
| 118 | + } | |
| 119 | + if (!name) throw new HttpError(422, "Enter your name."); | |
| 120 | + if (name.length > MAX_NAME) throw new HttpError(422, `Keep your name to ${MAX_NAME} characters.`); | |
| 121 | + if (!topic) throw new HttpError(422, "Choose what your message is about."); | |
| 122 | + if (!isTopic(topic)) throw new HttpError(422, "Choose what your message is about from the list."); | |
| 123 | + if (!message) throw new HttpError(422, "Write your message."); | |
| 124 | + if (message.length < MIN_MESSAGE) throw new HttpError(422, `Write a little more: at least ${MIN_MESSAGE} characters.`); | |
| 125 | + if (message.length > MAX_MESSAGE) throw new HttpError(422, `Keep your message to ${MAX_MESSAGE.toLocaleString("en")} characters. Offer to send more once we reply.`); | |
| 126 | + if ((message.match(LINK) ?? []).length > MAX_LINKS) throw new HttpError(422, `Keep your message to ${MAX_LINKS} links or fewer.`); | |
| 127 | + return { email, name, topic, message }; | |
| 128 | +} | |
| 129 | + | |
| 130 | +// ---------------------------------------------------------------- storage | |
| 131 | + | |
| 132 | +/** Only hashes of the sender's IP address and of the typed address are kept for the daily limits. */ | |
| 133 | +export const ipHash = (ip: string | null | undefined): Promise<string> => sha256(`diffinery-contact-ip:${ip || "unknown"}`); | |
| 134 | +export const emailHash = (email: string): Promise<string> => sha256(`diffinery-contact-email:${email.toLowerCase()}`); | |
| 135 | + | |
| 136 | +const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); | |
| 137 | + | |
| 138 | +/** Contact messages in D1. */ | |
| 139 | +export class ContactMessages { | |
| 140 | + constructor(private db: D1Database) {} | |
| 141 | + | |
| 142 | + async get(id: string): Promise<ContactMessage | null> { | |
| 143 | + if (!/^[0-9a-f]{32}$/.test(id)) return null; | |
| 144 | + return this.db.prepare("SELECT * FROM contact_messages WHERE id = ?").bind(id).first<ContactMessage>(); | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Newest first, optionally in one state and matching `q` (in the address, name or message). | |
| 149 | + * One more than `limit` is read, so callers know whether there is a next page. | |
| 150 | + */ | |
| 151 | + async list(o: { state?: MessageState; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: ContactMessage[]; more: boolean }> { | |
| 152 | + const limit = Math.min(Math.max(o.limit ?? 50, 1), 500); | |
| 153 | + const offset = Math.min(Math.max(o.offset ?? 0, 0), 100_000); | |
| 154 | + const where: string[] = []; | |
| 155 | + const binds: unknown[] = []; | |
| 156 | + if (o.state) { | |
| 157 | + where.push("state = ?"); | |
| 158 | + binds.push(o.state); | |
| 159 | + } | |
| 160 | + const q = (o.q ?? "").trim().slice(0, 100); | |
| 161 | + if (q) { | |
| 162 | + const like = `%${likeEscape(q)}%`; | |
| 163 | + where.push("(email LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\')"); | |
| 164 | + binds.push(like, like, like); | |
| 165 | + } | |
| 166 | + const rows = ( | |
| 167 | + await this.db | |
| 168 | + .prepare(`SELECT * FROM contact_messages${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`) | |
| 169 | + .bind(...binds, limit + 1, offset) | |
| 170 | + .all<ContactMessage>() | |
| 171 | + ).results; | |
| 172 | + return { items: rows.slice(0, limit), more: rows.length > limit }; | |
| 173 | + } | |
| 174 | + | |
| 175 | + async counts(): Promise<Record<MessageState, number>> { | |
| 176 | + const rows = (await this.db.prepare("SELECT state, count(*) AS n FROM contact_messages GROUP BY state").all<{ state: MessageState; n: number }>()).results; | |
| 177 | + const out: Record<MessageState, number> = { unconfirmed: 0, new: 0, handled: 0, spam: 0 }; | |
| 178 | + for (const r of rows) out[r.state] = r.n; | |
| 179 | + return out; | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** New messages waiting for a site administrator: the count on the Site administration menu. */ | |
| 183 | + async waiting(): Promise<number> { | |
| 184 | + return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE state = 'new'").first<{ n: number }>())?.n ?? 0; | |
| 185 | + } | |
| 186 | + | |
| 187 | + /** Whether a site administrator marked a message from this address as spam (and it's still kept). */ | |
| 188 | + async spammer(email: string): Promise<boolean> { | |
| 189 | + return !!(await this.db.prepare("SELECT 1 AS x FROM contact_messages WHERE email = ? AND state = 'spam' LIMIT 1").bind(email).first()); | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** Submissions from this IP address, emails to this address, and emails in total, in the last 24 hours. */ | |
| 193 | + async sentToday(ip: string, email: string): Promise<{ ip: number; email: number; all: number }> { | |
| 194 | + const since = new Date(Date.now() - 86400_000).toISOString(); | |
| 195 | + const r = await this.db | |
| 196 | + .prepare( | |
| 197 | + "SELECT (SELECT count(*) FROM contact_attempts WHERE ip_hash = ? AND created_at > ?) AS ip, (SELECT count(*) FROM contact_attempts WHERE email_hash = ? AND emailed = 1 AND created_at > ?) AS email, (SELECT count(*) FROM contact_attempts WHERE emailed = 1 AND created_at > ?) AS a", | |
| 198 | + ) | |
| 199 | + .bind(ip, since, email, since, since) | |
| 200 | + .first<{ ip: number; email: number; a: number }>(); | |
| 201 | + return { ip: r?.ip ?? 0, email: r?.email ?? 0, all: r?.a ?? 0 }; | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** Messages from this signed-in person in the last 24 hours. */ | |
| 205 | + async fromAccountToday(accountId: string): Promise<number> { | |
| 206 | + const since = new Date(Date.now() - 86400_000).toISOString(); | |
| 207 | + return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE account_id = ? AND created_at > ?").bind(accountId, since).first<{ n: number }>())?.n ?? 0; | |
| 208 | + } | |
| 209 | + | |
| 210 | + async attempt(ip: string, email: string, emailed: boolean): Promise<void> { | |
| 211 | + await this.db.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), ip, email, emailed ? 1 : 0, now()).run(); | |
| 212 | + } | |
| 213 | +} | |
| 214 | + | |
| 215 | +// ---------------------------------------------------------------- sending and confirming | |
| 216 | + | |
| 217 | +type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>; | |
| 218 | + | |
| 219 | +/** What a submission did, for tests and logs; the visitor sees the same page whatever it is. */ | |
| 220 | +export type Outcome = "confirm" | "sent" | "blocked" | "limited"; | |
| 221 | + | |
| 222 | +/** | |
| 223 | + * Take a validated message. `sender` is the signed-in person (their message arrives new, they get a | |
| 224 | + * receipt and the site administrators hear about it now), or null: then the message is stored | |
| 225 | + * unconfirmed and the typed address gets a link to confirm it. | |
| 226 | + * | |
| 227 | + * Refuses (429) when this network or this person sent too many today. Otherwise the visitor sees the | |
| 228 | + * same page whatever happens; no email is sent and nothing is stored once the address had | |
| 229 | + * EMAILS_PER_ADDRESS_PER_DAY today or the form EMAILS_PER_DAY in total, or when a message from the | |
| 230 | + * address is marked spam. | |
| 231 | + */ | |
| 232 | +export async function submitMessage(env: Env, audit: Audit, input: MessageInput, sender: Account | null, ip: string | null): Promise<Outcome> { | |
| 233 | + const store = new ContactMessages(env.DB); | |
| 234 | + const at = now(); | |
| 235 | + if (sender) { | |
| 236 | + if ((await store.fromAccountToday(sender.id)) >= MESSAGES_PER_ACCOUNT_PER_DAY) throw new HttpError(429, `You have sent ${MESSAGES_PER_ACCOUNT_PER_DAY} messages today. We'll reply to those first; try again tomorrow.`); | |
| 237 | + const email = sender.email ?? ""; | |
| 238 | + if (!email) throw new HttpError(422, "Your account has no email address to reply to. Add one in Settings → Email first."); | |
| 239 | + if (await store.spammer(email)) return "blocked"; | |
| 240 | + const id = newId(); | |
| 241 | + await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, account_id, sender_login, state, confirmed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)") | |
| 242 | + .bind(id, email, input.name, input.topic, input.message, sender.id, sender.login, at, at, at) | |
| 243 | + .run(); | |
| 244 | + await audit({ actorId: sender.id, actorLogin: sender.login, action: "contact.create", target: id, detail: { signed_in: true } }); | |
| 245 | + await enqueueMail(env, [{ kind: "contact_received", data: { id, topic: topicLabel(input.topic), message: input.message }, to: { accountId: sender.id }, reason: "account", at }]); | |
| 246 | + await notifyAdmins(env, { id, email, name: input.name, topic: input.topic, message: input.message, sender_login: sender.login, created_at: at }); | |
| 247 | + return "sent"; | |
| 248 | + } | |
| 249 | + | |
| 250 | + const email = input.email!; | |
| 251 | + const [ipH, emailH] = await Promise.all([ipHash(ip), emailHash(email)]); | |
| 252 | + const sent = await store.sentToday(ipH, emailH); | |
| 253 | + if (sent.ip >= SUBMISSIONS_PER_IP_PER_DAY) throw new HttpError(429, "Too many messages from your network today. Please try again tomorrow."); | |
| 254 | + if (sent.email >= EMAILS_PER_ADDRESS_PER_DAY || sent.all >= EMAILS_PER_DAY) { | |
| 255 | + await store.attempt(ipH, emailH, false); | |
| 256 | + return "limited"; | |
| 257 | + } | |
| 258 | + if (await store.spammer(email)) { | |
| 259 | + await store.attempt(ipH, emailH, false); | |
| 260 | + return "blocked"; | |
| 261 | + } | |
| 262 | + const id = newId(); | |
| 263 | + const token = randomToken(24); | |
| 264 | + const expiresAt = new Date(Date.now() + CONFIRM_HOURS * 3600_000).toISOString(); | |
| 265 | + await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, state, confirm_hash, confirm_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'unconfirmed', ?, ?, ?, ?)") | |
| 266 | + .bind(id, email, input.name, input.topic, input.message, await sha256(token), expiresAt, at, at) | |
| 267 | + .run(); | |
| 268 | + await store.attempt(ipH, emailH, true); | |
| 269 | + await audit({ action: "contact.create", target: id, detail: { signed_in: false } }); | |
| 270 | + // The token rides in the fragment, which browsers never send, so request logs never hold it. | |
| 271 | + await enqueueMail(env, [{ kind: "contact_confirm", data: { url: `${env.PUBLIC_URL}/contact/confirm#${token}`, expiresAt, topic: topicLabel(input.topic) }, to: { email }, reason: "contact", at }]); | |
| 272 | + return "confirm"; | |
| 273 | +} | |
| 274 | + | |
| 275 | +/** | |
| 276 | + * Confirm the message a link's token belongs to: it becomes new, and the site administrators hear | |
| 277 | + * about it. Null when the token is unknown, already used or expired. | |
| 278 | + */ | |
| 279 | +export async function confirmMessage(env: Env, audit: Audit, token: string): Promise<ContactMessage | null> { | |
| 280 | + if (!/^[A-Za-z0-9_-]{20,100}$/.test(token)) return null; | |
| 281 | + const hash = await sha256(token); | |
| 282 | + const at = now(); | |
| 283 | + const row = await env.DB.prepare("SELECT * FROM contact_messages WHERE confirm_hash = ? AND state = 'unconfirmed' AND confirm_expires_at > ?").bind(hash, at).first<ContactMessage>(); | |
| 284 | + if (!row) return null; | |
| 285 | + const done = await env.DB.prepare("UPDATE contact_messages SET state = 'new', confirmed_at = ?, confirm_hash = NULL, confirm_expires_at = NULL, updated_at = ? WHERE id = ? AND confirm_hash = ? AND state = 'unconfirmed'").bind(at, at, row.id, hash).run(); | |
| 286 | + if (!done.meta.changes) return null; | |
| 287 | + await audit({ action: "contact.confirm", target: row.id }); | |
| 288 | + await notifyAdmins(env, row); | |
| 289 | + return { ...row, state: "new", confirmed_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at }; | |
| 290 | +} | |
| 291 | + | |
| 292 | +/** | |
| 293 | + * Tell every active site administrator about a new message: an email each (the "Organizations" group | |
| 294 | + * of their notification settings, with the sender as Reply-To), and one in-app notification, which | |
| 295 | + * stays one however many messages arrive until it is read. | |
| 296 | + */ | |
| 297 | +export async function notifyAdmins(env: Env, m: Pick<ContactMessage, "id" | "email" | "name" | "topic" | "message" | "sender_login" | "created_at">): Promise<void> { | |
| 298 | + const db = env.DB; | |
| 299 | + const admins = (await db.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND suspended_at IS NULL LIMIT 50").all<{ id: string }>()).results; | |
| 300 | + if (!admins.length) return; | |
| 301 | + const at = now(); | |
| 302 | + await db.batch( | |
| 303 | + admins.map((a) => | |
| 304 | + db | |
| 305 | + .prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) SELECT ?, ?, NULL, 'contact', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM notifications WHERE account_id = ? AND kind = 'contact' AND read_at IS NULL)") | |
| 306 | + .bind(newId(), a.id, "New contact messages are waiting for you", "/settings/admin/contact", at, a.id), | |
| 307 | + ), | |
| 308 | + ); | |
| 309 | + await enqueueMail( | |
| 310 | + env, | |
| 311 | + admins.map((a): MailJob => ({ kind: "contact_message", data: { id: m.id, name: m.name, email: m.email, login: m.sender_login, topic: topicLabel(m.topic), message: m.message }, to: { accountId: a.id }, reason: "admin", at: m.created_at })), | |
| 312 | + ); | |
| 313 | +} | |
| 314 | + | |
| 315 | +// ---------------------------------------------------------------- site administrators | |
| 316 | + | |
| 317 | +/** A message that exists, or 404. */ | |
| 318 | +async function existing(db: D1Database, id: string): Promise<ContactMessage> { | |
| 319 | + const m = await new ContactMessages(db).get(id); | |
| 320 | + if (!m) throw new HttpError(404, "no contact message with that id"); | |
| 321 | + return m; | |
| 322 | +} | |
| 323 | + | |
| 324 | +const ACTION: Record<"new" | "handled" | "spam", string> = { new: "contact.reopen", handled: "contact.handle", spam: "contact.spam" }; | |
| 325 | + | |
| 326 | +/** | |
| 327 | + * Mark a message handled or spam, or put it back (`new`: new again, or unconfirmed if its address | |
| 328 | + * never confirmed it), and keep a note for the other site administrators when one is given (an empty | |
| 329 | + * note clears it; leaving it out keeps it). Nobody is emailed. Audited, without the message. | |
| 330 | + */ | |
| 331 | +export async function changeMessage(db: D1Database, audit: Audit, me: Account, id: string, change: { state?: unknown; note?: unknown }, via: "web" | "api"): Promise<ContactMessage> { | |
| 332 | + const { state, note } = change; | |
| 333 | + if (state !== undefined && state !== "new" && state !== "handled" && state !== "spam") throw new HttpError(422, "state must be one of new, handled, spam"); | |
| 334 | + if (note !== undefined && note !== null && typeof note !== "string") throw new HttpError(422, "note must be a string"); | |
| 335 | + if (state === undefined && note === undefined) throw new HttpError(422, "send a state, a note, or both"); | |
| 336 | + const text = typeof note === "string" ? note.replace(/\r\n?/g, "\n").trim() : ""; | |
| 337 | + if (text.length > MAX_NOTE) throw new HttpError(422, `Keep the note to ${MAX_NOTE.toLocaleString("en")} characters.`); | |
| 338 | + const m = await existing(db, id); | |
| 339 | + const at = now(); | |
| 340 | + const next: MessageState = state === undefined ? m.state : state === "new" ? (m.confirmed_at ? "new" : "unconfirmed") : state; | |
| 341 | + const newNote = note === undefined ? m.note : text || null; | |
| 342 | + const handledAt = next === "handled" || next === "spam" ? (next === m.state ? m.handled_at : at) : null; | |
| 343 | + await db.prepare("UPDATE contact_messages SET state = ?, note = ?, updated_by = ?, handled_at = ?, updated_at = ? WHERE id = ?").bind(next, newNote, me.login, handledAt, at, m.id).run(); | |
| 344 | + if (next !== m.state) await audit({ actorId: me.id, actorLogin: me.login, action: ACTION[state as "new" | "handled" | "spam"], target: m.id, detail: { from: m.state, to: next, via } }); | |
| 345 | + if (newNote !== m.note) await audit({ actorId: me.id, actorLogin: me.login, action: "contact.note", target: m.id, detail: { via } }); | |
| 346 | + return { ...m, state: next, note: newNote, updated_by: me.login, handled_at: handledAt, updated_at: at }; | |
| 347 | +} | |
| 348 | + | |
| 349 | +/** Delete a message, for someone who asks for their data to be removed. The audit log records that it happened, never what it said or its address. */ | |
| 350 | +export async function deleteMessage(db: D1Database, audit: Audit, me: Account, id: string, via: "web" | "api"): Promise<void> { | |
| 351 | + const m = await existing(db, id); | |
| 352 | + await db.prepare("DELETE FROM contact_messages WHERE id = ?").bind(m.id).run(); | |
| 353 | + await audit({ actorId: me.id, actorLogin: me.login, action: "contact.delete", target: m.id, detail: { state: m.state, via } }); | |
| 354 | +} | |
| 355 | + | |
| 356 | +/** The API's view of a message. */ | |
| 357 | +export const messageJson = (env: Pick<Env, "PUBLIC_URL">, m: ContactMessage) => ({ | |
| 358 | + id: m.id, | |
| 359 | + state: m.state, | |
| 360 | + email: m.email, | |
| 361 | + name: m.name, | |
| 362 | + topic: m.topic, | |
| 363 | + message: m.message, | |
| 364 | + sender: m.sender_login, | |
| 365 | + confirmed_at: m.confirmed_at, | |
| 366 | + note: m.note, | |
| 367 | + updated_by: m.updated_by, | |
| 368 | + handled_at: m.handled_at, | |
| 369 | + created_at: m.created_at, | |
| 370 | + updated_at: m.updated_at, | |
| 371 | + html_url: `${env.PUBLIC_URL}/settings/admin/contact/${m.id}`, | |
| 372 | +}); |
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | * |
| 11 | 11 | * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private |
| 12 | 12 | * security reports (/security/report), early access requests and their confirmations |
| 13 | - * (/early-access and its confirm and remove pages), CLI sign-ins (starting one, typing or approving a | |
| 13 | + * (/early-access and its confirm and remove pages), contact messages and their confirmations | |
| 14 | + * (/contact and /contact/confirm), CLI sign-ins (starting one, typing or approving a | |
| 14 | 15 | * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address. |
| 15 | 16 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 16 | 17 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account; from an |
| @@ -35,7 +36,7 @@ | ||
| 35 | 36 | |
| 36 | 37 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 37 | 38 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 38 | -const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/; | |
| 39 | +const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|contact(?:\/confirm)?$|-\/cli\/device$|oauth\/register$)/; | |
| 39 | 40 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 40 | 41 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 41 | 42 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
| @@ -97,7 +98,7 @@ | ||
| 97 | 98 | } |
| 98 | 99 | |
| 99 | 100 | const WHAT: Record<Bucket, string> = { |
| 100 | - RL_AUTH: "sign-in attempts, security reports and early access requests from your network", | |
| 101 | + RL_AUTH: "sign-in attempts, security reports, early access requests and contact messages from your network", | |
| 101 | 102 | RL_ANON: "requests from your network without signing in", |
| 102 | 103 | RL_ACCOUNT: "requests from your account", |
| 103 | 104 | RL_CREATE: "new repositories, issues, pull requests and comments from your account", |
modifiedapps/diffinery/src/lib/retention.ts+13−3View file
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { HANDLED_DAYS, SPAM_DAYS, UNCONFIRMED_DAYS as CONTACT_UNCONFIRMED_DAYS } from "./contact.js"; | |
| 1 | 2 | import { UNCONFIRMED_DAYS } from "./early-access.js"; |
| 2 | 3 | |
| 3 | 4 | /** |
| @@ -10,21 +11,28 @@ | ||
| 10 | 11 | * - hosted compute records (lib/quotas.ts) ended more than COMPUTE_RETENTION_DAYS ago, past any month |
| 11 | 12 | * the usage pages show; |
| 12 | 13 | * - early access requests (lib/early-access.ts) never confirmed within UNCONFIRMED_DAYS of their last |
| 13 | - * submission, and the form's daily-limit rows once they are a day old. | |
| 14 | + * submission, and the form's daily-limit rows once they are a day old; | |
| 15 | + * - contact messages (lib/contact.ts) never confirmed within CONTACT_UNCONFIRMED_DAYS, marked spam more | |
| 16 | + * than SPAM_DAYS ago or handled more than HANDLED_DAYS ago, and the form's daily-limit rows once they | |
| 17 | + * are a day old. | |
| 14 | 18 | */ |
| 15 | 19 | export const NOTIFICATION_RETENTION_DAYS = 180; |
| 16 | 20 | export const COMPUTE_RETENTION_DAYS = 400; |
| 17 | 21 | /** Rows deleted per table per run: an hourly run keeps up with 120,000 new rows a day per table. */ |
| 18 | 22 | export const RETENTION_BATCH = 5000; |
| 19 | 23 | |
| 20 | -export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts", number>> { | |
| 24 | +export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts" | "contact_messages" | "contact_attempts", number>> { | |
| 21 | 25 | const ago = (days: number) => new Date(at.getTime() - days * 86400_000).toISOString(); |
| 22 | - const [sessions, notifications, compute, early, attempts] = await db.batch([ | |
| 26 | + const [sessions, notifications, compute, early, attempts, contact, contactAttempts] = await db.batch([ | |
| 23 | 27 | db.prepare("DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE expires_at < ? LIMIT ?)").bind(ago(1), batch), |
| 24 | 28 | db.prepare("DELETE FROM notifications WHERE id IN (SELECT id FROM notifications WHERE created_at < ? LIMIT ?)").bind(ago(NOTIFICATION_RETENTION_DAYS), batch), |
| 25 | 29 | db.prepare("DELETE FROM compute_usage WHERE id IN (SELECT id FROM compute_usage WHERE ended_at < ? LIMIT ?)").bind(ago(COMPUTE_RETENTION_DAYS), batch), |
| 26 | 30 | db.prepare("DELETE FROM early_access WHERE id IN (SELECT id FROM early_access WHERE state = 'unconfirmed' AND updated_at < ? LIMIT ?)").bind(ago(UNCONFIRMED_DAYS), batch), |
| 27 | 31 | db.prepare("DELETE FROM early_access_attempts WHERE id IN (SELECT id FROM early_access_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch), |
| 32 | + db | |
| 33 | + .prepare("DELETE FROM contact_messages WHERE id IN (SELECT id FROM contact_messages WHERE (state = 'unconfirmed' AND created_at < ?) OR (state = 'spam' AND handled_at < ?) OR (state = 'handled' AND handled_at < ?) LIMIT ?)") | |
| 34 | + .bind(ago(CONTACT_UNCONFIRMED_DAYS), ago(SPAM_DAYS), ago(HANDLED_DAYS), batch), | |
| 35 | + db.prepare("DELETE FROM contact_attempts WHERE id IN (SELECT id FROM contact_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch), | |
| 28 | 36 | ]); |
| 29 | 37 | return { |
| 30 | 38 | sessions: sessions!.meta.changes ?? 0, |
| @@ -32,5 +40,7 @@ | ||
| 32 | 40 | compute_usage: compute!.meta.changes ?? 0, |
| 33 | 41 | early_access: early!.meta.changes ?? 0, |
| 34 | 42 | early_access_attempts: attempts!.meta.changes ?? 0, |
| 43 | + contact_messages: contact!.meta.changes ?? 0, | |
| 44 | + contact_attempts: contactAttempts!.meta.changes ?? 0, | |
| 35 | 45 | }; |
| 36 | 46 | } |
modifiedapps/diffinery/src/lib/util.ts+1−1View file
| @@ -111,7 +111,7 @@ | ||
| 111 | 111 | } |
| 112 | 112 | |
| 113 | 113 | /** Top-level paths the app serves; no account may use them as its login. */ |
| 114 | -export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 114 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "contact", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 115 | 115 | |
| 116 | 116 | /** |
| 117 | 117 | * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account: |
modifiedapps/diffinery/src/mail/prefs.ts+1−1View file
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | actions: { web: boolean; email: boolean; failuresOnly: boolean }; |
| 13 | 13 | /** Repositories created, deleted, or access granted to you. */ |
| 14 | 14 | repos: { email: boolean }; |
| 15 | - /** Organization membership, accepted invitations and (for admins) access requests. */ | |
| 15 | + /** Organization membership, accepted invitations and (for admins) access requests and contact messages. */ | |
| 16 | 16 | orgs: { email: boolean }; |
| 17 | 17 | /** Also email me about my own comments and changes (GitHub's "Include your own updates"). */ |
| 18 | 18 | ownActivity: boolean; |
modifiedapps/diffinery/src/mail/samples.ts+7−0View file
| @@ -94,6 +94,13 @@ | ||
| 94 | 94 | item("early-access-waiting", "Early access: already on the list", { kind: "early_access_known", reason: "early_access", data: { variant: "waiting", since: at } }), |
| 95 | 95 | item("early-access-invited", "Early access: already invited", { kind: "early_access_known", reason: "early_access", data: { variant: "invited", since: at } }), |
| 96 | 96 | item("early-access-account", "Early access: already has an account", { kind: "early_access_known", reason: "early_access", data: { variant: "account", since: at } }), |
| 97 | + item("contact-confirm", "Contact: confirm the message", { kind: "contact_confirm", reason: "contact", data: { url: `${pub}/contact/confirm#sample`, expiresAt: exp, topic: "A question about Diffinery" } }), | |
| 98 | + item("contact-received", "Contact: copy for a signed-in sender", { kind: "contact_received", reason: "account", data: { id: "0123456789abcdef0123456789abcdef", topic: "Running my own Diffinery", message: "Hi! We'd like to run Diffinery for our 40 repositories.\n\nDoes it work with a custom domain on a Cloudflare zone we already have?" } }), | |
| 99 | + item("contact-message", "Contact: new message for site administrators", { | |
| 100 | + kind: "contact_message", | |
| 101 | + reason: "admin", | |
| 102 | + data: { id: "0123456789abcdef0123456789abcdef", name: "Rivera Kim", email: "rivera@example.com", login: null, topic: "Partnerships and integrations", message: "Hello,\n\nWe build a code review tool and would like to list Diffinery as a supported forge. Who should we talk to?\n\nThanks, Rivera" }, | |
| 103 | + }), | |
| 97 | 104 | item("security-report", "Security report", { kind: "security_report", reason: "admin", data: { id: "0123456789abcdef0123456789abcdef", summary: "Stored XSS in release notes" } }), |
| 98 | 105 | item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }), |
| 99 | 106 | item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }), |
modifiedapps/diffinery/src/mail/send.ts+2−0View file
| @@ -91,6 +91,7 @@ | ||
| 91 | 91 | html: string; |
| 92 | 92 | text: string; |
| 93 | 93 | headers: Record<string, string>; |
| 94 | + replyTo?: { name: string; email: string } | string; | |
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | /** Render a job for one recipient: the message plus the headers mail clients use for threading and unsubscribing. */ |
| @@ -143,6 +144,7 @@ | ||
| 143 | 144 | html: toHtml(email, ctx), |
| 144 | 145 | text: toText(email, ctx), |
| 145 | 146 | headers, |
| 147 | + ...(email.replyTo ? { replyTo: email.replyTo.name ? { name: clean(email.replyTo.name), email: email.replyTo.email } : email.replyTo.email } : {}), | |
| 146 | 148 | }; |
| 147 | 149 | } |
| 148 | 150 |
modifiedapps/diffinery/src/mail/templates.ts+57−1View file
| @@ -17,7 +17,9 @@ | ||
| 17 | 17 | | { callout: Inline[]; tone: "info" | "warn" | "danger" } |
| 18 | 18 | | { code: string } |
| 19 | 19 | | { items: Array<{ mark: "ok" | "fail" | "skip" | "dot"; text: Inline[]; url?: string }> } |
| 20 | - | { quote: Inline[] }; | |
| 20 | + | { quote: Inline[] } | |
| 21 | + /** Plain text exactly as someone typed it, lines kept, never read as Markdown or HTML. */ | |
| 22 | + | { plain: string }; | |
| 21 | 23 | |
| 22 | 24 | export interface Email { |
| 23 | 25 | subject: string; |
| @@ -35,6 +37,8 @@ | ||
| 35 | 37 | thread?: string; |
| 36 | 38 | /** Main link (also "View it on Diffinery" in the footer). */ |
| 37 | 39 | url?: string; |
| 40 | + /** Where the recipient's reply goes, when it isn't to us (a contact message's confirmed sender). */ | |
| 41 | + replyTo?: { email: string; name?: string }; | |
| 38 | 42 | } |
| 39 | 43 | |
| 40 | 44 | export interface RenderCtx { |
| @@ -129,6 +133,8 @@ | ||
| 129 | 133 | if ("code" in b) |
| 130 | 134 | return `<pre class="md" style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${C.codeBg};font-family:${MONO};font-size:12.5px;line-height:1.55;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.code)}</pre>`; |
| 131 | 135 | if ("quote" in b) return `<div class="muted" style="margin:0 0 16px;padding:2px 0 2px 14px;border-left:3px solid ${C.border};color:${C.fg2};font-size:14px;line-height:1.6">${inlineHtml(b.quote)}</div>`; |
| 136 | + if ("plain" in b) | |
| 137 | + return `<div class="md box" style="margin:0 0 16px;padding:14px 16px;border:1px solid ${C.border};border-radius:8px;font-size:14px;line-height:1.6;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.plain)}</div>`; | |
| 132 | 138 | const marks = { ok: ["✓", C.tones.green![1]], fail: ["✕", C.tones.red![1]], skip: ["–", C.fg3], dot: ["•", C.fg3] } as const; |
| 133 | 139 | return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.items |
| 134 | 140 | .map( |
| @@ -146,6 +152,7 @@ | ||
| 146 | 152 | if ("callout" in b) return `! ${inlineText(b.callout)}`; |
| 147 | 153 | if ("code" in b) return b.code.split("\n").map((l) => ` ${l}`).join("\n"); |
| 148 | 154 | if ("quote" in b) return `> ${inlineText(b.quote)}`; |
| 155 | + if ("plain" in b) return b.plain.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n"); | |
| 149 | 156 | const marks = { ok: "[ok]", fail: "[failed]", skip: "[skipped]", dot: "-" }; |
| 150 | 157 | return b.items.map((it) => `${marks[it.mark]} ${inlineText(it.text)}${it.url ? ` <${it.url}>` : ""}`).join("\n"); |
| 151 | 158 | } |
| @@ -167,6 +174,7 @@ | ||
| 167 | 174 | account: "You are receiving this because you have a Diffinery account.", |
| 168 | 175 | signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.", |
| 169 | 176 | early_access: "You are receiving this because someone asked for early access to Diffinery with this email address. If it wasn't you, you can ignore this email.", |
| 177 | + contact: "You are receiving this because someone sent a message through Diffinery's contact form with this email address. If it wasn't you, you can ignore this email.", | |
| 170 | 178 | digest: "You are receiving this because you turned on the morning digest in your notification settings.", |
| 171 | 179 | }; |
| 172 | 180 | |
| @@ -840,6 +848,54 @@ | ||
| 840 | 848 | ], |
| 841 | 849 | url: `${pub}/settings/admin`, |
| 842 | 850 | }; |
| 851 | + case "contact_confirm": { | |
| 852 | + // Nothing typed into the form: the address isn't confirmed yet, so this must not carry anyone's words. | |
| 853 | + const d = job.data; | |
| 854 | + return { | |
| 855 | + subject: "[Diffinery] Confirm your message to the Diffinery team", | |
| 856 | + preheader: `Confirm before ${day(d.expiresAt)} and it goes to the team. Nothing is sent without it.`, | |
| 857 | + eyebrow: { text: "Contact", tone: "blue" }, | |
| 858 | + title: ["Confirm your message"], | |
| 859 | + blocks: [ | |
| 860 | + { p: ["Thanks for writing to us. We received a message sent with this email address through the contact form, under the topic ", { b: d.topic }, ". Confirm that it's yours and it goes to the team, who reply to this address."] }, | |
| 861 | + { button: { label: "Confirm my message", url: d.url } }, | |
| 862 | + { p: ["The link works once and expires ", at(d.expiresAt), ". If you didn't write to us, ignore this email: the message is never read and is deleted after a week. Please don't reply to this email; replies aren't read."], muted: true }, | |
| 863 | + ], | |
| 864 | + url: d.url, | |
| 865 | + }; | |
| 866 | + } | |
| 867 | + case "contact_received": { | |
| 868 | + const d = job.data; | |
| 869 | + return { | |
| 870 | + subject: "[Diffinery] We got your message", | |
| 871 | + preheader: `Topic: ${d.topic}. The team replies to this address.`, | |
| 872 | + eyebrow: { text: "Contact", tone: "green" }, | |
| 873 | + title: ["We got your message"], | |
| 874 | + blocks: [ | |
| 875 | + { p: ["Thanks for writing to us. The team reads every message and replies to this address. Here is a copy of what you sent, under the topic ", { b: d.topic }, ":"] }, | |
| 876 | + { plain: d.message }, | |
| 877 | + { p: ["Reference ", { code: d.id }, ". Please don't reply to this email; replies aren't read. To add something, send another message."], muted: true }, | |
| 878 | + ], | |
| 879 | + }; | |
| 880 | + } | |
| 881 | + case "contact_message": { | |
| 882 | + const d = job.data; | |
| 883 | + const url = `${pub}/settings/admin/contact/${d.id}`; | |
| 884 | + return { | |
| 885 | + subject: `[Diffinery] Contact: ${d.topic} from ${d.name}`, | |
| 886 | + preheader: d.message.replace(/\s+/g, " ").slice(0, 140), | |
| 887 | + eyebrow: { text: "Contact message", tone: "blue" }, | |
| 888 | + title: ["New message from ", { b: d.name }], | |
| 889 | + blocks: [ | |
| 890 | + { kv: [["From", d.login ? [d.name, " (", { b: user(d.login) }, ")"] : d.name], ["Email", d.email], ["Topic", d.topic], ["When", at(job.at)]] }, | |
| 891 | + { plain: d.message }, | |
| 892 | + { button: { label: "Open in Diffinery", url } }, | |
| 893 | + { p: [`Reply to this email to answer ${d.email} from your own mailbox, then mark the message handled in Site administration → Contact messages. The address ${d.login ? "belongs to the sender's account" : "was confirmed through an emailed link"}.`], muted: true }, | |
| 894 | + ], | |
| 895 | + url, | |
| 896 | + replyTo: { email: d.email, name: d.name }, | |
| 897 | + }; | |
| 898 | + } | |
| 843 | 899 | case "security_report": { |
| 844 | 900 | // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in. |
| 845 | 901 | const url = `${pub}/settings/admin/security-reports/${job.data.id}`; |
modifiedapps/diffinery/src/mail/types.ts+14−0View file
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | | "account" |
| 23 | 23 | | "signup" |
| 24 | 24 | | "early_access" |
| 25 | + | "contact" | |
| 25 | 26 | | "digest"; |
| 26 | 27 | |
| 27 | 28 | /** |
| @@ -139,6 +140,15 @@ | ||
| 139 | 140 | | { kind: "early_access_remove"; data: { url: string; expiresAt: string } } |
| 140 | 141 | | { kind: "early_access_known"; data: { variant: "waiting" | "invited" | "account"; since: string } } |
| 141 | 142 | | { kind: "security_report"; data: { id: string; summary: string } } |
| 143 | + /** | |
| 144 | + * The link that confirms a message sent at /contact by someone who isn't signed in (lib/contact.ts). Never carries | |
| 145 | + * what they typed. `topic` is the topic's label, from the form's fixed list. | |
| 146 | + */ | |
| 147 | + | { kind: "contact_confirm"; data: { url: string; expiresAt: string; topic: string } } | |
| 148 | + /** A signed-in person's copy of the message they sent at /contact. */ | |
| 149 | + | { kind: "contact_received"; data: { id: string; topic: string; message: string } } | |
| 150 | + /** A confirmed contact message, for each site administrator, with the sender as Reply-To. */ | |
| 151 | + | { kind: "contact_message"; data: { id: string; name: string; email: string; login: string | null; topic: string; message: string } } | |
| 142 | 152 | | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } } |
| 143 | 153 | | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } } |
| 144 | 154 | | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } } |
| @@ -189,12 +199,16 @@ | ||
| 189 | 199 | case "early_access_confirm": |
| 190 | 200 | case "early_access_known": |
| 191 | 201 | case "early_access_remove": |
| 202 | + // The sender's own confirmation link or copy of their message. | |
| 203 | + case "contact_confirm": | |
| 204 | + case "contact_received": | |
| 192 | 205 | // It is about the payer's own model spend, so it always sends. |
| 193 | 206 | case "review_limit_reached": |
| 194 | 207 | case "test": |
| 195 | 208 | return "account"; |
| 196 | 209 | case "invite_accepted": |
| 197 | 210 | case "access_request": |
| 211 | + case "contact_message": | |
| 198 | 212 | case "org_membership": |
| 199 | 213 | return "orgs"; |
| 200 | 214 | case "repo_access": |
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
| @@ -179,7 +179,7 @@ | ||
| 179 | 179 | } |
| 180 | 180 | /> |
| 181 | 181 | <PrefRow title="Repositories" hint="Repositories created or deleted in your organizations, and access granted to you." email={{ name: "repos.email", on: p.repos.email }} /> |
| 182 | - <PrefRow title="Organizations" hint="Your organization role, accepted invitations and access requests (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} /> | |
| 182 | + <PrefRow title="Organizations" hint="Your organization role, accepted invitations, and access requests and contact messages (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} /> | |
| 183 | 183 | <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} /> |
| 184 | 184 | </tbody> |
| 185 | 185 | </table> |
modifiedapps/diffinery/src/web/account.tsx+21−4View file
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js"; |
| 24 | 24 | import { MAX_AVATAR_BYTES } from "../lib/image.js"; |
| 25 | 25 | import { gatherToday } from "../agentic/today.js"; |
| 26 | +import { ContactMessages } from "../lib/contact.js"; | |
| 26 | 27 | import { EarlyAccess } from "../lib/early-access.js"; |
| 27 | 28 | import { COLLAPSED_COOKIE, Rows, Section, TodayView, collapsedSections } from "../agentic/web.js"; |
| 28 | 29 | import { landingPage } from "./site.js"; |
| @@ -158,7 +159,7 @@ | ||
| 158 | 159 | if (kind.endsWith("comment")) return ICONS.comment; |
| 159 | 160 | if (kind === "org_membership") return ICONS.org; |
| 160 | 161 | if (kind === "repo_access") return ICONS.repo; |
| 161 | - if (kind === "invitation" || kind === "early_access") return ICONS.envelope; | |
| 162 | + if (kind === "invitation" || kind === "early_access" || kind === "contact") return ICONS.envelope; | |
| 162 | 163 | return ICONS.bell; |
| 163 | 164 | } |
| 164 | 165 | |
| @@ -634,9 +635,9 @@ | ||
| 634 | 635 | { value: "rebase", label: "Rebase and merge" }, |
| 635 | 636 | ]; |
| 636 | 637 | |
| 637 | -export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "search-engines"; | |
| 638 | +export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "contact" | "search-engines"; | |
| 638 | 639 | /** The site administration pages: they show when hosted compute is paused. */ |
| 639 | -export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"]; | |
| 640 | +export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "contact", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"]; | |
| 640 | 641 | |
| 641 | 642 | /** The number of confirmed early access requests waiting, next to Early access in the sidebar (lib/early-access.ts). */ |
| 642 | 643 | const EarlyAccessWaiting = async ({ db }: { db?: D1Database }) => { |
| @@ -650,6 +651,18 @@ | ||
| 650 | 651 | ); |
| 651 | 652 | }; |
| 652 | 653 | |
| 654 | +/** The number of new contact messages, next to Contact messages in the sidebar (lib/contact.ts). */ | |
| 655 | +const ContactWaiting = async ({ db }: { db?: D1Database }) => { | |
| 656 | + const n = db ? await new ContactMessages(db).waiting().catch(() => 0) : 0; | |
| 657 | + return n ? ( | |
| 658 | + <span class="badge blue" style="margin-left:6px" title={`${n} new message${n === 1 ? "" : "s"}`}> | |
| 659 | + {n} | |
| 660 | + </span> | |
| 661 | + ) : ( | |
| 662 | + <></> | |
| 663 | + ); | |
| 664 | +}; | |
| 665 | + | |
| 653 | 666 | /** On People and invitations: a pointer to the early access requests waiting, when there are any. */ |
| 654 | 667 | const EarlyAccessCallout = async ({ db }: { db: D1Database }) => { |
| 655 | 668 | const n = await new EarlyAccess(db).waiting().catch(() => 0); |
| @@ -667,7 +680,7 @@ | ||
| 667 | 680 | ); |
| 668 | 681 | }; |
| 669 | 682 | |
| 670 | -/** The Settings sidebar. `db` lets it show how many early access requests wait (site administrators only). */ | |
| 683 | +/** The Settings sidebar. `db` lets it show how many early access requests and contact messages wait (site administrators only). */ | |
| 671 | 684 | export const SettingsNav: FC<{ me: Account; tab: SettingsTab; db?: D1Database }> = ({ me, tab, db }) => ( |
| 672 | 685 | <aside class="side vt-side"> |
| 673 | 686 | <section> |
| @@ -738,6 +751,10 @@ | ||
| 738 | 751 | Early access |
| 739 | 752 | <EarlyAccessWaiting db={db} /> |
| 740 | 753 | </a> |
| 754 | + <a href="/settings/admin/contact" class={tab === "contact" ? "on" : ""}> | |
| 755 | + Contact messages | |
| 756 | + <ContactWaiting db={db} /> | |
| 757 | + </a> | |
| 741 | 758 | <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}> |
| 742 | 759 | Reports |
| 743 | 760 | </a> |
modifiedapps/diffinery/src/web/assets.ts+2−0View file
| @@ -328,6 +328,8 @@ | ||
| 328 | 328 | .ea-av.lg{width:48px;height:48px;font-size:20px} |
| 329 | 329 | .ea-use{margin-top:6px;color:var(--fg);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} |
| 330 | 330 | .ea-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end} |
| 331 | +/* The contact form (web/contact.tsx): its card is taller than the heading, so the heading's row keeps its own height. */ | |
| 332 | +@media (min-width:901px){.ea.ct{grid-template-rows:auto 1fr}} | |
| 331 | 333 | @media (max-width:900px){.ea{grid-template-columns:1fr;grid-template-areas:"head" "card" "more";row-gap:24px;margin-top:24px}.ea h1{font-size:28px}.ea-list>li{flex-wrap:wrap}.ea-actions{width:100%;justify-content:flex-start;padding-left:42px}} |
| 332 | 334 | .stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px} |
| 333 | 335 | .stats b{color:var(--fg)} |
addedapps/diffinery/src/web/contact.tsx+595−0View file
| @@ -1,0 +1,595 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { turnstileMode } from "../auth/signup.js"; | |
| 6 | +import { formStamp } from "../lib/early-access.js"; | |
| 7 | +import type { Account, Directory } from "../lib/directory.js"; | |
| 8 | +import { | |
| 9 | + CONFIRM_HOURS, | |
| 10 | + ContactMessages, | |
| 11 | + MAX_MESSAGE, | |
| 12 | + MAX_NAME, | |
| 13 | + MAX_NOTE, | |
| 14 | + MESSAGE_STATES, | |
| 15 | + TOPICS, | |
| 16 | + UNCONFIRMED_DAYS, | |
| 17 | + changeMessage, | |
| 18 | + confirmMessage, | |
| 19 | + deleteMessage, | |
| 20 | + isTopic, | |
| 21 | + submitMessage, | |
| 22 | + topicLabel, | |
| 23 | + validateMessage, | |
| 24 | + type ContactMessage, | |
| 25 | + type MessageState, | |
| 26 | +} from "../lib/contact.js"; | |
| 27 | +import { HttpError } from "../lib/util.js"; | |
| 28 | +import { ICONS } from "./assets.js"; | |
| 29 | +import { SettingsNav } from "./account.js"; | |
| 30 | +import { csrfToken } from "./csrf.js"; | |
| 31 | +import { CONFIRM_SCRIPT, Protections, guard } from "./early-access.js"; | |
| 32 | +import { ComputePausedBanner } from "./kill-switches.js"; | |
| 33 | +import { KSelect } from "./kumo.js"; | |
| 34 | +import { Form, Pager, Time, act, flash, pageNum, post, principalAccount, prm, qs, render, statusOf, type WebApp } from "./ui.js"; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * The contact form in the web UI (lib/contact.ts): /contact for anyone, signed in or not, the page | |
| 38 | + * its confirmation email links to, and the **Contact messages** page of site administration, where | |
| 39 | + * messages are read, marked handled or spam, noted and deleted. | |
| 40 | + */ | |
| 41 | + | |
| 42 | +/** Largest message form, in bytes: the message's cap with room for percent-encoded UTF-8. */ | |
| 43 | +const MAX_FORM_BYTES = 64 * 1024; | |
| 44 | +const BASE = "/settings/admin/contact"; | |
| 45 | + | |
| 46 | +const TURNSTILE_MISSING = "This instance has no Turnstile configured (TURNSTILE_SITE_KEY and TURNSTILE_SECRET), so its contact form takes messages only from people who are signed in."; | |
| 47 | + | |
| 48 | +export const STATE_TEXT: Record<MessageState, string> = { new: "New", handled: "Handled", spam: "Spam", unconfirmed: "Unconfirmed" }; | |
| 49 | +const STATE_BADGE: Record<MessageState, string> = { new: "badge blue", handled: "badge green", spam: "badge red", unconfirmed: "badge" }; | |
| 50 | +const StateBadge: FC<{ state: MessageState }> = ({ state }) => <span class={STATE_BADGE[state]}>{STATE_TEXT[state]}</span>; | |
| 51 | + | |
| 52 | +/** Who writes as themselves: an active person signed in. Anyone else goes through Turnstile and confirms by email. */ | |
| 53 | +const senderOf = (c: AppContext): Account | null => { | |
| 54 | + const a = principalAccount(c); | |
| 55 | + return a && a.kind === "user" && a.status === "active" ? a : null; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +interface FormValues { | |
| 59 | + email?: string; | |
| 60 | + name?: string; | |
| 61 | + topic?: string; | |
| 62 | + message?: string; | |
| 63 | +} | |
| 64 | + | |
| 65 | +// ---------------------------------------------------------------- the form | |
| 66 | + | |
| 67 | +const Way: FC<{ icon: string; title: string; children: Child }> = ({ icon, title, children }) => ( | |
| 68 | + <li> | |
| 69 | + <span class="ic">{raw(icon)}</span> | |
| 70 | + <span> | |
| 71 | + <b>{title}</b> | |
| 72 | + <span class="muted">{children}</span> | |
| 73 | + </span> | |
| 74 | + </li> | |
| 75 | +); | |
| 76 | + | |
| 77 | +async function contactPage(c: AppContext, o: { values?: FormValues; error?: string; status?: number } = {}): Promise<Response> { | |
| 78 | + const me = senderOf(c); | |
| 79 | + const mode = turnstileMode(c.env); | |
| 80 | + const siteKey = !me && mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined; | |
| 81 | + const closed = !me && mode === "missing"; | |
| 82 | + const v = o.values ?? {}; | |
| 83 | + const stamp = me || closed ? "" : await formStamp(c.env); | |
| 84 | + const topic = v.topic ?? (isTopic(c.req.query("topic") ?? "") ? c.req.query("topic") : ""); | |
| 85 | + const origin = c.env.PUBLIC_URL.replace(/\/$/, ""); | |
| 86 | + return render( | |
| 87 | + c, | |
| 88 | + { | |
| 89 | + title: "Contact us", | |
| 90 | + seoTitle: "Contact the Diffinery team", | |
| 91 | + status: o.status, | |
| 92 | + turnstile: !!siteKey, | |
| 93 | + description: "Write to the people who run this Diffinery instance: questions, help with your account, running your own Diffinery, partnerships, press and privacy requests.", | |
| 94 | + jsonLd: [{ "@type": "ContactPage", "@id": `${origin}/contact#webpage`, url: `${origin}/contact`, name: "Contact the Diffinery team", isPartOf: { "@id": `${origin}/#website` }, about: { "@id": `${origin}/#organization` }, inLanguage: "en" }], | |
| 95 | + }, | |
| 96 | + <div class="ea ct"> | |
| 97 | + <section class="ea-head"> | |
| 98 | + <span class="ea-eyebrow">{raw(ICONS.envelope)} Contact</span> | |
| 99 | + <h1>Contact us</h1> | |
| 100 | + <p class="ea-lede">Questions, help with your account, running your own Diffinery, partnerships or press: write to the people who run this instance. A person reads every message and replies by email.</p> | |
| 101 | + </section> | |
| 102 | + <section class="ea-more"> | |
| 103 | + <ul class="ea-points"> | |
| 104 | + <Way icon={ICONS.shield} title="Found a security problem?"> | |
| 105 | + Report it privately with the <a href="/security/report">security report form</a> instead, so only the site administrators see it. | |
| 106 | + </Way> | |
| 107 | + <Way icon={ICONS.zap} title="Is something down?"> | |
| 108 | + The <a href="/status">status page</a> shows incidents as they happen. | |
| 109 | + </Way> | |
| 110 | + <Way icon={ICONS.code} title="Want to know how something works?"> | |
| 111 | + The <a href="/docs">docs</a> answer most questions, and the <a href="/faq">FAQ</a> the common ones. | |
| 112 | + </Way> | |
| 113 | + </ul> | |
| 114 | + {me ? null : ( | |
| 115 | + <ol class="ea-steps"> | |
| 116 | + <li>Send this form.</li> | |
| 117 | + <li>Confirm your email address with the link we send you.</li> | |
| 118 | + <li>Your message goes to the team, who reply to that address.</li> | |
| 119 | + </ol> | |
| 120 | + )} | |
| 121 | + </section> | |
| 122 | + <div class="box ea-card"> | |
| 123 | + <div class="body"> | |
| 124 | + {closed ? ( | |
| 125 | + <p class="m0"> | |
| 126 | + {TURNSTILE_MISSING} <a href="/login?next=%2Fcontact">Sign in</a> to write to them. | |
| 127 | + </p> | |
| 128 | + ) : ( | |
| 129 | + <Form action="/contact" t={csrfToken(c)}> | |
| 130 | + <h2>Send us a message</h2> | |
| 131 | + <p class="muted small">{me ? `Signed in as ${me.login}. We'll reply to ${me.email ?? "your account's email address"}.` : "Every field is required."}</p> | |
| 132 | + {o.error ? ( | |
| 133 | + <div class="flash error" role="alert"> | |
| 134 | + {o.error} | |
| 135 | + </div> | |
| 136 | + ) : null} | |
| 137 | + <div class="field"> | |
| 138 | + <label for="ct-name">Name</label> | |
| 139 | + <input id="ct-name" name="name" type="text" required maxlength={MAX_NAME} value={v.name ?? (me ? me.name || "" : undefined)} autocomplete="name" /> | |
| 140 | + </div> | |
| 141 | + {me ? null : ( | |
| 142 | + <div class="field"> | |
| 143 | + <label for="ct-email">Email</label> | |
| 144 | + <input id="ct-email" name="email" type="email" required maxlength={254} value={v.email} autocomplete="email" placeholder="you@example.com" /> | |
| 145 | + </div> | |
| 146 | + )} | |
| 147 | + <div class="field"> | |
| 148 | + <label>Topic</label> | |
| 149 | + <KSelect name="topic" label="Topic" block value={topic || undefined} placeholder="Choose a topic" options={TOPICS.map((t) => ({ value: t.value, label: t.label }))} /> | |
| 150 | + </div> | |
| 151 | + <div class="field"> | |
| 152 | + <label for="ct-message">Message</label> | |
| 153 | + <textarea id="ct-message" name="message" required maxlength={MAX_MESSAGE} style="min-height:160px"> | |
| 154 | + {v.message ?? ""} | |
| 155 | + </textarea> | |
| 156 | + </div> | |
| 157 | + {me ? null : <Protections stamp={stamp} siteKey={siteKey} />} | |
| 158 | + <button class="btn primary block">Send message</button> | |
| 159 | + <p class="ea-privacy"> | |
| 160 | + Only the site administrators can read your message. We use it and your address only to reply, and delete it within a year of handling it. See <a href="/privacy">Privacy</a>. | |
| 161 | + </p> | |
| 162 | + </Form> | |
| 163 | + )} | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + </div>, | |
| 167 | + ); | |
| 168 | +} | |
| 169 | + | |
| 170 | +/** What someone who isn't signed in sees after sending, whatever happened to the message. */ | |
| 171 | +function checkInboxPage(c: AppContext, email: string): Response | Promise<Response> { | |
| 172 | + return render( | |
| 173 | + c, | |
| 174 | + { title: "Check your inbox" }, | |
| 175 | + <div class="ea-done"> | |
| 176 | + <span class="ic">{raw(ICONS.envelope)}</span> | |
| 177 | + <h1>Check your inbox</h1> | |
| 178 | + <p> | |
| 179 | + We've sent <b>{email}</b> a link to confirm your message. Open it within {CONFIRM_HOURS} hours and your message goes to the team. | |
| 180 | + </p> | |
| 181 | + <p class="muted">Nothing there? Check your spam folder. Messages that are never confirmed are deleted after {UNCONFIRMED_DAYS} days, unread.</p> | |
| 182 | + <p class="row" style="justify-content:center"> | |
| 183 | + <a class="btn" href="/"> | |
| 184 | + Back to the home page | |
| 185 | + </a> | |
| 186 | + <a class="btn" href="/docs"> | |
| 187 | + Read the docs meanwhile | |
| 188 | + </a> | |
| 189 | + </p> | |
| 190 | + </div>, | |
| 191 | + ); | |
| 192 | +} | |
| 193 | + | |
| 194 | +/** What a signed-in person sees after sending. */ | |
| 195 | +function sentPage(c: AppContext, email: string): Response | Promise<Response> { | |
| 196 | + return render( | |
| 197 | + c, | |
| 198 | + { title: "Message sent" }, | |
| 199 | + <div class="ea-done"> | |
| 200 | + <span class="ic">{raw(ICONS.check)}</span> | |
| 201 | + <h1>Message sent</h1> | |
| 202 | + <p> | |
| 203 | + Thanks for writing to us. The team reads every message and replies to <b>{email}</b>. We've emailed you a copy. | |
| 204 | + </p> | |
| 205 | + <p class="row" style="justify-content:center"> | |
| 206 | + <a class="btn" href="/"> | |
| 207 | + Back to your dashboard | |
| 208 | + </a> | |
| 209 | + </p> | |
| 210 | + </div>, | |
| 211 | + ); | |
| 212 | +} | |
| 213 | + | |
| 214 | +/** Runs on the confirmation page: the early access form's, for this form. */ | |
| 215 | +const SCRIPT = CONFIRM_SCRIPT.replace('"ea-confirm"', '"ct-confirm"').replace('"ea-confirm-status"', '"ct-confirm-status"'); | |
| 216 | + | |
| 217 | +function confirmPage(c: AppContext): Response | Promise<Response> { | |
| 218 | + return render( | |
| 219 | + c, | |
| 220 | + { title: "Confirm your message", script: SCRIPT }, | |
| 221 | + <div class="ea-done"> | |
| 222 | + <span class="ic">{raw(ICONS.envelope)}</span> | |
| 223 | + <h1>Confirm your message</h1> | |
| 224 | + <p class="muted" id="ct-confirm-status" aria-live="polite"> | |
| 225 | + Checking your link… | |
| 226 | + </p> | |
| 227 | + <noscript> | |
| 228 | + <p class="muted">Turn on JavaScript for this page to confirm your message: the link's code stays in your browser until you press the button.</p> | |
| 229 | + </noscript> | |
| 230 | + <Form action="/contact/confirm" t={csrfToken(c)} id="ct-confirm" style="display:none"> | |
| 231 | + <input type="hidden" name="token" value="" /> | |
| 232 | + <p>Press the button to confirm that you wrote this message from this email address. It then goes to the team.</p> | |
| 233 | + <button class="btn primary">Confirm my message</button> | |
| 234 | + </Form> | |
| 235 | + </div>, | |
| 236 | + ); | |
| 237 | +} | |
| 238 | + | |
| 239 | +function confirmedPage(c: AppContext, m: ContactMessage | null): Response | Promise<Response> { | |
| 240 | + if (!m) | |
| 241 | + return render( | |
| 242 | + c, | |
| 243 | + { title: "This link doesn't work", status: 410 }, | |
| 244 | + <div class="ea-done"> | |
| 245 | + <span class="ic">{raw(ICONS.x)}</span> | |
| 246 | + <h1>This link doesn't work any more</h1> | |
| 247 | + <p class="muted">Confirmation links work once, for {CONFIRM_HOURS} hours. If you already confirmed, your message is with the team and there's nothing else to do.</p> | |
| 248 | + <p> | |
| 249 | + <a class="btn primary" href="/contact"> | |
| 250 | + Write a new message | |
| 251 | + </a> | |
| 252 | + </p> | |
| 253 | + </div>, | |
| 254 | + ); | |
| 255 | + return render( | |
| 256 | + c, | |
| 257 | + { title: "Message sent" }, | |
| 258 | + <div class="ea-done"> | |
| 259 | + <span class="ic">{raw(ICONS.check)}</span> | |
| 260 | + <h1>Your message is with the team</h1> | |
| 261 | + <p> | |
| 262 | + Thanks, <b>{m.name}</b>. A person reads every message and replies to <b>{m.email}</b>. | |
| 263 | + </p> | |
| 264 | + <p class="row" style="justify-content:center"> | |
| 265 | + <a class="btn" href="/"> | |
| 266 | + Back to the home page | |
| 267 | + </a> | |
| 268 | + <a class="btn" href="/docs"> | |
| 269 | + Read the docs meanwhile | |
| 270 | + </a> | |
| 271 | + </p> | |
| 272 | + </div>, | |
| 273 | + ); | |
| 274 | +} | |
| 275 | + | |
| 276 | +// ---------------------------------------------------------------- site administration | |
| 277 | + | |
| 278 | +/** The initial on a message's avatar: its sender's name's first letter. */ | |
| 279 | +const initial = (m: Pick<ContactMessage, "name" | "email">) => (m.name || m.email).trim().charAt(0).toUpperCase() || "?"; | |
| 280 | + | |
| 281 | +const Actions: FC<{ m: ContactMessage; t: string; back: string }> = ({ m, t, back }) => { | |
| 282 | + const button = (state: "new" | "handled" | "spam", label: Child, cls = "btn sm") => ( | |
| 283 | + <Form action={`${BASE}/${m.id}/state`} t={t} class="inline"> | |
| 284 | + <input type="hidden" name="state" value={state} /> | |
| 285 | + <input type="hidden" name="back" value={back} /> | |
| 286 | + <button class={cls}>{label}</button> | |
| 287 | + </Form> | |
| 288 | + ); | |
| 289 | + return ( | |
| 290 | + <> | |
| 291 | + {m.state === "new" || m.state === "unconfirmed" ? button("handled", <>{raw(ICONS.check)} Mark handled</>, "btn sm primary") : null}{" "} | |
| 292 | + {m.state !== "spam" ? button("spam", "Spam") : null} {m.state === "handled" || m.state === "spam" ? button("new", "Reopen") : null} | |
| 293 | + </> | |
| 294 | + ); | |
| 295 | +}; | |
| 296 | + | |
| 297 | +type Row = ContactMessage & { account_login: string | null }; | |
| 298 | + | |
| 299 | +/** Messages with the login of an active account that uses the address, if any (a signed-in sender's own). */ | |
| 300 | +async function withAccounts(db: D1Database, rows: ContactMessage[]): Promise<Row[]> { | |
| 301 | + if (!rows.length) return []; | |
| 302 | + const emails = [...new Set(rows.map((r) => r.email.toLowerCase()))]; | |
| 303 | + const found = ( | |
| 304 | + await db | |
| 305 | + .prepare(`SELECT lower(email) AS email, login FROM accounts WHERE kind = 'user' AND status = 'active' AND lower(email) IN (${emails.map(() => "?").join(", ")})`) | |
| 306 | + .bind(...emails) | |
| 307 | + .all<{ email: string; login: string }>() | |
| 308 | + ).results; | |
| 309 | + const by = new Map(found.map((f) => [f.email, f.login])); | |
| 310 | + return rows.map((r) => ({ ...r, account_login: r.sender_login ?? by.get(r.email.toLowerCase()) ?? null })); | |
| 311 | +} | |
| 312 | + | |
| 313 | +/** A reply in the site administrator's own mail app, to the sender, quoting the topic. */ | |
| 314 | +const mailto = (m: ContactMessage) => `mailto:${encodeURIComponent(m.email).replace(/%40/g, "@")}?subject=${encodeURIComponent(`Re: ${topicLabel(m.topic)}`)}`; | |
| 315 | + | |
| 316 | +const backTo = (v: string): string => (v.startsWith(BASE) && !v.startsWith("//") && !/[\r\n]/.test(v) ? v : BASE); | |
| 317 | + | |
| 318 | +export function registerContact(web: WebApp): void { | |
| 319 | + // ------------------------------------------------------------ the form | |
| 320 | + | |
| 321 | + web.get("/contact", (c) => contactPage(c)); | |
| 322 | + web.get("/contact/", (c) => c.redirect("/contact", 301)); | |
| 323 | + | |
| 324 | + post( | |
| 325 | + web, | |
| 326 | + "/contact", | |
| 327 | + async (c, f) => { | |
| 328 | + const values: FormValues = { email: f.s("email").slice(0, 254), name: f.s("name").slice(0, MAX_NAME * 2), topic: f.s("topic").slice(0, 40), message: f.s("message").slice(0, MAX_MESSAGE * 2) }; | |
| 329 | + try { | |
| 330 | + const me = senderOf(c); | |
| 331 | + // A filled honeypot or a form sent faster than a person types: answer as if it worked, keep nothing. | |
| 332 | + if (!me && (await guard(c, f, TURNSTILE_MISSING)) === "silent") return checkInboxPage(c, values.email?.trim() || "your address"); | |
| 333 | + const input = validateMessage(values, !!me); | |
| 334 | + await submitMessage(c.env, (e) => c.get("dir").audit(e), input, me, c.req.header("cf-connecting-ip") ?? null); | |
| 335 | + return me ? sentPage(c, me.email ?? "your address") : checkInboxPage(c, input.email!); | |
| 336 | + } catch (e) { | |
| 337 | + const s = statusOf(e); | |
| 338 | + if (s.status >= 500 && s.status !== 503) throw e; | |
| 339 | + return contactPage(c, { values, error: s.message, status: s.status }); | |
| 340 | + } | |
| 341 | + }, | |
| 342 | + { maxBytes: MAX_FORM_BYTES }, | |
| 343 | + ); | |
| 344 | + | |
| 345 | + web.get("/contact/confirm", (c) => confirmPage(c)); | |
| 346 | + | |
| 347 | + post( | |
| 348 | + web, | |
| 349 | + "/contact/confirm", | |
| 350 | + async (c, f) => confirmedPage(c, await confirmMessage(c.env, (e) => c.get("dir").audit(e), f.s("token").slice(0, 200))), | |
| 351 | + { maxBytes: 4096 }, | |
| 352 | + ); | |
| 353 | + | |
| 354 | + // ------------------------------------------------------------ site administration: Contact messages | |
| 355 | + | |
| 356 | + /** Messages don't exist for anyone but site administrators: 404, not 403. */ | |
| 357 | + const requireSiteAdmin = (c: AppContext): Account => { | |
| 358 | + const me = requireAccount(c); | |
| 359 | + if (!me.site_admin || me.kind !== "user") throw new HttpError(404, "not found"); | |
| 360 | + return me; | |
| 361 | + }; | |
| 362 | + | |
| 363 | + const page = (c: AppContext, me: Account, title: string, body: Child) => | |
| 364 | + render( | |
| 365 | + c, | |
| 366 | + { title }, | |
| 367 | + <div class="grid left"> | |
| 368 | + <SettingsNav me={me} tab="contact" db={c.env.DB} /> | |
| 369 | + <div> | |
| 370 | + <ComputePausedBanner env={c.env} /> | |
| 371 | + {body} | |
| 372 | + </div> | |
| 373 | + </div>, | |
| 374 | + ); | |
| 375 | + | |
| 376 | + const PER_PAGE = 50; | |
| 377 | + | |
| 378 | + web.get(BASE, async (c) => { | |
| 379 | + const me = requireSiteAdmin(c); | |
| 380 | + const qState = c.req.query("state"); | |
| 381 | + const state: MessageState | "all" = qState === "all" ? "all" : MESSAGE_STATES.includes(qState as MessageState) ? (qState as MessageState) : "new"; | |
| 382 | + const q = (c.req.query("q") ?? "").trim().slice(0, 100); | |
| 383 | + const pg = pageNum(c); | |
| 384 | + const store = new ContactMessages(c.env.DB); | |
| 385 | + const [list, counts] = await Promise.all([store.list({ state: state === "all" ? undefined : state, q, limit: PER_PAGE, offset: (pg - 1) * PER_PAGE }), store.counts()]); | |
| 386 | + const rows = await withAccounts(c.env.DB, list.items); | |
| 387 | + const total = counts.new + counts.handled + counts.spam + counts.unconfirmed; | |
| 388 | + const here = `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: pg > 1 ? pg : undefined })}`; | |
| 389 | + const t = csrfToken(c); | |
| 390 | + const tab = (s: MessageState | "all", label: string, n: number) => ( | |
| 391 | + <a href={`${BASE}${qs({ state: s === "new" ? undefined : s, q })}`} class={state === s ? "on" : ""}> | |
| 392 | + {label} <span class="n">{n}</span> | |
| 393 | + </a> | |
| 394 | + ); | |
| 395 | + return page( | |
| 396 | + c, | |
| 397 | + me, | |
| 398 | + "Contact messages", | |
| 399 | + <> | |
| 400 | + <h1>Contact messages</h1> | |
| 401 | + <p class="muted"> | |
| 402 | + Messages from <a href="/contact">/contact</a>. A message from someone who isn't signed in arrives once its address follows the emailed link. Reply from your own mailbox (the notification email's Reply-To is the sender), then mark it handled. Every change is in the <a href="/settings/audit">audit log</a>. | |
| 403 | + </p> | |
| 404 | + <nav class="tabs mb" aria-label="Messages by state"> | |
| 405 | + {tab("new", "New", counts.new)} | |
| 406 | + {tab("handled", "Handled", counts.handled)} | |
| 407 | + {tab("spam", "Spam", counts.spam)} | |
| 408 | + {tab("unconfirmed", "Unconfirmed", counts.unconfirmed)} | |
| 409 | + {tab("all", "All", total)} | |
| 410 | + </nav> | |
| 411 | + <form method="get" action={BASE} class="row mb" role="search"> | |
| 412 | + {state === "new" ? null : <input type="hidden" name="state" value={state} />} | |
| 413 | + <input type="search" name="q" value={q} maxlength={100} placeholder="Search by email, name or message" aria-label="Search messages" class="grow" style="max-width:420px" /> | |
| 414 | + <button class="btn">{raw(ICONS.search)} Search</button> | |
| 415 | + {q ? ( | |
| 416 | + <a class="btn" href={`${BASE}${qs({ state: state === "new" ? undefined : state })}`}> | |
| 417 | + Clear | |
| 418 | + </a> | |
| 419 | + ) : null} | |
| 420 | + </form> | |
| 421 | + <div class="box"> | |
| 422 | + {rows.length ? ( | |
| 423 | + <ul class="list ea-list"> | |
| 424 | + {rows.map((m) => ( | |
| 425 | + <li> | |
| 426 | + <span class="ea-av" aria-hidden="true"> | |
| 427 | + {initial(m)} | |
| 428 | + </span> | |
| 429 | + <div class="grow" style="min-width:0"> | |
| 430 | + <div class="row" style="gap:6px;flex-wrap:wrap"> | |
| 431 | + <a class="title" href={`${BASE}/${m.id}`}> | |
| 432 | + {m.name} | |
| 433 | + </a> | |
| 434 | + <span class="muted small">{m.email}</span> | |
| 435 | + <StateBadge state={m.state} /> | |
| 436 | + {m.account_login ? <span class="badge amber">{m.sender_login ? "signed in" : "has an account"}: {m.account_login}</span> : null} | |
| 437 | + </div> | |
| 438 | + <div class="meta"> | |
| 439 | + {topicLabel(m.topic)} · sent <Time at={m.created_at} /> | |
| 440 | + {m.handled_at && m.updated_by ? ( | |
| 441 | + <> | |
| 442 | + {" "} | |
| 443 | + · {m.state === "spam" ? "marked spam" : "handled"} by {m.updated_by} <Time at={m.handled_at} /> | |
| 444 | + </> | |
| 445 | + ) : null} | |
| 446 | + </div> | |
| 447 | + <div class="ea-use">{m.message.replace(/\s+/g, " ")}</div> | |
| 448 | + </div> | |
| 449 | + <div class="ea-actions nowrap"> | |
| 450 | + <Actions m={m} t={t} back={here} /> | |
| 451 | + </div> | |
| 452 | + </li> | |
| 453 | + ))} | |
| 454 | + </ul> | |
| 455 | + ) : ( | |
| 456 | + <div class="empty">{q ? "No messages match your search." : state === "new" ? "No new messages. Messages show up here once their address confirms." : state === "all" ? "No contact messages yet." : `No ${STATE_TEXT[state].toLowerCase()} messages.`}</div> | |
| 457 | + )} | |
| 458 | + </div> | |
| 459 | + <Pager page={pg} hasNext={list.more} href={(p) => `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: p > 1 ? p : undefined })}`} /> | |
| 460 | + </>, | |
| 461 | + ); | |
| 462 | + }); | |
| 463 | + | |
| 464 | + web.get(`${BASE}/:id`, async (c) => { | |
| 465 | + const me = requireSiteAdmin(c); | |
| 466 | + const found = await new ContactMessages(c.env.DB).get(prm(c, "id")); | |
| 467 | + if (!found) throw new HttpError(404, "no contact message with that id"); | |
| 468 | + const [m] = (await withAccounts(c.env.DB, [found])) as [Row]; | |
| 469 | + const t = csrfToken(c); | |
| 470 | + const here = `${BASE}/${m.id}`; | |
| 471 | + return page( | |
| 472 | + c, | |
| 473 | + me, | |
| 474 | + `Contact: ${m.name}`, | |
| 475 | + <> | |
| 476 | + <p class="small"> | |
| 477 | + <a href={BASE}>← Contact messages</a> | |
| 478 | + </p> | |
| 479 | + <div class="row mb" style="gap:12px;align-items:center;flex-wrap:wrap"> | |
| 480 | + <span class="ea-av lg" aria-hidden="true"> | |
| 481 | + {initial(m)} | |
| 482 | + </span> | |
| 483 | + <div class="grow" style="min-width:0"> | |
| 484 | + <h1 class="m0" style="overflow-wrap:anywhere">{m.name}</h1> | |
| 485 | + <div class="row" style="gap:6px;margin-top:4px;flex-wrap:wrap"> | |
| 486 | + <StateBadge state={m.state} /> | |
| 487 | + <span class="badge">{topicLabel(m.topic)}</span> | |
| 488 | + </div> | |
| 489 | + </div> | |
| 490 | + <div class="ea-actions"> | |
| 491 | + <a class="btn sm" href={mailto(m)}> | |
| 492 | + {raw(ICONS.envelope)} Reply by email | |
| 493 | + </a> | |
| 494 | + <Actions m={m} t={t} back={here} /> | |
| 495 | + </div> | |
| 496 | + </div> | |
| 497 | + <h2>Message</h2> | |
| 498 | + <div class="box"> | |
| 499 | + {/* Plain text exactly as sent: never rendered as Markdown or HTML. */} | |
| 500 | + <pre class="body m0" style="white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--sans);font-size:14px"> | |
| 501 | + {m.message} | |
| 502 | + </pre> | |
| 503 | + </div> | |
| 504 | + <div class="box"> | |
| 505 | + <div class="body"> | |
| 506 | + <dl class="kv m0"> | |
| 507 | + <dt>From</dt> | |
| 508 | + <dd style="overflow-wrap:anywhere"> | |
| 509 | + {m.name} <<a href={mailto(m)}>{m.email}</a>> | |
| 510 | + </dd> | |
| 511 | + <dt>Account</dt> | |
| 512 | + <dd> | |
| 513 | + {m.account_login ? ( | |
| 514 | + <> | |
| 515 | + <a href={`/${m.account_login}`}>{m.account_login}</a> <span class="muted small">{m.sender_login ? "sent it signed in" : "uses this address"}</span> | |
| 516 | + </> | |
| 517 | + ) : ( | |
| 518 | + <span class="muted">none uses this address</span> | |
| 519 | + )} | |
| 520 | + </dd> | |
| 521 | + <dt>Topic</dt> | |
| 522 | + <dd>{topicLabel(m.topic)}</dd> | |
| 523 | + <dt>Sent</dt> | |
| 524 | + <dd> | |
| 525 | + <Time at={m.created_at} /> <span class="muted small">{m.created_at}</span> | |
| 526 | + </dd> | |
| 527 | + <dt>Confirmed</dt> | |
| 528 | + <dd>{m.confirmed_at ? m.sender_login ? <span class="muted">signed in, so no confirmation was needed</span> : <Time at={m.confirmed_at} /> : <span class="muted">not yet: the address hasn't followed the emailed link</span>}</dd> | |
| 529 | + {m.handled_at && m.updated_by ? ( | |
| 530 | + <> | |
| 531 | + <dt>{m.state === "spam" ? "Marked spam" : "Handled"}</dt> | |
| 532 | + <dd> | |
| 533 | + by {m.updated_by} <Time at={m.handled_at} /> | |
| 534 | + </dd> | |
| 535 | + </> | |
| 536 | + ) : null} | |
| 537 | + <dt>Reference</dt> | |
| 538 | + <dd class="mono">{m.id}</dd> | |
| 539 | + </dl> | |
| 540 | + </div> | |
| 541 | + </div> | |
| 542 | + <h2>Note</h2> | |
| 543 | + <Form action={`${BASE}/${m.id}/note`} t={t} class="box"> | |
| 544 | + <div class="body"> | |
| 545 | + <div class="field"> | |
| 546 | + <label for="ct-note">For the other site administrators: who replied, what was agreed. The sender never sees it.</label> | |
| 547 | + <textarea id="ct-note" name="note" maxlength={MAX_NOTE} style="min-height:80px;width:100%"> | |
| 548 | + {m.note ?? ""} | |
| 549 | + </textarea> | |
| 550 | + </div> | |
| 551 | + <button class="btn">Save note</button> | |
| 552 | + </div> | |
| 553 | + </Form> | |
| 554 | + <h2>Delete</h2> | |
| 555 | + <Form action={`${BASE}/${m.id}/delete`} t={t} class="box" confirm={`Delete the message from ${m.email} and everything stored about it? This can't be undone.`}> | |
| 556 | + <div class="body row between" style="gap:12px;flex-wrap:wrap"> | |
| 557 | + <span class="muted">For someone who asks for their data to be removed. The audit log keeps that a message was deleted, not what it said.</span> | |
| 558 | + <button class="btn danger">{raw(ICONS.trash)} Delete message</button> | |
| 559 | + </div> | |
| 560 | + </Form> | |
| 561 | + </>, | |
| 562 | + ); | |
| 563 | + }); | |
| 564 | + | |
| 565 | + const audit = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e); | |
| 566 | + const DONE: Record<MessageState, string> = { new: "Reopened the message.", unconfirmed: "Reopened the message. Its address still has to confirm it.", handled: "Marked the message handled.", spam: "Marked the message as spam. Nothing more from its address gets through while it stays spam." }; | |
| 567 | + | |
| 568 | + post(web, `${BASE}/:id/state`, async (c, f) => { | |
| 569 | + const me = requireSiteAdmin(c); | |
| 570 | + return act(c, backTo(f.s("back")), async () => { | |
| 571 | + const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { state: f.s("state") }, "web"); | |
| 572 | + return void flash(c, "notice", DONE[m.state]); | |
| 573 | + }); | |
| 574 | + }); | |
| 575 | + post( | |
| 576 | + web, | |
| 577 | + `${BASE}/:id/note`, | |
| 578 | + async (c, f) => { | |
| 579 | + const me = requireSiteAdmin(c); | |
| 580 | + return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => { | |
| 581 | + const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { note: f.s("note") }, "web"); | |
| 582 | + return void flash(c, "notice", m.note ? "Note saved." : "Note cleared."); | |
| 583 | + }); | |
| 584 | + }, | |
| 585 | + { maxBytes: 16 * 1024 }, | |
| 586 | + ); | |
| 587 | + post(web, `${BASE}/:id/delete`, async (c) => { | |
| 588 | + const me = requireSiteAdmin(c); | |
| 589 | + return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => { | |
| 590 | + await deleteMessage(c.env.DB, audit(c), me, prm(c, "id"), "web"); | |
| 591 | + flash(c, "notice", "Message deleted."); | |
| 592 | + return BASE; | |
| 593 | + }); | |
| 594 | + }); | |
| 595 | +} |
modifiedapps/diffinery/src/web/early-access.tsx+8−7View file
| @@ -204,7 +204,7 @@ | ||
| 204 | 204 | } |
| 205 | 205 | |
| 206 | 206 | /** Runs on the confirmation page: takes the token from the fragment (never sent to the server in a request line) into the form. */ |
| 207 | -const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`; | |
| 207 | +export const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`; | |
| 208 | 208 | |
| 209 | 209 | function confirmPage(c: AppContext): Response | Promise<Response> { |
| 210 | 210 | return render( |
| @@ -309,13 +309,14 @@ | ||
| 309 | 309 | } |
| 310 | 310 | |
| 311 | 311 | /** |
| 312 | - * The checks every public form here makes before it does anything: Turnstile is configured, the | |
| 313 | - * honeypot is empty, the timing stamp is genuine and the form wasn't sent faster than a person types | |
| 314 | - * ("silent": answer as if it worked and keep nothing), and the Turnstile token is good. | |
| 312 | + * The checks every public form here (and the contact form, web/contact.tsx) makes before it does | |
| 313 | + * anything: Turnstile is configured (else 503 with `missing`), the honeypot is empty, the timing stamp | |
| 314 | + * is genuine and the form wasn't sent faster than a person types ("silent": answer as if it worked and | |
| 315 | + * keep nothing), and the Turnstile token is good. | |
| 315 | 316 | */ |
| 316 | -async function guard(c: AppContext, f: FormData): Promise<"ok" | "silent"> { | |
| 317 | +export async function guard(c: AppContext, f: FormData, missing = TURNSTILE_MISSING): Promise<"ok" | "silent"> { | |
| 317 | 318 | const mode = turnstileMode(c.env); |
| 318 | - if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING); | |
| 319 | + if (mode === "missing") throw new HttpError(503, missing); | |
| 319 | 320 | const stamp = await checkStamp(c.env, f.s("stamp")); |
| 320 | 321 | if (f.s("website") !== "" || stamp === "fast" || stamp === "bad") return "silent"; |
| 321 | 322 | if (stamp === "stale") throw new HttpError(422, "This form was open for a long time. Please check it and send it again."); |
| @@ -324,7 +325,7 @@ | ||
| 324 | 325 | } |
| 325 | 326 | |
| 326 | 327 | /** The honeypot, timing stamp and Turnstile widget every public form here carries. */ |
| 327 | -const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => ( | |
| 328 | +export const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => ( | |
| 328 | 329 | <> |
| 329 | 330 | {/* A field people never see or fill in: a script filling every field gives itself away. */} |
| 330 | 331 | <div class="ea-hp" aria-hidden="true"> |
modifiedapps/diffinery/src/web/indexnow.ts+3−1View file
| @@ -28,7 +28,7 @@ | ||
| 28 | 28 | import { hmacSha256 } from "../lib/crypto.js"; |
| 29 | 29 | import type { Directory } from "../lib/directory.js"; |
| 30 | 30 | import { sha256 } from "../lib/util.js"; |
| 31 | -import { homeContent, homeMarkdown, sitePageMarkdown, sitePages } from "./site.js"; | |
| 31 | +import { CONTACT, homeContent, homeMarkdown, sitePageMarkdown, sitePages } from "./site.js"; | |
| 32 | 32 | |
| 33 | 33 | /** |
| 34 | 34 | * Where submissions go, in order. IndexNow engines share what each one receives, so one accepted |
| @@ -104,6 +104,8 @@ | ||
| 104 | 104 | pages.set(pageUrl(p.slug), `${p.section}\n${md}`); |
| 105 | 105 | pages.set(markdownUrl(p.slug), md); |
| 106 | 106 | } |
| 107 | + // The contact form: its content is the code, so its last-changed date stands in for it. | |
| 108 | + pages.set(CONTACT.path, `contact form, updated ${CONTACT.updated}`); | |
| 107 | 109 | pages.set("/llms.txt", llmsTxt(o)); |
| 108 | 110 | pages.set("/llms-full.txt", llmsFullTxt(o)); |
| 109 | 111 | pages.set("/llms-small.txt", llmsSmallTxt(o)); |
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { registerTerminals } from "../agentic/terminal-web.js"; |
| 12 | 12 | import { registerMailWeb } from "../mail/web.js"; |
| 13 | 13 | import { registerSecurityReports } from "./security-reports.js"; |
| 14 | +import { registerContact } from "./contact.js"; | |
| 14 | 15 | import { registerEarlyAccess } from "./early-access.js"; |
| 15 | 16 | import { registerStatus } from "./status.js"; |
| 16 | 17 | import { registerAuth } from "./auth.js"; |
| @@ -80,6 +81,7 @@ | ||
| 80 | 81 | registerMailWeb(web); |
| 81 | 82 | registerSecurityReports(web); |
| 82 | 83 | registerEarlyAccess(web); |
| 84 | +registerContact(web); | |
| 83 | 85 | // /status: before the /:owner catch-all. |
| 84 | 86 | registerStatus(web); |
| 85 | 87 | // Before registerAccount: its /:owner catch-all would otherwise swallow /imports, /new/import and /settings/linked-accounts. |
modifiedapps/diffinery/src/web/seo.tsx+5−3View file
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | import type { WebApp } from "./ui.js"; |
| 13 | 13 | import { ICON_FILES } from "./icons.generated.js"; |
| 14 | 14 | import { docsSitemapEntries } from "../docs/web.js"; |
| 15 | -import { siteSitemapEntries } from "./site.js"; | |
| 15 | +import { CONTACT, siteSitemapEntries } from "./site.js"; | |
| 16 | 16 | import { indexNowKey } from "./indexnow.js"; |
| 17 | 17 | |
| 18 | 18 | export const SITE = { |
| @@ -44,7 +44,7 @@ | ||
| 44 | 44 | const CACHE = "public, max-age=604800"; |
| 45 | 45 | |
| 46 | 46 | /** Paths that are never useful in search results. */ |
| 47 | -const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|-)(\/|$)/; | |
| 47 | +const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|contact\/confirm|-)(\/|$)/; | |
| 48 | 48 | |
| 49 | 49 | export function registerSeo(web: WebApp): void { |
| 50 | 50 | for (const name of Object.keys(ICON_FILES)) { |
| @@ -80,7 +80,7 @@ | ||
| 80 | 80 | "User-agent: *", |
| 81 | 81 | "Content-Signal: search=yes, ai-input=yes, ai-train=yes", |
| 82 | 82 | "Allow: /", |
| 83 | - ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`), | |
| 83 | + ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/contact/confirm", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`), | |
| 84 | 84 | "", |
| 85 | 85 | `Sitemap: ${origin}/sitemap.xml`, |
| 86 | 86 | "", |
| @@ -105,6 +105,8 @@ | ||
| 105 | 105 | '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', |
| 106 | 106 | // The home page, then the product, comparison and company pages (web/site.tsx). |
| 107 | 107 | ...siteSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)), |
| 108 | + // The contact form (web/contact.tsx), for people and AI assistants looking for how to reach the team. | |
| 109 | + url(`${origin}${CONTACT.path}`, CONTACT.updated), | |
| 108 | 110 | ...docsSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)), |
| 109 | 111 | ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)), |
| 110 | 112 | "</urlset>", |
modifiedapps/diffinery/src/web/site.tsx+4−0View file
| @@ -267,6 +267,9 @@ | ||
| 267 | 267 | /** Where to ask to try Diffinery on the project's own instance, whatever instance serves this page (web/early-access.tsx). */ |
| 268 | 268 | export const EARLY_ACCESS = "https://diffinery.com/early-access"; |
| 269 | 269 | |
| 270 | +/** The contact form (web/contact.tsx): on every instance, it reaches that instance's site administrators. `updated` is for the sitemap. */ | |
| 271 | +export const CONTACT = { path: "/contact", updated: "2026-10-26" }; | |
| 272 | + | |
| 270 | 273 | /** schema.org ids for the site's entities, shared by every page's graph. */ |
| 271 | 274 | const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` }); |
| 272 | 275 | |
| @@ -676,6 +679,7 @@ | ||
| 676 | 679 | `- [Home page](${o}/index.md): ${HOME.description}`, |
| 677 | 680 | `- Open source under the Apache License 2.0 (SPDX: ${SOURCE.spdx}). Source code: ${SOURCE.repo}, mirrored to ${SOURCE.github}. Free, with no paid edition and nothing sold.`, |
| 678 | 681 | `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for teams to sign up for: diffinery.com is the project's home and its own instance, with accounts by invitation. To try Diffinery before deploying your own, request early access at ${EARLY_ACCESS} (how it works: ${o}/docs/accounts/early-access.md).`, |
| 682 | + `- Contact the Diffinery team: ${o}${CONTACT.path} (a form; a person replies by email). Private security reports: ${o}/security/report. Bugs and ideas: issues on ${SOURCE.repo}.`, | |
| 679 | 683 | `- Runs entirely on Cloudflare (Workers, Durable Objects, R2, D1, Queues, Workflows, Containers): code, pull requests, CI runners and the agent features alike. It is ${CLOUDFLARE_CLAIM} See ${o}/built-on-cloudflare.md, which compares the other Git and CI projects on Cloudflare.`, |
| 680 | 684 | "- Hosted CI runners: Ubuntu 24.04 x64 containers, 4 vCPU and 12 GiB of memory by default, with smaller sizes chosen per job from its measured runs. No hosted macOS, Windows or Arm runners; self-hosted runners cover them.", |
| 681 | 685 | "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`, |
modifiedapps/diffinery/src/web/ui.tsx+2−1View file
| @@ -476,7 +476,7 @@ | ||
| 476 | 476 | { title: "Product", links: [["/features", "Features"], ["/agents", "For AI agents"], ["/performance", "Performance"], ["/security", "Security"], ["/built-on-cloudflare", "Built on Cloudflare"], ["/open-source", "Open source"], ["/docs/migrate-from-github", "Migrate from GitHub"]] }, |
| 477 | 477 | { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] }, |
| 478 | 478 | { title: "Resources", links: [["/docs", "Docs"], ["/docs/self-hosting", "Self-hosting"], ["/docs/guides", "Guides"], ["/docs/connect", "Connect AI apps"], ["/llms.txt", "llms.txt"], ["/status", "Status"], ["/docs/security/disclosure", "Report a vulnerability"]] }, |
| 479 | - { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/privacy", "Privacy"]] }, | |
| 479 | + { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/contact", "Contact"], ["/privacy", "Privacy"]] }, | |
| 480 | 480 | ].map((col) => ( |
| 481 | 481 | <nav aria-label={col.title}> |
| 482 | 482 | <h2>{col.title}</h2> |
| @@ -619,6 +619,7 @@ | ||
| 619 | 619 | <a href="/docs">Docs</a> |
| 620 | 620 | <a href="/docs/security/disclosure">Security</a> |
| 621 | 621 | <a href="/status">Status</a> |
| 622 | + <a href="/contact">Contact</a> | |
| 622 | 623 | </nav> |
| 623 | 624 | ) : ( |
| 624 | 625 | <a href="/built-on-cloudflare">Runs entirely on Cloudflare</a> |
addedapps/diffinery/test/e2e/contact.test.mjs+392−0View file
| @@ -1,0 +1,392 @@ | ||
| 1 | +// End-to-end contact form (lib/contact.ts, web/contact.tsx) on a real `wrangler dev` Worker: the | |
| 2 | +// footer, sitemap, robots.txt and llms.txt lead to /contact; a visitor's message is confirmed with the | |
| 3 | +// link from its email and only then reaches the site administrators, by email (Reply-To the sender) and | |
| 4 | +// in Diffinery; a signed-in person's message arrives at once with a copy for them; bots, bursts, | |
| 5 | +// oversized and hostile input get nowhere; only site administrators see messages, on the web and | |
| 6 | +// through the API; marking handled, spam, reopening, noting and deleting work and are audited without | |
| 7 | +// the message's content, and an address with a message marked spam gets nothing more through. | |
| 8 | +// | |
| 9 | +// The Worker keeps env.dev's DEV_AUTH so it keeps whole copies of the emails it sends (for reading the | |
| 10 | +// links). Visitors who aren't signed in are requests with another Host header: local development's fixed | |
| 11 | +// identity only applies to requests addressed to this machine. | |
| 12 | +// | |
| 13 | +// pnpm --filter @diffinery/app exec node --test test/e2e/contact.test.mjs | |
| 14 | +import "./fresh-connections.mjs"; | |
| 15 | +import { spawn, execFileSync } from "node:child_process"; | |
| 16 | +import { createHash, randomBytes } from "node:crypto"; | |
| 17 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 18 | +import { tmpdir } from "node:os"; | |
| 19 | +import { join, dirname, resolve } from "node:path"; | |
| 20 | +import { fileURLToPath } from "node:url"; | |
| 21 | +import { request as httpRequest } from "node:http"; | |
| 22 | +import { test, after } from "node:test"; | |
| 23 | +import assert from "node:assert/strict"; | |
| 24 | +import { migratedState } from "./d1-state.mjs"; | |
| 25 | +import { freePort } from "./ports.mjs"; | |
| 26 | + | |
| 27 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 28 | +const work = mkdtempSync(join(tmpdir(), "diffinery-contact-e2e-")); | |
| 29 | +const running = []; | |
| 30 | + | |
| 31 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 32 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * A fetch-like request over node:http, which (unlike fetch) sends the Host header it is given: the | |
| 36 | + * Worker sees a visitor's requests addressed to VISITOR. Redirects are never followed. | |
| 37 | + */ | |
| 38 | +function send(url, { method = "GET", headers = {}, body } = {}) { | |
| 39 | + const data = body === undefined ? undefined : Buffer.from(typeof body === "string" ? body : body.toString()); | |
| 40 | + return new Promise((res, rej) => { | |
| 41 | + const r = httpRequest(url, { method, headers: { ...headers, ...(data ? { "content-length": String(data.length) } : {}) } }, (resp) => { | |
| 42 | + const chunks = []; | |
| 43 | + resp.on("data", (c) => chunks.push(c)); | |
| 44 | + resp.on("end", () => { | |
| 45 | + const text = Buffer.concat(chunks).toString("utf8"); | |
| 46 | + const h = resp.headers; | |
| 47 | + res({ | |
| 48 | + status: resp.statusCode, | |
| 49 | + headers: { get: (k) => (Array.isArray(h[k.toLowerCase()]) ? h[k.toLowerCase()].join(", ") : (h[k.toLowerCase()] ?? null)) }, | |
| 50 | + text: async () => text, | |
| 51 | + json: async () => JSON.parse(text), | |
| 52 | + }); | |
| 53 | + }); | |
| 54 | + }); | |
| 55 | + r.on("error", rej); | |
| 56 | + if (data) r.write(data); | |
| 57 | + r.end(); | |
| 58 | + }); | |
| 59 | +} | |
| 60 | + | |
| 61 | +function stopWorker(dev) { | |
| 62 | + try { | |
| 63 | + process.kill(-dev.pid, "SIGTERM"); | |
| 64 | + } catch {} | |
| 65 | +} | |
| 66 | + | |
| 67 | +after(() => { | |
| 68 | + for (const dev of running) stopWorker(dev); | |
| 69 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` }; | |
| 73 | +const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` }; | |
| 74 | +/** The Host a visitor who isn't signed in uses (see the header comment). */ | |
| 75 | +const VISITOR = "visitor.example"; | |
| 76 | + | |
| 77 | +let w; | |
| 78 | +async function worker() { | |
| 79 | + if (w) return w; | |
| 80 | + const port = await freePort(); | |
| 81 | + const base = `http://127.0.0.1:${port}`; | |
| 82 | + const persist = join(work, "state"); | |
| 83 | + const at = new Date().toISOString(); | |
| 84 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 85 | + const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`; | |
| 86 | + const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; | |
| 87 | + const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 88 | + const seed = [person("u-root", "root", "root@example.com", 1), person("u-ann", "ann", "ann@example.com", 0), token("t-root", "u-root", T.root), token("t-ann", "u-ann", T.ann), session("u-root", S.root), session("u-ann", S.ann)]; | |
| 89 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 90 | + migratedState(APP, persist); | |
| 91 | + wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; ")); | |
| 92 | + const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"]; | |
| 93 | + const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }); | |
| 94 | + let log = ""; | |
| 95 | + dev.stdout.on("data", (d) => (log += d)); | |
| 96 | + dev.stderr.on("data", (d) => (log += d)); | |
| 97 | + running.push(dev); | |
| 98 | + const end = Date.now() + 120_000; | |
| 99 | + while (Date.now() < end) { | |
| 100 | + try { | |
| 101 | + if ((await fetch(`${base}/healthz`)).ok) break; | |
| 102 | + } catch {} | |
| 103 | + await sleep(500); | |
| 104 | + } | |
| 105 | + assert.ok((await fetch(`${base}/healthz`).catch(() => ({ ok: false }))).ok, `wrangler dev did not start\n${log.slice(-4000)}`); | |
| 106 | + | |
| 107 | + /** Headers for a visitor (no `who`), or a signed-in person's browser. */ | |
| 108 | + const headers = (who, extra = {}) => ({ ...(who ? { cookie: `__Host-dfy_session=${S[who]}` } : { host: VISITOR }), ...extra }); | |
| 109 | + const page = (path, who) => send(`${base}${path}`, { headers: headers(who) }); | |
| 110 | + const api = async (method, path, body, tok) => { | |
| 111 | + const r = await send(`${base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : { host: VISITOR }) }, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 112 | + const text = await r.text(); | |
| 113 | + let json = null; | |
| 114 | + try { | |
| 115 | + json = text ? JSON.parse(text) : null; | |
| 116 | + } catch { | |
| 117 | + json = text; | |
| 118 | + } | |
| 119 | + return { status: r.status, json }; | |
| 120 | + }; | |
| 121 | + /** A form's CSRF pair and timing stamp, read from `path` the way a browser does. */ | |
| 122 | + const formOf = async (path, who) => { | |
| 123 | + const res = await page(path, who); | |
| 124 | + assert.equal(res.status, 200, `GET ${path} -> ${res.status}`); | |
| 125 | + const html = await res.text(); | |
| 126 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1]; | |
| 127 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 128 | + assert.ok(csrf && pair, `no CSRF pair on ${path}`); | |
| 129 | + return { csrf, pair, stamp: /name="stamp" value="([^"]*)"/.exec(html)?.[1] ?? "", html }; | |
| 130 | + }; | |
| 131 | + /** POST a form; `form` is from formOf. `ip` sets the address the Worker sees. */ | |
| 132 | + const post = async (action, form, fields, who, ip) => { | |
| 133 | + const body = new URLSearchParams({ _csrf: form.csrf, ...(form.stamp ? { stamp: form.stamp } : {}), ...fields }); | |
| 134 | + const cookie = [who ? `__Host-dfy_session=${S[who]}` : "", `dfy_csrf=${form.pair}`].filter(Boolean).join("; "); | |
| 135 | + const r = await send(`${base}${action}`, { method: "POST", body, headers: { ...headers(who), cookie, "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...(ip ? { "cf-connecting-ip": ip } : {}) } }); | |
| 136 | + // Apostrophes come back as '; nothing else is unescaped, so the escaping checks still hold. | |
| 137 | + return { status: r.status, location: r.headers.get("location"), html: (await r.text()).replace(/'/g, "'") }; | |
| 138 | + }; | |
| 139 | + /** Every email the Worker sent, newest first, with the whole message (DEV_AUTH keeps it). */ | |
| 140 | + const emails = async () => { | |
| 141 | + const r = await api("GET", "/diffinery/emails?all=1&per_page=200", undefined, T.root); | |
| 142 | + assert.equal(r.status, 200, JSON.stringify(r.json)); | |
| 143 | + return r.json; | |
| 144 | + }; | |
| 145 | + const email = async (what, pred) => { | |
| 146 | + const end = Date.now() + 60_000; | |
| 147 | + while (Date.now() < end) { | |
| 148 | + const m = (await emails()).find(pred); | |
| 149 | + if (m) return m; | |
| 150 | + await sleep(500); | |
| 151 | + } | |
| 152 | + assert.fail(`timed out waiting for the email: ${what}\n${log.slice(-3000)}`); | |
| 153 | + }; | |
| 154 | + const linkIn = (m, path) => { | |
| 155 | + const text = m.dev_copy?.text ?? ""; | |
| 156 | + const found = new RegExp(`${base.replace(/[.]/g, "\\.")}${path}#([A-Za-z0-9_-]{20,100})`).exec(text); | |
| 157 | + assert.ok(found, `no ${path} link in:\n${text}`); | |
| 158 | + return found[1]; | |
| 159 | + }; | |
| 160 | + /** A form whose stamp is old enough to count as a person's (3 seconds). */ | |
| 161 | + const ready = async (path = "/contact", who) => { | |
| 162 | + const f = await formOf(path, who); | |
| 163 | + await sleep(3200); | |
| 164 | + return f; | |
| 165 | + }; | |
| 166 | + w = { base, page, api, formOf, post, emails, email, linkIn, ready, log: () => log }; | |
| 167 | + return w; | |
| 168 | +} | |
| 169 | + | |
| 170 | +const message = (email, over = {}) => ({ email, name: "Ada", topic: "question", message: "Does Diffinery run my workflows as they are?", website: "", ...over }); | |
| 171 | +const byEmail = async (api, address) => (await api("GET", `/diffinery/admin/contact?q=${encodeURIComponent(address)}`, undefined, T.root)).json; | |
| 172 | +let nextIp = 1; | |
| 173 | +/** A fresh address for the Worker to see each request from, so the daily limit per network doesn't mix tests. */ | |
| 174 | +const ip = () => `198.51.100.${nextIp++}`; | |
| 175 | + | |
| 176 | +test("contact: the footer, sitemap, robots.txt and llms.txt lead to the form", async () => { | |
| 177 | + const { page } = await worker(); | |
| 178 | + assert.match(await (await page("/")).text(), /href="\/contact">Contact</, "the footer links it"); | |
| 179 | + assert.match(await (await page("/about")).text(), /href="https:\/\/diffinery\.com\/contact"/, "the About page links it"); | |
| 180 | + const form = await page("/contact"); | |
| 181 | + assert.equal(form.status, 200); | |
| 182 | + const html = await form.text(); | |
| 183 | + assert.match(html, /<h1>Contact us<\/h1>/); | |
| 184 | + for (const name of ["name", "email", "message", "website", "stamp"]) assert.match(html, new RegExp(`name="${name}"`), name); | |
| 185 | + assert.match(html, /data-k="select" data-p="[^"]*"name":"topic"/, "the topic is a Kumo select"); | |
| 186 | + assert.doesNotMatch(html, /<select|type="checkbox"/, "Kumo controls only"); | |
| 187 | + assert.match(html, /"@type":"ContactPage"/, "structured data for search engines"); | |
| 188 | + assert.match(html, /href="\/security\/report"/, "security problems go to the private form"); | |
| 189 | + assert.match(await (await page("/contact?topic=press")).text(), /name="topic" value="press"/, "a link can choose the topic"); | |
| 190 | + assert.doesNotMatch(html, /name="topic" value=/, "no topic until one is chosen"); | |
| 191 | + // Signed in: no email field, the account's address is used. | |
| 192 | + const ann = (await (await page("/contact", "ann")).text()).replace(/'/g, "'"); | |
| 193 | + assert.doesNotMatch(ann, /name="email"/); | |
| 194 | + assert.match(ann, /Signed in as ann\. We'll reply to ann@example\.com\./); | |
| 195 | + assert.match(ann, /href="\/contact">Contact</, "the signed-in footer links it too"); | |
| 196 | + // Search engines find the form but not the confirmation page. | |
| 197 | + const robots = await (await page("/robots.txt")).text(); | |
| 198 | + assert.match(robots, /Disallow: \/contact\/confirm/); | |
| 199 | + assert.doesNotMatch(robots, /Disallow: \/contact\n/); | |
| 200 | + assert.match(await (await page("/sitemap.xml")).text(), /:\d+\/contact<\/loc>/); | |
| 201 | + assert.match(await (await page("/llms.txt")).text(), /Contact the Diffinery team: http:\/\/[^ ]+\/contact/); | |
| 202 | + assert.match(await (await page("/contact/confirm")).text(), /name="robots" content="noindex/); | |
| 203 | +}); | |
| 204 | + | |
| 205 | +test("contact: a visitor's message is confirmed with the emailed link, then reaches the site administrators", async () => { | |
| 206 | + const { page, api, post, email, linkIn, ready } = await worker(); | |
| 207 | + const f = await ready(); | |
| 208 | + const hostile = { name: "Ada <script>alert(1)</script>", topic: "partnership", message: "Hello team,\n<img src=x onerror=alert(1)>\nLet's talk." }; | |
| 209 | + const sent = await post("/contact", f, message("Ada@Example.net", hostile), undefined, ip()); | |
| 210 | + assert.equal(sent.status, 200, sent.html.slice(0, 600)); | |
| 211 | + assert.match(sent.html, /Check your inbox/); | |
| 212 | + assert.match(sent.html, /ada@example\.net/); | |
| 213 | + const m = await email("the confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "ada@example.net"); | |
| 214 | + assert.equal(m.subject, "[Diffinery] Confirm your message to the Diffinery team"); | |
| 215 | + assert.match(m.dev_copy.text, /Partnerships and integrations/); | |
| 216 | + assert.doesNotMatch(JSON.stringify(m.dev_copy), /Ada|onerror|Let's talk/, "nothing typed in the form goes into the email"); | |
| 217 | + const token = linkIn(m, "/contact/confirm"); | |
| 218 | + | |
| 219 | + // Unconfirmed: nobody is told yet. | |
| 220 | + const [before] = await byEmail(api, "ada@example.net"); | |
| 221 | + assert.equal(before.state, "unconfirmed"); | |
| 222 | + assert.doesNotMatch(await (await page("/settings/admin/contact", "root")).text(), /ada@example\.net/); | |
| 223 | + | |
| 224 | + const cf = { ...(await w.formOf("/contact/confirm")), stamp: "" }; | |
| 225 | + const wrong = await post("/contact/confirm", cf, { token: "x".repeat(40) }); | |
| 226 | + assert.equal(wrong.status, 410); | |
| 227 | + assert.match(wrong.html, /This link doesn't work any more/); | |
| 228 | + const ok = await post("/contact/confirm", cf, { token }); | |
| 229 | + assert.equal(ok.status, 200, ok.html.slice(0, 600)); | |
| 230 | + assert.match(ok.html, /Your message is with the team/); | |
| 231 | + assert.ok(ok.html.includes("Ada <script>alert(1)</script>"), "the name is escaped"); | |
| 232 | + assert.equal((await post("/contact/confirm", cf, { token })).status, 410, "a link works once"); | |
| 233 | + | |
| 234 | + const [after] = await byEmail(api, "ada@example.net"); | |
| 235 | + assert.equal(after.state, "new"); | |
| 236 | + assert.deepEqual({ name: after.name, topic: after.topic, message: after.message, sender: after.sender }, { name: hostile.name, topic: "partnership", message: hostile.message, sender: null }); | |
| 237 | + assert.ok(!("confirm_hash" in after)); | |
| 238 | + // The site administrators get an email to answer from their mailbox, and a notification. | |
| 239 | + const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.to_addr === "root@example.com"); | |
| 240 | + assert.equal(note.subject, "[Diffinery] Contact: Partnerships and integrations from Ada <script>alert(1)</script>"); | |
| 241 | + assert.deepEqual(note.dev_copy.replyTo, { name: hostile.name, email: "ada@example.net" }); | |
| 242 | + assert.match(note.dev_copy.text, /> Hello team,\n> <img src=x onerror=alert\(1\)>\n> Let's talk\./); | |
| 243 | + assert.ok(!note.dev_copy.html.includes("<img src=x"), "the message is escaped in the HTML email"); | |
| 244 | + assert.match(await (await page("/notifications", "root")).text(), /New contact messages are waiting for you/); | |
| 245 | + const admin = await (await page("/settings/admin/contact", "root")).text(); | |
| 246 | + assert.match(admin, /ada@example\.net/); | |
| 247 | + assert.ok(!admin.includes("<script>alert(1)") && !admin.includes("<img src=x"), "everything is escaped"); | |
| 248 | + assert.match(admin, /Contact messages\s*<span class="badge blue"[^>]*>\s*1\s*</, "the sidebar counts what waits"); | |
| 249 | + const detail = (await (await page(`/settings/admin/contact/${after.id}`, "root")).text()).replace(/'/g, "'"); | |
| 250 | + assert.ok(detail.includes("Hello team,\n<img src=x onerror=alert(1)>\nLet's talk."), "the message is plain text"); | |
| 251 | + assert.match(detail, /href="mailto:ada@example\.net\?subject=Re%3A%20Partnerships%20and%20integrations"/); | |
| 252 | +}); | |
| 253 | + | |
| 254 | +test("contact: a signed-in person's message arrives at once, with a copy for them", async () => { | |
| 255 | + const { api, post, email, formOf } = await worker(); | |
| 256 | + const f = await formOf("/contact", "ann"); | |
| 257 | + assert.equal(f.stamp, "", "no timing stamp for a signed-in person"); | |
| 258 | + const sent = await post("/contact", f, { name: "Ann", topic: "account", message: "My invitation link expired, can you resend?", email: "someone-else@example.net" }, "ann"); | |
| 259 | + assert.equal(sent.status, 200, sent.html.slice(0, 600)); | |
| 260 | + assert.match(sent.html, /Message sent/); | |
| 261 | + const [m] = await byEmail(api, "ann@example.com"); | |
| 262 | + assert.equal(m.state, "new"); | |
| 263 | + assert.equal(m.sender, "ann"); | |
| 264 | + assert.ok(m.confirmed_at); | |
| 265 | + assert.deepEqual(await byEmail(api, "someone-else@example.net"), [], "the address typed is ignored"); | |
| 266 | + const copy = await email("the copy", (e) => e.kind === "contact_received" && e.to_addr === "ann@example.com"); | |
| 267 | + assert.equal(copy.subject, "[Diffinery] We got your message"); | |
| 268 | + assert.match(copy.dev_copy.text, /My invitation link expired/); | |
| 269 | + const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.dev_copy?.replyTo?.email === "ann@example.com"); | |
| 270 | + assert.match(note.dev_copy.text, /From: Ann \(@ann\)/); | |
| 271 | +}); | |
| 272 | + | |
| 273 | +test("contact: bots, bad input and big bodies get nowhere", async () => { | |
| 274 | + const { api, post, emails, formOf, ready } = await worker(); | |
| 275 | + const fresh = await formOf("/contact"); | |
| 276 | + // Too fast after the page was shown, a filled honeypot, or a forged or missing stamp: the usual page, nothing kept or sent. | |
| 277 | + const fast = await post("/contact", fresh, message("bot1@example.net"), undefined, ip()); | |
| 278 | + assert.equal(fast.status, 200); | |
| 279 | + assert.match(fast.html, /Check your inbox/); | |
| 280 | + const f = await ready(); | |
| 281 | + const honey = await post("/contact", f, message("bot2@example.net", { website: "https://spam.example" }), undefined, ip()); | |
| 282 | + assert.match(honey.html, /Check your inbox/); | |
| 283 | + const forged = await post("/contact", { ...f, stamp: `${Date.now() - 10_000}.${"A".repeat(43)}` }, message("bot3@example.net"), undefined, ip()); | |
| 284 | + assert.match(forged.html, /Check your inbox/); | |
| 285 | + const missing = await post("/contact", { ...f, stamp: "" }, message("bot4@example.net"), undefined, ip()); | |
| 286 | + assert.match(missing.html, /Check your inbox/); | |
| 287 | + for (const a of ["bot1", "bot2", "bot3", "bot4"]) assert.deepEqual(await byEmail(api, `${a}@example.net`), [], a); | |
| 288 | + | |
| 289 | + // Bad input is refused with the reason, keeping what was typed. | |
| 290 | + const long = await post("/contact", f, message("long@example.net", { message: "x".repeat(5001), name: "Keep me" }), undefined, ip()); | |
| 291 | + assert.equal(long.status, 422); | |
| 292 | + assert.match(long.html, /Keep your message to 5,000 characters/); | |
| 293 | + assert.match(long.html, /value="Keep me"/); | |
| 294 | + const links = await post("/contact", f, message("links@example.net", { message: Array.from({ length: 6 }, (_, i) => `https://s${i}.example`).join(" ") }), undefined, ip()); | |
| 295 | + assert.equal(links.status, 422); | |
| 296 | + assert.match(links.html, /5 links or fewer/); | |
| 297 | + const bad = await post("/contact", f, message("not-an-address"), undefined, ip()); | |
| 298 | + assert.equal(bad.status, 422); | |
| 299 | + assert.match(bad.html, /Enter a valid email address/); | |
| 300 | + const topic = await post("/contact", f, message("t@example.net", { topic: "<script>" }), undefined, ip()); | |
| 301 | + assert.equal(topic.status, 422); | |
| 302 | + assert.ok(!topic.html.includes('value="<script>"')); | |
| 303 | + const huge = await post("/contact", f, message("huge@example.net", { message: "y".repeat(80_000) }), undefined, ip()); | |
| 304 | + assert.equal(huge.status, 413); | |
| 305 | + await sleep(1500); | |
| 306 | + const tried = new Set(["bot1", "bot2", "bot3", "bot4", "long", "links", "t", "huge"].map((n) => `${n}@example.net`)); | |
| 307 | + assert.deepEqual((await emails()).filter((e) => tried.has(e.to_addr)), [], "nothing was emailed"); | |
| 308 | +}); | |
| 309 | + | |
| 310 | +test("contact: only site administrators see messages (404 for anyone else)", async () => { | |
| 311 | + const { page, api, post, formOf } = await worker(); | |
| 312 | + const [ada] = await byEmail(api, "ada@example.net"); | |
| 313 | + assert.ok(ada, "the message from the tests above"); | |
| 314 | + for (const path of ["/settings/admin/contact", `/settings/admin/contact/${ada.id}`]) { | |
| 315 | + assert.equal((await page(path, "ann")).status, 404, `${path} for a person who isn't a site administrator`); | |
| 316 | + const anon = await page(path); | |
| 317 | + assert.equal(anon.status, 302, `${path} sends a visitor to sign in`); | |
| 318 | + assert.match(anon.headers.get("location") ?? "", /^\/login\?next=/); | |
| 319 | + } | |
| 320 | + const annForm = await formOf("/settings", "ann"); | |
| 321 | + for (const action of ["state", "note", "delete"]) assert.equal((await post(`/settings/admin/contact/${ada.id}/${action}`, annForm, { state: "spam", note: "x" }, "ann")).status, 404, action); | |
| 322 | + assert.equal((await api("GET", "/diffinery/admin/contact", undefined, T.ann)).status, 404); | |
| 323 | + assert.equal((await api("GET", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404); | |
| 324 | + assert.equal((await api("PATCH", `/diffinery/admin/contact/${ada.id}`, { state: "spam" }, T.ann)).status, 404); | |
| 325 | + assert.equal((await api("DELETE", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404); | |
| 326 | + assert.equal((await api("GET", "/diffinery/admin/contact")).status, 401); | |
| 327 | + assert.doesNotMatch(await (await page("/settings", "ann")).text(), /href="\/settings\/admin\/contact"/); | |
| 328 | + assert.equal((await byEmail(api, "ada@example.net"))[0].state, "new", "nothing changed"); | |
| 329 | +}); | |
| 330 | + | |
| 331 | +test("contact: handle, note, spam, reopen and delete, on the web and through the API, audited", async () => { | |
| 332 | + const { page, api, post, formOf, email, emails, linkIn, ready } = await worker(); | |
| 333 | + const [ada] = await byEmail(api, "ada@example.net"); | |
| 334 | + // Mark handled from the web, with a note. | |
| 335 | + const handled = await post(`/settings/admin/contact/${ada.id}/state`, await formOf("/settings/admin/contact", "root"), { state: "handled", back: "/settings/admin/contact" }, "root"); | |
| 336 | + assert.equal(handled.status, 303); | |
| 337 | + assert.equal(handled.location, "/settings/admin/contact"); | |
| 338 | + const noted = await post(`/settings/admin/contact/${ada.id}/note`, await formOf(`/settings/admin/contact/${ada.id}`, "root"), { note: "Replied with the partner guide." }, "root"); | |
| 339 | + assert.equal(noted.status, 303); | |
| 340 | + const [now] = await byEmail(api, "ada@example.net"); | |
| 341 | + assert.deepEqual({ state: now.state, note: now.note, updated_by: now.updated_by }, { state: "handled", note: "Replied with the partner guide.", updated_by: "root" }); | |
| 342 | + assert.match(await (await page("/settings/admin/contact?state=handled", "root")).text(), /handled by root/); | |
| 343 | + assert.match(await (await page(`/settings/admin/contact/${ada.id}`, "root")).text(), />Replied with the partner guide\.</); | |
| 344 | + | |
| 345 | + // A second message: spam through the API, after which its address gets nothing through; reopen; then delete on the web. | |
| 346 | + const f = await ready(); | |
| 347 | + await post("/contact", f, message("bob@example.net", { message: "Buy cheap watches, secret plans." }), undefined, ip()); | |
| 348 | + const token = linkIn(await email("bob's confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "bob@example.net"), "/contact/confirm"); | |
| 349 | + await post("/contact/confirm", { ...(await formOf("/contact/confirm")), stamp: "" }, { token }); | |
| 350 | + const [bob] = await byEmail(api, "bob@example.net"); | |
| 351 | + assert.equal(bob.state, "new"); | |
| 352 | + const spam = await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "spam" }, T.root); | |
| 353 | + assert.equal(spam.status, 200, JSON.stringify(spam.json)); | |
| 354 | + assert.equal(spam.json.state, "spam"); | |
| 355 | + assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "deleted" }, T.root)).status, 422); | |
| 356 | + const toBob = async () => (await emails()).filter((e) => e.to_addr === "bob@example.net").length; | |
| 357 | + const sent = await toBob(); | |
| 358 | + const again = await post("/contact", f, message("bob@example.net"), undefined, ip()); | |
| 359 | + assert.match(again.html, /Check your inbox/, "the same page"); | |
| 360 | + await sleep(1500); | |
| 361 | + assert.equal(await toBob(), sent, "nothing is emailed to an address marked spam"); | |
| 362 | + assert.equal((await byEmail(api, "bob@example.net")).length, 1, "and nothing is stored"); | |
| 363 | + assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "new", note: "Not spam after all" }, T.root)).json.state, "new"); | |
| 364 | + const del = await post(`/settings/admin/contact/${bob.id}/delete`, await formOf(`/settings/admin/contact/${bob.id}`, "root"), {}, "root"); | |
| 365 | + assert.equal(del.status, 303); | |
| 366 | + assert.equal(del.location, "/settings/admin/contact"); | |
| 367 | + assert.equal((await api("GET", `/diffinery/admin/contact/${bob.id}`, undefined, T.root)).status, 404); | |
| 368 | + | |
| 369 | + const audit = await (await page("/settings/audit", "root")).text(); | |
| 370 | + for (const action of ["contact.create", "contact.confirm", "contact.handle", "contact.note", "contact.spam", "contact.reopen", "contact.delete"]) assert.ok(audit.includes(action), `${action} is audited`); | |
| 371 | + assert.ok(!audit.includes("secret plans") && !audit.includes("partner guide") && !audit.includes("onerror"), "the audit log holds no message content"); | |
| 372 | +}); | |
| 373 | + | |
| 374 | +test("contact: daily limits per network and per address", async () => { | |
| 375 | + const { api, post, emails, ready } = await worker(); | |
| 376 | + const f = await ready(); | |
| 377 | + // One address: three emails a day, then the page stays the same and nothing more is sent. | |
| 378 | + const flood = "flood@example.net"; | |
| 379 | + for (let i = 0; i < 4; i++) assert.equal((await post("/contact", f, message(flood), undefined, ip())).status, 200); | |
| 380 | + await sleep(1500); | |
| 381 | + assert.equal((await emails()).filter((e) => e.to_addr === flood).length, 3); | |
| 382 | + assert.equal((await byEmail(api, flood)).length, 3); | |
| 383 | + // One network: ten submissions a day, then a visible refusal. | |
| 384 | + const net = "203.0.113.77"; | |
| 385 | + for (let i = 0; i < 10; i++) assert.equal((await post("/contact", f, message(`n${i}@example.net`), undefined, net)).status, 200, `submission ${i + 1}`); | |
| 386 | + const over = await post("/contact", f, message("n10@example.net"), undefined, net); | |
| 387 | + assert.equal(over.status, 429); | |
| 388 | + assert.match(over.html, /Too many messages from your network today/); | |
| 389 | + assert.deepEqual(await byEmail(api, "n10@example.net"), []); | |
| 390 | + // Another network still gets through. | |
| 391 | + assert.equal((await post("/contact", f, message("other@example.net"), undefined, ip())).status, 200); | |
| 392 | +}); |
addedapps/diffinery/test/unit/contact.test.ts+316−0View file
| @@ -1,0 +1,316 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import type { Env } from "../../src/env.js"; | |
| 3 | +import type { Account } from "../../src/lib/directory.js"; | |
| 4 | +import { | |
| 5 | + ContactMessages, | |
| 6 | + EMAILS_PER_ADDRESS_PER_DAY, | |
| 7 | + EMAILS_PER_DAY, | |
| 8 | + HANDLED_DAYS, | |
| 9 | + MAX_LINKS, | |
| 10 | + MAX_MESSAGE, | |
| 11 | + MAX_NAME, | |
| 12 | + MAX_NOTE, | |
| 13 | + MESSAGES_PER_ACCOUNT_PER_DAY, | |
| 14 | + SPAM_DAYS, | |
| 15 | + SUBMISSIONS_PER_IP_PER_DAY, | |
| 16 | + UNCONFIRMED_DAYS, | |
| 17 | + changeMessage, | |
| 18 | + confirmMessage, | |
| 19 | + deleteMessage, | |
| 20 | + messageJson, | |
| 21 | + submitMessage, | |
| 22 | + validateMessage, | |
| 23 | + type MessageInput, | |
| 24 | +} from "../../src/lib/contact.js"; | |
| 25 | +import { pruneExpired } from "../../src/lib/retention.js"; | |
| 26 | +import { HttpError } from "../../src/lib/util.js"; | |
| 27 | +import type { MailJob } from "../../src/mail/types.js"; | |
| 28 | +import { testD1 } from "./stubs/d1.js"; | |
| 29 | + | |
| 30 | +const at = "2026-10-01T00:00:00.000Z"; | |
| 31 | +const PUB = "https://diffinery.example"; | |
| 32 | + | |
| 33 | +/** A D1 with two site administrators (one disabled), a person, and a mail queue that records what it gets. */ | |
| 34 | +function setup() { | |
| 35 | + const DB = testD1(); | |
| 36 | + const add = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)"); | |
| 37 | + add.run("u-root", "root", "Root", "root@example.com", "active", 1, at); | |
| 38 | + add.run("u-old", "old", "Old", "old@example.com", "disabled", 1, at); | |
| 39 | + add.run("u-ann", "ann", "Ann", "ann@example.com", "active", 0, at); | |
| 40 | + const queued: MailJob[] = []; | |
| 41 | + const MAIL = { sendBatch: async (b: Array<{ body: MailJob }>) => void queued.push(...b.map((m) => m.body)) }; | |
| 42 | + const env = { DB, MAIL, PUBLIC_URL: PUB } as unknown as Env; | |
| 43 | + const audits: Array<{ action: string; target?: string; actorLogin?: string; detail?: unknown }> = []; | |
| 44 | + const audit = async (e: (typeof audits)[number]) => void audits.push(e); | |
| 45 | + const account = (id: string) => DB.sqlite.prepare("SELECT * FROM accounts WHERE id = ?").get(id) as unknown as Account; | |
| 46 | + return { env, DB, queued, audits, audit, root: account("u-root"), ann: account("u-ann") }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +const refused = (f: () => unknown, status: number, msg: RegExp) => { | |
| 50 | + try { | |
| 51 | + f(); | |
| 52 | + } catch (e) { | |
| 53 | + expect(e).toBeInstanceOf(HttpError); | |
| 54 | + expect((e as HttpError).status).toBe(status); | |
| 55 | + expect((e as HttpError).detail).toMatch(msg); | |
| 56 | + return; | |
| 57 | + } | |
| 58 | + throw new Error("expected it to be refused"); | |
| 59 | +}; | |
| 60 | + | |
| 61 | +const rejects = async (p: Promise<unknown>, status: number, msg: RegExp) => { | |
| 62 | + const e = await p.then( | |
| 63 | + () => null, | |
| 64 | + (x: unknown) => x, | |
| 65 | + ); | |
| 66 | + expect(e).toBeInstanceOf(HttpError); | |
| 67 | + expect((e as HttpError).status).toBe(status); | |
| 68 | + expect((e as HttpError).detail).toMatch(msg); | |
| 69 | +}; | |
| 70 | + | |
| 71 | +const input = (email: string | null, over: Partial<MessageInput> = {}): MessageInput => ({ email, name: "Ada", topic: "question", message: "Does Diffinery run my workflows as they are?", ...over }); | |
| 72 | +const tokenOf = (job: MailJob | undefined) => /#([A-Za-z0-9_-]+)$/.exec((job as { data: { url: string } }).data.url)![1]!; | |
| 73 | +const rows = (DB: ReturnType<typeof testD1>) => DB.sqlite.prepare("SELECT * FROM contact_messages ORDER BY created_at, id").all() as Array<Record<string, unknown>>; | |
| 74 | + | |
| 75 | +describe("message validation", () => { | |
| 76 | + it("needs a name, a valid address, a topic from the list and a message, and tidies them", () => { | |
| 77 | + expect(validateMessage({ email: " Ada@Example.COM ", name: " Ada \n Lovelace ", topic: "press", message: "\r\nLine one\r\n\r\n\r\n\r\nLine two " }, false)).toEqual({ email: "ada@example.com", name: "Ada Lovelace", topic: "press", message: "Line one\n\nLine two" }); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("takes no address from a signed-in person: their account's is used", () => { | |
| 81 | + expect(validateMessage({ email: "someone-else@example.com", name: "Ann", topic: "other", message: "Hello there, team!" }, true)).toEqual({ email: null, name: "Ann", topic: "other", message: "Hello there, team!" }); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it("strips control and text-direction characters that could disguise text", () => { | |
| 85 | + expect(validateMessage({ email: "a@b.co", name: "Ada\u0000x", topic: "question", message: "okay then\u0007, thanks" }, false)).toMatchObject({ name: "Adax", message: "okay then, thanks" }); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("refuses what's missing, too short, too long (never cut), off the list or full of links", () => { | |
| 89 | + const ok = { email: "a@b.co", name: "Ada", topic: "question", message: "A real question here." }; | |
| 90 | + refused(() => validateMessage({ ...ok, email: " " }, false), 422, /Enter your email address/); | |
| 91 | + refused(() => validateMessage({ ...ok, email: "<script>@x.y" }, false), 422, /valid email address/); | |
| 92 | + refused(() => validateMessage({ ...ok, name: " " }, false), 422, /Enter your name/); | |
| 93 | + refused(() => validateMessage({ ...ok, name: "x".repeat(MAX_NAME + 1) }, false), 422, /100 characters/); | |
| 94 | + refused(() => validateMessage({ ...ok, topic: "" }, false), 422, /Choose what your message is about/); | |
| 95 | + refused(() => validateMessage({ ...ok, topic: "sales" }, false), 422, /from the list/); | |
| 96 | + refused(() => validateMessage({ ...ok, message: "" }, false), 422, /Write your message/); | |
| 97 | + refused(() => validateMessage({ ...ok, message: "hi" }, false), 422, /at least 10 characters/); | |
| 98 | + refused(() => validateMessage({ ...ok, message: "x".repeat(MAX_MESSAGE + 1) }, false), 422, /5,000 characters/); | |
| 99 | + expect(validateMessage({ ...ok, message: "x".repeat(MAX_MESSAGE) }, false).message).toHaveLength(MAX_MESSAGE); | |
| 100 | + const links = (n: number) => Array.from({ length: n }, (_, i) => `https://spam${i}.example`).join(" "); | |
| 101 | + expect(validateMessage({ ...ok, message: links(MAX_LINKS) }, false).message).toContain("spam4"); | |
| 102 | + refused(() => validateMessage({ ...ok, message: `${links(MAX_LINKS)} www.more.example` }, false), 422, /5 links or fewer/); | |
| 103 | + refused(() => validateMessage({ ...ok, message: ["a list, not text"] }, false), 422, /Write your message/); | |
| 104 | + }); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +describe("sending without signing in", () => { | |
| 108 | + it("stores the message unconfirmed and emails only a confirmation link, with nothing typed in it", async () => { | |
| 109 | + const { env, DB, queued, audits, audit } = setup(); | |
| 110 | + expect(await submitMessage(env, audit, input("new@example.net", { name: "<b>Buy now</b>", message: "Visit https://spam.example for deals!" }), null, "203.0.113.9")).toBe("confirm"); | |
| 111 | + const [m] = rows(DB); | |
| 112 | + expect(m).toMatchObject({ email: "new@example.net", name: "<b>Buy now</b>", state: "unconfirmed", account_id: null, confirmed_at: null }); | |
| 113 | + expect(queued).toHaveLength(1); | |
| 114 | + expect(queued[0]).toMatchObject({ kind: "contact_confirm", to: { email: "new@example.net" }, reason: "contact", data: { topic: "A question about Diffinery" } }); | |
| 115 | + expect(JSON.stringify(queued[0])).not.toMatch(/Buy now|spam\.example/); | |
| 116 | + // Only a hash of the link's code is kept. | |
| 117 | + expect(m!.confirm_hash).toMatch(/^[0-9a-f]{64}$/); | |
| 118 | + expect(JSON.stringify(rows(DB))).not.toContain(tokenOf(queued[0])); | |
| 119 | + expect(audits).toEqual([{ action: "contact.create", target: m!.id, detail: { signed_in: false } }]); | |
| 120 | + // The site administrators hear nothing until it is confirmed. | |
| 121 | + expect(DB.sqlite.prepare("SELECT count(*) AS n FROM notifications").get()).toEqual({ n: 0 }); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it("confirms with the link once: the message becomes new and every active site administrator hears about it", async () => { | |
| 125 | + const { env, DB, queued, audits, audit } = setup(); | |
| 126 | + await submitMessage(env, audit, input("new@example.net"), null, "203.0.113.9"); | |
| 127 | + const token = tokenOf(queued[0]); | |
| 128 | + const m = (await confirmMessage(env, audit, token))!; | |
| 129 | + expect(m).toMatchObject({ state: "new", confirm_hash: null }); | |
| 130 | + expect(rows(DB)[0]).toMatchObject({ state: "new", confirm_hash: null, confirm_expires_at: null }); | |
| 131 | + expect(audits.at(-1)).toEqual({ action: "contact.confirm", target: m.id }); | |
| 132 | + const mails = queued.slice(1); | |
| 133 | + expect(mails).toEqual([{ kind: "contact_message", data: { id: m.id, name: "Ada", email: "new@example.net", login: null, topic: "A question about Diffinery", message: "Does Diffinery run my workflows as they are?" }, to: { accountId: "u-root" }, reason: "admin", at: m.created_at }]); | |
| 134 | + expect(DB.sqlite.prepare("SELECT account_id, kind, url FROM notifications").all()).toEqual([{ account_id: "u-root", kind: "contact", url: "/settings/admin/contact" }]); | |
| 135 | + // The link works once. | |
| 136 | + expect(await confirmMessage(env, audit, token)).toBeNull(); | |
| 137 | + // A second message adds no second unread notification, but does email again. | |
| 138 | + await submitMessage(env, audit, input("other@example.net"), null, "203.0.113.10"); | |
| 139 | + await confirmMessage(env, audit, tokenOf(queued.at(-1))); | |
| 140 | + expect(DB.sqlite.prepare("SELECT count(*) AS n FROM notifications").get()).toEqual({ n: 1 }); | |
| 141 | + expect(queued.filter((j) => j.kind === "contact_message")).toHaveLength(2); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it("refuses unknown, malformed and expired links", async () => { | |
| 145 | + const { env, DB, queued, audit } = setup(); | |
| 146 | + expect(await confirmMessage(env, audit, "nope")).toBeNull(); | |
| 147 | + expect(await confirmMessage(env, audit, "a".repeat(32))).toBeNull(); | |
| 148 | + await submitMessage(env, audit, input("late@example.net"), null, "203.0.113.9"); | |
| 149 | + DB.sqlite.prepare("UPDATE contact_messages SET confirm_expires_at = ?").run(at); | |
| 150 | + expect(await confirmMessage(env, audit, tokenOf(queued[0]))).toBeNull(); | |
| 151 | + expect(rows(DB)[0]).toMatchObject({ state: "unconfirmed" }); | |
| 152 | + }); | |
| 153 | + | |
| 154 | + it("keeps each message separate, so a second one from the same address needs its own confirmation", async () => { | |
| 155 | + const { env, DB, queued, audit } = setup(); | |
| 156 | + await submitMessage(env, audit, input("ada@example.net", { message: "First message, about one thing." }), null, "203.0.113.9"); | |
| 157 | + await submitMessage(env, audit, input("ada@example.net", { message: "Second message, about another." }), null, "203.0.113.9"); | |
| 158 | + expect(rows(DB)).toHaveLength(2); | |
| 159 | + await confirmMessage(env, audit, tokenOf(queued[1])); | |
| 160 | + expect(rows(DB).map((r) => [r.message, r.state])).toEqual([ | |
| 161 | + ["First message, about one thing.", "unconfirmed"], | |
| 162 | + ["Second message, about another.", "new"], | |
| 163 | + ]); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + it("limits one network, one address and the whole form per day, quietly past the last two", async () => { | |
| 167 | + const { env, DB, queued, audit } = setup(); | |
| 168 | + for (let i = 0; i < EMAILS_PER_ADDRESS_PER_DAY; i++) expect(await submitMessage(env, audit, input("same@example.net"), null, `198.51.100.${i}`)).toBe("confirm"); | |
| 169 | + expect(await submitMessage(env, audit, input("same@example.net"), null, "198.51.100.99")).toBe("limited"); | |
| 170 | + expect(queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY); | |
| 171 | + expect(rows(DB)).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY); | |
| 172 | + | |
| 173 | + for (let i = 0; i < SUBMISSIONS_PER_IP_PER_DAY; i++) await submitMessage(env, audit, input(`n${i}@example.net`), null, "192.0.2.1"); | |
| 174 | + await rejects(submitMessage(env, audit, input("one-more@example.net"), null, "192.0.2.1"), 429, /Too many messages from your network today/); | |
| 175 | + | |
| 176 | + DB.sqlite.prepare("DELETE FROM contact_attempts").run(); | |
| 177 | + const many = DB.sqlite.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'x', ?, 1, ?)"); | |
| 178 | + for (let i = 0; i < EMAILS_PER_DAY; i++) many.run(`a${i}`, `e${i}`, new Date().toISOString()); | |
| 179 | + const before = queued.length; | |
| 180 | + expect(await submitMessage(env, audit, input("fresh@example.net"), null, "203.0.113.50")).toBe("limited"); | |
| 181 | + expect(queued).toHaveLength(before); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + it("keeps only hashes of the IP address and the address typed for the limits", async () => { | |
| 185 | + const { env, DB, audit } = setup(); | |
| 186 | + await submitMessage(env, audit, input("hash@example.net"), null, "203.0.113.77"); | |
| 187 | + const dump = JSON.stringify(DB.sqlite.prepare("SELECT * FROM contact_attempts").all()); | |
| 188 | + expect(dump).not.toContain("203.0.113.77"); | |
| 189 | + expect(dump).not.toContain("hash@example.net"); | |
| 190 | + }); | |
| 191 | + | |
| 192 | + it("drops messages from an address while one from it is marked spam, until it is reopened", async () => { | |
| 193 | + const { env, DB, queued, audit, root } = setup(); | |
| 194 | + await submitMessage(env, audit, input("spammer@example.net"), null, "203.0.113.9"); | |
| 195 | + const [first] = rows(DB); | |
| 196 | + await changeMessage(env.DB, audit, root, first!.id as string, { state: "spam" }, "web"); | |
| 197 | + const sent = queued.length; | |
| 198 | + expect(await submitMessage(env, audit, input("SPAMMER@example.net"), null, "203.0.113.10")).toBe("blocked"); | |
| 199 | + expect(queued).toHaveLength(sent); | |
| 200 | + expect(rows(DB)).toHaveLength(1); | |
| 201 | + await changeMessage(env.DB, audit, root, first!.id as string, { state: "new" }, "web"); | |
| 202 | + expect(await submitMessage(env, audit, input("spammer@example.net"), null, "203.0.113.10")).toBe("confirm"); | |
| 203 | + }); | |
| 204 | +}); | |
| 205 | + | |
| 206 | +describe("sending signed in", () => { | |
| 207 | + it("arrives new from the account's address, with a copy to the sender and a notice to the site administrators", async () => { | |
| 208 | + const { env, DB, queued, audits, audit, ann } = setup(); | |
| 209 | + expect(await submitMessage(env, audit, input(null, { name: "Ann A." }), ann, "203.0.113.9")).toBe("sent"); | |
| 210 | + const [m] = rows(DB); | |
| 211 | + expect(m).toMatchObject({ email: "ann@example.com", name: "Ann A.", account_id: "u-ann", sender_login: "ann", state: "new", confirm_hash: null }); | |
| 212 | + expect(m!.confirmed_at).toBe(m!.created_at); | |
| 213 | + expect(queued.map((j) => [j.kind, j.to])).toEqual([ | |
| 214 | + ["contact_received", { accountId: "u-ann" }], | |
| 215 | + ["contact_message", { accountId: "u-root" }], | |
| 216 | + ]); | |
| 217 | + expect(queued[1]).toMatchObject({ data: { login: "ann", email: "ann@example.com" } }); | |
| 218 | + expect(audits).toEqual([{ actorId: "u-ann", actorLogin: "ann", action: "contact.create", target: m!.id, detail: { signed_in: true } }]); | |
| 219 | + // No daily-limit rows: a signed-in person is counted by account. | |
| 220 | + expect(DB.sqlite.prepare("SELECT count(*) AS n FROM contact_attempts").get()).toEqual({ n: 0 }); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + it("limits one person per day", async () => { | |
| 224 | + const { env, DB, audit, ann } = setup(); | |
| 225 | + const add = DB.sqlite.prepare("INSERT INTO contact_messages (id, email, name, topic, message, account_id, state, created_at, updated_at) VALUES (?, 'ann@example.com', 'Ann', 'other', 'm', 'u-ann', 'new', ?, ?)"); | |
| 226 | + for (let i = 0; i < MESSAGES_PER_ACCOUNT_PER_DAY; i++) add.run(`m${i}`, new Date().toISOString(), new Date().toISOString()); | |
| 227 | + await rejects(submitMessage(env, audit, input(null), ann, null), 429, /20 messages today/); | |
| 228 | + }); | |
| 229 | +}); | |
| 230 | + | |
| 231 | +describe("site administrators", () => { | |
| 232 | + async function confirmed() { | |
| 233 | + const s = setup(); | |
| 234 | + await submitMessage(s.env, s.audit, input("ada@example.net"), null, "203.0.113.9"); | |
| 235 | + await confirmMessage(s.env, s.audit, tokenOf(s.queued[0])); | |
| 236 | + return { ...s, id: rows(s.DB)[0]!.id as string }; | |
| 237 | + } | |
| 238 | + | |
| 239 | + it("mark handled and spam, reopen, and keep a note, auditing each change without the message", async () => { | |
| 240 | + const { env, audits, audit, root, id } = await confirmed(); | |
| 241 | + const handled = await changeMessage(env.DB, audit, root, id, { state: "handled", note: " Replied with the guide. " }, "api"); | |
| 242 | + expect(handled).toMatchObject({ state: "handled", note: "Replied with the guide.", updated_by: "root" }); | |
| 243 | + expect(handled.handled_at).toBeTruthy(); | |
| 244 | + expect(audits.slice(-2)).toEqual([ | |
| 245 | + { actorId: "u-root", actorLogin: "root", action: "contact.handle", target: id, detail: { from: "new", to: "handled", via: "api" } }, | |
| 246 | + { actorId: "u-root", actorLogin: "root", action: "contact.note", target: id, detail: { via: "api" } }, | |
| 247 | + ]); | |
| 248 | + expect(JSON.stringify(audits)).not.toContain("Replied with"); | |
| 249 | + // A note alone keeps the state; an empty note clears it. | |
| 250 | + expect(await changeMessage(env.DB, audit, root, id, { note: "" }, "web")).toMatchObject({ state: "handled", note: null }); | |
| 251 | + expect(await changeMessage(env.DB, audit, root, id, { state: "spam" }, "web")).toMatchObject({ state: "spam" }); | |
| 252 | + const reopened = await changeMessage(env.DB, audit, root, id, { state: "new" }, "web"); | |
| 253 | + expect(reopened).toMatchObject({ state: "new", handled_at: null }); | |
| 254 | + expect(audits.at(-1)).toMatchObject({ action: "contact.reopen", detail: { from: "spam", to: "new" } }); | |
| 255 | + await rejects(changeMessage(env.DB, audit, root, id, { state: "deleted" }, "api"), 422, /state must be one of/); | |
| 256 | + await rejects(changeMessage(env.DB, audit, root, id, {}, "api"), 422, /a state, a note, or both/); | |
| 257 | + await rejects(changeMessage(env.DB, audit, root, id, { note: "x".repeat(MAX_NOTE + 1) }, "api"), 422, /2,000 characters/); | |
| 258 | + await rejects(changeMessage(env.DB, audit, root, "0".repeat(32), { state: "handled" }, "api"), 404, /no contact message/); | |
| 259 | + }); | |
| 260 | + | |
| 261 | + it("reopen an unconfirmed message as unconfirmed", async () => { | |
| 262 | + const { env, DB, audit, root } = setup(); | |
| 263 | + await submitMessage(env, audit, input("ada@example.net"), null, "203.0.113.9"); | |
| 264 | + const id = rows(DB)[0]!.id as string; | |
| 265 | + await changeMessage(env.DB, audit, root, id, { state: "spam" }, "web"); | |
| 266 | + expect(await changeMessage(env.DB, audit, root, id, { state: "new" }, "web")).toMatchObject({ state: "unconfirmed" }); | |
| 267 | + }); | |
| 268 | + | |
| 269 | + it("delete a message, auditing that it happened but not what it said or who sent it", async () => { | |
| 270 | + const { env, DB, audits, audit, root, id } = await confirmed(); | |
| 271 | + await deleteMessage(env.DB, audit, root, id, "web"); | |
| 272 | + expect(rows(DB)).toHaveLength(0); | |
| 273 | + expect(audits.at(-1)).toEqual({ actorId: "u-root", actorLogin: "root", action: "contact.delete", target: id, detail: { state: "new", via: "web" } }); | |
| 274 | + expect(JSON.stringify(audits)).not.toContain("ada@example.net"); | |
| 275 | + await rejects(deleteMessage(env.DB, audit, root, id, "web"), 404, /no contact message/); | |
| 276 | + }); | |
| 277 | + | |
| 278 | + it("list, filter, search, count and show messages as the API does", async () => { | |
| 279 | + const { env, audit, root, id } = await confirmed(); | |
| 280 | + await submitMessage(env, audit, input("bob@example.net", { name: "Bob", message: "Partnership idea with 100% more agents" }), null, "203.0.113.20"); | |
| 281 | + const store = new ContactMessages(env.DB); | |
| 282 | + expect(await store.counts()).toEqual({ new: 1, handled: 0, spam: 0, unconfirmed: 1 }); | |
| 283 | + expect(await store.waiting()).toBe(1); | |
| 284 | + expect((await store.list({ state: "new" })).items.map((m) => m.id)).toEqual([id]); | |
| 285 | + expect((await store.list({ q: "100%" })).items.map((m) => m.name)).toEqual(["Bob"]); | |
| 286 | + expect((await store.list({ q: "%" })).items.map((m) => m.name)).toEqual(["Bob"]); | |
| 287 | + expect((await store.list({ limit: 1 })).more).toBe(true); | |
| 288 | + expect(await store.get("not-an-id")).toBeNull(); | |
| 289 | + const json = messageJson(env, (await changeMessage(env.DB, audit, root, id, { state: "handled" }, "api")) as never); | |
| 290 | + expect(Object.keys(json)).toEqual(["id", "state", "email", "name", "topic", "message", "sender", "confirmed_at", "note", "updated_by", "handled_at", "created_at", "updated_at", "html_url"]); | |
| 291 | + expect(json).toMatchObject({ state: "handled", topic: "question", html_url: `${PUB}/settings/admin/contact/${id}` }); | |
| 292 | + expect(JSON.stringify(json)).not.toContain("confirm_hash"); | |
| 293 | + }); | |
| 294 | +}); | |
| 295 | + | |
| 296 | +describe("retention", () => { | |
| 297 | + it("deletes unconfirmed messages after a week, spam after a month, handled after a year, and limit rows after a day", async () => { | |
| 298 | + const { DB } = setup(); | |
| 299 | + const now = new Date("2026-10-20T00:00:00.000Z"); | |
| 300 | + const ago = (d: number) => new Date(now.getTime() - d * 86400_000).toISOString(); | |
| 301 | + const add = DB.sqlite.prepare("INSERT INTO contact_messages (id, email, name, topic, message, state, handled_at, created_at, updated_at) VALUES (?, 'a@b.co', 'A', 'other', 'm', ?, ?, ?, ?)"); | |
| 302 | + add.run("old-unconfirmed", "unconfirmed", null, ago(UNCONFIRMED_DAYS + 1), ago(UNCONFIRMED_DAYS + 1)); | |
| 303 | + add.run("new-unconfirmed", "unconfirmed", null, ago(1), ago(1)); | |
| 304 | + add.run("old-spam", "spam", ago(SPAM_DAYS + 1), ago(400), ago(SPAM_DAYS + 1)); | |
| 305 | + add.run("new-spam", "spam", ago(SPAM_DAYS - 1), ago(400), ago(SPAM_DAYS - 1)); | |
| 306 | + add.run("old-handled", "handled", ago(HANDLED_DAYS + 1), ago(400), ago(HANDLED_DAYS + 1)); | |
| 307 | + add.run("new-handled", "handled", ago(HANDLED_DAYS - 1), ago(400), ago(HANDLED_DAYS - 1)); | |
| 308 | + add.run("waiting", "new", null, ago(800), ago(800)); | |
| 309 | + const attempt = DB.sqlite.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'i', 'e', 1, ?)"); | |
| 310 | + attempt.run("old", ago(2)); | |
| 311 | + attempt.run("new", ago(0.5)); | |
| 312 | + const r = await pruneExpired(DB, now); | |
| 313 | + expect(r).toMatchObject({ contact_messages: 3, contact_attempts: 1 }); | |
| 314 | + expect((DB.sqlite.prepare("SELECT id FROM contact_messages ORDER BY id").all() as Array<{ id: string }>).map((x) => x.id)).toEqual(["new-handled", "new-spam", "new-unconfirmed", "waiting"]); | |
| 315 | + }); | |
| 316 | +}); |
modifiedapps/diffinery/test/unit/docs.test.ts+1−1View file
| @@ -43,7 +43,7 @@ | ||
| 43 | 43 | } |
| 44 | 44 | |
| 45 | 45 | /** Paths outside /docs that pages may link to: real routes of the app. */ |
| 46 | -const SITE_PATHS = [/^\/$/, /^\/llms(-full|-small)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/status$/, /^\/healthz$/]; | |
| 46 | +const SITE_PATHS = [/^\/$/, /^\/llms(-full|-small)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/contact$/, /^\/status$/, /^\/healthz$/]; | |
| 47 | 47 | |
| 48 | 48 | describe("docs pages", () => { |
| 49 | 49 | it("has every page in the navigation, in order", () => { |
modifiedapps/diffinery/test/unit/retention.test.ts+1−1View file
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | usage.run("old-job", ago(COMPUTE_RETENTION_DAYS + 2), ago(COMPUTE_RETENTION_DAYS + 1)); |
| 20 | 20 | usage.run("recent-job", ago(30), ago(30)); |
| 21 | 21 | usage.run("running", ago(0), null); |
| 22 | - expect(await pruneExpired(DB, at)).toEqual({ sessions: 1, notifications: 1, compute_usage: 1, early_access: 0, early_access_attempts: 0 }); | |
| 22 | + expect(await pruneExpired(DB, at)).toEqual({ sessions: 1, notifications: 1, compute_usage: 1, early_access: 0, early_access_attempts: 0, contact_messages: 0, contact_attempts: 0 }); | |
| 23 | 23 | const ids = (t: string) => (DB.sqlite.prepare(`SELECT id FROM ${t} ORDER BY id`).all() as Array<{ id: string }>).map((r) => r.id); |
| 24 | 24 | expect(ids("sessions")).toEqual(["just-expired", "live"]); |
| 25 | 25 | expect(ids("notifications")).toEqual(["recent"]); |
modifiedapps/diffinery/test/unit/site.test.ts+1−1View file
| @@ -39,7 +39,7 @@ | ||
| 39 | 39 | const prose = (md: string) => md.replace(/^```[\s\S]*?^```/gm, "").replace(/`[^`\n]*`/g, ""); |
| 40 | 40 | |
| 41 | 41 | /** Paths outside /docs and the site pages that the site may link to. */ |
| 42 | -const OTHER_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/early-access$/, /^\/status$/, /^\/security\/report$/, /^\/\.well-known\/security\.txt$/, /^\/docs\/api\/openapi\.json$/]; | |
| 42 | +const OTHER_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/early-access$/, /^\/contact$/, /^\/status$/, /^\/security\/report$/, /^\/\.well-known\/security\.txt$/, /^\/docs\/api\/openapi\.json$/]; | |
| 43 | 43 | |
| 44 | 44 | function linkProblems(md: string): string[] { |
| 45 | 45 | const problems: string[] = []; |