docs: document new features, ops env vars, and API surface; refresh roadmap

- README: feature map updated (redirect types, protected links, QR vector
  export, audience analytics, API keys, admin observability, operations);
  API reference covers new endpoints and params. Roadmap now lists
  self-serve accounts, security hardening (rate limiting/CSRF/SSRF guard),
  Stripe billing, custom domains, and remaining power features.
- INSTALL: new environment variables (WEBHOOK_URL, GEOIP_DB_PATH,
  GEO_API_FALLBACK, IP_ANONYMIZE, CLICK_RETENTION_DAYS, BACKUPS,
  BACKUP_DIR, BACKUP_KEEP) with GeoLite2 setup notes and the ip-api.com
  licensing caveat.
- api-docs page: API-key authentication, new link/analytics/QR params,
  aggregate analytics, admin overview/audit/keys/purge, unlock route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:40:44 -05:00
parent 45be86bb36
commit 48d3445b09
3 changed files with 108 additions and 30 deletions
+9 -1
View File
@@ -124,7 +124,7 @@ Click **Apply**.
- Add a proxy host: your domain → `unraid-lan-ip:5000` - Add a proxy host: your domain → `unraid-lan-ip:5000`
- Issue a Let's Encrypt certificate on the SSL tab - Issue a Let's Encrypt certificate on the SSL tab
- Keep `COOKIE_SECURE=false` — same reason as Cloudflare - Keep `COOKIE_SECURE=false` — same reason as Cloudflare
- Country detection falls back to ip-api.com (free, no key required, <45 req/min) - For country analytics without Cloudflare, mount a free MaxMind GeoLite2-Country database at `GEOIP_DB_PATH` (see Environment Variables), or set `GEO_API_FALLBACK=true` for non-commercial deployments
**Step 5 — Verify** **Step 5 — Verify**
```bash ```bash
@@ -175,6 +175,14 @@ Then click **Force Update** on the container in the Docker tab.
| `DEBUG` | `false` | Flask debug mode — keep `false` in production | | `DEBUG` | `false` | Flask debug mode — keep `false` in production |
| `COOKIE_SECURE` | `false` | Set `true` only if Flask receives HTTPS directly (not behind a proxy) | | `COOKIE_SECURE` | `false` | Set `true` only if Flask receives HTTPS directly (not behind a proxy) |
| `DB_PATH` | `/app/data/qrknit.db` | SQLite file path — leave as-is when using a Docker volume | | `DB_PATH` | `/app/data/qrknit.db` | SQLite file path — leave as-is when using a Docker volume |
| `WEBHOOK_URL` | *(empty)* | Slack/Discord-compatible webhook — notified on new contact messages |
| `GEOIP_DB_PATH` | `/app/data/GeoLite2-Country.mmdb` | Local MaxMind GeoLite2 country database for click geolocation. Download from maxmind.com (free account) and mount into the data volume. Preferred over any network lookup. |
| `GEO_API_FALLBACK` | `false` | Enable ip-api.com lookups when no GeoLite2 DB is present. **Their free tier is licensed for non-commercial use only and rate-limited (~45 req/min)** — behind Cloudflare you don't need this (the `CF-IPCountry` header is used automatically). |
| `IP_ANONYMIZE` | `none` | Privacy policy for stored click IPs: `none`, `truncate` (zero the host part), or `hash` (SHA-256 — unique-visitor counting still works) |
| `CLICK_RETENTION_DAYS` | `0` | Purge click events older than N days (0 = keep forever) |
| `BACKUPS` | `true` | Daily SQLite backup via `VACUUM INTO` |
| `BACKUP_DIR` | `<db dir>/backups` | Where daily backups are written |
| `BACKUP_KEEP` | `7` | Number of daily backups to retain |
--- ---
+32 -18
View File
@@ -18,27 +18,32 @@ A managed URL shortener with QR code generation, deep click analytics, and tag-b
| Feature area | What's included | | Feature area | What's included |
|---|---| |---|---|
| **Short links** | Random & custom codes, link expiry, pinned links, one-click copy, auto-fetch page title | | **Short links** | Random & custom codes, link expiry, pinned links, one-click copy, auto-fetch page title, per-link redirect type (302 default / 301 opt-in) |
| **QR codes** | QR generation for any link or URL, logo overlay, dot-shape presets, inline thumbnail, copy to clipboard | | **Protected links** | Per-link password protection with a branded unlock page; branded visitor pages for missing, expired, and quota-limited links |
| **Analytics** | Per-link daily click charts, referrer / device / browser / country breakdowns, hourly 7×24 heatmap, raw click CSV export | | **QR codes** | QR generation for any link or URL, logo overlay, dot-shape presets, inline thumbnail, copy to clipboard, **SVG / PDF / PNG export**, selectable error-correction level (L/M/Q/H) |
| **Analytics** | Per-link daily click charts, unique-visitor counts, bot/crawler filtering, referrer / device / browser / country breakdowns, hourly 7×24 heatmap, raw click CSV export |
| **Audience analytics** | Aggregate dashboard across the whole account or org, filterable by tag — one chart for a whole campaign |
| **Tags** | Tag links for filtering; tags are scoped per user — org members share a namespace, solo users and admins each have their own | | **Tags** | Tag links for filtering; tags are scoped per user — org members share a namespace, solo users and admins each have their own |
| **Bulk tools** | Bulk delete, bulk tag, bulk expire; CSV import & export | | **Bulk tools** | Bulk delete, bulk tag, bulk expire; CSV import & export |
| **Auth** | Session-cookie login, 30-day HttpOnly cookie, works behind Cloudflare and Nginx Proxy Manager | | **Auth** | Session-cookie login, 30-day HttpOnly cookie, **API keys** (`Authorization: Bearer qrk_…`) with read/write scopes, works behind Cloudflare and Nginx Proxy Manager |
| **Multi-user** | Per-user link isolation, admin user-management panel, username + password login | | **Multi-user** | Per-user link isolation, admin user-management panel, username + password login |
| **Organizations** | Group users into orgs that share link/tag visibility and pool usage quotas under a shared plan | | **Organizations** | Group users into orgs that share link/tag visibility and pool usage quotas under a shared plan |
| **Plan tiers** | Free / Starter / Pro / Team plans with enforced limits on active links, monthly clicks, and analytics history; org plan overrides member plans | | **Plan tiers** | Free / Starter / Pro / Team plans with enforced limits on active links, monthly clicks, and analytics history; org plan overrides member plans |
| **Admin tier** | Admin accounts sit above all plans with unlimited quotas and an isolated workspace; can filter into any user's links on demand | | **Admin tier** | Admin accounts sit above all plans with unlimited quotas and an isolated workspace; can filter into any user's links on demand |
| **Admin observability** | Platform overview (totals, 30-day series, top links), per-user/org quota usage with near-limit warnings, audit log of admin actions, webhook notifications for contact messages |
| **Landing & portal** | Marketing landing page, SaaS pricing page, contact form, admin inbox with unread badge | | **Landing & portal** | Marketing landing page, SaaS pricing page, contact form, admin inbox with unread badge |
| **Operations** | Async click logging (redirects never block), local GeoLite2 geolocation, daily SQLite backups (`VACUUM INTO`), click-data retention & IP anonymisation options, hard-purge of deleted links, multi-worker-safe uptime tracking |
| **Deployment** | `APP_NAME` / `BASE_URL` env vars, four colour themes, single Docker container, Unraid-ready | | **Deployment** | `APP_NAME` / `BASE_URL` env vars, four colour themes, single Docker container, Unraid-ready |
### Upcoming ### Roadmap
| Feature area | What's planned | | Feature area | What's planned |
|---|---| |---|---|
| **Self-serve accounts** | Public signup with email verification, change-own-password, account page with plan & usage |
| **Security hardening** | Rate limiting (login, contact, QR endpoints), CSRF tokens, SSRF guard on title fetching |
| **Billing** | Stripe checkout & subscriptions, upgrade/downgrade UI, self-serve account portal | | **Billing** | Stripe checkout & subscriptions, upgrade/downgrade UI, self-serve account portal |
| **Custom domains** | CNAME-based short domains, SSL provisioning, domain verification, Team tier multi-domain support | | **Custom domains** | CNAME-based short domains, SSL provisioning, domain verification, Team tier multi-domain support |
| **Power features** | API key auth, password-protected links, UTM parameter builder, custom 404 & expired-link pages | | **Power features** | UTM parameter builder, dead-link detection, folders/groups, duplicate link |
| **Link organisation** | Folders/groups, duplicate link, dead-link detection, per-link redirect type (301 vs 302) |
--- ---
@@ -48,26 +53,30 @@ A managed URL shortener with QR code generation, deep click analytics, and tag-b
## 🔌 API Reference ## 🔌 API Reference
All write endpoints require an active session (log in via the web UI or `POST /api/auth/login`). Authenticated endpoints accept either an active session (log in via the web UI or `POST /api/auth/login`) **or an API key**: `Authorization: Bearer qrk_…`. Keys are created in the dashboard (or via `POST /api/keys`) with a `read` or `write` scope — read-only keys can only call GET endpoints.
### Auth ### Auth & API keys
| Method | Endpoint | Auth | Description | | Method | Endpoint | Auth | Description |
|---|---|---|---| |---|---|---|---|
| POST | `/api/auth/login` | — | Login — `{"username": "...", "password": "..."}`, sets session cookie; returns `plan`, `plan_limits`, `org_id` | | POST | `/api/auth/login` | — | Login — `{"username": "...", "password": "..."}`, sets session cookie; returns `plan`, `plan_limits`, `org_id` |
| POST | `/api/auth/logout` | ✓ | Clear session | | POST | `/api/auth/logout` | ✓ | Clear session |
| GET | `/api/auth/me` | ✓ | Returns `{"authenticated": true, "username": "...", "is_admin": bool, "org_id": int\|null, "plan": "...", "plan_limits": {…}}` | | GET | `/api/auth/me` | ✓ | Returns `{"authenticated": true, "username": "...", "is_admin": bool, "org_id": int\|null, "plan": "...", "plan_limits": {…}}` |
| GET | `/api/keys` | ✓ | List your API keys (prefix, scope, last used — never the full token) |
| POST | `/api/keys` | ✓ | Create key — `{name, scope: "read"\|"write"}`; response includes `token` **once** |
| DELETE | `/api/keys/:id` | ✓ | Revoke a key (admins can revoke any key) |
### Links ### Links
| Method | Endpoint | Auth | Description | | Method | Endpoint | Auth | Description |
|---|---|---|---| |---|---|---|---|
| POST | `/api/shorten` | ✓ | Create a short link | | POST | `/api/shorten` | ✓ | Create a short link`{url, custom_code?, title?, expires_at?, tags?, redirect_type?: 301\|302, password?}` |
| GET | `/api/links` | ✓ | List links — supports `?q=`, `?tag=`, `?page=`, `?per_page=`; org members see all links within their org; admin sees own links only, optionally filtered with `?user=<username>` | | GET | `/api/links` | ✓ | List links — supports `?q=`, `?tag=`, `?page=`, `?per_page=`; org members see all links within their org; admin sees own links only, optionally filtered with `?user=<username>` |
| GET | `/api/links/:code` | ✓ | Link detail — includes `created_by` username | | GET | `/api/links/:code` | ✓ | Link detail — includes `created_by`, `redirect_type`, `has_password` |
| PATCH | `/api/links/:code` | ✓ | Edit link — `url`, `title`, `expires_at`, `tags`, `is_pinned` | | PATCH | `/api/links/:code` | ✓ | Edit link — `url`, `title`, `expires_at`, `tags`, `is_pinned`, `redirect_type` (301/302), `password` (empty string removes protection) |
| DELETE | `/api/links/:code` | ✓ | Delete link | | DELETE | `/api/links/:code` | ✓ | Delete link (soft — recoverable until purged by an admin) |
| GET | `/api/links/:code/analytics` | ✓ | Click analytics — supports `?days=7\|30\|90` (capped to plan's max window); returns `daily`, `referrers`, `devices`, `browsers`, `countries`, `heatmap` (7×24 array), and `max_days` | | GET | `/api/links/:code/analytics` | ✓ | Click analytics — supports `?days=7\|30\|90` (capped to plan's max window) and `?exclude_bots=1`; returns `daily` (with per-day `unique`), `unique_visitors`, `bot_clicks`, `referrers`, `devices`, `browsers`, `countries`, `heatmap` (7×24 array), and `max_days` |
| GET | `/api/analytics/aggregate` | ✓ | Account/org-wide analytics — `?days=`, `?tag=`, `?exclude_bots=1`; returns `daily`, `unique_visitors`, `referrers`, `devices`, `browsers`, `countries`, `top_links` |
| GET | `/api/links/:code/clicks/export` | ✓ | Download raw click events as CSV — columns: `timestamp`, `referrer`, `device`, `browser`, `country` | | GET | `/api/links/:code/clicks/export` | ✓ | Download raw click events as CSV — columns: `timestamp`, `referrer`, `device`, `browser`, `country` |
### Utilities ### Utilities
@@ -78,14 +87,15 @@ All write endpoints require an active session (log in via the web UI or `POST /a
| GET | `/api/plan` | ✓ | Current user's effective plan, limits, and live usage — `{plan, limits: {max_links, monthly_clicks, analytics_days}, usage: {active_links, monthly_clicks}}`; org members see pooled usage across all org members; admin plan is always `"admin"` (unlimited) | | GET | `/api/plan` | ✓ | Current user's effective plan, limits, and live usage — `{plan, limits: {max_links, monthly_clicks, analytics_days}, usage: {active_links, monthly_clicks}}`; org members see pooled usage across all org members; admin plan is always `"admin"` (unlimited) |
| GET | `/api/tags` | ✓ | Tags scoped to the requesting user — own tags only for solo users and admins; all org member tags for org members | | GET | `/api/tags` | ✓ | Tags scoped to the requesting user — own tags only for solo users and admins; all org member tags for org members |
| GET | `/api/fetch-title` | ✓ | Server-side page title fetch — `?url=`. Returns `{"title":"…"}` | | GET | `/api/fetch-title` | ✓ | Server-side page title fetch — `?url=`. Returns `{"title":"…"}` |
| GET | `/api/qr/:code` | — | QR PNG for a short link | | GET | `/api/qr/:code` | — | QR for a short link`?format=png\|svg\|pdf`, `?ec=L\|M\|Q\|H`, `?fg=`, `?bg=`, `?size=`, `?style=` (raster only), `?download=1` |
| GET | `/api/qr/custom` | — | QR PNG for any URL — `?url=`, `?fg=`, `?bg=`, `?size=`, `?style=` | | GET | `/api/qr/custom` | — | QR for any URL — same params as above plus `?url=` |
| POST | `/api/qr/custom` | — | QR PNG with logo overlay — `{url, fg, bg, size, style, logo}` (logo as base64) | | POST | `/api/qr/custom` | — | QR with logo overlay — `{url, fg, bg, size, style, ec, format, logo}` (logo as base64; forces error correction H) |
| POST | `/api/links/bulk` | ✓ | Bulk operations — `{action: "delete"\|"tag"\|"expire", codes: […]}` | | POST | `/api/links/bulk` | ✓ | Bulk operations — `{action: "delete"\|"tag"\|"expire", codes: […]}` |
| GET | `/api/links/export` | ✓ | Download all links as CSV | | GET | `/api/links/export` | ✓ | Download all links as CSV |
| POST | `/api/links/import` | ✓ | Import links from CSV — `{csv: "…"}` | | POST | `/api/links/import` | ✓ | Import links from CSV — `{csv: "…"}` |
| GET | `/api/health` | — | Health check — `{"status":"ok"}` | | GET | `/api/health` | — | Health check — `{"status":"ok"}` |
| GET | `/:code` | — | Redirect to destination URL | | GET | `/:code` | — | Redirect to destination URL (302 by default; 301 if configured per link; password-protected links show a branded unlock page) |
| POST | `/:code/unlock` | — | Password submission for protected links (form field `password`) |
### Admin ### Admin
@@ -103,6 +113,10 @@ All write endpoints require an active session (log in via the web UI or `POST /a
| GET | `/api/admin/messages` | Admin | List all contact/portal messages, newest first | | GET | `/api/admin/messages` | Admin | List all contact/portal messages, newest first |
| DELETE | `/api/admin/messages/:id` | Admin | Delete a message | | DELETE | `/api/admin/messages/:id` | Admin | Delete a message |
| PATCH | `/api/admin/messages/:id/read` | Admin | Mark a message as read | | PATCH | `/api/admin/messages/:id/read` | Admin | Mark a message as read |
| GET | `/api/admin/overview` | Admin | Platform totals, 30-day click series, top links, per-user quota usage with near-limit flags |
| GET | `/api/admin/audit` | Admin | Audit log of admin actions — `?limit=` (default 200) |
| GET | `/api/admin/keys` | Admin | List all API keys across users (prefix, scope, last used) |
| POST | `/api/admin/purge` | Admin | Hard-delete soft-deleted links older than `{days}` — frees short codes, removes click history |
### Contact ### Contact
+67 -11
View File
@@ -278,10 +278,12 @@ footer {
<div class="intro-box reveal"> <div class="intro-box reveal">
<h2>Authentication</h2> <h2>Authentication</h2>
<p> <p>
All write endpoints require an active session. Log in via the web UI or call <code>POST /api/auth/login</code> with Authenticated endpoints accept either of two credentials. <strong>Session cookie</strong>: log in via the web UI
<code>{"username": "…", "password": "…"}</code> — the server sets an HttpOnly session cookie valid for 30 days. or call <code>POST /api/auth/login</code> with <code>{"username": "…", "password": "…"}</code> — the server sets
Include that cookie in all subsequent requests. Endpoints marked <strong>Admin</strong> additionally require an HttpOnly session cookie valid for 30 days. <strong>API key</strong>: create a key in the dashboard (or via
the authenticated user to have admin privileges. <code>POST /api/keys</code>) and send it as <code>Authorization: Bearer qrk_…</code>. Keys carry a
<code>read</code> or <code>write</code> scope — read-only keys may only call GET endpoints. Endpoints marked
<strong>Admin</strong> additionally require the authenticated user to have admin privileges.
</p> </p>
</div> </div>
@@ -334,6 +336,24 @@ footer {
<td><span class="auth-badge auth-yes">Session</span></td> <td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Returns <code>{"authenticated": true, "username": "…", "is_admin": bool, "org_id": int|null, "plan": "…", "plan_limits": {…}}</code>.</td> <td class="ep-desc">Returns <code>{"authenticated": true, "username": "…", "is_admin": bool, "org_id": int|null, "plan": "…", "plan_limits": {…}}</code>.</td>
</tr> </tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/keys</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">List your API keys — prefix, scope, created and last-used timestamps. Full tokens are never returned.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/keys</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Create an API key. Body: <code>{"name": "…", "scope": "read"|"write"}</code>. The response includes the full <code>token</code> <strong>exactly once</strong> — only a hash is stored.</td>
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/keys/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Revoke an API key immediately. Admins may revoke any user's key.</td>
</tr>
</tbody> </tbody>
</table> </table>
</section> </section>
@@ -362,7 +382,7 @@ footer {
<td><span class="method method-post">POST</span></td> <td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/shorten</span></td> <td><span class="ep-path">/api/shorten</span></td>
<td><span class="auth-badge auth-yes">Session</span></td> <td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Create a short link. Body: <code>{"url": "…", "code": "…", "title": "…", "expires_at": "…", "tags": […], "is_pinned": bool}</code>. <code>code</code> is optional — omit for a random code.</td> <td class="ep-desc">Create a short link. Body: <code>{"url": "…", "custom_code": "…", "title": "…", "expires_at": "…", "tags": […], "redirect_type": 301|302, "password": "…"}</code>. All fields except <code>url</code> are optional — redirects default to 302; a <code>password</code> gates the link behind a branded unlock page.</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
@@ -380,7 +400,7 @@ footer {
<td><span class="method method-patch">PATCH</span></td> <td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span></span></td> <td><span class="ep-path">/api/links/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td> <td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Edit link — updatable fields: <code>url</code>, <code>title</code>, <code>expires_at</code>, <code>tags</code>, <code>is_pinned</code>.</td> <td class="ep-desc">Edit link — updatable fields: <code>url</code>, <code>title</code>, <code>expires_at</code>, <code>tags</code>, <code>is_pinned</code>, <code>redirect_type</code> (301/302), <code>password</code> (empty string removes protection; omit to leave unchanged).</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-delete">DELETE</span></td> <td><span class="method method-delete">DELETE</span></td>
@@ -392,7 +412,13 @@ footer {
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span>/analytics</span></td> <td><span class="ep-path">/api/links/<span class="ep-param">:code</span>/analytics</span></td>
<td><span class="auth-badge auth-yes">Session</span></td> <td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Click analytics. Query: <code>?days=7|30|90</code> (capped to plan's max window). Returns <code>daily</code>, <code>referrers</code>, <code>devices</code>, <code>browsers</code>, <code>countries</code>, <code>heatmap</code> (7×24 array), and <code>max_days</code>.</td> <td class="ep-desc">Click analytics. Query: <code>?days=7|30|90</code> (capped to plan's max window), <code>?exclude_bots=1</code> to filter crawler/bot traffic. Returns <code>daily</code> (each day includes <code>unique</code> visitors), <code>unique_visitors</code>, <code>bot_clicks</code>, <code>referrers</code>, <code>devices</code>, <code>browsers</code>, <code>countries</code>, <code>heatmap</code> (7×24 array), and <code>max_days</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/analytics/aggregate</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Aggregate analytics across your whole account (or org). Query: <code>?days=</code>, <code>?tag=</code> to scope to one tag, <code>?exclude_bots=1</code>. Returns <code>daily</code>, <code>unique_visitors</code>, <code>referrers</code>, <code>devices</code>, <code>browsers</code>, <code>countries</code>, and <code>top_links</code>.</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
@@ -452,19 +478,19 @@ footer {
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/qr/<span class="ep-param">:code</span></span></td> <td><span class="ep-path">/api/qr/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-none"></span></td> <td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR PNG for a short link. Query params: <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</code>.</td> <td class="ep-desc">QR for a short link. Query params: <code>?format=png|svg|pdf</code>, <code>?ec=L|M|Q|H</code> (error correction), <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</code> (raster formats only), <code>?download=1</code> for an attachment.</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/qr/custom</span></td> <td><span class="ep-path">/api/qr/custom</span></td>
<td><span class="auth-badge auth-none"></span></td> <td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR PNG for any URL. Query: <code>?url=</code>, <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</code>. Styles: <code>square</code>, <code>rounded</code>, <code>dots</code>, <code>vertical</code>, <code>horizontal</code>.</td> <td class="ep-desc">QR for any URL. Query: <code>?url=</code> plus the same params as above (<code>format</code>, <code>ec</code>, <code>fg</code>, <code>bg</code>, <code>size</code>, <code>style</code>, <code>download</code>). Styles: <code>square</code>, <code>rounded</code>, <code>dots</code>, <code>vertical</code>, <code>horizontal</code>.</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-post">POST</span></td> <td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/qr/custom</span></td> <td><span class="ep-path">/api/qr/custom</span></td>
<td><span class="auth-badge auth-none"></span></td> <td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR PNG with logo overlay. Body: <code>{url, fg, bg, size, style, logo}</code> where <code>logo</code> is a base64-encoded image string.</td> <td class="ep-desc">QR with logo overlay. Body: <code>{url, fg, bg, size, style, ec, format, logo}</code> where <code>logo</code> is a base64-encoded image string (forces error correction H; SVG embeds the logo as a data URI).</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-post">POST</span></td> <td><span class="method method-post">POST</span></td>
@@ -592,6 +618,30 @@ footer {
<td><span class="auth-badge auth-admin">Admin</span></td> <td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Mark a message as read.</td> <td class="ep-desc">Mark a message as read.</td>
</tr> </tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/overview</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Platform overview — totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota usage with near-limit flags.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/audit</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Audit log of administrative actions (user/org changes, password resets, key events, purges). Query: <code>?limit=</code> (default 200).</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/keys</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">List all API keys across users — prefix, scope, last-used timestamp, revocation state.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/admin/purge</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Hard-delete soft-deleted links older than <code>{"days": N}</code> — frees their short codes and removes click history. Irreversible.</td>
</tr>
</tbody> </tbody>
</table> </table>
</section> </section>
@@ -650,7 +700,13 @@ footer {
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/<span class="ep-param">:code</span></span></td> <td><span class="ep-path">/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-none"></span></td> <td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Redirect to the destination URL for a short code. Records click analytics (referrer, device, browser, country).</td> <td class="ep-desc">Redirect to the destination URL for a short code (302 by default, 301 if configured per link). Records click analytics asynchronously (referrer, device, browser, country, bot detection). Password-protected links show a branded unlock page; expired or missing links show branded visitor pages.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/<span class="ep-param">:code</span>/unlock</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Password submission for protected links (form field <code>password</code>). Redirects to the destination on success.</td>
</tr> </tr>
<tr> <tr>
<td><span class="method method-get">GET</span></td> <td><span class="method method-get">GET</span></td>