Compare commits

11 Commits

Author SHA1 Message Date
jason b4338af38c ci: unify to canonical docker-build workflow (drift labels baked in)
Build and Push Docker Image / build (push) Successful in 1m20s
2026-07-19 13:50:44 -05:00
Jason Stedwell e073a0f05d ci: trigger PORT auto-redeploy after image push (container QR.knit)
Build and Push Docker Image / build (push) Successful in 50s
2026-07-18 20:25:42 -05:00
jason b3d61e027a Add .gitea/workflows/docker-build.yml
Build and Push Docker Image / build (push) Successful in 53s
2026-07-03 09:09:45 -05:00
Jason Stedwell 48d3445b09 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>
2026-07-03 01:40:44 -05:00
Jason Stedwell 45be86bb36 feat: admin observability — platform overview, audit log, purge, notifications
- GET /api/admin/overview: platform totals (users, orgs, links, clicks),
  a 30-day click series, top links, and per-user quota consumption with
  near-limit flags (org members show pooled usage).
- Audit logging wired into all admin mutations: user create/update/delete,
  password resets, org create/update/delete, purges. Browsable via
  GET /api/admin/audit and a new Audit Log section in the admin UI.
- POST /api/admin/purge hard-deletes soft-deleted links older than N days,
  freeing their short codes and click history.
- Contact-form submissions now fire a WEBHOOK_URL notification.
- Fix: deleting a user who owned links or tags failed with a foreign-key
  500 — their links/tags are now deliberately orphaned (kept in the DB,
  unowned) and their API keys removed, matching the UI's wording.
- Admin UI: Platform Overview stat cards, quota-usage table with ⚠ NEAR
  LIMIT badges, audit table, and a purge action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:40:29 -05:00
Jason Stedwell a321ff6171 feat: unique visitors, bot filtering, and account-wide audience analytics
- Per-link analytics gain unique-visitor counts (period total and per-day),
  a bot_clicks figure, and an exclude_bots=1 filter applied across every
  breakdown, the daily series, and the heatmap. Bot classification comes
  from the is_bot flag stamped at click time.
- New GET /api/analytics/aggregate: one chart across the whole account (or
  org pool), optionally scoped to a single tag — daily clicks + uniques,
  referrers, devices, browsers, countries, and top links, capped to the
  plan's analytics window.
- Dashboard: Audience panel with tag/window/bot controls wired into
  loadDashboard, plus a NO BOTS toggle and PERIOD/UNIQUE/BOTS stat line on
  each link's analytics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:40:14 -05:00
Jason Stedwell 8f2bfd23d2 feat: API key management endpoints and dashboard panel
Self-serve keys: GET/POST /api/keys, DELETE /api/keys/:id (admins may
revoke any key), GET /api/admin/keys for a platform-wide view. Tokens are
qrk_-prefixed, stored only as a SHA-256 hash, and returned exactly once at
creation. Read-scope keys are rejected with 403 on non-GET requests; key
creation and revocation are recorded in the audit log.

Dashboard gets a self-serve key panel with copy-once token display, scope
badges, and last-used timestamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:39:50 -05:00
Jason Stedwell a1cb6ec5f2 feat: QR export as SVG and PDF with selectable error correction
All QR endpoints (/api/qr/:code, /api/qr/custom GET+POST) accept
format=png|svg|pdf, ec=L|M|Q|H, and download=1 for attachment responses,
unified behind a shared qr_response() renderer. SVG is generated as a true
vector (one path of modules, crispEdges) with an optional logo embedded as
a data URI over a cleared centre tile; PDF wraps the raster render for
print. A logo always forces error correction H. Dot styles remain
raster-only.

QR Studio: format and error-correction selectors, PDF previews as PNG,
format-aware download button with contextual hints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:39:50 -05:00
Jason Stedwell 14d6e8c50e feat: per-link redirect type, password-protected links, branded visitor pages
- Redirects default to 302 so browsers stop permanently caching the hop —
  click analytics stay accurate and destination edits take effect for
  returning visitors. 301 is a per-link opt-in (create/edit/API).
- Links can carry a password: visitors get a branded unlock page at /:code
  and POST to /:code/unlock. Unlocks always redirect with 302.
- Missing, expired, and quota-limited links now show branded standalone
  visitor pages (404/410/429) instead of bouncing to the marketing site
  with ?error= query params.
- Clicks are recorded through the async pipeline with bot/crawler tagging
  (UA heuristics), GeoLite2 country lookup (ip-api fallback gated behind
  GEO_API_FALLBACK), and the IP_ANONYMIZE policy applied at write time.
