diff --git a/INSTALL.md b/INSTALL.md index 9303ed2..ff50e1b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -124,7 +124,7 @@ Click **Apply**. - Add a proxy host: your domain → `unraid-lan-ip:5000` - Issue a Let's Encrypt certificate on the SSL tab - 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** ```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 | | `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 | +| `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` | `/backups` | Where daily backups are written | +| `BACKUP_KEEP` | `7` | Number of daily backups to retain | --- diff --git a/README.md b/README.md index 6dd71c2..a1929ca 100644 --- a/README.md +++ b/README.md @@ -18,27 +18,32 @@ A managed URL shortener with QR code generation, deep click analytics, and tag-b | Feature area | What's included | |---|---| -| **Short links** | Random & custom codes, link expiry, pinned links, one-click copy, auto-fetch page title | -| **QR codes** | QR generation for any link or URL, logo overlay, dot-shape presets, inline thumbnail, copy to clipboard | -| **Analytics** | Per-link daily click charts, referrer / device / browser / country breakdowns, hourly 7×24 heatmap, raw click CSV export | +| **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) | +| **Protected links** | Per-link password protection with a branded unlock page; branded visitor pages for missing, expired, and quota-limited links | +| **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 | | **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 | | **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 | | **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 | +| **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 | -### Upcoming +### Roadmap | 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 | | **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 | -| **Link organisation** | Folders/groups, duplicate link, dead-link detection, per-link redirect type (301 vs 302) | +| **Power features** | UTM parameter builder, dead-link detection, folders/groups, duplicate link | --- @@ -48,26 +53,30 @@ A managed URL shortener with QR code generation, deep click analytics, and tag-b ## 🔌 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 | |---|---|---|---| | POST | `/api/auth/login` | — | Login — `{"username": "...", "password": "..."}`, sets session cookie; returns `plan`, `plan_limits`, `org_id` | | 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/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 | 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=` | -| GET | `/api/links/:code` | ✓ | Link detail — includes `created_by` username | -| PATCH | `/api/links/:code` | ✓ | Edit link — `url`, `title`, `expires_at`, `tags`, `is_pinned` | -| DELETE | `/api/links/:code` | ✓ | Delete link | -| 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` | ✓ | Link detail — includes `created_by`, `redirect_type`, `has_password` | +| 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 (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) 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` | ### 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/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/qr/:code` | — | QR PNG for a short link | -| GET | `/api/qr/custom` | — | QR PNG for any URL — `?url=`, `?fg=`, `?bg=`, `?size=`, `?style=` | -| POST | `/api/qr/custom` | — | QR PNG with logo overlay — `{url, fg, bg, size, style, logo}` (logo as base64) | +| 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 for any URL — same params as above plus `?url=` | +| 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: […]}` | | GET | `/api/links/export` | ✓ | Download all links as CSV | | POST | `/api/links/import` | ✓ | Import links from CSV — `{csv: "…"}` | | 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 @@ -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 | | DELETE | `/api/admin/messages/:id` | Admin | Delete a message | | 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 diff --git a/api-docs.html b/api-docs.html index 908851f..e55a202 100644 --- a/api-docs.html +++ b/api-docs.html @@ -278,10 +278,12 @@ footer {

Authentication

- All write endpoints require an active session. Log in via the web UI or call POST /api/auth/login with - {"username": "…", "password": "…"} — the server sets an HttpOnly session cookie valid for 30 days. - Include that cookie in all subsequent requests. Endpoints marked Admin additionally require - the authenticated user to have admin privileges. + Authenticated endpoints accept either of two credentials. Session cookie: log in via the web UI + or call POST /api/auth/login with {"username": "…", "password": "…"} — the server sets + an HttpOnly session cookie valid for 30 days. API key: create a key in the dashboard (or via + POST /api/keys) and send it as Authorization: Bearer qrk_…. Keys carry a + read or write scope — read-only keys may only call GET endpoints. Endpoints marked + Admin additionally require the authenticated user to have admin privileges.

