Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4338af38c | |||
| e073a0f05d | |||
| b3d61e027a | |||
| 48d3445b09 | |||
| 45be86bb36 | |||
| a321ff6171 | |||
| 8f2bfd23d2 | |||
| a1cb6ec5f2 | |||
| 14d6e8c50e | |||
| d0e99b5244 | |||
| c892588d31 |
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+385
-21
@@ -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 & 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user