- Deletes stamp deleted_at for later hard-purge.
- Dashboard: redirect-type selector and password set/replace/remove in the
  edit form, lock badge on protected links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:39:50 -05:00
Jason Stedwell d0e99b5244 ops: async click pipeline, honest uptime, backups, retention; auth/audit plumbing
- Click events are enqueued and written by a background thread — redirects
  never block on DB writes or geo lookups.
- Uptime heartbeat marks per-minute rows (INSERT OR IGNORE), safe across
  Gunicorn workers; /api/uptime now measures real availability from each
  day's first heartbeat instead of a per-process counter that could only
  report 100%. Legacy uptime_daily rows still honoured.
- Hourly maintenance thread: daily SQLite backup via VACUUM INTO (atomic
  temp-file rename, race-safe across workers, BACKUP_KEEP retention),
  CLICK_RETENTION_DAYS purge, uptime-mark pruning.
- notify_webhook() helper for Slack/Discord-compatible WEBHOOK_URL.
- Auth groundwork: _try_api_key_auth() lets the login/admin decorators
  accept Bearer qrk_ tokens (scope-checked, no Set-Cookie), and
  log_audit() records admin actions — used by the following feature
  commits.
- New env config: WEBHOOK_URL, GEOIP_DB_PATH, GEO_API_FALLBACK,
  IP_ANONYMIZE, CLICK_RETENTION_DAYS, BACKUPS, BACKUP_DIR, BACKUP_KEEP.