@@ -334,6 +336,24 @@ footer { Session Returns {"authenticated": true, "username": "…", "is_admin": bool, "org_id": int|null, "plan": "…", "plan_limits": {…}}. + + GET + /api/keys + Session + List your API keys — prefix, scope, created and last-used timestamps. Full tokens are never returned. + + + POST + /api/keys + Session + Create an API key. Body: {"name": "…", "scope": "read"|"write"}. The response includes the full token exactly once — only a hash is stored. + + + DELETE + /api/keys/:id + Session + Revoke an API key immediately. Admins may revoke any user's key. + @@ -362,7 +382,7 @@ footer { POST /api/shorten Session - Create a short link. Body: {"url": "…", "code": "…", "title": "…", "expires_at": "…", "tags": […], "is_pinned": bool}. code is optional — omit for a random code. + Create a short link. Body: {"url": "…", "custom_code": "…", "title": "…", "expires_at": "…", "tags": […], "redirect_type": 301|302, "password": "…"}. All fields except url are optional — redirects default to 302; a password gates the link behind a branded unlock page. GET @@ -380,7 +400,7 @@ footer { PATCH /api/links/:code Session - Edit link — updatable fields: url, title, expires_at, tags, is_pinned. + Edit link — updatable fields: url, title, expires_at, tags, is_pinned, redirect_type (301/302), password (empty string removes protection; omit to leave unchanged). DELETE @@ -392,7 +412,13 @@ footer { GET /api/links/:code/analytics Session - Click analytics. Query: ?days=7|30|90 (capped to plan's max window). Returns daily, referrers, devices, browsers, countries, heatmap (7×24 array), and max_days. + Click analytics. Query: ?days=7|30|90 (capped to plan's max window), ?exclude_bots=1 to filter crawler/bot traffic. Returns daily (each day includes unique visitors), unique_visitors, bot_clicks, referrers, devices, browsers, countries, heatmap (7×24 array), and max_days. + + + GET + /api/analytics/aggregate + Session + Aggregate analytics across your whole account (or org). Query: ?days=, ?tag= to scope to one tag, ?exclude_bots=1. Returns daily, unique_visitors, referrers, devices, browsers, countries, and top_links. GET @@ -452,19 +478,19 @@ footer { GET /api/qr/:code - QR PNG for a short link. Query params: ?fg=, ?bg=, ?size=, ?style=. + QR for a short link. Query params: ?format=png|svg|pdf, ?ec=L|M|Q|H (error correction), ?fg=, ?bg=, ?size=, ?style= (raster formats only), ?download=1 for an attachment. GET /api/qr/custom - QR PNG for any URL. Query: ?url=, ?fg=, ?bg=, ?size=, ?style=. Styles: square, rounded, dots, vertical, horizontal. + QR for any URL. Query: ?url= plus the same params as above (format, ec, fg, bg, size, style, download). Styles: square, rounded, dots, vertical, horizontal. POST /api/qr/custom - QR PNG with logo overlay. Body: {url, fg, bg, size, style, logo} where logo is a base64-encoded image string. + QR with logo overlay. Body: {url, fg, bg, size, style, ec, format, logo} where logo is a base64-encoded image string (forces error correction H; SVG embeds the logo as a data URI). POST @@ -592,6 +618,30 @@ footer { Admin Mark a message as read. + + GET + /api/admin/overview + Admin + Platform overview — totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota usage with near-limit flags. + + + GET + /api/admin/audit + Admin + Audit log of administrative actions (user/org changes, password resets, key events, purges). Query: ?limit= (default 200). + + + GET + /api/admin/keys + Admin + List all API keys across users — prefix, scope, last-used timestamp, revocation state. + + + POST + /api/admin/purge + Admin + Hard-delete soft-deleted links older than {"days": N} — frees their short codes and removes click history. Irreversible. + @@ -650,7 +700,13 @@ footer { GET /:code - Redirect to the destination URL for a short code. Records click analytics (referrer, device, browser, country). + 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. + + + POST + /:code/unlock + + Password submission for protected links (form field password). Redirects to the destination on success. GET