- requirements: add geoip2 for local GeoLite2 lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:39:50 -05:00
Jason Stedwell c892588d31 db: add schema for API keys, audit log, and per-minute uptime; migrate links/clicks
New tables: api_keys (hashed bearer tokens with scopes), audit_log (admin
action trail), uptime_minutes (multi-worker-safe availability marks).
Idempotent column migrations: links.redirect_type (default 302),
links.password_hash, links.deleted_at, clicks.is_bot. Existing databases
upgrade in place on next startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:36:56 -05:00
7 changed files with 1435 additions and 167 deletions
+55
View File
@@ -0,0 +1,55 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
# Runs on the forgerunner host: bundled Docker CLI + mounted /var/run/docker.sock.
runs-on: host
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so `git rev-list --count HEAD` yields the true commit count
# driving both the app version (v1.NNN) and the org.alwisp.version label.
fetch-depth: 0
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
GIT_SHA="$(git rev-parse --short HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
COMMIT_COUNT="$(git rev-list --count HEAD)"
docker build \
--build-arg GIT_SHA="${GIT_SHA}" \
--build-arg BUILD_TIME="${BUILD_TIME}" \
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
--label org.alwisp.git-sha="${{ gitea.sha }}" \
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
--label org.alwisp.repo="${{ gitea.repository }}" \
-t "${IMAGE}:latest" .
docker push "${IMAGE}:latest"
# Dangling-only prune: removes untagged leftovers from previous builds. Never
# removes the tagged :latest or any image referenced by a running container.
- name: Prune dangling images on host
if: always()
run: docker image prune -f 2>/dev/null || true
- name: Trigger PORT redeploy
if: success()
run: |
curl -fsS -X POST https://port.alwisp.com/hooks/gitea \
-H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \
-H "Content-Type: application/json" \
-d "{\"container\":\"QR.knit\"}" || echo "PORT redeploy trigger failed (non-fatal)"
+9 -1
View File
@@ -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` | `<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 |
|---|---|
| **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=<username>` |
| 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
+67 -11
View File
@@ -278,10 +278,12 @@ footer {
<div class="intro-box reveal">
<h2>Authentication</h2>
<p>
All write endpoints require an active session. Log in via the web UI or call <code>POST /api/auth/login</code> with
<code>{"username": "…", "password": "…"}</code> — the server sets an HttpOnly session cookie valid for 30 days.
Include that cookie in all subsequent requests. Endpoints marked <strong>Admin</strong> additionally require
the authenticated user to have admin privileges.
Authenticated endpoints accept either of two credentials. <strong>Session cookie</strong>: log in via the web UI
or call <code>POST /api/auth/login</code> with <code>{"username": "…", "password": "…"}</code> — the server sets
an HttpOnly session cookie valid for 30 days. <strong>API key</strong>: create a key in the dashboard (or via
<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>
</div>
@@ -334,6 +336,24 @@ footer {
<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>
</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>
</table>
</section>
@@ -362,7 +382,7 @@ footer {
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/shorten</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>
<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="ep-path">/api/links/<span class="ep-param">:code</span></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>
<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="ep-path">/api/links/<span class="ep-param">:code</span>/analytics</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>
<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="ep-path">/api/qr/<span class="ep-param">:code</span></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>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/qr/custom</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>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/qr/custom</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>
<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 class="ep-desc">Mark a message as read.</td>
</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>
</table>
</section>
@@ -650,7 +700,13 @@ footer {
<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="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>
<td><span class="method method-get">GET</span></td>
+868 -98
View File
File diff suppressed because it is too large Load Diff
+385 -21
View File
@@ -635,6 +635,27 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<canvas class="dash-chart-canvas" id="dash-chart" height="60"></canvas>
</div>
<!-- Audience analytics — aggregate across the account/org, filterable by tag -->
<div class="panel" id="audience-panel" style="margin-bottom:20px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:14px">
<h3 style="margin:0">AUDIENCE — ACCOUNT ANALYTICS</h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select class="field-input" id="agg-tag" style="padding:5px 8px;font-size:12px;width:auto" onchange="loadAudience()">
<option value="">All links</option>
</select>
<select class="field-input" id="agg-days" style="padding:5px 8px;font-size:12px;width:auto" onchange="loadAudience()">
<option value="7">7d</option>
<option value="30" selected>30d</option>
<option value="90">90d</option>
</select>
<label style="display:flex;align-items:center;gap:5px;font-size:11px;font-family:var(--font-mono);color:var(--muted);cursor:pointer">
<input type="checkbox" id="agg-bots" onchange="loadAudience()"> exclude bots
</label>
</div>
</div>
<div id="agg-content"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
<div class="toolbar">
<div class="search-wrap">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.5"/><path d="M11 11L14.5 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
@@ -670,6 +691,25 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<div class="links-list" id="links-list">
<div class="empty-state"><div class="empty-icon">🔗</div><p>Loading...</p></div>
</div>
<!-- API keys — programmatic access tokens -->
<div class="panel" id="apikeys-panel" style="margin-top:28px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h3 style="margin:0">API KEYS</h3>
<button class="btn-sm" onclick="createApiKey()">+ New Key</button>
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">
Authenticate API requests with <code style="font-family:var(--font-mono)">Authorization: Bearer qrk_…</code> — see the <a href="/api-docs" target="_blank" style="color:var(--accent)">API docs</a>.
</div>
<div id="apikey-new" style="display:none;border:1px solid var(--accent);border-radius:8px;padding:12px 14px;margin-bottom:12px;font-family:var(--font-mono);font-size:12px">
<div style="color:var(--accent);letter-spacing:.06em;margin-bottom:6px">NEW KEY — COPY IT NOW, IT WON'T BE SHOWN AGAIN</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<code id="apikey-token" style="word-break:break-all"></code>
<button class="btn-sm" onclick="copyToClipboard(document.getElementById('apikey-token').textContent)">Copy</button>
</div>
</div>
<div id="apikeys-list"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
</div>
<!-- ── QR ── -->
@@ -697,6 +737,26 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<button class="style-btn" data-style="horizontal">▬ H-Bars</button>
</div>
</div>
<div class="color-row" style="margin-bottom:18px">
<div>
<label class="field-label">FORMAT</label>
<select class="field-input" id="qr-format">
<option value="png" selected>PNG — raster</option>
<option value="svg">SVG — vector (print)</option>
<option value="pdf">PDF — print-ready</option>
</select>
<div style="font-size:10px;color:var(--muted);font-family:var(--font-mono);margin-top:4px">Dot styles apply to PNG &amp; PDF only</div>
</div>
<div>
<label class="field-label">ERROR CORRECTION</label>
<select class="field-input" id="qr-ec">
<option value="L">L — 7% (densest)</option>
<option value="M" selected>M — 15% (default)</option>
<option value="Q">Q — 25%</option>
<option value="H">H — 30% (logos)</option>
</select>
</div>
</div>
<div style="margin-bottom:18px">
<label class="field-label">LOGO / ICON <span style="font-weight:400;color:var(--muted)">(optional — centered overlay)</span></label>
<div class="logo-row">
@@ -713,7 +773,7 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<div id="qr-preview-wrap"><div class="qr-placeholder">Enter a URL and click Generate</div></div>
<div id="qr-dl-row" style="display:none;flex-direction:column;align-items:center;gap:8px;width:100%">
<a class="btn-primary" id="qr-download-link" download="qrcode.png" style="text-decoration:none;display:inline-block;text-align:center">↓ Download PNG</a>
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)">Right-click to save</div>
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)" id="qr-dl-hint">Right-click to save</div>
</div>
</div>
</div>
@@ -722,6 +782,22 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<!-- ── ADMIN ── -->
<div class="view" id="view-admin">
<!-- Platform overview -->
<div class="section-head" style="margin-bottom:18px">
<div><div class="section-title">Platform Overview</div><div style="color:var(--muted);font-size:13px;margin-top:3px">All users, links, and clicks across the instance</div></div>
<button class="btn-sm" onclick="loadAdminOverview()">↻ Refresh</button>
</div>
<div class="stats-strip" id="admin-stats">
<div class="stat-card"><div class="stat-value" id="astat-users"></div><div class="stat-label">USERS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-orgs"></div><div class="stat-label">ORGS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-links"></div><div class="stat-label">ACTIVE LINKS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-clicks30"></div><div class="stat-label">CLICKS / 30D</div></div>
</div>
<div class="panel" style="margin-bottom:32px">
<h3>QUOTA USAGE</h3>
<div id="admin-usage"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
<!-- Users -->
<div class="section-head" style="margin-bottom:24px">
<div><div class="section-title">User Management</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Create and manage user accounts</div></div>
@@ -793,6 +869,16 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
</div>
<div id="orgs-list"><div class="empty-state"><p>Loading...</p></div></div>
<!-- Audit log & maintenance -->
<div class="section-head" style="margin-bottom:24px;margin-top:40px">
<div><div class="section-title">Audit Log</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Administrative actions on this instance</div></div>
<div style="display:flex;gap:8px">
<button class="btn-sm danger" onclick="purgeDeleted()" title="Hard-delete soft-deleted links and free their codes">🗑 Purge deleted links</button>
<button class="btn-sm" onclick="loadAudit()"></button>
</div>
</div>
<div id="audit-list"><div class="empty-state"><p>Loading...</p></div></div>
</div>
</main>
@@ -1178,6 +1264,140 @@ function renderUptimeChart(days) {
async function loadDashboard() {
await Promise.all([loadStats(), loadTags(), loadPlanUsage()]);
await loadLinks();
loadAudience();
loadApiKeys();
}
// ── Audience — aggregate analytics across the account/org ──
async function loadAudience() {
const panel = document.getElementById('audience-panel');
panel.style.display = '';
// Keep the tag dropdown in sync with the user's tags
const tagSel = document.getElementById('agg-tag');
const current = tagSel.value;
tagSel.innerHTML = '<option value="">All links</option>' +
allTags.map(t=>`<option value="${escHtml(t.name)}"${t.name===current?' selected':''}>#${escHtml(t.name)}</option>`).join('');
const days = document.getElementById('agg-days').value;
const bots = document.getElementById('agg-bots').checked ? 1 : 0;
const tag = tagSel.value;
const content = document.getElementById('agg-content');
try {
let url = `${BASE}/api/analytics/aggregate?days=${days}&exclude_bots=${bots}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
const d = await (await authFetch(url)).json();
renderAudience(d);
} catch(e) {
content.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load</div>';
}
}
function renderAudience(d) {
const content = document.getElementById('agg-content');
const hasCountries = d.countries && d.countries.length > 0;
const topLinksHtml = (d.top_links||[]).slice(0,5).map(l=>`
<div class="breakdown-row">
<div class="breakdown-label" title="${escHtml(l.title||l.long_url)}">/${escHtml(l.code)}</div>
<div class="breakdown-bar-wrap"><div class="breakdown-bar" style="width:${Math.round((l.count/Math.max(d.top_links[0].count,1))*100)}%"></div></div>
<div class="breakdown-count">${l.count}</div>
</div>`).join('') || '<div style="color:var(--muted);font-size:11px;font-family:var(--font-mono)">No data</div>';
content.innerHTML = `
<div style="display:flex;gap:18px;flex-wrap:wrap;font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;letter-spacing:.04em">
<span>PERIOD <b style="color:var(--text)">${d.period_clicks}</b> clicks</span>
<span>UNIQUE <b style="color:var(--text)">${d.unique_visitors??'—'}</b> visitors</span>
${d.tag?`<span>TAG <b style="color:var(--accent)">#${escHtml(d.tag)}</b></span>`:''}
<span style="opacity:.7">window capped at ${d.max_days}d on your plan</span>
</div>
<div class="chart-wrap"><canvas class="chart-canvas" id="agg-chart" height="70"></canvas></div>
<div class="breakdown-grid" style="grid-template-columns:${hasCountries?'1fr 1fr 1fr 1fr':'1fr 1fr 1fr'}">
<div class="breakdown-card"><h5>TOP LINKS</h5>${topLinksHtml}</div>
<div class="breakdown-card"><h5>REFERRERS</h5>${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}</div>
<div class="breakdown-card"><h5>DEVICES</h5>${renderBreakdown(d.devices.map(r=>({label:r.device,count:r.count})))}</div>
${hasCountries?`<div class="breakdown-card"><h5>COUNTRIES</h5>${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}</div>`:''}
</div>`;
requestAnimationFrame(() => {
const canvas = document.getElementById('agg-chart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio||1;
const W = canvas.offsetWidth, H = 70;
canvas.width = W*dpr; canvas.height = H*dpr; ctx.scale(dpr,dpr);
const data = d.daily, n = data.length;
const max = Math.max(...data.map(x=>x.clicks),1);
const barW = Math.max(2,(W/n)-2);
const gap = (W-barW*n)/(n-1||1);
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim()||'0,184,212';
data.forEach((pt,i)=>{
const x=i*(barW+gap), bH=(pt.clicks/max)*(H-14), y=H-bH-2;
ctx.fillStyle=`rgba(${accentRgb},${pt.clicks>0?.8:.15})`;
ctx.beginPath(); ctx.roundRect(x,y,barW,bH+2,[2,2,0,0]); ctx.fill();
});
ctx.fillStyle='rgba(107,107,128,.8)'; ctx.font='10px "Share Tech Mono"';
if (data.length>0) {
ctx.textAlign='left'; ctx.fillText(data[0].date.slice(5),0,H);
ctx.textAlign='right'; ctx.fillText(data[data.length-1].date.slice(5),W,H);
}
});
}
// ── API keys ────────────────────────────────────────
async function loadApiKeys() {
const container = document.getElementById('apikeys-list');
try {
const d = await (await authFetch(`${BASE}/api/keys`)).json();
const keys = d.keys || [];
if (!keys.length) {
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">No API keys yet.</div>';
return;
}
container.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:6px 10px">NAME</th>
<th style="text-align:left;padding:6px 10px">KEY</th>
<th style="text-align:left;padding:6px 10px">SCOPE</th>
<th style="text-align:left;padding:6px 10px">CREATED</th>
<th style="text-align:left;padding:6px 10px">LAST USED</th>
<th style="text-align:right;padding:6px 10px"></th>
</tr></thead>
<tbody>${keys.map(k=>`
<tr style="border-bottom:1px solid var(--border);${k.revoked?'opacity:.45':''}">
<td style="padding:8px 10px">${escHtml(k.name)}</td>
<td style="padding:8px 10px;color:var(--muted)">${escHtml(k.prefix)}…</td>
<td style="padding:8px 10px"><span style="color:${k.scope==='write'?'var(--accent)':'var(--muted)'}">${k.scope.toUpperCase()}</span></td>
<td style="padding:8px 10px;color:var(--muted)">${(k.created_at||'').slice(0,10)}</td>
<td style="padding:8px 10px;color:var(--muted)">${k.last_used_at?k.last_used_at.slice(0,16).replace('T',' '):'never'}</td>
<td style="padding:8px 10px;text-align:right">${k.revoked?'<span style="color:var(--muted);font-size:10px">REVOKED</span>':`<button class="btn-sm danger" onclick="revokeApiKey(${k.id},'${escHtml(k.name)}')">Revoke</button>`}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch(e) {
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load keys</div>';
}
}
async function createApiKey() {
const name = prompt('Name for this key (e.g. "CI pipeline"):');
if (name === null) return;
const write = confirm('Allow WRITE access (create/edit/delete links)?\n\nOK = read + write · Cancel = read-only');
try {
const resp = await authFetch(`${BASE}/api/keys`, {method:'POST', body:JSON.stringify({name: name||'API key', scope: write?'write':'read'})});
const d = await resp.json();
if (!resp.ok) { toast(d.error||'Failed to create key','error'); return; }
document.getElementById('apikey-new').style.display = 'block';
document.getElementById('apikey-token').textContent = d.token;
toast('API key created — copy it now');
loadApiKeys();
} catch(e) { toast('Network error','error'); }
}
async function revokeApiKey(id, name) {
if (!confirm(`Revoke API key "${name}"? Requests using it will stop working immediately.`)) return;
const resp = await authFetch(`${BASE}/api/keys/${id}`, {method:'DELETE'});
if (!resp.ok) { toast('Failed to revoke','error'); return; }
toast(`Key "${name}" revoked`);
loadApiKeys();
}
async function loadStats() {
@@ -1349,6 +1569,7 @@ function buildLinkItem(l) {
</div>
<div class="link-meta">
${ownerBadge}
${l.has_password?'<span title="Password protected" style="font-size:12px">🔒</span>':''}
<div class="link-clicks"><strong>${l.clicks}</strong> clicks${expNote}</div>
<button class="copy-inline" title="Copy short URL"
onclick="event.stopPropagation();copyToClipboard('${l.short_url}')">copy</button>
@@ -1379,6 +1600,19 @@ function buildLinkItem(l) {
<div class="edit-full"><label class="field-label">DESTINATION URL</label><input class="field-input" id="edit-url-${l.code}" value="${escHtml(l.long_url)}" onblur="autoFetchTitleForEdit('${l.code}')"></div>
<div><label class="field-label">TITLE</label><input class="field-input" id="edit-title-${l.code}" value="${escHtml(l.title||'')}"></div>
<div><label class="field-label">EXPIRY DATE</label><input class="field-input" type="date" id="edit-expiry-${l.code}" value="${(l.expires_at||'').slice(0,10)}"></div>
<div><label class="field-label">REDIRECT TYPE</label>
<select class="field-input" id="edit-rt-${l.code}">
<option value="302"${l.redirect_type==301?'':' selected'}>302 — Temporary (recommended)</option>
<option value="301"${l.redirect_type==301?' selected':''}>301 — Permanent (browsers cache)</option>
</select>
</div>
<div><label class="field-label">PASSWORD ${l.has_password?'<span style="color:var(--accent)">● set</span>':''}</label>
<div style="display:flex;gap:6px">
<input class="field-input" type="password" id="edit-pw-${l.code}" autocomplete="new-password" style="flex:1"
placeholder="${l.has_password?'Unchanged — type to replace':'None — type to protect'}">
${l.has_password?`<button class="btn-sm danger" onclick="removeLinkPassword('${l.code}')" title="Remove password protection">✕</button>`:''}
</div>
</div>
<div class="edit-full"><label class="field-label">TAGS (comma separated)</label><input class="field-input" id="edit-tags-${l.code}" value="${(l.tags||[]).map(t=>t.name).join(', ')}"></div>
</div>
<div class="edit-actions">
@@ -1390,6 +1624,9 @@ function buildLinkItem(l) {
<div class="analytics-header">
<div class="analytics-title">CLICK ANALYTICS</div>
<div style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:10px;font-family:var(--font-mono);color:var(--muted);cursor:pointer;letter-spacing:.04em">
<input type="checkbox" id="bots-${l.code}" onchange="loadAnalytics('${l.code}', analyticsDaysByCode['${l.code}']||7)"> NO BOTS
</label>
<div class="analytics-days">
<button class="day-btn active" onclick="loadAnalytics('${l.code}',7,this)">7d</button>
<button class="day-btn" onclick="loadAnalytics('${l.code}',30,this)">30d</button>
@@ -1469,8 +1706,12 @@ async function saveEdit(code) {
const url = document.getElementById(`edit-url-${code}`).value.trim();
const title = document.getElementById(`edit-title-${code}`).value.trim();
const expiry = document.getElementById(`edit-expiry-${code}`).value;
const rt = parseInt(document.getElementById(`edit-rt-${code}`).value);
const pw = document.getElementById(`edit-pw-${code}`).value;
const tags = document.getElementById(`edit-tags-${code}`).value.split(',').map(t=>t.trim()).filter(Boolean);
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify({url,title,expires_at:expiry||null,tags})});
const body = {url, title, expires_at: expiry||null, tags, redirect_type: rt};
if (pw) body.password = pw; // omit = leave password unchanged
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify(body)});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Save failed','error'); return; }
toast('Link updated!');
@@ -1481,6 +1722,18 @@ async function saveEdit(code) {
loadStats(); loadTags();
}
async function removeLinkPassword(code) {
if (!confirm(`Remove password protection from /${code}?`)) return;
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH', body:JSON.stringify({password:''})});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Failed','error'); return; }
toast('Password removed');
const old = document.getElementById(`item-${code}`);
const fresh = buildLinkItem(data);
fresh.classList.add('expanded');
old.parentNode.replaceChild(fresh, old);
}
async function deleteLink(code) {
if (!confirm(`Delete /${code}?`)) return;
await authFetch(`${BASE}/api/links/${code}`, {method:'DELETE'});
@@ -1527,15 +1780,19 @@ async function autoFetchTitleForEdit(code) {
}
// ── Analytics ──────────────────────────────────────
const analyticsDaysByCode = {};
async function loadAnalytics(code, days, btnEl) {
if (btnEl) {
document.querySelectorAll(`#analytics-${code} .day-btn`).forEach(b=>b.classList.remove('active'));
btnEl.classList.add('active');
}
analyticsDaysByCode[code] = days;
const excludeBots = document.getElementById(`bots-${code}`)?.checked ? 1 : 0;
const container = document.getElementById(`analytics-content-${code}`);
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono);padding:10px 0">Loading...</div>';
try {
const d = await (await authFetch(`${BASE}/api/links/${code}/analytics?days=${days}`)).json();
const d = await (await authFetch(`${BASE}/api/links/${code}/analytics?days=${days}&exclude_bots=${excludeBots}`)).json();
renderAnalytics(code, d);
} catch(e) { container.innerHTML='<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load</div>'; }
}
@@ -1551,7 +1808,15 @@ function renderAnalytics(code, d) {
? `<div class="breakdown-card"><h5>COUNTRIES</h5>${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}</div>`
: '';
const statLine = `
<div style="display:flex;gap:18px;flex-wrap:wrap;font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;letter-spacing:.04em">
<span>PERIOD <b style="color:var(--text)">${d.period_clicks}</b> clicks</span>
<span>UNIQUE <b style="color:var(--text)">${d.unique_visitors??'—'}</b> visitors</span>
${d.bot_clicks ? `<span>BOTS <b style="color:var(--text)">${d.bot_clicks}</b>${d.exclude_bots?' (hidden)':''}</span>` : ''}
</div>`;
container.innerHTML = `
${statLine}
<div class="chart-wrap"><canvas class="chart-canvas" id="${chartId}" height="90"></canvas></div>
<div class="breakdown-grid" style="grid-template-columns:${hasCountries?'1fr 1fr 1fr 1fr':'1fr 1fr 1fr'}">
<div class="breakdown-card"><h5>REFERRERS</h5>${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}</div>
@@ -1798,37 +2063,52 @@ function clearQrLogo() {
document.getElementById('qr-logo-clear').style.display = 'none';
}
async function postQrBlob(body) {
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
if (!resp.ok) throw new Error('QR generation failed');
return await resp.blob();
}
document.getElementById('qr-generate-btn').addEventListener('click', async () => {
const url = document.getElementById('qr-url-input').value.trim();
if (!url) { toast('Please enter a URL','error'); return; }
const fg = qrFgH.value.replace('#','') || '000000';
const bg = qrBgH.value.replace('#','') || 'ffffff';
const size = parseInt(qrSz.value);
const fmt = document.getElementById('qr-format').value;
const ec = document.getElementById('qr-ec').value;
const btn = document.getElementById('qr-generate-btn');
const dlLink = document.getElementById('qr-download-link');
// PDFs can't render in an <img> — preview those as PNG, download as PDF
const previewFmt = fmt === 'pdf' ? 'png' : fmt;
if (qrLogoBase64 || qrStyle !== 'square') {
btn.disabled = true; btn.textContent = 'Generating...';
try {
const body = { url, fg, bg, size, style: qrStyle };
if (qrLogoBase64) body.logo = qrLogoBase64;
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
if (!resp.ok) { toast('Failed to generate QR','error'); return; }
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${blobUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
document.getElementById('qr-download-link').href = blobUrl;
} catch(e) { toast('Network error','error'); }
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
let previewUrl, dlUrl;
if (qrLogoBase64) {
const params = { url, fg, bg, size, style: qrStyle, ec, logo: qrLogoBase64 };
const previewBlob = await postQrBlob({...params, format: previewFmt});
previewUrl = URL.createObjectURL(previewBlob);
dlUrl = fmt === 'pdf'
? URL.createObjectURL(await postQrBlob({...params, format: 'pdf'}))
: previewUrl;
} else {
const qrUrl = `${BASE}/api/qr/custom?url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&t=${Date.now()}`;
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${qrUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
document.getElementById('qr-download-link').href = qrUrl;
const qs = `url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&style=${qrStyle}&ec=${ec}`;
previewUrl = `${BASE}/api/qr/custom?${qs}&format=${previewFmt}&t=${Date.now()}`;
dlUrl = `${BASE}/api/qr/custom?${qs}&format=${fmt}&download=1`;
}
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${previewUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
dlLink.href = dlUrl;
dlLink.download = `qrcode.${fmt}`;
dlLink.textContent = `↓ Download ${fmt.toUpperCase()}`;
document.getElementById('qr-dl-hint').textContent =
fmt === 'svg' ? 'Vector — scales losslessly for print' :
fmt === 'pdf' ? 'Print-ready single-page PDF' : 'Right-click to save';
toast('QR generated!');
} catch(e) { toast('Failed to generate QR','error'); }
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
});
// ── Select Mode & Bulk Ops ─────────────────────────
@@ -1954,10 +2234,94 @@ async function doImport() {
let allOrgs = [];
async function loadAdminPanel() {
loadAdminOverview();
loadAudit();
await loadOrgs();
await loadUsers();
}
// ── Admin — Platform overview & quota usage ────────
async function loadAdminOverview() {
try {
const resp = await authFetch(`${BASE}/api/admin/overview`);
if (!resp.ok) return;
const d = await resp.json();
document.getElementById('astat-users').textContent = d.totals.users.toLocaleString();
document.getElementById('astat-orgs').textContent = d.totals.organizations.toLocaleString();
document.getElementById('astat-links').textContent = d.totals.active_links.toLocaleString();
document.getElementById('astat-clicks30').textContent = d.totals.clicks_30d.toLocaleString();
const fmtQuota = (used, limit, pct) => limit === null
? `${used.toLocaleString()} <span style="opacity:.5">/ ∞</span>`
: `${used.toLocaleString()} / ${limit.toLocaleString()} <span style="color:${pct>=100?'#ff5470':pct>=80?'#ffb020':'var(--muted)'}">(${pct}%)</span>`;
document.getElementById('admin-usage').innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:6px 10px">USER</th>
<th style="text-align:left;padding:6px 10px">PLAN</th>
<th style="text-align:left;padding:6px 10px">ACTIVE LINKS</th>
<th style="text-align:left;padding:6px 10px">MONTHLY CLICKS</th>
<th style="text-align:left;padding:6px 10px"></th>
</tr></thead>
<tbody>${d.usage.map(u=>`
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 10px">${escHtml(u.username)}${u.org_name?` <span style="color:var(--muted);font-size:10px">@${escHtml(u.org_name)}</span>`:''}</td>
<td style="padding:8px 10px;color:${u.is_admin?'var(--accent)':'var(--muted)'}">${u.plan.toUpperCase()}</td>
<td style="padding:8px 10px">${fmtQuota(u.links_used, u.links_limit, u.links_pct)}</td>
<td style="padding:8px 10px">${fmtQuota(u.clicks_used, u.clicks_limit, u.clicks_pct)}</td>
<td style="padding:8px 10px">${u.near_limit?'<span style="color:#ffb020;font-size:10px;letter-spacing:.06em">⚠ NEAR LIMIT</span>':''}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch(e) {}
}
// ── Admin — Audit log & maintenance ────────────────
async function loadAudit() {
const container = document.getElementById('audit-list');
try {
const resp = await authFetch(`${BASE}/api/admin/audit?limit=100`);
if (!resp.ok) { container.innerHTML = '<div class="empty-state"><p>Access denied.</p></div>'; return; }
const { entries } = await resp.json();
if (!entries.length) { container.innerHTML = '<div class="empty-state"><p>No admin actions recorded yet.</p></div>'; return; }
container.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:6px 10px">WHEN</th>
<th style="text-align:left;padding:6px 10px">ACTOR</th>
<th style="text-align:left;padding:6px 10px">ACTION</th>
<th style="text-align:left;padding:6px 10px">TARGET</th>
<th style="text-align:left;padding:6px 10px">DETAILS</th>
</tr></thead>
<tbody>${entries.map(e=>`
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 10px;color:var(--muted);white-space:nowrap">${(e.created_at||'').slice(0,16).replace('T',' ')}</td>
<td style="padding:7px 10px">${escHtml(e.actor||'—')}</td>
<td style="padding:7px 10px;color:var(--accent)">${escHtml(e.action)}</td>
<td style="padding:7px 10px">${escHtml(e.target||'')}</td>
<td style="padding:7px 10px;color:var(--muted)">${escHtml(e.details||'')}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch(e) { container.innerHTML = '<div class="empty-state"><p>Failed to load audit log.</p></div>'; }
}
async function purgeDeleted() {
const days = prompt('Hard-delete links that were soft-deleted more than N days ago.\nTheir short codes become reusable and click history is removed.\n\nN =', '30');
if (days === null) return;
const n = parseInt(days);
if (isNaN(n) || n < 0) { toast('Enter a number of days','error'); return; }
if (!confirm(`Permanently purge links deleted more than ${n} day(s) ago? This cannot be undone.`)) return;
try {
const resp = await authFetch(`${BASE}/api/admin/purge`, {method:'POST', body:JSON.stringify({days:n})});
const d = await resp.json();
if (!resp.ok) { toast(d.error||'Purge failed','error'); return; }
toast(`Purged ${d.purged} link(s)`);
loadAudit(); loadAdminOverview();
} catch(e) { toast('Network error','error'); }
}
async function loadUsers() {
const container = document.getElementById('users-list');
try {
+1
View File
@@ -2,3 +2,4 @@ flask>=2.3.0
qrcode[pil]>=7.4.2
Pillow>=10.0.0
gunicorn>=21.0.0
geoip2>=4.7.0