From 4b443dd5d4ed5c56072ad00b1a1fbd660e572a8a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Fri, 13 Mar 2026 15:17:18 +1100 Subject: [PATCH 01/95] Add ability to append content to post --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/post_views.py | 19 ++++++++++ links/templates/links/post_detail.html | 46 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 0c4948761b58713d83e7bd683f0a1f5929e31ee2..1dfb6de269849a6ad434692bbc858c2fc6a99c6b 100644 GIT binary patch delta 2284 zcmZ`*-;Wbj6mECB#T|`E!UF`*!!n65(59XKaA_n2SCBPIj7vg@`>?mu+s?Stxn=Im zmP*LL13c@~#`xgAXiSU=i9(GDiT{CbKB~b-4fD(Xlo$q|-JNNe1 z){$FVM?OB3jXycOl#Oq%-C4`tS(~}PHv9GG^OZ~{6XW~(=KihOvvUHE>)-C{eYN-Q zz1h3>au4p!WqW&PzRTpYS2MGFvsY)%WFHRq{W!Cn%{S+t%e;1S^M{4}EAxhNj4n#w zSDU{)nmcl^UMN)xk(Q%$ zR+H~xOH)NIi9q*dsL6@LPzI4dpnm8@kqpQS_*Lw>E;R*Gwt#sp_8fq=K8$tL#r7g( z2qk$4>L%D5O0nKj{eapkBq)d?FFqgdUw3i#>y$`%Mp=CO=d?u0m5MtXcA7SG?S4Osy@bmag-l-16|}u z;41KKt2m^tK!zJ4^aLpB23gicDO$ARik`HrQy8BMphFym<~OUhTF zWfcwMyl@hiW2VcT`8Y0qf(Vf`sX&ryr>cy?MxF|agql6N^(jiSQ!eoL!d8lgOtqBM zcTq^FJeC8hT2p)*mPN@GNv_!7(l2?TgIL;Y@y{5hgr}M~Fq4yCN#7{Y&7-hXv%Sk@( zv*VHJ8-6TziTYM32Nb{woKs?0){;UOIT4*pyNc1Ss+hE^V!uVLP{1BfG$-~ncBJGi zYeFV5LM6htsi4$LhPsr?aUFF%Xv&UU0pvt%-(RGk#bQsV=#a>{y zd4V&|rzsr~!|-Osrq#&nLeo{VY@5_iGizdHH8CrTRTE9>dT>9RHO6mO!E%u+Mu&;H zYSybJMG6%_*T7=w!2*}39WT%=-KG}^ZVhf0?B^(427bh=9l^l?7~vRdX^MM5BUs!? z`0yG9NXz9QCUfSxOSqgd(Kv<)$m?{H%GCj#A55yquHu*s0R*@SrI7R-r zLnEmp(p};E=wFyZr>C4bFh@>HCK;YUq;e4zT5hBK3>|a(lvDnEDkB-T2jrjvsDMeW zWESaot;Es=Dx#LR;b{+N6Ez1^w&7JApicwZV?ltJOhMXt7%-l*F}Fd$sMGNj(GF-6UzYaJe*nq9(EtDd delta 779 zcmZvYUr19?9LLW&_wKhp?t1UtotjONVNgbv)4?pWc~gmjWf7Eu;(QI%)fiGjdhVuA zJ(v$3f}p(wk%WTf5ou}}ND|>g;Dcf6OGHsU%zP>8syIgVaNx)J{JuZF-|s-OcpzCk z?7`8yMfEsJ!-HWw7$(oc!q@}*4TMn4d{O333PHSjDBn(#O5!$JPN|5N_ZkI~u1l!IhLFVTw#hl1m$004Aw#B}M%OO$ z+K?_Wy_slyW*RoI=C5`k8l+{3%NfqcXP^P|tddxVHWpiT% zim(=s)vKs4ZZTpRG&KujnB7{18dpSDP_UL>&#=7&Z23I^TlENw?gXh*(apFP7yjDe z=FSJKT?m>fLsX!x8O|5h;0*Sdz8u7fn zh#pr^?^UCRb^EOO{N5t8VAWb~77(KC*&YtE$XOU+Y=j-O3W&#_o>88Zs {% markdown_with_tasks post.content post.task_states %} + + +
+

{% trans "Append Content" %}

+ +
+ + +
+
{% endblock %} @@ -868,6 +883,37 @@ function handlePostVoiceChange() { } } +async function appendContent() { + const input = document.getElementById('append-content-input'); + const btn = document.getElementById('append-btn'); + const status = document.getElementById('append-status'); + const extra = input.value.trim(); + + if (!extra) return; + + btn.disabled = true; + status.textContent = '{% trans "Saving..." %}'; + + try { + const response = await fetch(`/api/posts/{{ post.id }}/append_content`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: extra }) + }); + + if (response.ok) { + window.location.reload(); + } else { + const err = await response.json(); + status.textContent = '{% trans "Error: " %}' + (err.error || err.detail || JSON.stringify(err)); + btn.disabled = false; + } + } catch (e) { + status.textContent = '{% trans "Network error" %}'; + btn.disabled = false; + } +} + function regenerateWithVoice() { const voiceSelect = document.getElementById('post-voice-select'); const customInput = document.getElementById('post-custom-voice-input'); From 4d73ed9ac9255f3c44353f8971625adece101aaf Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Fri, 13 Mar 2026 15:18:57 +1100 Subject: [PATCH 02/95] Minor fix --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/post_views.py | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 1dfb6de269849a6ad434692bbc858c2fc6a99c6b..a5abcb49e7911fdc78f637af56bab063362ff8cf 100644 GIT binary patch delta 106 zcmV-w0G0oMpdEmq9grIVc99%I0d|34g3;1BGA&g09 zw+b)^&H@aYB@PJyGqW>6K@AbN&|n2!3%5Z&2c`iTHZ3qSEip47Ff=+bI65;hE;BVT MH8eDPeWd{h5Y?z1=Kufz delta 81 zcmV-X0IvUlpdEmq9grIVb&(uH0d;|3g3;1BGA&g>&*FSp<_2Vo1hEo=q50Rnq{r2z*JQu-Pn diff --git a/links/post_views.py b/links/post_views.py index 78aa8e1..a11e8a7 100644 --- a/links/post_views.py +++ b/links/post_views.py @@ -71,7 +71,9 @@ class PostUpdateView(UpdateView): model = Post form_class = PostForm template_name = 'links/post_form.html' - success_url = reverse_lazy('post-list') + + def get_success_url(self): + return reverse_lazy('post-detail', kwargs={'pk': self.object.pk}) def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) From 551fdf5cbb35680ff001b7ca37621eec0cbafb56 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 16:41:14 +1100 Subject: [PATCH 03/95] Add network scan --- NETSCAN_PLAN.md | 429 ++++++++++++++++++ core/settings.py | 1 + core/urls.py | 3 + data/db.sqlite3 | Bin 479232 -> 479232 bytes links/mini_apps_views.py | 2 +- memory/MEMORY.md | 18 + netscan/__init__.py | 0 netscan/admin.py | 21 + netscan/apps.py | 20 + netscan/checks/__init__.py | 0 netscan/checks/base.py | 10 + netscan/checks/cameras.py | 117 +++++ netscan/checks/dns.py | 86 ++++ netscan/checks/ingress.py | 93 ++++ netscan/checks/ports.py | 58 +++ netscan/checks/router.py | 117 +++++ netscan/checks/tls.py | 131 ++++++ netscan/fields.py | 39 ++ netscan/forms.py | 73 +++ netscan/migrations/0001_initial.py | 63 +++ .../0002_encrypt_telegram_fields.py | 22 + netscan/migrations/__init__.py | 0 netscan/models.py | 84 ++++ netscan/notifications.py | 60 +++ netscan/scanner.py | 74 +++ netscan/signals.py | 17 + netscan/tasks.py | 25 + netscan/templates/netscan/dashboard.html | 133 ++++++ .../netscan/profile_confirm_delete.html | 29 ++ netscan/templates/netscan/profile_form.html | 234 ++++++++++ netscan/templates/netscan/run_detail.html | 147 ++++++ netscan/templates/netscan/run_list.html | 98 ++++ netscan/urls.py | 14 + netscan/views.py | 215 +++++++++ pyproject.toml | 1 + templates/base.html | 10 + uv.lock | 121 +++++ 37 files changed, 2564 insertions(+), 1 deletion(-) create mode 100644 NETSCAN_PLAN.md create mode 100644 memory/MEMORY.md create mode 100644 netscan/__init__.py create mode 100644 netscan/admin.py create mode 100644 netscan/apps.py create mode 100644 netscan/checks/__init__.py create mode 100644 netscan/checks/base.py create mode 100644 netscan/checks/cameras.py create mode 100644 netscan/checks/dns.py create mode 100644 netscan/checks/ingress.py create mode 100644 netscan/checks/ports.py create mode 100644 netscan/checks/router.py create mode 100644 netscan/checks/tls.py create mode 100644 netscan/fields.py create mode 100644 netscan/forms.py create mode 100644 netscan/migrations/0001_initial.py create mode 100644 netscan/migrations/0002_encrypt_telegram_fields.py create mode 100644 netscan/migrations/__init__.py create mode 100644 netscan/models.py create mode 100644 netscan/notifications.py create mode 100644 netscan/scanner.py create mode 100644 netscan/signals.py create mode 100644 netscan/tasks.py create mode 100644 netscan/templates/netscan/dashboard.html create mode 100644 netscan/templates/netscan/profile_confirm_delete.html create mode 100644 netscan/templates/netscan/profile_form.html create mode 100644 netscan/templates/netscan/run_detail.html create mode 100644 netscan/templates/netscan/run_list.html create mode 100644 netscan/urls.py create mode 100644 netscan/views.py diff --git a/NETSCAN_PLAN.md b/NETSCAN_PLAN.md new file mode 100644 index 0000000..bfe2219 --- /dev/null +++ b/NETSCAN_PLAN.md @@ -0,0 +1,429 @@ +# NetScan — Home Network Security Scanner + +A new Django sub-app (`netscan`) added to the Links project that runs configurable, scheduled security checks against your home network, stores results in SQLite, sends Telegram alerts for critical findings, and surfaces everything through a Tailwind UI inside the existing mini-apps section. + +--- + +## Context & Constraints + +- **Codebase**: Django + APScheduler + Tailwind + SQLite (PostgreSQL in prod via env var) +- **Deployment**: Same Docker image as the Links app, deployed in K3s at 192.168.1.2 (server-3) +- **Why in-cluster**: The scanner must run **inside the LAN** to probe 192.168.1.x addresses (router, cameras). Running on the cluster node satisfies this automatically. +- **No new dependencies except `requests`** (already present). All checks use stdlib `socket`, `ssl`, `subprocess`, `struct`. +- **No new JS framework** — pure Django templates + Tailwind, matching the rest of the app. + +--- + +## Database Models (`netscan/models.py`) + +### `ScanProfile` +| Field | Type | Notes | +|---|---|---| +| `name` | CharField | Human label e.g. "Home Network" | +| `enabled` | BooleanField | Controls scheduler | +| `schedule_interval` | IntegerField | 1 / 3 / 7 / 30 days (choices) | +| `gateway_ip` | GenericIPAddressField | e.g. 192.168.1.1 | +| `public_ip` | GenericIPAddressField | e.g. 14.137.198.99 | +| `network_cidr` | CharField | e.g. 192.168.1.0/24 | +| `auth_provider_host` | CharField | e.g. pass.junv.cc (for ingress check) | +| `domains` | JSONField | List of public hostnames to check | +| `cameras` | JSONField | List of camera IPs to probe | +| `telegram_bot_token` | CharField | blank/null; stored encrypted in env preferred | +| `telegram_chat_id` | CharField | blank/null | +| `notify_on_severity` | CharField | "warning" or "critical" (default "critical") | +| `last_run_at` | DateTimeField | null | +| `created_at` | DateTimeField | auto | + +### `ScanRun` +| Field | Type | Notes | +|---|---|---| +| `profile` | FK → ScanProfile | cascade delete | +| `started_at` | DateTimeField | | +| `finished_at` | DateTimeField | null | +| `status` | CharField | pending / running / success / failed | +| `summary` | JSONField | `{ok:N, info:N, warning:N, critical:N}` | +| `triggered_by` | CharField | "scheduler" or "manual" | + +### `ScanFinding` +| Field | Type | Notes | +|---|---|---| +| `run` | FK → ScanRun | cascade delete | +| `check_name` | CharField | e.g. "router_ports", "tls_expiry" | +| `severity` | CharField | ok / info / warning / critical | +| `title` | CharField | Short human summary | +| `detail` | TextField | Full explanation | +| `raw` | JSONField | Raw probe output for debugging | + +--- + +## Check Modules (`netscan/checks/`) + +Each module exposes a single function `run(profile) -> list[Finding]`. All use only stdlib + `requests`. + +### `checks/base.py` +```python +@dataclass +class Finding: + check_name: str + severity: str # ok | info | warning | critical + title: str + detail: str + raw: dict +``` + +### `checks/router.py` — Gateway security +- TCP connect-probe gateway IP on ports: 22, 23, 53, 80, 139, 443, 445, 8080, 8443 +- Fetch HTTP headers from port 80 to identify plain-HTTP admin (`Server: httpd`) +- Flag: SMB open (139/445) → **warning** +- Flag: SSH closed → **ok**; SSH open from WAN → **warning** +- Flag: plain-HTTP admin (no HTTPS on 443) → **warning** +- Flag: unknown port 8080 open → **info** + +### `checks/dns.py` — Open resolver (WAN DNS exposure) +- Send raw DNS query for `google.com A` to `profile.public_ip:53` over UDP using `socket` + `struct` +- Parse the response: if `NOERROR` + answer section returned → recursion available +- Flag: open recursive resolver → **critical** +- Note in detail: "Verify from off-LAN; NAT hairpin may cause false positive" + +### `checks/ingress.py` — Public domain auth verification +- For each domain in `profile.domains`: + - `requests.get(f"https://{domain}/", allow_redirects=True, timeout=8)` + - Check: does the redirect chain pass through `profile.auth_provider_host`? + - Check: does the final URL (after all redirects) contain the auth provider host? + - If final URL is the app itself (not the auth host) → **critical** (auth bypassed) + - If redirect goes through auth provider → **ok** + - If connection refused / DNS fails → **warning** + +### `checks/cameras.py` — RTSP unauthenticated access +- For each IP in `profile.cameras`: + 1. TCP connect to port 554 — if closed → **info** (skip) + 2. Send `OPTIONS rtsp://{ip}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n` over raw socket + 3. Parse response status line + 4. If OPTIONS returns 200, send `DESCRIBE rtsp://{ip}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n` + 5. If DESCRIBE returns 200 (stream accessible with no creds) → **critical** + 6. If DESCRIBE returns 401/403 → **ok** (auth required) + 7. If OPTIONS returns 404 / no common path accessible → **ok** + +### `checks/tls.py` — TLS certificate validity +- For each domain in `profile.domains`: + - `ssl.get_server_certificate((domain, 443))` + parse `notAfter` + - Days to expiry < 0 → **critical** (expired) + - Days to expiry < 14 → **critical** + - Days to expiry < 30 → **warning** + - Otherwise → **ok** + - Also flag if cert `CN`/`SAN` doesn't match the domain → **warning** + +### `checks/ports.py` — Public IP port exposure +- TCP connect-probe `profile.public_ip` on: 22, 23, 25, 53, 80, 443, 3306, 5432, 6379, 8080, 8443 +- Flag presence of each open port with canned risk descriptions: + - 80/443 → **info** (expected for web services) + - 22 → **warning** (SSH exposed to internet) + - 23 → **critical** (Telnet exposed) + - 53 → **warning** (DNS — run dns check to confirm resolver) + - Database ports (3306/5432/6379) → **critical** + - 8443/8080 → **warning** (alt web ports) + +--- + +## Scanner Orchestrator (`netscan/scanner.py`) + +```python +def run_scan(profile_id: int) -> int: + """ + Runs all checks for the given profile. + Returns the ScanRun PK. + Called by APScheduler jobs and TriggerScanView. + """ + profile = ScanProfile.objects.get(pk=profile_id) + run = ScanRun.objects.create(profile=profile, status='running', triggered_by=...) + + all_findings = [] + check_modules = [router, dns, ingress, cameras, tls, ports] + for mod in check_modules: + try: + findings = mod.run(profile) + all_findings.extend(findings) + except Exception as e: + # Wrap uncaught errors as a warning finding so run still completes + all_findings.append(Finding(check_name=mod.__name__, severity='warning', + title='Check errored', detail=str(e), raw={})) + + # Persist findings + ScanFinding.objects.bulk_create([...]) + + # Update run summary + summary = Counter(f.severity for f in all_findings) + run.summary = dict(summary) + run.status = 'success' + run.finished_at = now() + run.save() + + # Telegram notification + if profile.telegram_bot_token and profile.telegram_chat_id: + notify_telegram(profile, run, all_findings) + + # Update profile.last_run_at + profile.last_run_at = now() + profile.save(update_fields=['last_run_at']) + + return run.pk +``` + +--- + +## Telegram Notifications (`netscan/notifications.py`) + +```python +def notify_telegram(profile, run, findings): + """ + Sends a Telegram message if any finding meets or exceeds notify_on_severity. + Uses the Bot API sendMessage endpoint directly via requests (no library needed). + """ + threshold_order = ['ok', 'info', 'warning', 'critical'] + threshold_idx = threshold_order.index(profile.notify_on_severity) + + flagged = [f for f in findings + if threshold_order.index(f.severity) >= threshold_idx] + if not flagged: + return + + lines = [f"🔒 *NetScan Alert* — {profile.name}", + f"Run #{run.pk} finished at {run.finished_at:%Y-%m-%d %H:%M}", + f"Summary: {run.summary}", + ""] + for f in flagged[:10]: # cap at 10 to stay under TG message limit + icon = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': 'ℹ️'}[f.severity] + lines.append(f"{icon} *{f.title}*\n {f.detail[:120]}") + + if len(flagged) > 10: + lines.append(f"_...and {len(flagged)-10} more findings_") + + text = "\n".join(lines) + url = f"https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage" + requests.post(url, json={ + "chat_id": profile.telegram_chat_id, + "text": text, + "parse_mode": "Markdown" + }, timeout=10) +``` + +--- + +## APScheduler Integration (`netscan/tasks.py` + `netscan/apps.py`) + +### `tasks.py` +```python +from core.scheduler import scheduler +from apscheduler.triggers.interval import IntervalTrigger + +def schedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + scheduler.add_job( + run_scan, + trigger=IntervalTrigger(days=profile.schedule_interval), + id=job_id, + args=[profile.pk], + replace_existing=True + ) + +def unschedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) +``` + +### `apps.py` +```python +class NetscanConfig(AppConfig): + name = 'netscan' + + def ready(self): + from netscan.tasks import schedule_profile + from netscan.models import ScanProfile + for profile in ScanProfile.objects.filter(enabled=True): + schedule_profile(profile) +``` + +### `signals.py` +```python +@receiver(post_save, sender=ScanProfile) +def reschedule_on_save(sender, instance, **kwargs): + if instance.enabled: + schedule_profile(instance) + else: + unschedule_profile(instance) +``` + +--- + +## Views (`netscan/views.py`) + +All views use `LoginRequiredMixin`. + +| View | URL | Notes | +|---|---|---| +| `DashboardView` | `/ui/netscan/` | List profiles, worst-severity badge per profile, last run time, Run Now + Edit buttons | +| `ProfileCreateView` | `/ui/netscan/profile/new/` | ModelForm | +| `ProfileUpdateView` | `/ui/netscan/profile//edit/` | ModelForm | +| `ProfileDeleteView` | `/ui/netscan/profile//delete/` | Confirm page | +| `ScanRunListView` | `/ui/netscan/profile//runs/` | Paginated history, status + severity count cols | +| `ScanRunDetailView` | `/ui/netscan/run//` | Findings grouped by severity, collapsible raw JSON | +| `TriggerScanView` | `/ui/netscan/profile//trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list | +| `TestTelegramView` | `/ui/netscan/profile//test-telegram/` | POST-only; sends a test message, returns JSON | + +--- + +## Templates (`netscan/templates/netscan/`) + +All extend `base.html`, use Tailwind classes matching the existing app. + +### `dashboard.html` +- Grid of profile cards (matches mini_apps card style) +- Each card: name, schedule chip (e.g. "Every 7 days"), last run timestamp, worst-severity badge (🔴/🟡/🟢), finding count breakdown +- "Run Now" button (POST to trigger URL), "Edit" link, "History" link +- Empty state with "Create your first scan profile" CTA + +### `profile_form.html` +- Fields: Name, Schedule (dropdown: 1/3/7/30 days), Gateway IP, Public IP, Network CIDR, Auth Provider Host, Domains (textarea, one per line), Camera IPs (textarea, one per line), Telegram Bot Token, Telegram Chat ID, Notify on Severity (dropdown: warning/critical), Enabled checkbox +- "Test Telegram" button (JS fetch to TestTelegramView, shows inline success/error) + +### `run_list.html` +- Table: Started, Duration, Triggered by, Status badge, 🔴 Critical, 🟡 Warning, 🟢 OK counts, View link +- Pagination + +### `run_detail.html` +- Header: profile name, run timestamp, status, summary badges, "Re-run" button +- Three collapsible sections: Critical findings, Warnings, OK/Info +- Each finding: title, detail text; expandable "Raw" disclosure showing JSON +- Back to history link + +--- + +## URL Wiring + +### `core/urls.py` — add: +```python +path('ui/netscan/', include('netscan.urls')), +``` + +### `netscan/urls.py`: +```python +urlpatterns = [ + path('', DashboardView.as_view(), name='netscan-dashboard'), + path('profile/new/', ProfileCreateView.as_view(), name='netscan-profile-create'), + path('profile//edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'), + path('profile//delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'), + path('profile//runs/', ScanRunListView.as_view(), name='netscan-run-list'), + path('profile//trigger/', TriggerScanView.as_view(), name='netscan-trigger'), + path('profile//test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'), + path('run//', ScanRunDetailView.as_view(), name='netscan-run-detail'), +] +``` + +### `core/settings.py` — add to INSTALLED_APPS: +```python +'netscan', +``` + +### `links/mini_apps_views.py` — add to `mini_apps` list: +```python +{ + 'name': 'NetScan', + 'description': 'Scheduled home network security scanner. Checks router exposure, DNS, TLS certs, public ingress auth, and camera access.', + 'url': 'netscan-dashboard', + 'thumbnail': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=300&fit=crop', + 'icon': 'fas fa-shield-alt', + 'color': '#e74c3c' +}, +``` + +--- + +## K3s Deployment Changes (`k8s/manifest.yaml`) + +The scanner runs **inside the same Pod** as the Links app — no new container or service needed. The existing Deployment only needs two new env vars (or they can be set per-profile in the DB): + +```yaml +# Optional: global fallback Telegram credentials +- name: NETSCAN_TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: netscan-credentials + key: telegram_bot_token +- name: NETSCAN_TELEGRAM_CHAT_ID + valueFrom: + secretKeyRef: + name: netscan-credentials + key: telegram_chat_id +``` + +Create the secret: +```bash +kubectl create secret generic netscan-credentials \ + --from-literal=telegram_bot_token=YOUR_TOKEN \ + --from-literal=telegram_chat_id=YOUR_CHAT_ID \ + -n home-apps +``` + +The Pod already runs inside the LAN (on server-3 at 192.168.1.2), so it can reach 192.168.1.1 (router), 192.168.1.70–250 (cameras), and the public IP. + +--- + +## File Checklist + +| File | Action | +|---|---| +| `netscan/__init__.py` | create (empty) | +| `netscan/apps.py` | create | +| `netscan/models.py` | create | +| `netscan/admin.py` | create (register all 3 models) | +| `netscan/checks/__init__.py` | create (empty) | +| `netscan/checks/base.py` | create | +| `netscan/checks/router.py` | create | +| `netscan/checks/dns.py` | create | +| `netscan/checks/ingress.py` | create | +| `netscan/checks/cameras.py` | create | +| `netscan/checks/tls.py` | create | +| `netscan/checks/ports.py` | create | +| `netscan/scanner.py` | create | +| `netscan/notifications.py` | create | +| `netscan/tasks.py` | create | +| `netscan/signals.py` | create | +| `netscan/forms.py` | create | +| `netscan/views.py` | create | +| `netscan/urls.py` | create | +| `netscan/migrations/0001_initial.py` | create (via makemigrations) | +| `netscan/templates/netscan/dashboard.html` | create | +| `netscan/templates/netscan/profile_form.html` | create | +| `netscan/templates/netscan/run_list.html` | create | +| `netscan/templates/netscan/run_detail.html` | create | +| `core/settings.py` | add `'netscan'` to INSTALLED_APPS | +| `core/urls.py` | add `path('ui/netscan/', include('netscan.urls'))` | +| `links/mini_apps_views.py` | add NetScan entry to mini_apps list | +| `k8s/manifest.yaml` | add env vars for Telegram credentials | + +--- + +## Verification Steps + +1. `python manage.py makemigrations netscan && python manage.py migrate` — no errors +2. `python manage.py runserver` — navigate to `/ui/netscan/` — dashboard loads +3. Create a ScanProfile via the form, ensure all fields save +4. Click "Run Now" — `ScanRun` + `ScanFinding` rows appear in DB; run_detail page shows them +5. Set schedule to 1 day, save — `scheduler.get_jobs()` shows a `netscan_profile_N` job +6. Toggle profile disabled — job is removed from scheduler +7. If Telegram configured, click "Test Telegram" — message appears in chat +8. After a scheduled run: Telegram alert arrives for any critical/warning findings +9. All views return 302 to login when accessed without auth +10. `kubectl apply -f k8s/manifest.yaml` — Pod starts cleanly with new env vars + +--- + +## Implementation Order (for LLM session in links workspace) + +1. **Phase 1** (parallel): models + migration + app registration + all check modules (no inter-dependencies) +2. **Phase 2**: scanner.py + notifications.py (depends on models + checks) +3. **Phase 3**: tasks.py + apps.py + signals.py (depends on scanner) +4. **Phase 4**: views.py + urls.py + forms.py (depends on models) +5. **Phase 5**: all 4 templates (depends on views) +6. **Phase 6**: wire into core/settings, core/urls, mini_apps_views, k8s/manifest diff --git a/core/settings.py b/core/settings.py index c1867ac..7504c3e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -20,6 +20,7 @@ INSTALLED_APPS = [ 'new_theme', 'simplemde', 'markdown', # 只需要基本的markdown包 + 'netscan', ] ROOT_URLCONF = 'core.urls' diff --git a/core/urls.py b/core/urls.py index 3b938e4..013b2f5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -26,6 +26,9 @@ urlpatterns = [ path('custom//', CustomLinkView.as_view(), name='custom_link'), path('custom//edit/', LinkUpdateView.as_view(), name='custom_link_update'), + # Include netscan BEFORE links.urls to prevent the alias catch-all from intercepting it + path('ui/netscan/', include('netscan.urls')), + # Include main app URLs with locale path('', include('links.urls')), ] diff --git a/data/db.sqlite3 b/data/db.sqlite3 index a5abcb49e7911fdc78f637af56bab063362ff8cf..663889aa32775ef931cee6e486a47917b3611150 100644 GIT binary patch delta 7005 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 z bool: + try: + with socket.create_connection((host, port), timeout=TIMEOUT): + return True + except (OSError, socket.timeout): + return False + + +def _rtsp_request(host: str, method: str, seq: int, extra_headers: str = '') -> str: + try: + with socket.create_connection((host, RTSP_PORT), timeout=TIMEOUT) as s: + request = ( + f'{method} rtsp://{host}/ RTSP/1.0\r\n' + f'CSeq: {seq}\r\n' + f'{extra_headers}' + '\r\n' + ) + s.sendall(request.encode()) + response = s.recv(4096).decode('utf-8', errors='replace') + return response + except Exception as e: + return f'ERROR: {e}' + + +def _parse_rtsp_status(response: str) -> int: + """Extract HTTP-style status code from RTSP response.""" + try: + first_line = response.splitlines()[0] + return int(first_line.split()[1]) + except (IndexError, ValueError): + return 0 + + +def run(profile) -> list: + findings = [] + cameras = profile.cameras or [] + + if not cameras: + return [Finding( + check_name=CHECK_NAME, + severity='info', + title='No camera IPs configured', + detail='Add camera IPs to the scan profile to enable RTSP unauthenticated access check.', + raw={}, + )] + + for ip in cameras: + raw = {'camera_ip': ip} + + if not _tcp_open(ip, RTSP_PORT): + findings.append(Finding( + check_name=CHECK_NAME, + severity='info', + title=f'{ip}: RTSP port 554 closed', + detail=f'Port 554 is not open on {ip}. Camera may be offline or not using RTSP.', + raw=raw, + )) + continue + + options_resp = _rtsp_request(ip, 'OPTIONS', 1) + raw['options_response'] = options_resp[:500] + options_status = _parse_rtsp_status(options_resp) + + if options_status == 0: + findings.append(Finding( + check_name=CHECK_NAME, + severity='info', + title=f'{ip}: RTSP OPTIONS failed', + detail=f'Got unexpected RTSP OPTIONS response from {ip}.', + raw=raw, + )) + continue + + describe_resp = _rtsp_request(ip, 'DESCRIBE', 2, 'Accept: application/sdp\r\n') + raw['describe_response'] = describe_resp[:500] + describe_status = _parse_rtsp_status(describe_resp) + + if describe_status == 200: + findings.append(Finding( + check_name=CHECK_NAME, + severity='critical', + title=f'{ip}: RTSP stream accessible without credentials', + detail=( + f'Camera at {ip} returned 200 to DESCRIBE without authentication. ' + 'Live stream may be publicly accessible on the LAN.' + ), + raw=raw, + )) + elif describe_status in (401, 403): + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title=f'{ip}: RTSP requires authentication', + detail=f'Camera at {ip} returned {describe_status} to DESCRIBE — auth is enforced.', + raw=raw, + )) + else: + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title=f'{ip}: RTSP DESCRIBE returned {describe_status}', + detail=f'Camera at {ip} responded with status {describe_status} — no unauthenticated stream detected.', + raw=raw, + )) + + return findings diff --git a/netscan/checks/dns.py b/netscan/checks/dns.py new file mode 100644 index 0000000..3e59956 --- /dev/null +++ b/netscan/checks/dns.py @@ -0,0 +1,86 @@ +import socket +import struct +import logging +from .base import Finding + +logger = logging.getLogger(__name__) + +CHECK_NAME = 'dns_resolver' +TIMEOUT = 5 + + +def _build_dns_query(domain: str) -> bytes: + """Build a minimal DNS A query packet.""" + header = struct.pack('>HHHHHH', 0xAAAA, 0x0100, 1, 0, 0, 0) + parts = domain.encode().split(b'.') + question = b''.join(bytes([len(p)]) + p for p in parts) + b'\x00' + question += struct.pack('>HH', 1, 1) # type A, class IN + return header + question + + +def _parse_dns_response(data: bytes) -> dict: + """Return basic info from a DNS response header.""" + if len(data) < 12: + return {'error': 'response too short'} + txid, flags, qdcount, ancount, nscount, arcount = struct.unpack('>HHHHHH', data[:12]) + rcode = flags & 0x000F + return { + 'txid': txid, + 'flags': flags, + 'rcode': rcode, + 'ancount': ancount, + } + + +def run(profile) -> list: + host = profile.public_ip + raw = {'public_ip': host} + + query = _build_dns_query('google.com') + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(TIMEOUT) + sock.sendto(query, (host, 53)) + data, _ = sock.recvfrom(512) + sock.close() + + parsed = _parse_dns_response(data) + raw['response'] = parsed + + if parsed.get('rcode') == 0 and parsed.get('ancount', 0) > 0: + return [Finding( + check_name=CHECK_NAME, + severity='critical', + title=f'Open DNS resolver detected on {host}:53', + detail=( + 'Your public IP responds to recursive DNS queries from external hosts. ' + 'This can be abused for DNS amplification attacks. ' + 'Note: NAT hairpin may cause false positive — verify from off-LAN.' + ), + raw=raw, + )] + else: + return [Finding( + check_name=CHECK_NAME, + severity='ok', + title='DNS port 53 does not appear to be an open resolver', + detail=f'DNS query to {host}:53 returned rcode={parsed.get("rcode")} with {parsed.get("ancount", 0)} answers.', + raw=raw, + )] + except socket.timeout: + return [Finding( + check_name=CHECK_NAME, + severity='ok', + title='DNS port 53 timed out (not an open resolver)', + detail=f'No response from {host}:53 within {TIMEOUT}s. Port is likely closed or filtered.', + raw={**raw, 'error': 'timeout'}, + )] + except Exception as e: + logger.warning(f'DNS check error: {e}') + return [Finding( + check_name=CHECK_NAME, + severity='info', + title='DNS resolver check failed', + detail=f'Could not complete DNS probe to {host}:53 — {e}', + raw={**raw, 'error': str(e)}, + )] diff --git a/netscan/checks/ingress.py b/netscan/checks/ingress.py new file mode 100644 index 0000000..4225579 --- /dev/null +++ b/netscan/checks/ingress.py @@ -0,0 +1,93 @@ +import logging +import requests +from .base import Finding + +logger = logging.getLogger(__name__) + +CHECK_NAME = 'ingress_auth' +TIMEOUT = 10 + + +def run(profile) -> list: + findings = [] + domains = profile.domains or [] + auth_host = profile.auth_provider_host + + if not domains: + return [Finding( + check_name=CHECK_NAME, + severity='info', + title='No domains configured for ingress check', + detail='Add domains to the scan profile to enable ingress auth verification.', + raw={}, + )] + + for domain in domains: + raw = {'domain': domain, 'auth_provider_host': auth_host} + try: + resp = requests.get(f'https://{domain}/', allow_redirects=True, timeout=TIMEOUT, + headers={'User-Agent': 'NetScan/1.0'}) + redirect_chain = [r.url for r in resp.history] + [resp.url] + raw['redirect_chain'] = redirect_chain + raw['final_url'] = resp.url + raw['status_code'] = resp.status_code + + if auth_host: + passed_through_auth = any(auth_host in url for url in redirect_chain[:-1]) + final_is_auth = auth_host in resp.url + + if passed_through_auth or final_is_auth: + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title=f'{domain}: auth provider in redirect chain', + detail=f'Request passed through {auth_host} as expected.', + raw=raw, + )) + else: + findings.append(Finding( + check_name=CHECK_NAME, + severity='critical', + title=f'{domain}: auth provider NOT in redirect chain', + detail=( + f'Expected redirect through {auth_host} but final URL is {resp.url}. ' + 'Authentication may be bypassed.' + ), + raw=raw, + )) + else: + findings.append(Finding( + check_name=CHECK_NAME, + severity='info', + title=f'{domain}: reachable (no auth provider configured)', + detail=f'Domain reached with status {resp.status_code}. Set auth_provider_host to verify auth.', + raw=raw, + )) + + except requests.exceptions.ConnectionError as e: + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: connection refused', + detail=f'Could not connect to https://{domain}/ — {e}', + raw={**raw, 'error': str(e)}, + )) + except requests.exceptions.Timeout: + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: request timed out', + detail=f'Request to https://{domain}/ timed out after {TIMEOUT}s.', + raw={**raw, 'error': 'timeout'}, + )) + except Exception as e: + logger.warning(f'Ingress check error for {domain}: {e}') + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: check error', + detail=str(e), + raw={**raw, 'error': str(e)}, + )) + + return findings diff --git a/netscan/checks/ports.py b/netscan/checks/ports.py new file mode 100644 index 0000000..2b2218a --- /dev/null +++ b/netscan/checks/ports.py @@ -0,0 +1,58 @@ +import socket +import logging +from .base import Finding + +logger = logging.getLogger(__name__) + +CHECK_NAME = 'public_ports' +TIMEOUT = 3 + +PORT_INFO = { + 22: ('warning', 'SSH', 'SSH exposed to the internet. Ensure key-only auth and restrict access.'), + 23: ('critical', 'Telnet', 'Telnet (cleartext) is exposed to the internet. Disable immediately.'), + 25: ('warning', 'SMTP', 'SMTP port exposed. Could be used for spam relay if misconfigured.'), + 53: ('warning', 'DNS', 'DNS port open. Run the DNS resolver check to confirm if recursive queries are allowed.'), + 80: ('info', 'HTTP', 'HTTP port open. Expected for public web services.'), + 443: ('info', 'HTTPS', 'HTTPS port open. Expected for public web services.'), + 3306: ('critical', 'MySQL', 'MySQL database port exposed to the internet. Restrict access immediately.'), + 5432: ('critical', 'Postgres','PostgreSQL database port exposed to the internet. Restrict access immediately.'), + 6379: ('critical', 'Redis', 'Redis port exposed to the internet. Redis has no auth by default — critical risk.'), + 8080: ('warning', 'HTTP-alt','Alternate HTTP port 8080 is open. Verify this is intentional.'), + 8443: ('warning', 'HTTPS-alt','Alternate HTTPS port 8443 is open. Verify this is intentional.'), +} + + +def _tcp_open(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=TIMEOUT): + return True + except (OSError, socket.timeout): + return False + + +def run(profile) -> list: + findings = [] + host = profile.public_ip + raw = {'public_ip': host, 'open_ports': []} + + for port, (severity, label, detail) in PORT_INFO.items(): + if _tcp_open(host, port): + raw['open_ports'].append(port) + findings.append(Finding( + check_name=CHECK_NAME, + severity=severity, + title=f'Port {port} ({label}) open on public IP {host}', + detail=detail, + raw={'public_ip': host, 'port': port}, + )) + + if not findings: + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title=f'No high-risk ports open on public IP {host}', + detail='All probed ports are closed or filtered.', + raw=raw, + )) + + return findings diff --git a/netscan/checks/router.py b/netscan/checks/router.py new file mode 100644 index 0000000..c401d83 --- /dev/null +++ b/netscan/checks/router.py @@ -0,0 +1,117 @@ +import socket +import logging +from .base import Finding + +logger = logging.getLogger(__name__) + +CHECK_NAME = 'router_ports' + +PORTS_TO_PROBE = [22, 23, 53, 80, 139, 443, 445, 8080, 8443] +TIMEOUT = 3 + + +def _tcp_open(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=TIMEOUT): + return True + except (OSError, socket.timeout): + return False + + +def _fetch_http_headers(host: str, port: int = 80) -> dict: + try: + with socket.create_connection((host, port), timeout=TIMEOUT) as s: + s.sendall(f'HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n'.encode()) + resp = s.recv(4096).decode('utf-8', errors='replace') + headers = {} + for line in resp.splitlines()[1:]: + if ':' in line: + k, _, v = line.partition(':') + headers[k.strip().lower()] = v.strip() + return headers + except Exception: + return {} + + +def run(profile) -> list: + findings = [] + host = profile.gateway_ip + open_ports = {} + + for port in PORTS_TO_PROBE: + open_ports[port] = _tcp_open(host, port) + + raw = {'gateway_ip': host, 'open_ports': {str(p): v for p, v in open_ports.items()}} + + # SMB exposure + if open_ports.get(139) or open_ports.get(445): + smb_ports = [p for p in [139, 445] if open_ports.get(p)] + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'SMB ports open on gateway ({", ".join(str(p) for p in smb_ports)})', + detail='Windows file sharing (SMB) is accessible on the gateway. This could expose network shares.', + raw=raw, + )) + + # Telnet + if open_ports.get(23): + findings.append(Finding( + check_name=CHECK_NAME, + severity='critical', + title='Telnet port 23 open on gateway', + detail='Telnet transmits credentials in plaintext. Disable telnet and use SSH instead.', + raw=raw, + )) + + # SSH open on gateway + if open_ports.get(22): + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title='SSH port 22 open on gateway', + detail='SSH is accessible on the gateway. Ensure key-only auth is enforced and access is restricted.', + raw=raw, + )) + + # Plain HTTP admin (port 80 open, port 443 closed) + if open_ports.get(80) and not open_ports.get(443): + headers = _fetch_http_headers(host, 80) + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title='Gateway admin over plain HTTP (no HTTPS)', + detail=f'Port 80 is open but 443 is closed. Admin interface may be served unencrypted. Server header: {headers.get("server", "unknown")}', + raw={**raw, 'http_headers': headers}, + )) + + # Unknown port 8080 + if open_ports.get(8080): + findings.append(Finding( + check_name=CHECK_NAME, + severity='info', + title='Port 8080 open on gateway', + detail='An alternate HTTP service is running on port 8080. Verify this is intentional.', + raw=raw, + )) + + # Port 8443 open + if open_ports.get(8443): + findings.append(Finding( + check_name=CHECK_NAME, + severity='info', + title='Port 8443 open on gateway', + detail='An alternate HTTPS service is running on port 8443. Verify this is intentional.', + raw=raw, + )) + + if not findings: + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title='Gateway port scan looks clean', + detail=f'No high-risk ports found open on {host}.', + raw=raw, + )) + + return findings diff --git a/netscan/checks/tls.py b/netscan/checks/tls.py new file mode 100644 index 0000000..658fce8 --- /dev/null +++ b/netscan/checks/tls.py @@ -0,0 +1,131 @@ +import ssl +import socket +import logging +from datetime import datetime, timezone +from .base import Finding + +logger = logging.getLogger(__name__) + +CHECK_NAME = 'tls_expiry' +TIMEOUT = 10 + + +def _get_cert_info(domain: str) -> dict: + ctx = ssl.create_default_context() + try: + with ctx.wrap_socket(socket.create_connection((domain, 443), timeout=TIMEOUT), + server_hostname=domain) as s: + cert = s.getpeercert() + return {'cert': cert, 'error': None} + except ssl.SSLCertVerificationError as e: + return {'cert': None, 'error': f'SSL verification failed: {e}'} + except Exception as e: + return {'cert': None, 'error': str(e)} + + +def _days_until_expiry(not_after: str) -> int: + expiry = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z').replace(tzinfo=timezone.utc) + now = datetime.now(tz=timezone.utc) + return (expiry - now).days + + +def _cert_covers_domain(cert: dict, domain: str) -> bool: + san_list = [v for t, v in cert.get('subjectAltName', []) if t == 'DNS'] + for san in san_list: + if san == domain: + return True + if san.startswith('*.') and domain.endswith(san[1:]): + return True + if not san_list: + cn = dict(x[0] for x in cert.get('subject', [])).get('commonName', '') + return cn == domain + return False + + +def run(profile) -> list: + findings = [] + domains = profile.domains or [] + + if not domains: + return [Finding( + check_name=CHECK_NAME, + severity='info', + title='No domains configured for TLS check', + detail='Add domains to the scan profile to enable TLS certificate checks.', + raw={}, + )] + + for domain in domains: + raw = {'domain': domain} + info = _get_cert_info(domain) + + if info['error']: + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: TLS check failed', + detail=info['error'], + raw={**raw, 'error': info['error']}, + )) + continue + + cert = info['cert'] + not_after = cert.get('notAfter', '') + raw['not_after'] = not_after + + try: + days = _days_until_expiry(not_after) + raw['days_to_expiry'] = days + + if days < 0: + findings.append(Finding( + check_name=CHECK_NAME, + severity='critical', + title=f'{domain}: TLS certificate EXPIRED {abs(days)} days ago', + detail=f'Certificate expired on {not_after}. Renew immediately.', + raw=raw, + )) + elif days < 14: + findings.append(Finding( + check_name=CHECK_NAME, + severity='critical', + title=f'{domain}: TLS certificate expires in {days} days', + detail=f'Certificate will expire on {not_after}. Renew urgently.', + raw=raw, + )) + elif days < 30: + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: TLS certificate expires in {days} days', + detail=f'Certificate expires on {not_after}. Plan renewal soon.', + raw=raw, + )) + else: + findings.append(Finding( + check_name=CHECK_NAME, + severity='ok', + title=f'{domain}: TLS certificate valid for {days} more days', + detail=f'Certificate expires on {not_after}.', + raw=raw, + )) + except ValueError as e: + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: could not parse certificate expiry', + detail=str(e), + raw=raw, + )) + continue + + if not _cert_covers_domain(cert, domain): + findings.append(Finding( + check_name=CHECK_NAME, + severity='warning', + title=f'{domain}: certificate CN/SAN does not match domain', + detail=f'The TLS certificate does not include {domain} in its names.', + raw=raw, + )) + + return findings diff --git a/netscan/fields.py b/netscan/fields.py new file mode 100644 index 0000000..8491d8c --- /dev/null +++ b/netscan/fields.py @@ -0,0 +1,39 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet +from django.conf import settings +from django.db import models + + +def _get_fernet(): + """Derive a Fernet key from Django's SECRET_KEY.""" + raw_key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() + return Fernet(base64.urlsafe_b64encode(raw_key)) + + +class EncryptedCharField(models.TextField): + """ + Stores values encrypted at rest using Fernet symmetric encryption. + The encryption key is derived from Django's SECRET_KEY so no extra + secrets management is required — if the SECRET_KEY is set, values + are protected. + + From the application's perspective this behaves like a plain text field: + you read/write plaintext; encryption/decryption happens transparently. + """ + + def from_db_value(self, value, expression, connection): + if not value: + return value + try: + return _get_fernet().decrypt(value.encode()).decode() + except Exception: + # Gracefully return raw value if decryption fails + # (e.g. migrating plaintext rows that were saved before encryption) + return value + + def get_prep_value(self, value): + if not value: + return value + return _get_fernet().encrypt(value.encode()).decode() diff --git a/netscan/forms.py b/netscan/forms.py new file mode 100644 index 0000000..c1df12b --- /dev/null +++ b/netscan/forms.py @@ -0,0 +1,73 @@ +from django import forms +from .models import ScanProfile + +_INPUT = ( + 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-700 ' + 'focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent' +) +_TEXTAREA = _INPUT + ' resize-none' + + +class ScanProfileForm(forms.ModelForm): + domains_text = forms.CharField( + widget=forms.Textarea(attrs={ + 'rows': 4, + 'placeholder': 'to.junv.cc\ngo.junv.cc', + 'class': _TEXTAREA, + }), + required=False, + label='Domains (one per line)', + help_text='Public hostnames to check for TLS and auth.', + ) + cameras_text = forms.CharField( + widget=forms.Textarea(attrs={ + 'rows': 3, + 'placeholder': '192.168.1.70\n192.168.1.71', + 'class': _TEXTAREA, + }), + required=False, + label='Camera IPs (one per line)', + help_text='Local IP addresses of cameras to probe for unauthenticated RTSP.', + ) + + class Meta: + model = ScanProfile + fields = [ + 'name', 'enabled', 'schedule_interval', + 'gateway_ip', 'public_ip', 'network_cidr', + 'auth_provider_host', + 'telegram_bot_token', 'telegram_chat_id', 'notify_on_severity', + ] + widgets = { + 'name': forms.TextInput(attrs={'class': _INPUT}), + 'schedule_interval': forms.Select(attrs={'class': _INPUT}), + 'gateway_ip': forms.TextInput(attrs={'class': _INPUT}), + 'public_ip': forms.TextInput(attrs={'class': _INPUT}), + 'network_cidr': forms.TextInput(attrs={'class': _INPUT}), + 'auth_provider_host': forms.TextInput(attrs={'class': _INPUT}), + 'telegram_bot_token': forms.PasswordInput(render_value=True, attrs={'class': _INPUT}), + 'telegram_chat_id': forms.TextInput(attrs={'class': _INPUT}), + 'notify_on_severity': forms.Select(attrs={'class': _INPUT}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields['domains_text'].initial = '\n'.join(self.instance.domains or []) + self.fields['cameras_text'].initial = '\n'.join(self.instance.cameras or []) + + def clean_domains_text(self): + raw = self.cleaned_data.get('domains_text', '') + return [line.strip() for line in raw.splitlines() if line.strip()] + + def clean_cameras_text(self): + raw = self.cleaned_data.get('cameras_text', '') + return [line.strip() for line in raw.splitlines() if line.strip()] + + def save(self, commit=True): + instance = super().save(commit=False) + instance.domains = self.cleaned_data['domains_text'] + instance.cameras = self.cleaned_data['cameras_text'] + if commit: + instance.save() + return instance diff --git a/netscan/migrations/0001_initial.py b/netscan/migrations/0001_initial.py new file mode 100644 index 0000000..c2e572f --- /dev/null +++ b/netscan/migrations/0001_initial.py @@ -0,0 +1,63 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ScanProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('schedule_interval', models.IntegerField(choices=[(1, 'Every 1 day'), (3, 'Every 3 days'), (7, 'Every 7 days'), (30, 'Every 30 days')], default=7)), + ('gateway_ip', models.GenericIPAddressField(help_text='e.g. 192.168.1.1')), + ('public_ip', models.GenericIPAddressField(help_text='Your public/WAN IP address')), + ('network_cidr', models.CharField(blank=True, help_text='e.g. 192.168.1.0/24', max_length=50)), + ('auth_provider_host', models.CharField(blank=True, help_text='e.g. pass.junv.cc', max_length=255)), + ('domains', models.JSONField(blank=True, default=list, help_text='List of public hostnames to check')), + ('cameras', models.JSONField(blank=True, default=list, help_text='List of camera IPs to probe')), + ('telegram_bot_token', models.CharField(blank=True, max_length=255)), + ('telegram_chat_id', models.CharField(blank=True, max_length=100)), + ('notify_on_severity', models.CharField(choices=[('warning', 'Warning and above'), ('critical', 'Critical only')], default='critical', max_length=20)), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='ScanRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20)), + ('summary', models.JSONField(default=dict)), + ('triggered_by', models.CharField(default='manual', max_length=20)), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='netscan.scanprofile')), + ], + options={ + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='ScanFinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('check_name', models.CharField(max_length=100)), + ('severity', models.CharField(choices=[('ok', 'OK'), ('info', 'Info'), ('warning', 'Warning'), ('critical', 'Critical')], max_length=20)), + ('title', models.CharField(max_length=255)), + ('detail', models.TextField()), + ('raw', models.JSONField(default=dict)), + ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='netscan.scanrun')), + ], + options={ + 'ordering': ['severity', 'check_name'], + }, + ), + ] diff --git a/netscan/migrations/0002_encrypt_telegram_fields.py b/netscan/migrations/0002_encrypt_telegram_fields.py new file mode 100644 index 0000000..60a0ed5 --- /dev/null +++ b/netscan/migrations/0002_encrypt_telegram_fields.py @@ -0,0 +1,22 @@ +from django.db import migrations +import netscan.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('netscan', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='scanprofile', + name='telegram_bot_token', + field=netscan.fields.EncryptedCharField(blank=True), + ), + migrations.AlterField( + model_name='scanprofile', + name='telegram_chat_id', + field=netscan.fields.EncryptedCharField(blank=True), + ), + ] diff --git a/netscan/migrations/__init__.py b/netscan/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netscan/models.py b/netscan/models.py new file mode 100644 index 0000000..7a30afc --- /dev/null +++ b/netscan/models.py @@ -0,0 +1,84 @@ +from django.db import models + +from .fields import EncryptedCharField + + +class ScanProfile(models.Model): + INTERVAL_CHOICES = [ + (1, 'Every 1 day'), + (3, 'Every 3 days'), + (7, 'Every 7 days'), + (30, 'Every 30 days'), + ] + SEVERITY_CHOICES = [ + ('warning', 'Warning and above'), + ('critical', 'Critical only'), + ] + + name = models.CharField(max_length=200) + enabled = models.BooleanField(default=True) + schedule_interval = models.IntegerField(choices=INTERVAL_CHOICES, default=7) + gateway_ip = models.GenericIPAddressField(help_text='e.g. 192.168.1.1') + public_ip = models.GenericIPAddressField(help_text='Your public/WAN IP address') + network_cidr = models.CharField(max_length=50, blank=True, help_text='e.g. 192.168.1.0/24') + auth_provider_host = models.CharField(max_length=255, blank=True, help_text='e.g. pass.junv.cc') + domains = models.JSONField(default=list, blank=True, help_text='List of public hostnames to check') + cameras = models.JSONField(default=list, blank=True, help_text='List of camera IPs to probe') + telegram_bot_token = EncryptedCharField(blank=True) + telegram_chat_id = EncryptedCharField(blank=True) + notify_on_severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES, default='critical') + last_run_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class ScanRun(models.Model): + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('running', 'Running'), + ('success', 'Success'), + ('failed', 'Failed'), + ] + + profile = models.ForeignKey(ScanProfile, on_delete=models.CASCADE, related_name='runs') + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + summary = models.JSONField(default=dict) + triggered_by = models.CharField(max_length=20, default='manual') + + class Meta: + ordering = ['-started_at'] + + def __str__(self): + return f'{self.profile.name} run #{self.pk} ({self.status})' + + @property + def duration_seconds(self): + if self.finished_at and self.started_at: + return int((self.finished_at - self.started_at).total_seconds()) + return None + + +class ScanFinding(models.Model): + SEVERITY_CHOICES = [ + ('ok', 'OK'), + ('info', 'Info'), + ('warning', 'Warning'), + ('critical', 'Critical'), + ] + + run = models.ForeignKey(ScanRun, on_delete=models.CASCADE, related_name='findings') + check_name = models.CharField(max_length=100) + severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES) + title = models.CharField(max_length=255) + detail = models.TextField() + raw = models.JSONField(default=dict) + + class Meta: + ordering = ['severity', 'check_name'] + + def __str__(self): + return f'[{self.severity.upper()}] {self.title}' diff --git a/netscan/notifications.py b/netscan/notifications.py new file mode 100644 index 0000000..7e5b683 --- /dev/null +++ b/netscan/notifications.py @@ -0,0 +1,60 @@ +import logging +import requests + +logger = logging.getLogger(__name__) + +SEVERITY_ORDER = ['ok', 'info', 'warning', 'critical'] +SEVERITY_ICONS = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': 'ℹ️'} + + +def notify_telegram(profile, run, findings): + """ + Send a Telegram message if any finding meets or exceeds notify_on_severity. + """ + threshold_idx = SEVERITY_ORDER.index(profile.notify_on_severity) + flagged = [f for f in findings if SEVERITY_ORDER.index(f.severity) >= threshold_idx] + + if not flagged: + return + + finished_str = run.finished_at.strftime('%Y-%m-%d %H:%M') if run.finished_at else 'unknown' + lines = [ + f'🔒 *NetScan Alert* — {profile.name}', + f'Run \\#{run.pk} finished at {finished_str}', + f'Summary: {run.summary}', + '', + ] + + for f in flagged[:10]: + icon = SEVERITY_ICONS.get(f.severity, '•') + lines.append(f'{icon} *{f.title}*\n {f.detail[:120]}') + + if len(flagged) > 10: + lines.append(f'_...and {len(flagged) - 10} more findings_') + + text = '\n'.join(lines) + url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage' + resp = requests.post(url, json={ + 'chat_id': profile.telegram_chat_id, + 'text': text, + 'parse_mode': 'Markdown', + }, timeout=10) + resp.raise_for_status() + logger.info(f'Telegram notification sent for run #{run.pk}') + + +def send_test_telegram(profile) -> dict: + """Send a test message. Returns {'ok': True} or {'ok': False, 'error': str}.""" + if not profile.telegram_bot_token or not profile.telegram_chat_id: + return {'ok': False, 'error': 'Telegram bot token or chat ID not configured.'} + try: + url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage' + resp = requests.post(url, json={ + 'chat_id': profile.telegram_chat_id, + 'text': f'✅ *NetScan test message* from profile _{profile.name}_. Notifications are working.', + 'parse_mode': 'Markdown', + }, timeout=10) + resp.raise_for_status() + return {'ok': True} + except Exception as e: + return {'ok': False, 'error': str(e)} diff --git a/netscan/scanner.py b/netscan/scanner.py new file mode 100644 index 0000000..1740e74 --- /dev/null +++ b/netscan/scanner.py @@ -0,0 +1,74 @@ +import logging +from collections import Counter +from django.utils.timezone import now + +from .models import ScanProfile, ScanRun, ScanFinding +from .checks import router, dns, ingress, cameras, tls, ports +from .checks.base import Finding +from .notifications import notify_telegram + +logger = logging.getLogger(__name__) + +CHECK_MODULES = [router, dns, ingress, cameras, tls, ports] + + +def run_scan(profile_id: int, triggered_by: str = 'scheduler') -> int: + """ + Run all checks for the given profile. Returns the ScanRun PK. + Called by APScheduler jobs and TriggerScanView. + """ + profile = ScanProfile.objects.get(pk=profile_id) + run = ScanRun.objects.create( + profile=profile, + status='running', + triggered_by=triggered_by, + ) + logger.info(f'Starting netscan run #{run.pk} for profile "{profile.name}" (triggered_by={triggered_by})') + + all_findings: list[Finding] = [] + + for mod in CHECK_MODULES: + mod_name = mod.__name__.split('.')[-1] + try: + findings = mod.run(profile) + all_findings.extend(findings) + logger.debug(f' {mod_name}: {len(findings)} findings') + except Exception as e: + logger.exception(f' {mod_name}: uncaught exception') + all_findings.append(Finding( + check_name=mod_name, + severity='warning', + title=f'{mod_name}: check errored', + detail=str(e), + raw={'exception': str(e)}, + )) + + ScanFinding.objects.bulk_create([ + ScanFinding( + run=run, + check_name=f.check_name, + severity=f.severity, + title=f.title, + detail=f.detail, + raw=f.raw, + ) + for f in all_findings + ]) + + summary = dict(Counter(f.severity for f in all_findings)) + run.summary = summary + run.status = 'success' + run.finished_at = now() + run.save() + + profile.last_run_at = now() + profile.save(update_fields=['last_run_at']) + + if profile.telegram_bot_token and profile.telegram_chat_id: + try: + notify_telegram(profile, run, all_findings) + except Exception as e: + logger.warning(f'Telegram notification failed: {e}') + + logger.info(f'Finished netscan run #{run.pk}: {summary}') + return run.pk diff --git a/netscan/signals.py b/netscan/signals.py new file mode 100644 index 0000000..449d2ec --- /dev/null +++ b/netscan/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + + +@receiver(post_save, sender='netscan.ScanProfile') +def reschedule_on_save(sender, instance, **kwargs): + from netscan.tasks import schedule_profile, unschedule_profile + if instance.enabled: + schedule_profile(instance) + else: + unschedule_profile(instance) + + +@receiver(post_delete, sender='netscan.ScanProfile') +def unschedule_on_delete(sender, instance, **kwargs): + from netscan.tasks import unschedule_profile + unschedule_profile(instance) diff --git a/netscan/tasks.py b/netscan/tasks.py new file mode 100644 index 0000000..ecd12bf --- /dev/null +++ b/netscan/tasks.py @@ -0,0 +1,25 @@ +import logging +from apscheduler.triggers.interval import IntervalTrigger +from core.scheduler import scheduler +from netscan.scanner import run_scan + +logger = logging.getLogger(__name__) + + +def schedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + scheduler.add_job( + run_scan, + trigger=IntervalTrigger(days=profile.schedule_interval), + id=job_id, + args=[profile.pk, 'scheduler'], + replace_existing=True, + ) + logger.info(f'Scheduled netscan job {job_id} every {profile.schedule_interval} day(s)') + + +def unschedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + logger.info(f'Removed netscan job {job_id}') diff --git a/netscan/templates/netscan/dashboard.html b/netscan/templates/netscan/dashboard.html new file mode 100644 index 0000000..5bf137e --- /dev/null +++ b/netscan/templates/netscan/dashboard.html @@ -0,0 +1,133 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+ + +
+
+

+ + NetScan +

+

Home network security scanner

+
+ + + New Profile + +
+ + {% if not profile_data %} + +
+
+ +
+

No scan profiles yet

+

Create your first scan profile to start monitoring your home network.

+ + + Create your first scan profile + +
+ + {% else %} + + + + + + + + + + + + + {% for item in profile_data %} + {% with p=item.profile run=item.last_run worst=item.worst_severity %} + + + + + + + + + + + + + + + + + + + {% endwith %} + {% endfor %} + +
ProfileActions
+
+ {% if worst %} + {% if worst == 'critical' %}🔴{% elif worst == 'warning' %}🟡{% else %}🟢{% endif %} + {% endif %} + {{ p.name }} +
+
+ + {% if p.enabled %}Enabled{% else %}Disabled{% endif %} + + + Every {{ p.schedule_interval }} day{{ p.schedule_interval|pluralize }} + +
+
+
+
+ {% csrf_token %} + +
+ + History + + + Edit + + + + +
+
+ {% endif %} + +
+
+{% endblock %} diff --git a/netscan/templates/netscan/profile_confirm_delete.html b/netscan/templates/netscan/profile_confirm_delete.html new file mode 100644 index 0000000..d4a8ab2 --- /dev/null +++ b/netscan/templates/netscan/profile_confirm_delete.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
+

Delete Scan Profile

+

+ Are you sure you want to delete {{ object.name }}? + All scan runs and findings for this profile will be permanently deleted. +

+
+ {% csrf_token %} +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/netscan/templates/netscan/profile_form.html b/netscan/templates/netscan/profile_form.html new file mode 100644 index 0000000..0366bea --- /dev/null +++ b/netscan/templates/netscan/profile_form.html @@ -0,0 +1,234 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+ + +
+

+ + {{ form_title }} +

+ + Back + +
+ +
+ {% csrf_token %} + + +
+

General

+
+ + +
+ + {{ form.name }} + {% for error in form.name.errors %}

{{ error }}

{% endfor %} +
+ + +
+ {{ form.enabled }} + +
+ + +
+ + {{ form.schedule_interval }} + {% for error in form.schedule_interval.errors %}

{{ error }}

{% endfor %} +
+ +
+
+ +
+ + +
+
+

Network

+ +
+
+ +
+ + {{ form.gateway_ip }} + {% for error in form.gateway_ip.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.public_ip }} + {% for error in form.public_ip.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.network_cidr }} + {% if form.network_cidr.help_text %}

{{ form.network_cidr.help_text }}

{% endif %} + {% for error in form.network_cidr.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.auth_provider_host }} + {% if form.auth_provider_host.help_text %}

{{ form.auth_provider_host.help_text }}

{% endif %} + {% for error in form.auth_provider_host.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.domains_text }} + {% if form.domains_text.help_text %}

{{ form.domains_text.help_text }}

{% endif %} + {% for error in form.domains_text.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.cameras_text }} + {% if form.cameras_text.help_text %}

{{ form.cameras_text.help_text }}

{% endif %} + {% for error in form.cameras_text.errors %}

{{ error }}

{% endfor %} +
+ +
+
+ +
+ + +
+

Telegram Notifications

+
+ +
+ + {{ form.telegram_bot_token }} + {% for error in form.telegram_bot_token.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.telegram_chat_id }} + {% for error in form.telegram_chat_id.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.notify_on_severity }} + {% for error in form.notify_on_severity.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {% if object.pk %} +
+ + +
+ {% endif %} +
+ + +
+ + Cancel + + +
+ +
+
+
+{% endblock %} + +{% block extra_js %} + + +{% if object.pk %} + +{% endif %} +{% endblock %} diff --git a/netscan/templates/netscan/run_detail.html b/netscan/templates/netscan/run_detail.html new file mode 100644 index 0000000..44024e6 --- /dev/null +++ b/netscan/templates/netscan/run_detail.html @@ -0,0 +1,147 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+ NetScan + / + {{ run.profile.name }} + / + Run #{{ run.pk }} +
+ + +
+
+
+

{{ run.profile.name }} — Run #{{ run.pk }}

+

+ Started {{ run.started_at|date:"N j, Y H:i:s" }} + {% if run.duration_seconds is not None %}· {{ run.duration_seconds }}s{% endif %} + · Triggered by {{ run.triggered_by }} +

+
+
+ + {{ run.status }} + +
+ {% csrf_token %} + +
+
+
+ + +
+ {% if run.summary.critical %} +
+ 🔴 {{ run.summary.critical }} Critical +
+ {% endif %} + {% if run.summary.warning %} +
+ 🟡 {{ run.summary.warning }} Warning +
+ {% endif %} + {% if run.summary.ok %} +
+ 🟢 {{ run.summary.ok }} OK +
+ {% endif %} + {% if run.summary.info %} +
+ ℹ️ {{ run.summary.info }} Info +
+ {% endif %} +
+
+ + + {% if critical_findings %} +
+ + 🔴 Critical Findings ({{ critical_findings|length }}) + +
+ {% for f in critical_findings %} +
+
{{ f.title }}
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if warning_findings %} +
+ + 🟡 Warnings ({{ warning_findings|length }}) + +
+ {% for f in warning_findings %} +
+
{{ f.title }}
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if ok_findings %} +
+ + 🟢 OK / Info ({{ ok_findings|length }}) + +
+ {% for f in ok_findings %} +
+
+ {% if f.severity == 'info' %}ℹ️{% else %}🟢{% endif %} + {{ f.title }} +
+

{{ f.detail }}

+ {% if f.raw %} +
+ View raw data +
{{ f.raw|pprint }}
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if not critical_findings and not warning_findings and not ok_findings %} +
+ +

No findings recorded yet — the scan may still be running.

+
+ {% endif %} +
+{% endblock %} diff --git a/netscan/templates/netscan/run_list.html b/netscan/templates/netscan/run_list.html new file mode 100644 index 0000000..12e404c --- /dev/null +++ b/netscan/templates/netscan/run_list.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + NetScan + +

{{ profile.name }} — Scan History

+
+
+ {% csrf_token %} + +
+
+ + {% if runs %} +
+ + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + {% endfor %} + +
StartedDurationTriggered byStatusFindings
{{ run.started_at|date:"M d, H:i:s" }} + {% if run.duration_seconds is not None %}{{ run.duration_seconds }}s{% else %}—{% endif %} + + + {{ run.triggered_by }} + + + + {{ run.status }} + + + + {% if run.summary.critical %}🔴{{ run.summary.critical }}{% endif %} + {% if run.summary.warning %}🟡{{ run.summary.warning }}{% endif %} + {% if run.summary.ok %}🟢{{ run.summary.ok }}{% endif %} + {% if run.summary.info %}ℹ️{{ run.summary.info }}{% endif %} + + + View → +
+
+ + + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+ +

No scan runs yet. Click Run Now to start.

+
+ {% endif %} +
+{% endblock %} diff --git a/netscan/urls.py b/netscan/urls.py new file mode 100644 index 0000000..0e1fe1b --- /dev/null +++ b/netscan/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.DashboardView.as_view(), name='netscan-dashboard'), + path('profile/new/', views.ProfileCreateView.as_view(), name='netscan-profile-create'), + path('profile//edit/', views.ProfileUpdateView.as_view(), name='netscan-profile-edit'), + path('profile//delete/', views.ProfileDeleteView.as_view(), name='netscan-profile-delete'), + path('profile//runs/', views.ScanRunListView.as_view(), name='netscan-run-list'), + path('profile//trigger/', views.TriggerScanView.as_view(), name='netscan-trigger'), + path('profile//test-telegram/', views.TestTelegramView.as_view(), name='netscan-test-telegram'), + path('detect-network/', views.DetectNetworkView.as_view(), name='netscan-detect-network'), + path('run//', views.ScanRunDetailView.as_view(), name='netscan-run-detail'), +] diff --git a/netscan/views.py b/netscan/views.py new file mode 100644 index 0000000..d5806d5 --- /dev/null +++ b/netscan/views.py @@ -0,0 +1,215 @@ +import json +import socket +import platform +import subprocess +import ipaddress +import threading +import logging +import requests as http_requests +from django.views.generic import TemplateView, CreateView, UpdateView, DeleteView, ListView, DetailView, View +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse_lazy, reverse +from django.http import JsonResponse + +from .models import ScanProfile, ScanRun, ScanFinding +from .forms import ScanProfileForm +from .scanner import run_scan +from .notifications import send_test_telegram + +logger = logging.getLogger(__name__) + +SEVERITY_ORDER = ['critical', 'warning', 'info', 'ok'] + + +def _worst_severity(summary: dict) -> str: + for s in SEVERITY_ORDER: + if summary.get(s, 0) > 0: + return s + return 'ok' + + +def _detect_gateway() -> str | None: + try: + if platform.system() == 'Linux': + r = subprocess.run(['ip', 'route', 'show', 'default'], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines(): + if 'default' in line and 'via' in line: + parts = line.split() + return parts[parts.index('via') + 1] + else: + r = subprocess.run(['netstat', '-rn'], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines(): + if line.startswith('default') or line.startswith('0.0.0.0'): + parts = line.split() + if len(parts) >= 2: + return parts[1] + except Exception: + pass + return None + + +def _detect_local_ip() -> str | None: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return None + + +def _detect_subnet_mask(local_ip: str) -> str | None: + """Try to get the real subnet mask from the OS, fallback to /24.""" + try: + if platform.system() == 'Linux': + r = subprocess.run(['ip', 'addr', 'show'], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines(): + line = line.strip() + if line.startswith('inet ') and local_ip in line: + cidr_part = line.split()[1] + net = ipaddress.IPv4Network(cidr_part, strict=False) + return str(net) + else: + r = subprocess.run(['ifconfig'], capture_output=True, text=True, timeout=5) + lines = r.stdout.splitlines() + for i, line in enumerate(lines): + if local_ip in line: + for detail in lines[i:i + 3]: + if 'netmask' in detail.lower(): + parts = detail.split() + try: + mask_idx = [p.lower() for p in parts].index('netmask') + mask = parts[mask_idx + 1] + # macOS outputs hex netmask like 0xffffff00 + if mask.startswith('0x'): + mask = socket.inet_ntoa(int(mask, 16).to_bytes(4, 'big')) + net = ipaddress.IPv4Network(f'{local_ip}/{mask}', strict=False) + return str(net) + except (ValueError, IndexError): + pass + except Exception: + pass + # fallback to /24 + try: + net = ipaddress.IPv4Network(f'{local_ip}/24', strict=False) + return str(net) + except Exception: + return None + + +def _detect_public_ip() -> str | None: + for url in ['https://api.ipify.org', 'https://icanhazip.com', 'https://checkip.amazonaws.com']: + try: + resp = http_requests.get(url, timeout=5) + ip = resp.text.strip() + ipaddress.ip_address(ip) # validate + return ip + except Exception: + continue + return None + + +class DashboardView(TemplateView): + template_name = 'netscan/dashboard.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profiles = ScanProfile.objects.all() + profile_data = [] + for p in profiles: + last_run = p.runs.first() + worst = _worst_severity(last_run.summary) if last_run else None + profile_data.append({ + 'profile': p, + 'last_run': last_run, + 'worst_severity': worst, + }) + ctx['profile_data'] = profile_data + return ctx + + +class ProfileCreateView(CreateView): + model = ScanProfile + form_class = ScanProfileForm + template_name = 'netscan/profile_form.html' + success_url = reverse_lazy('netscan-dashboard') + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['form_title'] = 'Create Scan Profile' + return ctx + + +class ProfileUpdateView(UpdateView): + model = ScanProfile + form_class = ScanProfileForm + template_name = 'netscan/profile_form.html' + success_url = reverse_lazy('netscan-dashboard') + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['form_title'] = f'Edit: {self.object.name}' + return ctx + + +class ProfileDeleteView(DeleteView): + model = ScanProfile + template_name = 'netscan/profile_confirm_delete.html' + success_url = reverse_lazy('netscan-dashboard') + + +class ScanRunListView(ListView): + template_name = 'netscan/run_list.html' + context_object_name = 'runs' + paginate_by = 20 + + def get_queryset(self): + self.profile = get_object_or_404(ScanProfile, pk=self.kwargs['pk']) + return ScanRun.objects.filter(profile=self.profile) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['profile'] = self.profile + return ctx + + +class ScanRunDetailView(DetailView): + model = ScanRun + template_name = 'netscan/run_detail.html' + context_object_name = 'run' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + findings = self.object.findings.all() + ctx['critical_findings'] = findings.filter(severity='critical') + ctx['warning_findings'] = findings.filter(severity='warning') + ctx['ok_findings'] = findings.filter(severity__in=['ok', 'info']) + return ctx + + +class TriggerScanView(View): + def post(self, request, pk): + profile = get_object_or_404(ScanProfile, pk=pk) + t = threading.Thread(target=run_scan, args=[profile.pk, 'manual'], daemon=True) + t.start() + return redirect(reverse('netscan-run-list', kwargs={'pk': profile.pk})) + + +class TestTelegramView(View): + def post(self, request, pk): + profile = get_object_or_404(ScanProfile, pk=pk) + result = send_test_telegram(profile) + return JsonResponse(result) + + +class DetectNetworkView(View): + def get(self, request): + local_ip = _detect_local_ip() + data = { + 'gateway_ip': _detect_gateway(), + 'local_ip': local_ip, + 'network_cidr': _detect_subnet_mask(local_ip) if local_ip else None, + 'public_ip': _detect_public_ip(), + } + return JsonResponse(data) diff --git a/pyproject.toml b/pyproject.toml index abd2978..9133546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pillow~=12.1.1", "whoosh==2.7.4", "qdrant-client>=1.13.2", + "cryptography>=42.0.0", ] [build-system] diff --git a/templates/base.html b/templates/base.html index 13900fa..87c3a73 100644 --- a/templates/base.html +++ b/templates/base.html @@ -129,6 +129,16 @@ + +
+ + + + {% trans "NetScan" %} +
+
+
diff --git a/uv.lock b/uv.lock index 055cc3d..9283076 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -179,6 +236,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + [[package]] name = "django" version = "5.2.11" @@ -467,6 +577,7 @@ dependencies = [ { name = "asgiref" }, { name = "beautifulsoup4" }, { name = "boto3" }, + { name = "cryptography" }, { name = "django" }, { name = "django-simplemde" }, { name = "django-tailwind" }, @@ -501,6 +612,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.12.3" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "boto3", specifier = ">=1.35.0" }, + { name = "cryptography", specifier = ">=42.0.0" }, { name = "django", specifier = ">=5.2.9" }, { name = "django-simplemde", specifier = "==0.1.4" }, { name = "django-tailwind", specifier = "==3.8.0" }, @@ -772,6 +884,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" From b8c256424ec5e6dac066cc40ab7f30e6d05361cd Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 16:55:22 +1100 Subject: [PATCH 04/95] Fix build error and page screenshot issue! --- Dockerfile | 1 + README.md | 282 +++++++++++++++--------------------------------- agents.md | 61 +++++++---- core/apps.py | 12 ++- data/db.sqlite3 | Bin 479232 -> 479232 bytes justfile | 136 +++++++++++++++++++++++ links/tasks.py | 101 ++++++++++++----- 7 files changed, 350 insertions(+), 243 deletions(-) create mode 100644 justfile diff --git a/Dockerfile b/Dockerfile index 5971848..d29218c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,7 @@ RUN uv sync --no-dev --frozen --no-install-project COPY manage.py ./ COPY core/ ./core/ COPY links/ ./links/ +COPY netscan/ ./netscan/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ diff --git a/README.md b/README.md index 8493f69..ed2c335 100644 --- a/README.md +++ b/README.md @@ -7,237 +7,125 @@ A URL management tool that helps you organize and access your links efficiently. - Create and manage short links - Template links with dynamic parameters - Bookmark pages with automatic title and summary extraction +- Screenshot capture for bookmarked pages - Advanced search capabilities -- API access -- Asynchronous page processing +- REST API access +- In-process background task scheduling (APScheduler) +- Network security scanner (NetScan) -## Installation +## Prerequisites -### Using Docker (Recommended) +- Python 3.12+ +- [uv](https://github.com/astral-sh/uv) — fast Python package manager +- [just](https://github.com/casey/just) — command runner (`brew install just`) +- Docker + Docker Compose (for containerised setup) -1. Prerequisites: - - Docker - - Docker Compose +## Quick Start (Local) -2. Clone the repository: - ```bash - git clone https://github.com/yourusername/url-manager.git - cd url-manager - ``` +```bash +# 1. Install dependencies and apply migrations +just install -3. Build and start services: - ```bash - ./docker.sh build - ./docker.sh start - ``` +# 2. Install Playwright browsers (for screenshot capture) +just install-browsers -The application will be available at `http://localhost:8000` +# 3. Run the full stack (Django + Tailwind watcher) +just dev-all +``` -### Docker Management Commands +The app will be available at **http://localhost:8000** -- Start all services: - ```bash - ./docker.sh start - ``` +> Run `just` (no arguments) to see all available recipes. -- Stop all services: - ```bash - ./docker.sh stop - ``` +## Local Development Commands -- Restart services: - ```bash - ./docker.sh restart - ``` +| Recipe | Description | +|---|---| +| `just dev` | Run Django server + Tailwind together | +| `just tailwind` | Run Tailwind CSS watcher (standalone) | +| `just migrate` | Apply pending migrations | +| `just makemigrations` | Generate new migrations | +| `just shell` | Open Django shell | +| `just dbshell` | Open raw DB shell | +| `just superuser` | Create a superuser | +| `just build-css` | Build minified Tailwind CSS | +| `just collectstatic` | Collect static files | +| `just test` | Run tests | +| `just compilemessages` | Compile i18n translation files | +| `just makemessages` | Extract translatable strings (zh_Hans) | -- View logs: - ```bash - ./docker.sh logs - ``` +## Docker Commands -- Run database migrations: - ```bash - ./docker.sh migrate - ``` - -- Create new migrations: - ```bash - ./docker.sh makemigrations - ``` - -- Access Django shell: - ```bash - ./docker.sh shell - ``` +| Recipe | Description | +|---|---| +| `just docker-build` | Build images | +| `just docker-start` | Start all services (detached) | +| `just docker-stop` | Stop all services | +| `just docker-restart` | Restart services | +| `just docker-logs` | Tail logs (all services) | +| `just docker-logs web` | Tail logs for a specific service | +| `just docker-shell` | Shell into the web container | +| `just docker-migrate` | Run migrations inside Docker | +| `just docker-static` | Collect static files inside Docker | +| `just docker-rebuild web` | Rebuild and restart a specific service | ### Docker Services -The application runs the following services: - -- `web`: Django web server (port 8000) -- `celery_worker`: Processes background tasks -- `celery_beat`: Schedules periodic tasks -- `redis`: Message broker and result backend - -### Manual Installation - -If you prefer not to use Docker: - -1. Install Python 3.12 and Redis - -2. Install uv: - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - ``` - -3. Install dependencies: - ```bash - uv pip install -r requirements.txt - ``` - -4. Run migrations: - ```bash - python manage.py migrate - ``` - -5. Start the development server: - ```bash - ./run_server.sh - ``` - -6. Start Celery worker: - ```bash - ./worker.sh - ``` - -7. Start Celery beat: - ```bash - ./celery_beat.sh - ``` +- `web` — Django server on port 8000 (APScheduler runs in-process) +- `node` — Tailwind CSS compiler ## Usage ### Managing Links -1. Create a new link: - - Visit `/create/` - - Enter the original URL and desired alias - - For template links, use `{param}` syntax +1. Create a new link at `/create/` +2. Access a link at `http://localhost:8000/your-alias` +3. For template links use `{param, default=value}` syntax in the URL -2. Access a link: - - Use `http://localhost:8000/your-alias` - - For template links: `http://localhost:8000/your-alias/parameter` +### Managing Pages (Bookmarks) -### Managing Pages +1. Add a bookmark at `/ui/pages/new/` — title and summary are auto-extracted +2. Screenshots are captured automatically in the background +3. View all bookmarks at `/ui/pages/` -1. Create a new page: - - Visit `/ui/pages/new/` - - Enter the URL - - Title and summary will be automatically extracted +### Network Scanner (NetScan) -2. View all pages: - - Visit `/ui/pages/` +Available at `/ui/netscan/`. Create a scan profile to automatically monitor your home network for security issues (router exposure, DNS, TLS certs, camera access). -### API Access - -The application provides a REST API: +### API - List pages: `GET /api/pages/` -- Create page: `POST /api/pages/` +- Full API docs: `/ui/api-docs/` -Example: +## Architecture -## Monitoring Celery Tasks - -The application includes Flower for monitoring Celery tasks. You can access it in two ways: - -1. Direct access: - - Visit `http://localhost:5555` in your browser - -2. Using management script: - ```bash - ./docker.sh flower - ``` - -Flower provides: -- Real-time monitoring of Celery tasks -- Task progress and history -- Worker status and statistics -- Error tracking -- Task graphs and charts - -### Monitoring Features - -1. View all tasks: - - Active tasks - - Scheduled tasks - - Failed tasks - - Success rate - -2. Worker information: - - Status - - Resource usage - - Queue length - -3. Task details: - - Arguments - - Start time - - Runtime - - Result - - Stack traces for failed tasks - - -## Why we have `core` folder and what it is? - -The core folder is the Django project's root configuration directory, created when you first run django-admin startproject core. It serves several essential purposes: -Project-wide settings (settings.py): -Database configuration -Installed apps -Middleware -Static/media files settings -Other global settings -Root URL configuration (urls.py): -Main URL routing -Admin site URLs -Including app-specific URLs -3. WSGI/ASGI configuration: -For production deployment -Server integration -Celery configuration (celery.py): -Project-wide Celery settings -Task discovery -We can't completely remove it because Django requires this project-level configuration. The links folder is an app within the project, and you could have multiple apps in one project. -Think of it this way: -core/ = Project configuration (can contain multiple apps) -links/ = One specific app's code -This separation allows you to: -1. Have multiple apps in one project -Reuse apps across different projects -Keep project-wide settings separate from app-specific code -If you want to simplify, you could rename core to something shorter like config or core, but you can't eliminate it entirely as it's a fundamental part of Django's project structure. -Would you like me to show you how to rename it to something shorter and cleaner? - -## Development - -### Using uv for Dependency Management - -uv is a fast Python package installer and resolver. To add new dependencies: - -```bash -uv pip +``` +core/ — Django project config, APScheduler setup +links/ — Main app: links, pages, tags, posts, collections +netscan/ — Network security scanner +templates/ — Base HTML templates ``` -### Favicon +Background tasks (screenshot capture, page metadata extraction, network scans) run via **APScheduler** in-process — no separate worker or Redis needed. -This favicon was generated using the following font: +## Dependencies -- Font Title: Zen Tokyo Zoo -- Font Author: undefined -- Font Source: https://fonts.gstatic.com/s/zentokyozoo/v7/NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg.ttf -- Font License: undefined) +Managed with `uv` via `pyproject.toml`. +```bash +# Add a new package +uv add package-name -### Image resizing +# Sync environment from lockfile +uv sync +``` + +## Favicon + +Generated using the Zen Tokyo Zoo font (https://fonts.gstatic.com/s/zentokyozoo/). + +## Image Resizing (Cloudflare) + +- Bind a custom domain +- Enable image resizing: https://developers.cloudflare.com/images/transform-images/ -* bind custom domain -* enable image resizing https://developers.cloudflare.com/images/transform-images/ diff --git a/agents.md b/agents.md index 444f5d0..ae4dfa8 100644 --- a/agents.md +++ b/agents.md @@ -682,44 +682,67 @@ When contributing code: ## Useful Commands Reference -### Django Management -Make sure enable enable `.venv` before running any commands through `source .venv/bin/activate` +### just (Command Runner) +The project uses [`just`](https://github.com/casey/just) as its command runner. Run `just` (no args) to list all recipes. + ```bash -python manage.py runserver # Development server (APScheduler starts automatically) -python manage.py shell # Django shell -python manage.py dbshell # Database shell -python manage.py createsuperuser # Create admin user -python manage.py test # Run tests -python manage.py collectstatic # Collect static files -python manage.py compilemessages # Compile translations -python manage.py makemessages -l zh_Hans # Extract translation strings +# Local development +just dev # Run Django + Tailwind together +just tailwind # Run Tailwind CSS watcher (standalone) +just migrate # Apply pending migrations +just makemigrations # Create new migrations +just shell # Django shell +just dbshell # Raw DB shell +just superuser # Create a superuser +just build-css # Build minified Tailwind CSS +just collectstatic # Collect static files +just test # Run tests +just compilemessages # Compile i18n translations +just makemessages # Extract translatable strings (zh_Hans) + +# Docker +just docker-build # Build images +just docker-start # Start services (detached) +just docker-stop # Stop services +just docker-logs # Tail all service logs +just docker-logs web # Tail a specific service's logs +just docker-shell # Shell into web container +just docker-migrate # Run migrations in Docker +just docker-static # Collect static in Docker +just docker-rebuild web # Rebuild + restart a service +``` + +### Direct Django Management +When you need to run `manage.py` directly, activate the venv first: +```bash +source .venv/bin/activate +uv run manage.py ``` ### APScheduler (In-Process) APScheduler starts automatically with Django. To interact with it: ```python -# In Django shell +# In Django shell (just shell) from core.scheduler import scheduler -scheduler.get_jobs() # List all scheduled jobs -scheduler.print_jobs() # Print job details -scheduler.running # Check if scheduler is running +scheduler.get_jobs() # List all scheduled jobs +scheduler.print_jobs() # Print job details +scheduler.running # Check if scheduler is running ``` ### UV Package Manager ```bash -uv pip install package_name # Install package -uv pip install -r requirements.txt # Install from requirements -uv pip freeze > requirements.txt # Export requirements -uv sync # Sync from pyproject.toml +uv add package_name # Add a new dependency +uv sync # Sync environment from pyproject.toml / uv.lock ``` ## Resources - **Django Documentation**: https://docs.djangoproject.com/ - **Django REST Framework**: https://www.django-rest-framework.org/ -- **Celery Documentation**: https://docs.celeryq.dev/ +- **APScheduler Documentation**: https://apscheduler.readthedocs.io/ - **Tailwind CSS**: https://tailwindcss.com/docs - **SwiftUI**: https://developer.apple.com/documentation/swiftui/ +- **just**: https://just.systems/man/en/ Note: - No need to generate extra summary guide or docs, unless I ask you to. diff --git a/core/apps.py b/core/apps.py index 73afd22..9100171 100644 --- a/core/apps.py +++ b/core/apps.py @@ -16,7 +16,7 @@ class CoreConfig(AppConfig): Initialize APScheduler when Django starts """ from core.scheduler import scheduler, start_scheduler - from links.tasks import schedule_pending_pages + from links.tasks import schedule_pending_pages, schedule_pending_screenshots from apscheduler.triggers.interval import IntervalTrigger # Start the scheduler @@ -31,3 +31,13 @@ class CoreConfig(AppConfig): replace_existing=True ) logger.info("Scheduled periodic task: schedule_pending_pages") + + # Add periodic job for recovering stuck screenshots + if not scheduler.get_job('schedule_pending_screenshots'): + scheduler.add_job( + schedule_pending_screenshots, + trigger=IntervalTrigger(seconds=120), + id='schedule_pending_screenshots', + replace_existing=True + ) + logger.info("Scheduled periodic task: schedule_pending_screenshots") diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 663889aa32775ef931cee6e486a47917b3611150..98da81a1e4b5394b518517a8174bac2a43fd29e1 100644 GIT binary patch delta 1081 zcmaJ z*%%5Z7+%Ts>9ORjcqq-XMfGO+Lx(<7X}YGr*`A(g1c>FnX)0wtIhf=_14gjvP0)_L zG^Vw+kb!6P|j|)uFD;sVg07s5de%fu4cLkpH7J0+eYrJfbc6K1!p}r=8mJBp?f&|t8$5obDGQ)|TI;C30v}+O+#qxw&S3@&)@JyZC?ahx7W+uVdb*=Xv z=z0XE{E@y6FgSzd9Vm5r^D%I<8yW`f3YK@s+I<1ZQL1;xA zUR<5Kr44k&G=~?XowMq42>QlK-N(+>xUh921T5*V=7=Jkw=q0L@azVJWNf&dAZQW> zD!N_GuA0kNl|Y8U%NvRNnegCN zoKqorlEsyZ?+H7NaZZKgIfgD4^W*9%2jn>T0IxD3YA=8A;#hOUZ5hLeQ73a2Ro1}Qewu4lC&PKa}MJ5SLY5EwK=FbqXH%+&~nY)1q^CpLLb zxV+xFhT6aBVo4HmEEf^vlU6kCaLferuL&siUlVJc;v~DVZHlwmSy(pbP`-?y8Txp6 VF&xM26?e&f<7keuYaD(4@E5@^hO+o_-aG_pHheRuvj;F&0>vcx z+GoZw12N0?nQ^RZXNa5X8XD>vm?#)nS^>>9HPtgPG_Wu?-yZv&RgGo3%SR?bP9rlT z19MZm0*&bjf^1UTxBq6nE;7ADfK8Ou%+R^O3@keRkN{f?+%Q8!D??)|LnA#iLo;I| zv+1#dYzwD95d`Y6FmWg_f|z3@#CE<`lsAEao#!0`e;>aL-!;Az-cLO5cpHEYy}`5n k-5fSCrs+%Ou?cbl9c5%{=u%)keZqV;+3nWz*^X`i0C9t6Q~&?~ diff --git a/justfile b/justfile new file mode 100644 index 0000000..2a13b19 --- /dev/null +++ b/justfile @@ -0,0 +1,136 @@ +# URL Manager — justfile +# Install just: brew install just +# Run `just` to see all available recipes. + +set shell := ["bash", "-c"] + +# Show available recipes +default: + @just --list + +# ── Setup ───────────────────────────────────────────────────────────────────── + +# Install Python dependencies and set up the project for first use +install: + uv sync + source .venv/bin/activate && uv run manage.py migrate + mkdir -p media/screenshots data + +# Install Playwright browsers (needed for screenshot capture) +install-browsers: + source .venv/bin/activate && playwright install chromium + +# ── Local Development ───────────────────────────────────────────────────────── + +# Run Django server + Tailwind CSS together (Tailwind in background) +dev: + #!/usr/bin/env bash + set -eu + source .venv/bin/activate + uv run manage.py tailwind start & + TAILWIND_PID=$! + trap "kill $TAILWIND_PID 2>/dev/null" EXIT + uv run manage.py runserver 0.0.0.0:8000 + +# Run Tailwind CSS in watch mode (standalone) +tailwind: + source .venv/bin/activate && uv run manage.py tailwind start + +# ── Database ────────────────────────────────────────────────────────────────── + +# Apply all pending migrations +migrate: + source .venv/bin/activate && uv run manage.py migrate + +# Create new migrations from model changes +makemigrations: + source .venv/bin/activate && uv run manage.py makemigrations + +# Open Django shell +shell: + source .venv/bin/activate && uv run manage.py shell + +# Open raw database shell +dbshell: + source .venv/bin/activate && uv run manage.py dbshell + +# Create a Django superuser +superuser: + source .venv/bin/activate && uv run manage.py createsuperuser + +# ── Static Assets ───────────────────────────────────────────────────────────── + +# Build Tailwind CSS (minified, for production) +build-css: + source .venv/bin/activate && uv run manage.py tailwind build + +# Collect all static files into staticfiles/ +collectstatic: + source .venv/bin/activate && uv run manage.py collectstatic --no-input + +# ── i18n ────────────────────────────────────────────────────────────────────── + +# Compile translation files +compilemessages: + source .venv/bin/activate && uv run manage.py compilemessages + +# Extract translatable strings for zh_Hans +makemessages: + source .venv/bin/activate && uv run manage.py makemessages -l zh_Hans + +# ── Testing ─────────────────────────────────────────────────────────────────── + +# Run tests +test: + source .venv/bin/activate && pytest + +# Run tests with coverage +test-cov: + source .venv/bin/activate && pytest --cov=links --cov-report=term-missing + +# ── Docker ──────────────────────────────────────────────────────────────────── + +# Build Docker images +docker-build: + docker-compose build + +# Start all Docker services (detached) +docker-start: + mkdir -p media/screenshots + chmod -R 777 media + docker-compose up -d + @echo "App running at http://localhost:8000" + +# Stop all Docker services +docker-stop: + docker-compose down + +# Restart Docker services +docker-restart: + docker-compose restart + +# Tail logs for all services (or a specific one: just docker-logs web) +docker-logs service="": + #!/usr/bin/env bash + if [ -n "{{ service }}" ]; then + docker-compose logs -f {{ service }} + else + docker-compose logs -f + fi + +# Open a shell inside the web container +docker-shell: + docker-compose exec web bash + +# Run migrations inside Docker +docker-migrate: + docker-compose exec web uv run manage.py makemigrations + docker-compose exec web uv run manage.py migrate + +# Collect static files inside Docker +docker-static: + docker-compose exec web uv run manage.py collectstatic --no-input + +# Rebuild and restart a specific service (e.g.: just docker-rebuild web) +docker-rebuild service="web": + docker-compose up -d --build {{ service }} diff --git a/links/tasks.py b/links/tasks.py index f02a726..8ba1ea5 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -104,10 +104,18 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): """Task to capture full-page screenshot with Playwright""" from .models import Page, Screenshot + screenshot = None try: page = Page.objects.get(id=page_id) screenshot = Screenshot.objects.get(id=screenshot_id) + + # Idempotency: skip if already completed + if screenshot.status == Screenshot.Status.COMPLETED: + logger.info(f"Screenshot {screenshot_id} already completed, skipping") + return + screenshot.status = Screenshot.Status.PROCESSING + screenshot.error = None screenshot.save() # Create screenshots directory @@ -123,14 +131,14 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): # Run async screenshot capture in sync context try: asyncio.run(_capture_screenshot_async(page.url, full_path)) - + # Update screenshot record screenshot.path = filepath screenshot.status = Screenshot.Status.COMPLETED screenshot.save() - + logger.info(f"Successfully captured screenshot for page {page_id}") - + except PlaywrightTimeoutError as e: logger.error(f"Timeout during screenshot capture: {str(e)}") screenshot.status = Screenshot.Status.FAILED @@ -145,35 +153,47 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): raise except Exception as exc: - logger.error(f"Failed to capture screenshot for page {page_id}: {exc}") + logger.error(f"Failed to capture screenshot for page {page_id}: {exc}", exc_info=True) if retry_count < MAX_RETRIES: - retry_delay = fibonacci(retry_count) - try: - screenshot.error = f"Error: {str(exc)}. Retrying in {retry_delay} seconds..." - screenshot.save() - except: - pass + retry_delay = fibonacci(retry_count + 1) + if screenshot is not None: + try: + screenshot.error = f"Retrying in {retry_delay}s (attempt {retry_count + 1}/{MAX_RETRIES}): {str(exc)}" + screenshot.save() + except Exception: + pass # Schedule retry using APScheduler from core.scheduler import scheduler from datetime import datetime run_date = datetime.now() + timedelta(seconds=retry_delay) - scheduler.add_job( - capture_screenshot, - 'date', - run_date=run_date, - args=[page_id, screenshot_id, retry_count + 1], - id=f'capture_screenshot_{screenshot_id}_retry_{retry_count + 1}', - replace_existing=True - ) - logger.info(f"Scheduled retry {retry_count + 1} for screenshot {screenshot_id} in {retry_delay} seconds") - else: - logger.error(f"Max retries exceeded for page {page_id}") try: - screenshot.status = Screenshot.Status.FAILED - screenshot.error = f"Failed after {retry_count} attempts. Last error: {str(exc)}" - screenshot.save() - except: - pass + scheduler.add_job( + capture_screenshot, + 'date', + run_date=run_date, + args=[page_id, screenshot_id, retry_count + 1], + id=f'capture_screenshot_{screenshot_id}_retry_{retry_count + 1}', + replace_existing=True + ) + logger.info(f"Scheduled retry {retry_count + 1} for screenshot {screenshot_id} in {retry_delay}s") + except Exception as sched_exc: + logger.error(f"Failed to schedule retry for screenshot {screenshot_id}: {sched_exc}") + if screenshot is not None: + try: + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Scheduler error, giving up: {str(sched_exc)}" + screenshot.save() + except Exception: + pass + else: + logger.error(f"Max retries exceeded for screenshot {screenshot_id}") + if screenshot is not None: + try: + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Failed after {retry_count} attempts: {str(exc)}" + screenshot.save() + except Exception: + pass def process_page(page_id, retry_count=0): """Task to process a page and extract its metadata""" @@ -277,6 +297,35 @@ def schedule_pending_pages(): thread.daemon = True thread.start() + +def schedule_pending_screenshots(): + """Periodic task to retry screenshots stuck in PENDING or PROCESSING state. + + Screenshots can get stuck when their background thread is killed (e.g. server + restart). Any screenshot that hasn't been updated in more than 5 minutes and + is not in a terminal state is considered stuck and will be re-queued. + """ + from .models import Screenshot + + threshold = timezone.now() - timedelta(minutes=5) + stuck = Screenshot.objects.filter( + status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING], + updated_at__lt=threshold, + ) + + count = stuck.count() + if count: + logger.info(f"Found {count} stuck screenshot(s) — retrying") + + for screenshot in stuck: + logger.info(f"Retrying screenshot {screenshot.id} (page {screenshot.page_id})") + # Reset to PENDING so the worker knows to re-attempt + screenshot.status = Screenshot.Status.PENDING + screenshot.save(update_fields=['status', 'updated_at']) + thread = Thread(target=capture_screenshot, args=(screenshot.page_id, screenshot.id)) + thread.daemon = True + thread.start() + def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0): """ Fetch webpage content using Crawl4AI /md endpoint From 1368eb795d2f23b9c92df6e8ca3c33b263464996 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 17:04:47 +1100 Subject: [PATCH 05/95] Update UI --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/templates/links/help.html | 1 + links/templates/links/mini_apps/list.html | 36 +++++++++------------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 98da81a1e4b5394b518517a8174bac2a43fd29e1..04a5d03806fe10e922c2d48091cfcbfb6f8b94e9 100644 GIT binary patch delta 1262 zcmb7DUuYaf9G;omox8pNo4X!fn``fsl!V01?#|BaZBmmS4T=Rd2&M&1@7kDZz{Vwa z2rUHnViTkfHdSyQYOe|ONwD{DL~74;IbRzT_`T?9lbp+vMSbznvXW1oQ zlW~3@EmsMt>Z;CEqH9%^(lTLOUPc+-e+fNWqU8}oQ_2>jx-kL(eB9GjsX%+-^_kgv zeQy30KK?!4_kI&)Qr;+G3e$CBXiwOO{qy(piw>G{i%s-t1f5Ip?^aOWUATnax*Hqz zB-Mtz-@s!llcAMKVPjH+YkBkykU$H-C71@Scp`QcoeiIYzlTciY3fD(s)H7g-4`64 z)vCnYqx{x2oZ~kgB=Nr-l#6?ssII7%Y8s3mYN7@l?0`wt*lRlg>uvFn@5Nw>!Bo{O zkJ|$sj9xXg|0X=Xg<|}|wwOKW1F51BW)OWW{0MluD=&VyEe3KSF{R)y~9oTL=eiJCRdj5yr;s{y?VdZi~-k zheARhh)a|dvsoa3-$~;seoDsP73&=iTIs11_=KAgdm{MWB>A{hXiCp&1$l`Jmq?k4#c8Qjl5Y~dK+ zZs8u8ji~yFp(~bd63d*hExvjk7v%05TBOW_CS6EluSu22iwxNA7+fjDK?H z(=x6joA?nGZ{h5&QO(y)%HC^MC+vz( wmUqPu@QYALeg%qGD!s0A4Nt+{Ugr33tq!Mu`>IMUZNeT60&+*MYCu}SM>7k>y08pY3hKh7 zVh|VFv5SjT+X&)L5R1-AbW?Frs4I~oh^~q&gF?h2m^38YbN=(4k8>*ZOr@S#N$BSAXJp*2!^zrkglLC%=;r{Z~Q# zf@+K@>S0}#3%W(C4SY2~i)e~t0RW@Riky?2EC)ae*f#Vo%$>h9cag4D&`7K|ry0iB zWs!cXp}}DOF1oeBTdi3)-v%74m6%$IeO$^DKl_^@Lu8n&m)UTps z5bWZ~c#;P$5W?I(Hi!PAJ7@rYgflP!o&ndl2asp%W#f8T)5-<8pec&1mKDXZI!h9i zlSrngVHlc96^UG79jzN!=p&h)fLYX3+f(_q0 zFE>QtEC};IK
- -
+ +
- -
- - From 7f7a890ad5e49f635b58fa939751988921276c17 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 20:30:44 +1100 Subject: [PATCH 06/95] Update files function! --- Dockerfile | 1 + core/settings.py | 4 + core/urls.py | 11 +- data/db.sqlite3 | Bin 479232 -> 479232 bytes k8s/manifest.yaml | 29 ++ links/api_urls.py | 2 + links/file_urls.py | 16 + links/file_views.py | 207 +++++++++++ links/migrations/0039_file_upload.py | 35 ++ links/models.py | 61 ++++ links/serializers.py | 32 +- links/templates/links/files/list.html | 473 ++++++++++++++++++++++++++ static/openapi.yaml | 243 ++++++++++++- templates/base.html | 10 + 14 files changed, 1121 insertions(+), 3 deletions(-) create mode 100644 links/file_urls.py create mode 100644 links/file_views.py create mode 100644 links/migrations/0039_file_upload.py create mode 100644 links/templates/links/files/list.html diff --git a/Dockerfile b/Dockerfile index d29218c..3d8d33b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -133,6 +133,7 @@ COPY --from=builder --chown=appuser:appuser /app/locale /app/locale COPY --chown=appuser:appuser manage.py ./ COPY --chown=appuser:appuser core/ ./core/ COPY --chown=appuser:appuser links/ ./links/ +COPY --chown=appuser:appuser netscan/ ./netscan/ COPY --chown=appuser:appuser new_theme/ ./new_theme/ COPY --chown=appuser:appuser templates/ ./templates/ COPY --chown=appuser:appuser qdrant_sync.py ./ diff --git a/core/settings.py b/core/settings.py index 7504c3e..ee7ba1e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -129,6 +129,10 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'data', 'media') MUSIC_ROOT = os.path.join(BASE_DIR, 'data', 'music') +# File uploads folder — override via FILE_UPLOADS_FOLDER env var. +# Defaults to ~/Downloads locally; set to /uploads on k8s. +FILE_UPLOADS_FOLDER = os.environ.get('FILE_UPLOADS_FOLDER', os.path.expanduser('~/Downloads')) + LOGGING = { 'version': 1, 'disable_existing_loggers': False, diff --git a/core/urls.py b/core/urls.py index 013b2f5..e887865 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,6 +4,7 @@ from django.conf import settings from django.conf.urls.static import static from django.views.static import serve from links.views import LinkDetailView, LinkUpdateView, CustomLinkView +from links.file_views import PublicFileView from django.urls import path, include, re_path from django.conf.urls.i18n import i18n_patterns @@ -26,8 +27,16 @@ urlpatterns = [ path('custom//', CustomLinkView.as_view(), name='custom_link'), path('custom//edit/', LinkUpdateView.as_view(), name='custom_link_update'), - # Include netscan BEFORE links.urls to prevent the alias catch-all from intercepting it + # Include netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them path('ui/netscan/', include('netscan.urls')), + path('ui/files/', include('links.file_urls')), + + # Public file access — /public/files/{uuid}-{filename} + re_path( + r'^public/files/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', + PublicFileView.as_view(), + name='public-file', + ), # Include main app URLs with locale path('', include('links.urls')), diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 04a5d03806fe10e922c2d48091cfcbfb6f8b94e9..1752d7e42b9e42aaae7e60be3bbecc0f49a7b3f8 100644 GIT binary patch delta 2801 zcmai0U2Ggj9pAaVk3HwktE#@ZG`MUoL5)fGZg*yOcIQx=oLe_3`0O^##n*yz*^gO! zll#KHb4wAroIvRV2mx+&l`2JkK$Q#+5V@86&?*H9@qh?v1us>Ff-0&~Q63OgD}jKy zJ12I|30Y}IyYv6e|Nr}c&FuNLiSug{KiqfMJAxpz`1mp(aX#Mt(E|v)bLq^NvY@p3 zz3g$2TKz-z@c5AtSrt;FB|-kX{9E~5`AvCE4n|8GW7^bcYAigyA5?qp6*${ZJcoY280PZf@wS{5$U{QG#ZL7b($nldXWqaO`ZoaZp@y1W5 zo=%D->GEpq@pUO4kv^9GC|&06#H6pK!r(9%cBV8Q23fG1_xxae7r|3wVK51Xh!i*O zed?nXv6TE|HFkAf8jX#D;2q)PpHzCWMp&&7@8pFaJwm>Qk$#@+&x zq0xB`|Ke!s?o?P9-#?V5=POshX5~ug<+mde&wja^SQGapZT@^U`M1%F$N8#X~a6edN;Hg#m{1liCu z2_?Fw8$0TUyBGidw?ae2^c>VO+>T+=+ekR)P9~Hnw6{3-3yA1MV}`2{h)v&ebkEft z&E+(p#x!z+^Q|Z&18csfxi)om&+`%AO`&OcrUxm;nzf7a7O(bJ%i;lLOp#^Xk}_{c zjQH6gUypJpEq1;TNfhqq9l&pqo&&_RAaY#SVHW0HU=fF4Q+G_yCx)S!j}?xU5j1*S z*a9QWfu`H`4C;F>b~Ro)j5wGX&}Gci-9BooOF2Gv@aH%4GW48IGdki)f;%NN2VoA9 z4DVh-@d&3Jl@AE=0r|N6p1dhnkMF}J>M!p-n9Q!t3y#8$Dv|ooG z>Fn)%E;jz83OY2XdFi6_Wgc~J!xfb zR!I-`M(x`=q+( zeXzgSL8IkpC@zD*uZA8on<7hW{n}L_QuC1rY5`k=_)}iY~bA;lKOr7kisRqA0{) yX++L!nR$O;=41JN`5*F!+|2LgiLfa3%*1XybnZ3KW|F delta 667 zcmZozAltA&c7n9vHUmN!`Gg3j&Xa= zIi}ecS$Tkt37^ihfLWS10LY93;&>qT-)^#i`GP+iBmW)-{yjiVoA|fy`Oob7Ko}^= zAOIp5!ufymf8+ni{|0D{1`B5l8#{x!s-q+CbgLHTP|jRdb_Ur*5O;P9a~h{73p<0P zEQrh7%Iw5FRg;l}L2+d;P+)s(D|0WG3eZ3ekYnPxfkYfTkO*J{g%k^m4FhWkD<{h= z78{^NI+R#6m3bkT7%xvB0~-e;1AhY79ZqwOc^r&9^H>dlMhmgZwuUx}u(t*@w)!@T zvWwf=GIr^fBqrsg=B1X$$4~!wm|2(=!aT^ss=^Ev)0-T?B{e[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', + file_views.FileDownloadView.as_view(), + name='file-download', + ), + path('/delete/', file_views.FileDeleteView.as_view(), name='file-delete'), + path('/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'), + path('/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'), +] diff --git a/links/file_views.py b/links/file_views.py new file mode 100644 index 0000000..5b8064e --- /dev/null +++ b/links/file_views.py @@ -0,0 +1,207 @@ +import json +import mimetypes +import os +import secrets +import logging +from pathlib import Path + +from django.conf import settings +from django.db.models import F +from django.http import FileResponse, JsonResponse, Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.dateparse import parse_datetime +from django.views import View + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response + +from .models import FileUpload +from .serializers import FileUploadSerializer + +logger = logging.getLogger(__name__) + + +def _get_upload_folder(): + folder = settings.FILE_UPLOADS_FOLDER + Path(folder).mkdir(parents=True, exist_ok=True) + return folder + + +def _save_uploaded_file(f): + """Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance.""" + folder = _get_upload_folder() + ext = Path(f.name).suffix.lower() + stored_name = f"{secrets.token_hex(16)}{ext}" + dest_path = os.path.join(folder, stored_name) + with open(dest_path, 'wb') as dst: + for chunk in f.chunks(): + dst.write(chunk) + mime_type = f.content_type or mimetypes.guess_type(f.name)[0] or 'application/octet-stream' + return FileUpload.objects.create( + name=f.name, + stored_name=stored_name, + mime_type=mime_type, + size=f.size, + ) + + +# ── UI Views ────────────────────────────────────────────────────────────────── + +class FileListView(View): + def get(self, request): + files = FileUpload.objects.all() + return render(request, 'links/files/list.html', {'files': files}) + + +class FileUploadView(View): + def post(self, request): + is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' + uploaded = request.FILES.getlist('files') + if not uploaded: + if is_ajax: + return JsonResponse({'error': 'No files provided'}, status=400) + return redirect('file-list') + results = [] + for f in uploaded: + record = _save_uploaded_file(f) + results.append({'id': str(record.pk), 'name': record.name, 'size': record.size}) + if is_ajax: + return JsonResponse({'uploaded': results}) + return redirect('file-list') + + +class FileDownloadView(View): + def get(self, request, pk, filename=''): + record = get_object_or_404(FileUpload, pk=pk) + if not os.path.exists(record.file_path): + raise Http404("File not found on disk") + FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response + + +class FileDeleteView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + if os.path.exists(record.file_path): + os.remove(record.file_path) + record.delete() + return redirect('file-list') + + +class FileTogglePublicView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + if record.is_public: + record.is_public = False + record.save(update_fields=['is_public', 'updated_at']) + return JsonResponse({'is_public': False, 'public_url': None}) + record.is_public = True + record.save(update_fields=['is_public', 'updated_at']) + return JsonResponse({ + 'is_public': True, + 'public_url': record.public_url, + }) + + +class FileSetExpiryView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'Invalid JSON'}, status=400) + expires_at = data.get('expires_at') + if expires_at: + dt = parse_datetime(expires_at) + if not dt: + return JsonResponse({'error': 'Invalid datetime format. Use ISO 8601.'}, status=400) + record.expires_at = dt + else: + record.expires_at = None + record.save(update_fields=['expires_at', 'updated_at']) + return JsonResponse({ + 'expires_at': record.expires_at.isoformat() if record.expires_at else None, + 'is_expired': record.is_expired, + }) + + +class PublicFileView(View): + def get(self, request, pk, filename=''): + record = get_object_or_404(FileUpload, pk=pk, is_public=True) + if record.is_expired: + raise Http404("This public link has expired") + if not os.path.exists(record.file_path): + raise Http404("File not found") + FileUpload.objects.filter(pk=record.pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response + + +# ── REST API ViewSet ────────────────────────────────────────────────────────── + +class FileUploadViewSet(viewsets.ModelViewSet): + queryset = FileUpload.objects.all() + serializer_class = FileUploadSerializer + parser_classes = [MultiPartParser, FormParser] + http_method_names = ['get', 'post', 'delete', 'head', 'options'] + + def create(self, request, *args, **kwargs): + uploaded = request.FILES.getlist('files') + if not uploaded: + return Response({'error': 'No files provided. Use files[] field.'}, status=status.HTTP_400_BAD_REQUEST) + created = [_save_uploaded_file(f) for f in uploaded] + serializer = self.get_serializer(created, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + record = self.get_object() + if os.path.exists(record.file_path): + os.remove(record.file_path) + record.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=['post'], url_path='toggle-public') + def toggle_public(self, request, pk=None): + record = self.get_object() + if record.is_public: + record.is_public = False + record.save(update_fields=['is_public', 'updated_at']) + return Response({'is_public': False, 'public_url': None}) + record.is_public = True + record.save(update_fields=['is_public', 'updated_at']) + return Response({ + 'is_public': True, + 'public_url': record.public_url, + }) + + @action(detail=True, methods=['post'], url_path='set-expiry') + def set_expiry(self, request, pk=None): + record = self.get_object() + expires_at = request.data.get('expires_at') + if expires_at: + dt = parse_datetime(str(expires_at)) + if not dt: + return Response({'error': 'Invalid datetime. Use ISO 8601.'}, status=status.HTTP_400_BAD_REQUEST) + record.expires_at = dt + else: + record.expires_at = None + record.save(update_fields=['expires_at', 'updated_at']) + return Response(self.get_serializer(record).data) + + @action(detail=True, methods=['get']) + def download(self, request, pk=None): + record = self.get_object() + if not os.path.exists(record.file_path): + raise Http404("File not found") + FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response diff --git a/links/migrations/0039_file_upload.py b/links/migrations/0039_file_upload.py new file mode 100644 index 0000000..d896898 --- /dev/null +++ b/links/migrations/0039_file_upload.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.11 on 2026-03-21 06:18 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0038_remove_iptv_models'), + ] + + operations = [ + migrations.CreateModel( + name='FileUpload', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('stored_name', models.CharField(max_length=255, verbose_name='Stored Name')), + ('mime_type', models.CharField(blank=True, max_length=128, verbose_name='MIME Type')), + ('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')), + ('is_public', models.BooleanField(db_index=True, default=False, verbose_name='Is Public')), + ('public_token', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Public Token')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires At')), + ('download_count', models.PositiveIntegerField(default=0, verbose_name='Download Count')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ], + options={ + 'verbose_name': 'File Upload', + 'verbose_name_plural': 'File Uploads', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/links/models.py b/links/models.py index 8f805a4..a5939f7 100644 --- a/links/models.py +++ b/links/models.py @@ -338,3 +338,64 @@ class Image(models.Model): height=height, fit='cover' ) + + +class FileUpload(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(_('Name'), max_length=255) + stored_name = models.CharField(_('Stored Name'), max_length=255) + mime_type = models.CharField(_('MIME Type'), max_length=128, blank=True) + size = models.PositiveBigIntegerField(_('Size'), default=0) + is_public = models.BooleanField(_('Is Public'), default=False, db_index=True) + public_token = models.CharField(_('Public Token'), max_length=64, unique=True, null=True, blank=True) + expires_at = models.DateTimeField(_('Expires At'), null=True, blank=True) + download_count = models.PositiveIntegerField(_('Download Count'), default=0) + created_at = models.DateTimeField(_('Created At'), auto_now_add=True) + updated_at = models.DateTimeField(_('Updated At'), auto_now=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = _('File Upload') + verbose_name_plural = _('File Uploads') + + def __str__(self): + return self.name + + @property + def file_path(self): + return os.path.join(settings.FILE_UPLOADS_FOLDER, self.stored_name) + + @property + def is_expired(self): + if self.expires_at: + return timezone.now() > self.expires_at + return False + + @property + def is_image(self): + return self.mime_type.startswith('image/') + + @property + def is_publicly_accessible(self): + return self.is_public and not self.is_expired + + @property + def download_url(self): + import re + safe_name = re.sub(r'[^\w.\-]', '-', self.name) + return f'/ui/files/{self.pk}-{safe_name}' + + @property + def public_url(self): + import re + safe_name = re.sub(r'[^\w.\-]', '-', self.name) + return f'/public/files/{self.pk}-{safe_name}' + + def formatted_size(self): + if self.size < 1024: + return f"{self.size} B" + elif self.size < 1024 * 1024: + return f"{self.size / 1024:.1f} KB" + elif self.size < 1024 * 1024 * 1024: + return f"{self.size / (1024 * 1024):.1f} MB" + return f"{self.size / (1024 * 1024 * 1024):.2f} GB" diff --git a/links/serializers.py b/links/serializers.py index 59c45e1..4e5d751 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Page, Post, ImageCollection, Image, Tag +from .models import Page, Post, ImageCollection, Image, Tag, FileUpload class PageSerializer(serializers.ModelSerializer): class Meta: @@ -83,3 +83,33 @@ class ImageCollectionSerializer(serializers.ModelSerializer): def get_image_count(self, obj): return obj.images.count() + +class FileUploadSerializer(serializers.ModelSerializer): + formatted_size = serializers.SerializerMethodField() + public_url = serializers.SerializerMethodField() + is_expired = serializers.SerializerMethodField() + + class Meta: + model = FileUpload + fields = [ + 'id', 'name', 'mime_type', 'size', 'formatted_size', + 'is_public', 'public_token', 'public_url', + 'expires_at', 'is_expired', + 'download_count', 'created_at', 'updated_at', + ] + read_only_fields = [ + 'id', 'mime_type', 'size', 'formatted_size', + 'public_token', 'public_url', 'is_expired', + 'download_count', 'created_at', 'updated_at', + ] + + def get_formatted_size(self, obj): + return obj.formatted_size() + + def get_public_url(self, obj): + if obj.is_public and obj.public_token: + return f'/public/files/{obj.public_token}/' + return None + + def get_is_expired(self, obj): + return obj.is_expired diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html new file mode 100644 index 0000000..f590f55 --- /dev/null +++ b/links/templates/links/files/list.html @@ -0,0 +1,473 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+ + + + + +
+
+ + + +

{% trans "Drop files to upload" %}

+
+
+ + +
+ + +
+

{% trans "Files" %}

+ +
+ + +
+
+

+ {% trans "All Files" %} ({{ files.count }}) +

+
+ + {% if files %} +
+ + + + + + + + + + + + + + {% for file in files %} + + + + + + + + + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Size" %}{% trans "Public" %}{% trans "Actions" %}
+ + + {% if file.is_image %} + + {% else %} + + {% endif %} + + {{ file.name }} + + {{ file.formatted_size }} +
+ + ● {% trans "Public" %} + + + ○ {% trans "Private" %} + + + + + {% trans "No expiry" %} + + + + +
+
+
+ + + + + + +
+ {% csrf_token %} + +
+
+ +
+
+ {% else %} +
+ + + +

{% trans "No files yet" %}

+

{% trans "Drag files anywhere on this page, or click Upload Files above." %}

+
+ {% endif %} +
+ +
+ + +
+ + + +
+ + +
+
+
+

{% trans "Set Expiry Date" %}

+ +
+

{% trans "Leave blank for no expiry. The public link will stop working after this date." %}

+ +
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + + u.status === 'uploading').length + ' / ' + uploads.length"> + {% trans "Uploads" %} + +
+
+ + + + + +
+
+ + +
+ + +
+ {% trans "Drag files anywhere or click Upload Files." %} +
+ + +
+ +
+ +
+
+
+ +
+ + +{% endblock %} diff --git a/static/openapi.yaml b/static/openapi.yaml index b61daf0..e30e958 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -221,7 +221,181 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' -components: + /api/files: + get: + summary: List all uploaded files + description: Retrieve a paginated list of all uploaded files + parameters: + - in: query + name: page + schema: + type: integer + description: Page number for pagination + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/FileList' + + post: + summary: Upload one or more files + description: Upload one or multiple files. Files are stored in FILE_UPLOADS_FOLDER on the server. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - files + properties: + files: + type: array + items: + type: string + format: binary + description: One or more files to upload + responses: + '201': + description: Files uploaded successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FileUpload' + '400': + description: No files provided + + /api/files/{id}: + get: + summary: Get file details + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: File details + content: + application/json: + schema: + $ref: '#/components/schemas/FileUpload' + '404': + description: File not found + + delete: + summary: Delete a file + description: Deletes the file record and removes it from disk + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '204': + description: File deleted + '404': + description: File not found + + /api/files/{id}/download: + get: + summary: Download or view a file + description: Serves the file content. Images are served inline; other files as attachments. + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: File content + '404': + description: File not found + + /api/files/{id}/toggle-public: + post: + summary: Toggle public/private visibility + description: Makes a private file public (generating a public token) or makes a public file private immediately. + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Updated visibility state + content: + application/json: + schema: + type: object + properties: + is_public: + type: boolean + public_token: + type: string + nullable: true + public_url: + type: string + nullable: true + example: /public/files// + + /api/files/{id}/set-expiry: + post: + summary: Set or clear expiry date for a public file + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expires_at: + type: string + format: date-time + nullable: true + description: ISO 8601 datetime. Send null or omit to clear the expiry. + responses: + '200': + description: Updated file details + content: + application/json: + schema: + $ref: '#/components/schemas/FileUpload' + + /public/files/{token}/: + get: + summary: Access a public file by token + description: Serves a publicly shared file. Returns 404 if the file is private or the link has expired. + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + '200': + description: File content (inline for images, attachment for others) + '404': + description: File not found, is private, or link has expired schemas: Page: type: object @@ -417,6 +591,73 @@ components: url: type: string + FileUpload: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + name: + type: string + description: Original filename + mime_type: + type: string + readOnly: true + size: + type: integer + readOnly: true + description: File size in bytes + formatted_size: + type: string + readOnly: true + description: Human-readable size (e.g. "1.4 MB") + is_public: + type: boolean + public_token: + type: string + nullable: true + readOnly: true + public_url: + type: string + nullable: true + readOnly: true + example: /public/files// + expires_at: + type: string + format: date-time + nullable: true + is_expired: + type: boolean + readOnly: true + download_count: + type: integer + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + + FileList: + type: object + properties: + count: + type: integer + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/FileUpload' + SuccessResponse: type: object properties: diff --git a/templates/base.html b/templates/base.html index 87c3a73..f8bd0a4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -139,6 +139,16 @@
+ +
+ + + + {% trans "Files" %} +
+
+
From 332654a72671fec8e26829fd6f6c2bf6cb0ed054 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 20:45:23 +1100 Subject: [PATCH 07/95] Test files API --- core/settings.py | 11 ++--------- data/db.sqlite3 | Bin 479232 -> 479232 bytes 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/core/settings.py b/core/settings.py index ee7ba1e..4ff9c99 100644 --- a/core/settings.py +++ b/core/settings.py @@ -154,15 +154,6 @@ LOGGING = { }, } -REST_FRAMEWORK = { - 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', - ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 10, - 'UNAUTHENTICATED_USER': None, # 添加这行 -} - LANGUAGE_URL_MAP = { 'en': 'en', 'zh-hans': 'zh', @@ -195,6 +186,8 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', ], + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': [], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 10, 'UNAUTHENTICATED_USER': None, diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 1752d7e42b9e42aaae7e60be3bbecc0f49a7b3f8..e3662c8586a9093916079ab212e91a15e1292113 100644 GIT binary patch delta 600 zcmaiwzfV(96vyAAufAssJu&GfjSyX5xxe4N-5LjfMBJE6%)9sYUZM%u(G5B@uE3z1 zVPIiy$m_zO3;%@Bp$iGRxVR9M7mYDEbU8W6_miB@`EKvGw)a~*cf-@&#fRbPARp%8 zFrORdm7_d5m^9;hHCXuC__(k*Z{TJ9uwJd{Q5QX_Z4H)_`PG%RdzB;zZg;zeDA^-g z2x>9Yk}1I`$(T`I+N`HCgfPk^PhY1d#qIv)I^~WCD>Ekrm)J3@9p}_xjJ$LibtUs^ zL$AA)>t(L>mLMWhL{oy7km}Ga)#0?w1y+*Zi2fu5Au?Ew|CQ_+Dz$dXFwU63Mk{2k z=LUIeFeCgwNr44Z$SY$Rx716oImL?geD;)4@BJ)G(c!!;7(tBOko=nnUL0OTFQR7L zxC-DtJcch&z((T=_F!*xr~j$({6S?koYikqLbLvhOX$P+B3ddQ{fv(DW(=bMF5w4! zhGQ7R2u|SS`sRD6MU@~-$`KdiH`Qk)diERr22(hL?{E(1rCy09C7OisyXmA_OeeQm F{a;8IqYnT8 delta 145 zcmZozAltA&c7inHrHL}mjF%b{S`!#s6PQ{Pn71adEV(bh#=Vk(_YL1XUKySw{y6TH zn;i|Na&KSxnkAZrgN{0UCTafZJ6^LWYxBQR!Kk>f+%AMzLe|(>H`{Vm;vIPJ~MKzQF From 2ff7ab85ed256e7019d19019839b1c3612c448e4 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 21:00:38 +1100 Subject: [PATCH 08/95] Add tests --- .github/workflows/build-and-deploy.yml | 25 ++++ links/file_views.py | 4 +- links/serializers.py | 4 +- links/tasks.py | 4 + pyproject.toml | 14 +++ tests/__init__.py | 0 tests/conftest.py | 49 ++++++++ tests/test_files_api.py | 151 +++++++++++++++++++++++++ tests/test_links_api.py | 65 +++++++++++ tests/test_pages_api.py | 61 ++++++++++ tests/test_posts_api.py | 83 ++++++++++++++ uv.lock | 12 ++ 12 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_files_api.py create mode 100644 tests/test_links_api.py create mode 100644 tests/test_pages_api.py create mode 100644 tests/test_posts_api.py diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index ed9d5e3..549f4f8 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -10,8 +10,33 @@ env: K8S_NAMESPACE: apps jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies (including dev) + run: uv sync --dev + + - name: Run tests + env: + DJANGO_SETTINGS_MODULE: core.settings + FILE_UPLOADS_FOLDER: /tmp/test-uploads + run: uv run pytest tests/ -v --tb=short + build-and-deploy: runs-on: ubuntu-latest + needs: test permissions: contents: read packages: write diff --git a/links/file_views.py b/links/file_views.py index 5b8064e..99d2a9a 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -14,7 +14,7 @@ from django.views import View from rest_framework import viewsets, status from rest_framework.decorators import action -from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.response import Response from .models import FileUpload @@ -149,7 +149,7 @@ class PublicFileView(View): class FileUploadViewSet(viewsets.ModelViewSet): queryset = FileUpload.objects.all() serializer_class = FileUploadSerializer - parser_classes = [MultiPartParser, FormParser] + parser_classes = [MultiPartParser, FormParser, JSONParser] http_method_names = ['get', 'post', 'delete', 'head', 'options'] def create(self, request, *args, **kwargs): diff --git a/links/serializers.py b/links/serializers.py index 4e5d751..b41ded0 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -107,9 +107,7 @@ class FileUploadSerializer(serializers.ModelSerializer): return obj.formatted_size() def get_public_url(self, obj): - if obj.is_public and obj.public_token: - return f'/public/files/{obj.public_token}/' - return None + return obj.public_url def get_is_expired(self, obj): return obj.is_expired diff --git a/links/tasks.py b/links/tasks.py index 8ba1ea5..2b23f63 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -252,6 +252,10 @@ def process_page(page_id, retry_count=0): except Exception as exc: logger.error(f"Failed to process page {page_id}: {exc}") + try: + page = Page.objects.get(id=page_id) + except Page.DoesNotExist: + return page.retry_count += 1 page.last_retry_at = timezone.now() diff --git a/pyproject.toml b/pyproject.toml index 9133546..f4ec4d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,21 @@ line-length = 100 target-version = ["py312"] include = '\.pyi?$' +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "core.settings" +pythonpath = ["."] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + [tool.isort] profile = "black" multi_line_output = 3 line_length = 100 + +[dependency-groups] +dev = [ + "pytest>=8.3.4", + "pytest-django>=4.10.0", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..23b6c19 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import io +import os +import tempfile + +import pytest +from django.test import Client +from rest_framework.test import APIClient + + +@pytest.fixture +def client(): + return Client() + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def png_file(): + """Minimal valid 1×1 PNG.""" + data = ( + b"\x89PNG\r\n\x1a\n" # signature + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" # 1x1 RGB + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + return io.BytesIO(data) + + +@pytest.fixture +def pdf_file(): + """Minimal valid PDF.""" + data = ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\ntrailer<>\nstartxref\n0\n%%EOF" + ) + return io.BytesIO(data) + + +@pytest.fixture(autouse=True) +def use_tmp_upload_dir(settings, tmp_path): + """Redirect FILE_UPLOADS_FOLDER to a temp dir for each test.""" + settings.FILE_UPLOADS_FOLDER = str(tmp_path) + yield diff --git a/tests/test_files_api.py b/tests/test_files_api.py new file mode 100644 index 0000000..f7870da --- /dev/null +++ b/tests/test_files_api.py @@ -0,0 +1,151 @@ +""" +Integration tests for the Files REST API (/api/files) and download endpoint. +""" + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + + +def _png(): + data = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + return SimpleUploadedFile("test.png", data, content_type="image/png") + + +def _pdf(): + data = ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\ntrailer<>\nstartxref\n0\n%%EOF" + ) + return SimpleUploadedFile("document.pdf", data, content_type="application/pdf") + + +@pytest.mark.django_db +class TestFilesAPI: + BASE = "/api/files" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_upload_single_file(self, api_client): + r = api_client.post(f"{self.BASE}", {"files": _png()}, format="multipart") + assert r.status_code == 201 + results = r.json() + assert len(results) == 1 + assert results[0]["name"] == "test.png" + assert results[0]["mime_type"] == "image/png" + assert results[0]["is_public"] is False + assert "id" in results[0] + + def test_upload_multiple_files(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"files": [_png(), _pdf()]}, + format="multipart", + ) + assert r.status_code == 201 + assert len(r.json()) == 2 + + def test_list_after_upload(self, api_client): + api_client.post(f"{self.BASE}", {"files": _png()}, format="multipart") + api_client.post(f"{self.BASE}", {"files": _pdf()}, format="multipart") + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 2 + + def test_get_file_detail(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.get(f"{self.BASE}/{pk}") + assert r.status_code == 200 + assert r.json()["name"] == "test.png" + + def test_upload_no_files_returns_400(self, api_client): + r = api_client.post(f"{self.BASE}", {}, format="multipart") + assert r.status_code == 400 + + def test_delete_file(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.delete(f"{self.BASE}/{pk}") + assert r.status_code == 204 + assert api_client.get(f"{self.BASE}/{pk}").status_code == 404 + + def test_download_url_works(self, client, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + pk, name = uploaded["id"], uploaded["name"] + r = client.get(f"/ui/files/{pk}-{name}") + assert r.status_code == 200 + assert r["Content-Type"] == "image/png" + + def test_download_increments_count(self, client, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + pk, name = uploaded["id"], uploaded["name"] + client.get(f"/ui/files/{pk}-{name}") + client.get(f"/ui/files/{pk}-{name}") + detail = api_client.get(f"{self.BASE}/{pk}").json() + assert detail["download_count"] == 2 + + def test_toggle_public(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + # Make public + r = api_client.post(f"{self.BASE}/{pk}/toggle-public") + assert r.status_code == 200 + assert r.json()["is_public"] is True + assert r.json()["public_url"] is not None + # Make private again + r2 = api_client.post(f"{self.BASE}/{pk}/toggle-public") + assert r2.json()["is_public"] is False + assert r2.json()["public_url"] is None + + def test_public_file_accessible(self, client, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + api_client.post(f"{self.BASE}/{pk}/toggle-public") + detail = api_client.get(f"{self.BASE}/{pk}").json() + r = client.get(detail["public_url"]) + assert r.status_code == 200 + + def test_private_file_not_accessible_via_public_url(self, client, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + name = "test.png" + r = client.get(f"/public/files/{pk}-{name}") + assert r.status_code == 404 + + def test_set_expiry(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.post( + f"{self.BASE}/{pk}/set-expiry", + {"expires_at": "2099-12-31T00:00:00Z"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["is_expired"] is False + + def test_formatted_size_present(self, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + assert "formatted_size" in uploaded + assert "B" in uploaded["formatted_size"] diff --git a/tests/test_links_api.py b/tests/test_links_api.py new file mode 100644 index 0000000..fcf3089 --- /dev/null +++ b/tests/test_links_api.py @@ -0,0 +1,65 @@ +""" +Integration tests for the Links feature. +Links are managed via Django template views, tested through the Django test client. +""" + +import pytest + +from links.models import Link + + +@pytest.mark.django_db +class TestLinksUI: + def test_list_page_returns_200(self, client): + response = client.get("/", follow=True) + assert response.status_code == 200 + + def test_create_link(self, client): + response = client.post( + "/create/", + { + "alias": "testlink", + "original_url": "https://example.com", + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert response.status_code == 200 + assert Link.objects.filter(alias="testlink").exists() + + def test_redirect_follows_alias(self, client): + Link.objects.create(alias="gh", original_url="https://github.com") + response = client.get("/gh/") + assert response.status_code == 302 + assert response["Location"] == "https://github.com" + + def test_redirect_unknown_alias_goes_to_create(self, client): + response = client.get("/definitely-not-a-real-alias/") + assert response.status_code == 302 + assert "create" in response["Location"] + + def test_delete_link(self, client): + link = Link.objects.create(alias="del-me", original_url="https://example.com") + response = client.post(f"/delete/{link.pk}/") + assert response.status_code in (200, 302) + assert not Link.objects.filter(pk=link.pk).exists() + + def test_link_with_template_url(self, client): + Link.objects.create( + alias="gosearch", + original_url="https://google.com/search?q={query,default=hello}", + ) + response = client.get("/gosearch/") + assert response.status_code == 302 + assert "q=hello" in response["Location"] + + def test_link_with_template_param_override(self, client): + Link.objects.create( + alias="wiki", + original_url="https://en.wikipedia.org/wiki/{topic,default=Python}", + ) + response = client.get("/wiki/Django/") + assert response.status_code == 302 + assert "Django" in response["Location"] diff --git a/tests/test_pages_api.py b/tests/test_pages_api.py new file mode 100644 index 0000000..3183bde --- /dev/null +++ b/tests/test_pages_api.py @@ -0,0 +1,61 @@ +""" +Integration tests for the Pages REST API (/api/pages). +""" + +import pytest + + +@pytest.mark.django_db +class TestPagesAPI: + BASE = "/api/pages" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_create_page(self, api_client): + r = api_client.post(f"{self.BASE}", {"url": "https://example.com"}, format="json") + assert r.status_code == 201 + data = r.json() + assert data["url"] == "https://example.com" + assert "id" in data + + def test_list_after_create(self, api_client): + api_client.post(f"{self.BASE}", {"url": "https://example.com"}, format="json") + api_client.post(f"{self.BASE}", {"url": "https://github.com"}, format="json") + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 2 + + def test_get_page_detail(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.get(f"{self.BASE}/{created['id']}") + assert r.status_code == 200 + assert r.json()["url"] == "https://example.com" + + def test_update_page_title(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.patch( + f"{self.BASE}/{created['id']}", + {"title": "Updated Title"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["title"] == "Updated Title" + + def test_delete_page(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.delete(f"{self.BASE}/{created['id']}") + assert r.status_code == 204 + r2 = api_client.get(f"{self.BASE}/{created['id']}") + assert r2.status_code == 404 + + def test_create_page_missing_url_returns_400(self, api_client): + r = api_client.post(f"{self.BASE}", {}, format="json") + assert r.status_code == 400 diff --git a/tests/test_posts_api.py b/tests/test_posts_api.py new file mode 100644 index 0000000..de0911a --- /dev/null +++ b/tests/test_posts_api.py @@ -0,0 +1,83 @@ +""" +Integration tests for the Posts REST API (/api/posts). +""" + +import pytest + + +@pytest.mark.django_db +class TestPostsAPI: + BASE = "/api/posts" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_create_post(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"title": "Hello World", "content": "# Hello\n\nThis is a test post."}, + format="json", + ) + assert r.status_code == 201 + data = r.json() + assert data["title"] == "Hello World" + assert "id" in data + + def test_list_after_create(self, api_client): + for i in range(3): + api_client.post( + f"{self.BASE}", + {"title": f"Post {i}", "content": f"Content {i}"}, + format="json", + ) + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 3 + + def test_get_post_detail(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Detail Test", "content": "Some content"}, + format="json", + ).json() + r = api_client.get(f"{self.BASE}/{created['id']}") + assert r.status_code == 200 + assert r.json()["title"] == "Detail Test" + + def test_update_post(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Original", "content": "Original content"}, + format="json", + ).json() + r = api_client.patch( + f"{self.BASE}/{created['id']}", + {"title": "Updated"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["title"] == "Updated" + + def test_delete_post(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Delete Me", "content": "Content"}, + format="json", + ).json() + r = api_client.delete(f"{self.BASE}/{created['id']}") + assert r.status_code == 204 + assert api_client.get(f"{self.BASE}/{created['id']}").status_code == 404 + + def test_create_post_missing_required_fields(self, api_client): + r = api_client.post(f"{self.BASE}", {"title": "No content"}, format="json") + assert r.status_code == 400 + + def test_create_post_with_summary(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"title": "With Summary", "content": "Body", "summary": "Short summary"}, + format="json", + ) + assert r.status_code == 201 + assert r.json()["summary"] == "Short summary" diff --git a/uv.lock b/uv.lock index 9283076..89748cf 100644 --- a/uv.lock +++ b/uv.lock @@ -605,6 +605,12 @@ dev = [ { name = "pytest-django" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-django" }, +] + [package.metadata] requires-dist = [ { name = "apscheduler", specifier = ">=3.10.0,<4.0.0" }, @@ -636,6 +642,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-django", specifier = ">=4.10.0" }, +] + [[package]] name = "markdown" version = "3.7" From f58d15e0da9252219985c4fdb33ca48f7d13f9fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:02:08 +0000 Subject: [PATCH 09/95] Bump markdown from 3.7 to 3.8.1 Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.7 to 3.8.1. - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.7...3.8.1) --- updated-dependencies: - dependency-name: markdown dependency-version: 3.8.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f4ec4d8..2fa5dc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "gunicorn==23.0.0", "whitenoise==5.3.0", "django-simplemde==0.1.4", - "markdown==3.7", + "markdown==3.8.1", "requests>=2.32.4,<3", "beautifulsoup4==4.12.3", "djangorestframework==3.15.2", diff --git a/uv.lock b/uv.lock index 89748cf..280845c 100644 --- a/uv.lock +++ b/uv.lock @@ -628,7 +628,7 @@ requires-dist = [ { name = "fontawesome-free", specifier = "==5.15.3" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, - { name = "markdown", specifier = "==3.7" }, + { name = "markdown", specifier = "==3.8.1" }, { name = "pillow", specifier = "~=12.1.1" }, { name = "playwright", specifier = ">=1.40.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, @@ -650,11 +650,11 @@ dev = [ [[package]] name = "markdown" -version = "3.7" +version = "3.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086, upload-time = "2024-08-16T15:55:17.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7c/0738e5ff0adccd0b4e02c66d0446c03a3c557e02bb49b7c263d7ab56c57d/markdown-3.8.1.tar.gz", hash = "sha256:a2e2f01cead4828ee74ecca9623045f62216aef2212a7685d6eb9163f590b8c1", size = 361280, upload-time = "2025-06-18T14:50:49.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349, upload-time = "2024-08-16T15:55:16.176Z" }, + { url = "https://files.pythonhosted.org/packages/50/34/3d1ff0cb4843a33817d06800e9383a2b2a2df4d508e37f53a40e829905d9/markdown-3.8.1-py3-none-any.whl", hash = "sha256:46cc0c0f1e5211ab2e9d453582f0b28a1bfaf058a9f7d5c50386b99b588d8811", size = 106642, upload-time = "2025-06-18T14:50:48.52Z" }, ] [[package]] From cdcf21c6fc48a6796e6c6a9fd43862e80cc96bee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:02:13 +0000 Subject: [PATCH 10/95] Bump black from 25.1.0 to 26.3.1 Bumps [black](https://github.com/psf/black) from 25.1.0 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.1.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- uv.lock | 65 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/uv.lock b/uv.lock index 89748cf..d620fd6 100644 --- a/uv.lock +++ b/uv.lock @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "black" -version = "25.1.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -72,18 +72,26 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -747,11 +755,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -1060,6 +1068,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32" version = "311" From 6afdeb525566abedb0e2b1bcb243a30fbab8c3cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:02:16 +0000 Subject: [PATCH 11/95] Bump django from 5.2.11 to 5.2.12 Bumps [django](https://github.com/django/django) from 5.2.11 to 5.2.12. - [Commits](https://github.com/django/django/compare/5.2.11...5.2.12) --- updated-dependencies: - dependency-name: django dependency-version: 5.2.12 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 89748cf..26d64e8 100644 --- a/uv.lock +++ b/uv.lock @@ -291,16 +291,16 @@ wheels = [ [[package]] name = "django" -version = "5.2.11" +version = "5.2.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/f2/3e57ef696b95067e05ae206171e47a8e53b9c84eec56198671ef9eaa51a6/django-5.2.11.tar.gz", hash = "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", size = 10885017, upload-time = "2026-02-03T13:52:50.554Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a7/2b112ab430575bf3135b8304ac372248500d99c352f777485f53fdb9537e/django-5.2.11-py3-none-any.whl", hash = "sha256:e7130df33ada9ab5e5e929bc19346a20fe383f5454acb2cc004508f242ee92c0", size = 8291375, upload-time = "2026-02-03T13:52:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, ] [[package]] From 45e72897559c4a6c5f7ea585ced72a20a5112888 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 21:14:16 +1100 Subject: [PATCH 12/95] Update debug --- core/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index 4ff9c99..527e3a2 100644 --- a/core/settings.py +++ b/core/settings.py @@ -57,7 +57,8 @@ TEMPLATES = [ # 添加以下基本设置(如果尚未存在) SECRET_KEY = 'your-secret-key-here' # 请更改为一个安全的随机值 -DEBUG = True + +DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes') ALLOWED_HOSTS = ['*'] # 数据库设置(使用默认的SQLite配置) From aa16044cc65694da44c5ef7c424db56ed3bb9729 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 21:16:00 +1100 Subject: [PATCH 13/95] Uploads --- k8s/manifest.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 43d9170..41ce996 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -95,6 +95,9 @@ spec: - name: data persistentVolumeClaim: claimName: links-local-pvc + - name: uploads + persistentVolumeClaim: + claimName: uploads-nfs-apps - name: cache emptyDir: {} initContainers: @@ -117,9 +120,13 @@ spec: mountPath: /app/.cache - name: downloads mountPath: /images + - name: uploads + mountPath: /uploads env: - name : DEBUG value: "false" + - name: FILE_UPLOADS_FOLDER + value: "/uploads" - name: R2_CUSTOM_DOMAIN value: home-links-prod.junv.cc - name: DB_HOST From e191147a1430475d022ab4a37b5aa84cdcd85c7b Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 21:22:04 +1100 Subject: [PATCH 14/95] Update sharing domain --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/migrations/0040_site_settings.py | 23 ++++++++++ links/models.py | 45 ++++++++++++++++++-- links/templates/links/settings.html | 56 +++++++++++++++++++++++++ links/urls.py | 1 + links/views.py | 17 +++++++- templates/base.html | 9 ++++ 7 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 links/migrations/0040_site_settings.py create mode 100644 links/templates/links/settings.html diff --git a/data/db.sqlite3 b/data/db.sqlite3 index e3662c8586a9093916079ab212e91a15e1292113..c92850cb262f4e621922235d42f3160005435ab9 100644 GIT binary patch delta 868 zcmZozAltA&c7n9vLk0$hQXsYlVh$j_JyFM)@!`gVCGyOod@7UK6*QUnR5m*reBzZ6 zwPZHr%*o8lE{-qGEJ-a+Eh)*&OD`6;U^Zr)F37QEWVtkI1*$tE!9XD$ji1Ueyv52cOf{j5oMQn3~-5N&b7)G<$|_7*8|KU`z#a z91Y6qbz@i=81*g1$}&^SQLJ`O%u@gfDg*=FqYw;rkYO}Xxv{NSN@`AONh*d0h%lO# zD2SHijKsWjbS=(#3J`HrJ=&2#J-X&%i76=t9G$iv4R!x1x?JzygT#|n-< zjuMU-V7SgT#O_OqcI~kUqnM>usAkKjD=B`5yMH5oA*b!mNUoj-kr>T zVIv3cd)}A4k9qF`t(WDMU}a`x6pI0q(O@zPOh#`0dYAtZvlySlboK*`+Dv>7K;=Aq z;-X?K4C1O7@z)-Hj&XbVIi?L4S=oWfC~-T_0_Gq7T+IA_4E%fe+xY!93l=!=TbMbp zGKd>Gg0&kMm>7UVE*_SYj0}v-bPbGkjSLkG4Xlg|tPBkGEDelIEX~{7{xeT+`_ED* z#m!>Oz@NZ%htr&69tR`OJk}QW{p__YuUL$M?h|D(Z8UC_Viz|wWNZ>FNleN~#Z0i6 zBJtB56B$K0;fZ?sh9o8nk delta 353 zcmZozAltA&c7n9vT?Ph*QXsYlVoo5wHBrZy@$SZiCGyN7eAbiM6*M;sCb02w$(S(P zGKyO;8#7K9soe3vF$T> zSaUA(urQuvU>0K9#pK3#5-7iuk+Yhcok2a((NTW-aYeQg&LS=dmv_3m5?eNB7$-Y} nxul~b@AQ32Z2sIcvpF~z6gPzf1-45ovpwc%`@p*F16u+B1s!Ig diff --git a/links/migrations/0040_site_settings.py b/links/migrations/0040_site_settings.py new file mode 100644 index 0000000..a88124a --- /dev/null +++ b/links/migrations/0040_site_settings.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-21 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0039_file_upload'), + ] + + operations = [ + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_sharing_domain', models.CharField(blank=True, default='', help_text='Domain (with protocol) used for public sharing links, e.g. https://go.example.com. Leave blank to use the same domain as the app.', max_length=255, verbose_name='Public Sharing Domain')), + ], + options={ + 'verbose_name': 'Site Settings', + }, + ), + ] diff --git a/links/models.py b/links/models.py index a5939f7..d46f6ec 100644 --- a/links/models.py +++ b/links/models.py @@ -387,9 +387,16 @@ class FileUpload(models.Model): @property def public_url(self): - import re - safe_name = re.sub(r'[^\w.\-]', '-', self.name) - return f'/public/files/{self.pk}-{safe_name}' + import re as _re + safe_name = _re.sub(r'[^\w.\-]', '-', self.name) + path = f'/public/files/{self.pk}-{safe_name}' + if not self.is_public: + return path + try: + domain = SiteSettings.get().public_sharing_domain.rstrip('/') + except Exception: + domain = '' + return f'{domain}{path}' if domain else path def formatted_size(self): if self.size < 1024: @@ -399,3 +406,35 @@ class FileUpload(models.Model): elif self.size < 1024 * 1024 * 1024: return f"{self.size / (1024 * 1024):.1f} MB" return f"{self.size / (1024 * 1024 * 1024):.2f} GB" + + +class SiteSettings(models.Model): + """ + Singleton model for app-wide configuration. + Always use SiteSettings.get() to retrieve the instance. + """ + public_sharing_domain = models.CharField( + _('Public Sharing Domain'), + max_length=255, + blank=True, + default='', + help_text=_( + 'Domain (with protocol) used for public sharing links, e.g. https://go.example.com. ' + 'Leave blank to use the same domain as the app.' + ), + ) + + class Meta: + verbose_name = _('Site Settings') + + def __str__(self): + return 'Site Settings' + + @classmethod + def get(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html new file mode 100644 index 0000000..7a67495 --- /dev/null +++ b/links/templates/links/settings.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

{% trans "Settings" %}

+ +
+ {% csrf_token %} + + +
+

{% trans "Public Sharing Domain" %}

+

+ {% trans "Domain (with protocol) used for public file sharing links. Leave blank to use the same domain as the app." %} +
+ {% trans "Example:" %} https://go.example.com +

+ + {% if site_settings.public_sharing_domain %} +

+ {% trans "Active. Public links will use:" %} + {{ site_settings.public_sharing_domain }}/public/files/… +

+ {% else %} +

+ {% trans "Not set. Public links will use the app's own domain." %} +

+ {% endif %} +
+ +
+ +
+
+
+{% endblock %} diff --git a/links/urls.py b/links/urls.py index 2549273..ffbade2 100644 --- a/links/urls.py +++ b/links/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ path('ui/tools/', views.ToolsView.as_view(), name='tools'), path('tools/export-database/', views.export_database, name='export_database'), path('ui/help/', views.HelpView.as_view(), name='help'), + path('ui/settings/', views.SiteSettingsView.as_view(), name='site-settings'), # Pages path('ui/pages/', page_views.PageListView.as_view(), name='page-list'), diff --git a/links/views.py b/links/views.py index d9c43d2..93dff08 100644 --- a/links/views.py +++ b/links/views.py @@ -4,7 +4,7 @@ from django.views import View # Add this import from django.urls import reverse_lazy, reverse # Add 'reverse' here from django.db.models import F, Count, Q, Case, When, Value, IntegerField from django.db.models.functions import TruncDate -from .models import Link, ClickLog, LinkChangeLog, Page, Post +from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings from .forms import LinkForm, PageForm import json from django.core.serializers.json import DjangoJSONEncoder @@ -533,6 +533,21 @@ def export_database(request): class HelpView(TemplateView): template_name = 'links/help.html' + +class SiteSettingsView(View): + template_name = 'links/settings.html' + + def get(self, request): + site_settings = SiteSettings.get() + return render(request, self.template_name, {'site_settings': site_settings}) + + def post(self, request): + site_settings = SiteSettings.get() + site_settings.public_sharing_domain = request.POST.get('public_sharing_domain', '').strip() + site_settings.save() + messages.success(request, _('Settings saved.')) + return redirect('site-settings') + @require_http_methods(["POST"]) def generate_tts(request, post_id): """Generate TTS audio for a post""" diff --git a/templates/base.html b/templates/base.html index f8bd0a4..05aaba5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -177,6 +177,15 @@ {% trans "Help" %}
+ + +
+ + + + {% trans "Settings" %} +
+
From 27fc5af885f0544f00b5c65dd9af812cc84baa22 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 22:01:50 +1100 Subject: [PATCH 15/95] Add jobs feature --- data/db.sqlite3 | Bin 479232 -> 479232 bytes k8s/manifest.yaml | 2 +- ...retry_count_sitesettings_max_concurrent.py | 25 + links/models.py | 9 + links/tasks.py | 100 +++- links/templates/links/jobs.html | 497 ++++++++++++++++++ links/templates/links/settings.html | 18 + links/urls.py | 1 + links/views.py | 162 ++++++ templates/base.html | 10 + 10 files changed, 794 insertions(+), 30 deletions(-) create mode 100644 links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py create mode 100644 links/templates/links/jobs.html diff --git a/data/db.sqlite3 b/data/db.sqlite3 index c92850cb262f4e621922235d42f3160005435ab9..e4ae7c43716f1bf2fa0cd6e7936ed4e7e32a14ba 100644 GIT binary patch delta 3424 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 diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 41ce996..4ecfa6d 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -39,7 +39,7 @@ spec: - ReadWriteMany nfs: server: 192.168.1.5 - path: "/fs/1000/nfs/data/uploads" + path: "/fs/1000/nfs/uploads" --- diff --git a/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py b/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py new file mode 100644 index 0000000..929b1a6 --- /dev/null +++ b/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0040_site_settings'), + ] + + operations = [ + migrations.AddField( + model_name='screenshot', + name='retry_count', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='sitesettings', + name='max_concurrent_screenshot_jobs', + field=models.IntegerField( + default=2, + help_text='Maximum number of Chromium screenshot processes to run simultaneously. Lower this value if the container runs out of memory.', + verbose_name='Max Concurrent Screenshot Jobs', + ), + ), + ] diff --git a/links/models.py b/links/models.py index d46f6ec..bb96fe6 100644 --- a/links/models.py +++ b/links/models.py @@ -256,6 +256,7 @@ class Screenshot(models.Model): path = models.CharField(max_length=255, blank=True, null=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) error = models.TextField(blank=True, null=True) + retry_count = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -423,6 +424,14 @@ class SiteSettings(models.Model): 'Leave blank to use the same domain as the app.' ), ) + max_concurrent_screenshot_jobs = models.IntegerField( + _('Max Concurrent Screenshot Jobs'), + default=2, + help_text=_( + 'Maximum number of Chromium screenshot processes to run simultaneously. ' + 'Lower this value if the container runs out of memory.' + ), + ) class Meta: verbose_name = _('Site Settings') diff --git a/links/tasks.py b/links/tasks.py index 2b23f63..e3a3b94 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -114,6 +114,14 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): logger.info(f"Screenshot {screenshot_id} already completed, skipping") return + # Respect model-level retry limit (model tracks total attempts across restarts) + if screenshot.retry_count >= MAX_RETRIES: + logger.warning(f"Screenshot {screenshot_id} exceeded max retries ({MAX_RETRIES}), marking FAILED") + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Exceeded maximum retries ({MAX_RETRIES})" + screenshot.save() + return + screenshot.status = Screenshot.Status.PROCESSING screenshot.error = None screenshot.save() @@ -154,14 +162,32 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): except Exception as exc: logger.error(f"Failed to capture screenshot for page {page_id}: {exc}", exc_info=True) - if retry_count < MAX_RETRIES: - retry_delay = fibonacci(retry_count + 1) - if screenshot is not None: - try: - screenshot.error = f"Retrying in {retry_delay}s (attempt {retry_count + 1}/{MAX_RETRIES}): {str(exc)}" - screenshot.save() - except Exception: - pass + if screenshot is None: + return + + # Increment model-level retry count + try: + screenshot.retry_count = Screenshot.objects.filter(pk=screenshot.pk).values_list('retry_count', flat=True).first() or 0 + screenshot.retry_count += 1 + screenshot.save(update_fields=['retry_count', 'error', 'updated_at']) + except Exception: + pass + + if screenshot.retry_count >= MAX_RETRIES: + logger.error(f"Max retries exceeded for screenshot {screenshot_id}") + try: + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Failed after {screenshot.retry_count} attempts: {str(exc)}" + screenshot.save() + except Exception: + pass + else: + retry_delay = fibonacci(screenshot.retry_count) + try: + screenshot.error = f"Retrying in {retry_delay}s (attempt {screenshot.retry_count}/{MAX_RETRIES}): {str(exc)}" + screenshot.save(update_fields=['error', 'updated_at']) + except Exception: + pass # Schedule retry using APScheduler from core.scheduler import scheduler from datetime import datetime @@ -171,26 +197,16 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): capture_screenshot, 'date', run_date=run_date, - args=[page_id, screenshot_id, retry_count + 1], - id=f'capture_screenshot_{screenshot_id}_retry_{retry_count + 1}', + args=[page_id, screenshot_id, screenshot.retry_count], + id=f'capture_screenshot_{screenshot_id}_retry_{screenshot.retry_count}', replace_existing=True ) - logger.info(f"Scheduled retry {retry_count + 1} for screenshot {screenshot_id} in {retry_delay}s") + logger.info(f"Scheduled retry {screenshot.retry_count} for screenshot {screenshot_id} in {retry_delay}s") except Exception as sched_exc: logger.error(f"Failed to schedule retry for screenshot {screenshot_id}: {sched_exc}") - if screenshot is not None: - try: - screenshot.status = Screenshot.Status.FAILED - screenshot.error = f"Scheduler error, giving up: {str(sched_exc)}" - screenshot.save() - except Exception: - pass - else: - logger.error(f"Max retries exceeded for screenshot {screenshot_id}") - if screenshot is not None: try: screenshot.status = Screenshot.Status.FAILED - screenshot.error = f"Failed after {retry_count} attempts: {str(exc)}" + screenshot.error = f"Scheduler error, giving up: {str(sched_exc)}" screenshot.save() except Exception: pass @@ -307,25 +323,51 @@ def schedule_pending_screenshots(): Screenshots can get stuck when their background thread is killed (e.g. server restart). Any screenshot that hasn't been updated in more than 5 minutes and - is not in a terminal state is considered stuck and will be re-queued. + is not in a terminal state is considered stuck and will be re-queued — up to + the configured concurrency limit to prevent OOM. """ - from .models import Screenshot + from .models import Screenshot, SiteSettings + + # First: mark any max-retried screenshots as FAILED so they stop being picked up + over_limit = Screenshot.objects.filter( + status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING], + retry_count__gte=MAX_RETRIES, + ) + failed_count = over_limit.count() + if failed_count: + logger.warning(f"Marking {failed_count} screenshot(s) as FAILED (exceeded {MAX_RETRIES} retries)") + over_limit.update( + status=Screenshot.Status.FAILED, + error=f"Exceeded maximum retries ({MAX_RETRIES})", + ) + + # Determine how many slots are available + max_concurrent = SiteSettings.get().max_concurrent_screenshot_jobs + currently_processing = Screenshot.objects.filter( + status=Screenshot.Status.PROCESSING, + ).count() + available_slots = max(0, max_concurrent - currently_processing) + + if available_slots == 0: + logger.info(f"All {max_concurrent} screenshot slots busy, skipping stuck check") + return threshold = timezone.now() - timedelta(minutes=5) stuck = Screenshot.objects.filter( status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING], updated_at__lt=threshold, - ) + retry_count__lt=MAX_RETRIES, + )[:available_slots] count = stuck.count() if count: - logger.info(f"Found {count} stuck screenshot(s) — retrying") + logger.info(f"Found {count} stuck screenshot(s) — retrying (slots available: {available_slots})") for screenshot in stuck: - logger.info(f"Retrying screenshot {screenshot.id} (page {screenshot.page_id})") - # Reset to PENDING so the worker knows to re-attempt + logger.info(f"Retrying screenshot {screenshot.id} (page {screenshot.page_id}, attempt {screenshot.retry_count + 1}/{MAX_RETRIES})") screenshot.status = Screenshot.Status.PENDING - screenshot.save(update_fields=['status', 'updated_at']) + screenshot.retry_count += 1 + screenshot.save(update_fields=['status', 'retry_count', 'updated_at']) thread = Thread(target=capture_screenshot, args=(screenshot.page_id, screenshot.id)) thread.daemon = True thread.start() diff --git a/links/templates/links/jobs.html b/links/templates/links/jobs.html new file mode 100644 index 0000000..a764aaf --- /dev/null +++ b/links/templates/links/jobs.html @@ -0,0 +1,497 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + +
+

{% trans "Jobs" %}

+
+ + + {% if scheduler_running %}{% trans "Scheduler running" %}{% else %}{% trans "Scheduler stopped" %}{% endif %} + + {% trans "Max concurrent:" %} {{ site_settings.max_concurrent_screenshot_jobs }} + {% trans "Change" %} +
+
+ + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + + + + + +
+
+ + + + + {% if tab != 'scheduler' %} +
+ {% for s, label, pill_active_style, pill_inactive_style in filter_options %} + + {{ label }} + + {% endfor %} +
+ {% endif %} +
+
+ + +
+ + +
+ + + + + +
+ + {% if tab == 'scheduler' %} + + {% if scheduled_jobs %} +
+ + + + + + + + + + + {% for job in scheduled_jobs %} + + + + + + + {% endfor %} + +
{% trans "Job ID" %}{% trans "Function" %}{% trans "Trigger" %}{% trans "Next Run" %}
{{ job.id }}{{ job.name }}{{ job.trigger }} + {% if job.next_run %}{{ job.next_run }}{% else %}—{% endif %} +
+
+ {% else %} +
{% trans "No scheduled jobs." %}
+ {% endif %} + + {% elif tab == 'screenshots' %} + + {% if page_obj.object_list %} +
+ + + + + + + + + + + + + + {% for ss in page_obj.object_list %} + + + + + + + + + + {% endfor %} + +
+ + ID{% trans "Page" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{% trans "Updated" %}
+ + {{ ss.id }} + + {{ ss.page.title|default:ss.page.url|truncatechars:55 }} + + + + {{ ss.status }} + + {{ ss.retry_count }}/3 + {% if ss.error %} + + {% else %}{% endif %} + {{ ss.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No screenshots match this filter." %}
+ {% endif %} + + {% elif tab == 'pages' %} + + {% if page_obj.object_list %} +
+ + + + + + + + + + + + + + {% for pg in page_obj.object_list %} + + + + + + + + + + {% endfor %} + +
+ + ID{% trans "Title / URL" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{% trans "Updated" %}
+ + {{ pg.id }} + + {{ pg.title|default:pg.url|truncatechars:55 }} + + + + {{ pg.process_status }} + + {{ pg.retry_count }}/3 + {% if pg.error_message %} + + {% else %}{% endif %} + {{ pg.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No pages match this filter." %}
+ {% endif %} + {% endif %} + + + {% if page_obj.has_other_pages %} +
+ + {% trans "Showing" %} {{ page_obj.start_index }}–{{ page_obj.end_index }} + {% trans "of" %} {{ page_obj.paginator.count }} + +
+ {% if page_obj.has_previous %} + ‹ {% trans "Prev" %} + {% endif %} + {% for num in page_obj.paginator.page_range %} + {% if page_obj.number == num %} + {{ num }} + {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} + {{ num }} + {% endif %} + {% endfor %} + {% if page_obj.has_next %} + {% trans "Next" %} › + {% endif %} +
+
+ {% endif %} +
+ + +
+
+
+
+

{% trans "Error Details" %}

+ +
+
+

{% trans "Job ID:" %}

+

+            
+
+ +
+
+
+ +
+ +{{ all_ids|json_script:"jobs-all-ids" }} + + +{% endblock %} + diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html index 7a67495..7cde9c3 100644 --- a/links/templates/links/settings.html +++ b/links/templates/links/settings.html @@ -41,6 +41,24 @@ {% endif %} + +
+

{% trans "Max Concurrent Screenshot Jobs" %}

+

+ {% trans "Maximum number of Chromium browser instances running simultaneously for screenshots. Reduce this if the container runs out of memory (OOM)." %} +

+ +

{% trans "Recommended: 1–3 for containers with limited RAM." %}

+
+ From 2e47fd1046ad4654c6e84f5032c53a4b015c29fc Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 22:08:50 +1100 Subject: [PATCH 16/95] Update settings --- core/apps.py | 44 +++++----- data/db.sqlite3 | Bin 479232 -> 479232 bytes .../0042_sitesettings_scheduler_intervals.py | 23 ++++++ links/models.py | 10 +++ links/templates/links/settings.html | 75 +++++++++++++++--- links/views.py | 27 +++++++ 6 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 links/migrations/0042_sitesettings_scheduler_intervals.py diff --git a/core/apps.py b/core/apps.py index 9100171..eceb15f 100644 --- a/core/apps.py +++ b/core/apps.py @@ -18,26 +18,34 @@ class CoreConfig(AppConfig): from core.scheduler import scheduler, start_scheduler from links.tasks import schedule_pending_pages, schedule_pending_screenshots from apscheduler.triggers.interval import IntervalTrigger - + # Start the scheduler start_scheduler() - + + # Read intervals from SiteSettings (fall back to 120s if DB not ready) + try: + from links.models import SiteSettings + ss = SiteSettings.get() + pages_interval = ss.schedule_pending_pages_interval or 120 + screenshots_interval = ss.schedule_pending_screenshots_interval or 120 + except Exception: + pages_interval = 120 + screenshots_interval = 120 + # Add periodic job for checking pending pages - if not scheduler.get_job('schedule_pending_pages'): - scheduler.add_job( - schedule_pending_pages, - trigger=IntervalTrigger(seconds=120), - id='schedule_pending_pages', - replace_existing=True - ) - logger.info("Scheduled periodic task: schedule_pending_pages") + scheduler.add_job( + schedule_pending_pages, + trigger=IntervalTrigger(seconds=pages_interval), + id='schedule_pending_pages', + replace_existing=True, + ) + logger.info(f"Scheduled periodic task: schedule_pending_pages (every {pages_interval}s)") # Add periodic job for recovering stuck screenshots - if not scheduler.get_job('schedule_pending_screenshots'): - scheduler.add_job( - schedule_pending_screenshots, - trigger=IntervalTrigger(seconds=120), - id='schedule_pending_screenshots', - replace_existing=True - ) - logger.info("Scheduled periodic task: schedule_pending_screenshots") + scheduler.add_job( + schedule_pending_screenshots, + trigger=IntervalTrigger(seconds=screenshots_interval), + id='schedule_pending_screenshots', + replace_existing=True, + ) + logger.info(f"Scheduled periodic task: schedule_pending_screenshots (every {screenshots_interval}s)") diff --git a/data/db.sqlite3 b/data/db.sqlite3 index e4ae7c43716f1bf2fa0cd6e7936ed4e7e32a14ba..95be530568e87c084b76f12ef5fee2fd5ede3acf 100644 GIT binary patch delta 507 zcmZozAltA&c7n9vQw9cxQXsYlVh$jFF;T~u@#)5dmAZ_!lezSy8ErSK>D^>xbZRna zGhl2pVA^KD%qGC{m4W~3b}k3z*ZiWKjBE_NjEsy-6&1Wd0~iDt82G<$7EE}{&&kfn z3X)=~*p|R7>>wBql+6O-1RzcVVzcck3z#4H^RekX$n$2tx{o_VZO z*w3@~vixE$VR`_Rik|FfU@p*<+$hg3Zf(uj5?hj(l#@D{S5F*-`AwaJTpdGP6+#@H zd|VZjKw{H7SXdLMKV@V!o_^spGpjYkW$^{6c_~1*#up@}rxwGVsiXjMaC&M{Qyk2O zrVtRz(2%huc)A@6tLF4%M%JxhqeQS7H9d=$RZa#L0B~!HlZ#SQ^NKU_ONv1%q_;~j bu}U*-f5pPOguQ(p4=WI}ZJ)=(p5zPwYBrS6 delta 621 zcmXBRJ!n%=7zW^T?oV$1dfr@Po2F^fXo?uI$xYK%%f*u5;G_;NIwcl#PzMD|r_v;9 zii4>VN(LdsLByehM0ydF4&opV;vgdawiZN1B#1*$s^_c2`<(YXaF+9xTG3J~dOc!p zFvezNY#F!A`i;F=zHzU3cZAz@Zz{s=_O8@x&hr(kYVoS2tXQfmpmiC#jyI=%!+cu4 z(z(WZxZ;(S1sKB#6mS-&FpCSA!(m*)RV-o&H*pJha16_6;{jIj1kdmSuka4P@B^Rl z8((k;dDs|78c8G&6`v7;Dc0bFF8+$H_$j`NH{zvuE}n|V;!)#bNeQ&gqDM5Y)s*Az zsZm|!{6MC$R#TG39vY^Sjp(8>HXn=8lpG?1gJfTX3=fd~VREpajD*NoA34-Z##z&5N$*kp}W6VvC8{~wG+^>_9+E!P$R5X=)CRd7T zFlSE4KRqK;l$n+(IKwUV<58a`It&j{#S!6{IdxqubT(C|v#AAmLd(i^q#26dx1N3O zrknel`ekF)nA5&0%SxE1dAy#t{tT37%2A_yUJ|lxUMZiZmdWtSY@^Qg_s$1Jk0?HW yjXD3XO}b9N^y@M%r|CDQ5B>uQpneAc diff --git a/links/migrations/0042_sitesettings_scheduler_intervals.py b/links/migrations/0042_sitesettings_scheduler_intervals.py new file mode 100644 index 0000000..3a2bc20 --- /dev/null +++ b/links/migrations/0042_sitesettings_scheduler_intervals.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-21 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0041_screenshot_retry_count_sitesettings_max_concurrent'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='schedule_pending_pages_interval', + field=models.IntegerField(default=120, help_text='How often (in seconds) to check for pending pages and queue them for processing.', verbose_name='Schedule Pending Pages Interval (seconds)'), + ), + migrations.AddField( + model_name='sitesettings', + name='schedule_pending_screenshots_interval', + field=models.IntegerField(default=120, help_text='How often (in seconds) to check for stuck or pending screenshots and retry them.', verbose_name='Schedule Pending Screenshots Interval (seconds)'), + ), + ] diff --git a/links/models.py b/links/models.py index bb96fe6..cb55354 100644 --- a/links/models.py +++ b/links/models.py @@ -432,6 +432,16 @@ class SiteSettings(models.Model): 'Lower this value if the container runs out of memory.' ), ) + schedule_pending_pages_interval = models.IntegerField( + _('Schedule Pending Pages Interval (seconds)'), + default=120, + help_text=_('How often (in seconds) to check for pending pages and queue them for processing.'), + ) + schedule_pending_screenshots_interval = models.IntegerField( + _('Schedule Pending Screenshots Interval (seconds)'), + default=120, + help_text=_('How often (in seconds) to check for stuck or pending screenshots and retry them.'), + ) class Meta: verbose_name = _('Site Settings') diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html index 7cde9c3..8bb9b3b 100644 --- a/links/templates/links/settings.html +++ b/links/templates/links/settings.html @@ -7,14 +7,16 @@ {% endblock %} {% block content %} -
+

{% trans "Settings" %}

{% csrf_token %} +
+ -
+

{% trans "Public Sharing Domain" %}

{% trans "Domain (with protocol) used for public file sharing links. Leave blank to use the same domain as the app." %} @@ -42,24 +44,71 @@

-
+

{% trans "Max Concurrent Screenshot Jobs" %}

{% trans "Maximum number of Chromium browser instances running simultaneously for screenshots. Reduce this if the container runs out of memory (OOM)." %}

- +
+ + {% trans "instances" %} +

{% trans "Recommended: 1–3 for containers with limited RAM." %}

-
+ +
+

{% trans "Page Processing Interval" %}

+

+ {% trans "How often (in seconds) the scheduler checks for pending pages and queues them for metadata extraction." %} +

+
+ + {% trans "seconds" %} +
+

{% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}

+
+ + +
+

{% trans "Screenshot Retry Interval" %}

+

+ {% trans "How often (in seconds) the scheduler checks for stuck or pending screenshots and retries them." %} +

+
+ + {% trans "seconds" %} +
+

{% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}

+
+ +
+ +
- -
- {% csrf_token %} - - -
{% csrf_token %} - -
-
{% csrf_token %} - -
-
@@ -123,11 +112,6 @@
{% trans "Failed" %}
-
-
{% csrf_token %} - -
-
@@ -182,31 +166,27 @@
- - + +
diff --git a/links/views.py b/links/views.py index 8ba04ce..1ff1280 100644 --- a/links/views.py +++ b/links/views.py @@ -708,27 +708,6 @@ class JobsView(View): n = qs.delete()[0] messages.success(request, _(f'Deleted {n} page(s).')) - # ── Global screenshot actions ───────────────────────────────────── - elif action == 'retry_failed_screenshots': - n = Screenshot.objects.filter(status=Screenshot.Status.FAILED).update( - status=Screenshot.Status.PENDING, retry_count=0, error=None) - messages.success(request, _(f'Reset {n} failed screenshot(s) to pending.')) - - elif action == 'cancel_pending_screenshots': - n = Screenshot.objects.filter( - status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING] - ).update(status=Screenshot.Status.FAILED, error='Cancelled by user') - messages.success(request, _(f'Cancelled {n} screenshot(s).')) - - elif action == 'clear_failed_screenshots': - n = Screenshot.objects.filter(status=Screenshot.Status.FAILED).delete()[0] - messages.success(request, _(f'Deleted {n} failed screenshot record(s).')) - - elif action == 'retry_failed_pages': - n = Page.objects.filter(process_status=Page.ProcessStatus.FAILED).update( - process_status=Page.ProcessStatus.PENDING, retry_count=0, error_message='') - messages.success(request, _(f'Reset {n} failed page(s) to pending.')) - else: messages.error(request, _('Unknown action.')) From 61670a7ee9b70a67691bc1338441e5c41e599796 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 14:05:10 +1100 Subject: [PATCH 20/95] Update style --- data/db.sqlite3 | Bin 479232 -> 479232 bytes k8s/manifest.yaml | 4 +- ..._screenshot_job_timeout_to_sitesettings.py | 18 ++++ links/models.py | 8 ++ links/tasks.py | 101 ++++++++++-------- links/templates/links/jobs.html | 2 +- 6 files changed, 86 insertions(+), 47 deletions(-) create mode 100644 links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py diff --git a/data/db.sqlite3 b/data/db.sqlite3 index cd054bb7b63779e6f0078026234524d1b5174314..1df67b1cef2c51dfec07779af731e36b949943d4 100644 GIT binary patch delta 662 zcmYk2T}V@57{}k|Js+=|^Bhl7lIC1lS+I?>Gv{)qI1B_8Luv$hv90Z7%XSu?BY0Ce z#gCi7aCYd!7zMitin4qOgMtd9fX6UsMv`WXS~t zRR93Sqod$mN^lJw4uctInL^ZC#H~zL&y84dD;GC2mTu~n zl`#fQP1TxxYS5=?9yJ(I)rc1Mhr=zQU}(Cu!O_wNKL8DmF66{(sO(Oe;<_Oe$Xjxe z|HD~$t-Pw7filz!?1=Eg0bw^d#Joq`oFO^`!V|*F33x+$9Ks{0nb?;1kO_r_>eg16 zoFJQ3AOMps_8bWNq1lcmVe9+@;gx+I3!V16*POKHe|S?*<&3ns%abuIeNZ3v^c_Cx z>5KLD(gm0B3|BJOBB_oAA>WoU()y?!UE!+iRUMS+f-KO2EM|X-yoPGft$9KkF-s~j zlzjZ46XlPyfVPi^qEWE(S4Fp7x-8yQ0$QXg6lv1@YM6aCc!;B8K<;B+Tt?kA`9<^r JO$lV7`yT~GxU>KO delta 459 zcmZozAltA&c7n9vD+UIJQXsYlVlE(lF;T~u@zutJ3H)rlK(2c3=5PGMa*VcgNj!giemCJV;t?~gEhGdG$RPv4lr zB)mP~D6<~pbk8&<`RT`#n9QcvCNuhMpOVZ}aEFwfm_Ck`-&GjE^A!+MBmdfhSR1#JSX+XUDcKd`awW{_fRn99zs&ctFg)qp*3 ba)803?UJ|IHZg6Fy~k$AxILMbeXbV(FC>LL diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index fd07ee2..a6afb2f 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -177,8 +177,8 @@ spec: cpu: 200m memory: 400Mi limits: - cpu: 1000m - memory: 1000Mi + cpu: 1200m + memory: 2Gi imagePullSecrets: - name: github-image-pull-secret --- diff --git a/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py b/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py new file mode 100644 index 0000000..2a28a91 --- /dev/null +++ b/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-22 03:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0042_sitesettings_scheduler_intervals'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='screenshot_job_timeout_seconds', + field=models.IntegerField(default=300, help_text='Maximum time in seconds a single screenshot job may run before it is cancelled and marked as failed. Default is 300 (5 minutes).', verbose_name='Screenshot Job Timeout (seconds)'), + ), + ] diff --git a/links/models.py b/links/models.py index cb55354..0aa3999 100644 --- a/links/models.py +++ b/links/models.py @@ -442,6 +442,14 @@ class SiteSettings(models.Model): default=120, help_text=_('How often (in seconds) to check for stuck or pending screenshots and retry them.'), ) + screenshot_job_timeout_seconds = models.IntegerField( + _('Screenshot Job Timeout (seconds)'), + default=300, + help_text=_( + 'Maximum time in seconds a single screenshot job may run before it is cancelled and ' + 'marked as failed. Default is 300 (5 minutes).' + ), + ) class Meta: verbose_name = _('Site Settings') diff --git a/links/tasks.py b/links/tasks.py index e3a3b94..0e2ac1b 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -54,55 +54,59 @@ def extract_description_from_soup(soup): return text return None -async def _capture_screenshot_async(page_url, full_path): - """Async function to capture screenshot using Playwright""" - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=[ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - ] - ) - - context = await browser.new_context( - viewport={'width': 1366, 'height': 768}, - locale='zh-CN', - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - extra_http_headers={ - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - }, - ignore_https_errors=True, - ) - - page = await context.new_page() - - try: - # Navigate and wait for network idle - await page.goto( - page_url, - wait_until='networkidle', - timeout=SCREENSHOT_TIMEOUT +async def _capture_screenshot_async(page_url, full_path, job_timeout_seconds=300): + """Async function to capture screenshot using Playwright. + + Raises asyncio.TimeoutError if the entire operation exceeds job_timeout_seconds. + """ + async def _run(): + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=[ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ] ) - - # Take full-page screenshot - await page.screenshot( - path=full_path, - full_page=True, - type='png' + + context = await browser.new_context( + viewport={'width': 1366, 'height': 768}, + locale='zh-CN', + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + extra_http_headers={ + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + ignore_https_errors=True, ) - - logger.info(f"Successfully captured screenshot: {full_path}") - - finally: - await context.close() - await browser.close() + + page = await context.new_page() + + try: + await page.goto( + page_url, + wait_until='networkidle', + timeout=SCREENSHOT_TIMEOUT + ) + + await page.screenshot( + path=full_path, + full_page=True, + type='png' + ) + + logger.info(f"Successfully captured screenshot: {full_path}") + + finally: + await context.close() + await browser.close() + + await asyncio.wait_for(_run(), timeout=job_timeout_seconds) def capture_screenshot(page_id, screenshot_id, retry_count=0): """Task to capture full-page screenshot with Playwright""" - from .models import Page, Screenshot + from .models import Page, Screenshot, SiteSettings screenshot = None try: @@ -122,6 +126,8 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): screenshot.save() return + job_timeout = SiteSettings.get().screenshot_job_timeout_seconds + screenshot.status = Screenshot.Status.PROCESSING screenshot.error = None screenshot.save() @@ -138,7 +144,7 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): # Run async screenshot capture in sync context try: - asyncio.run(_capture_screenshot_async(page.url, full_path)) + asyncio.run(_capture_screenshot_async(page.url, full_path, job_timeout_seconds=job_timeout)) # Update screenshot record screenshot.path = filepath @@ -147,6 +153,13 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): logger.info(f"Successfully captured screenshot for page {page_id}") + except asyncio.TimeoutError: + msg = f"Job timed out after {job_timeout}s" + logger.error(f"Screenshot {screenshot_id}: {msg}") + screenshot.status = Screenshot.Status.FAILED + screenshot.error = msg + screenshot.save() + return except PlaywrightTimeoutError as e: logger.error(f"Timeout during screenshot capture: {str(e)}") screenshot.status = Screenshot.Status.FAILED diff --git a/links/templates/links/jobs.html b/links/templates/links/jobs.html index b3fe224..6285383 100644 --- a/links/templates/links/jobs.html +++ b/links/templates/links/jobs.html @@ -400,7 +400,7 @@
From 8777aabdc916077ac4e1733d975c99bc0cdba700 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 14:36:10 +1100 Subject: [PATCH 21/95] Update image folder --- core/settings.py | 2 ++ links/image_api.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index 4a8a838..8a7dc44 100644 --- a/core/settings.py +++ b/core/settings.py @@ -121,6 +121,8 @@ MUSIC_ROOT = os.path.join(BASE_DIR, 'data', 'music') # File uploads folder — override via FILE_UPLOADS_FOLDER env var. # Defaults to ~/Downloads locally; set to /uploads on k8s. FILE_UPLOADS_FOLDER = os.environ.get('FILE_UPLOADS_FOLDER', os.path.expanduser('~/Downloads')) +IMAGES_FOLDER = os.environ.get('IMAGES_FOLDER', os.path.expanduser('~/Pictures')) + LOGGING = { 'version': 1, diff --git a/links/image_api.py b/links/image_api.py index 9ac7a51..e58c4e9 100644 --- a/links/image_api.py +++ b/links/image_api.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) FitMode = Literal['clip', 'crop', 'fill', 'scale'] # Configuration constants -IMAGES_FOLDER = os.getenv('IMAGES_FOLDER', '/Users/junv/Downloads') +IMAGES_FOLDER = settings.IMAGES_FOLDER CACHE_TIMEOUT = 600 # 10 minutes for HTTP caching MAX_FILE_SIZE = 3 * 1024 * 1024 # 3MB in bytes SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'} From 64871ab7e0a9ff38961561adcbbfe3ce81ef021a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 15:11:44 +1100 Subject: [PATCH 22/95] Fix OpenAPI docs, add image import, file preview modal, keyboard nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix OpenAPI YAML: correct components/schemas indentation (was inside paths:) - Downgrade openapi version 3.1.0 → 3.0.3 for ReDoc 2.5.2 compatibility - Add /import/images/ endpoint with worker-mode background download - Add source_url field to FileUpload model (migration 0044) - Add file preview modal with image/video/audio/unsupported cases - Add arrow key navigation + prev/next buttons + file counter in modal - Add /import/images/{image_url} to OpenAPI spec with source_url field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/urls.py | 5 +- links/file_views.py | 57 +++++++ .../0044_add_source_url_to_fileupload.py | 18 +++ links/models.py | 1 + links/tasks.py | 56 +++++++ links/templates/links/files/list.html | 152 +++++++++++++++++- static/openapi.yaml | 60 ++++++- 7 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 links/migrations/0044_add_source_url_to_fileupload.py diff --git a/core/urls.py b/core/urls.py index e887865..5fe99a1 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,7 +4,7 @@ from django.conf import settings from django.conf.urls.static import static from django.views.static import serve from links.views import LinkDetailView, LinkUpdateView, CustomLinkView -from links.file_views import PublicFileView +from links.file_views import PublicFileView, import_image_view from django.urls import path, include, re_path from django.conf.urls.i18n import i18n_patterns @@ -31,6 +31,9 @@ urlpatterns = [ path('ui/netscan/', include('netscan.urls')), path('ui/files/', include('links.file_urls')), + # Import external image by URL — /import/images/ + path('import/images/', import_image_view, name='import-image'), + # Public file access — /public/files/{uuid}-{filename} re_path( r'^public/files/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', diff --git a/links/file_views.py b/links/file_views.py index 99d2a9a..5ddc232 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -205,3 +205,60 @@ class FileUploadViewSet(viewsets.ModelViewSet): response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) response['Content-Disposition'] = disposition return response + + +def import_image_view(request, image_url): + """Proxy-and-cache an external image via /import/images/. + + The image_url path component has no scheme (e.g. 'example.com/path/img.jpg'). + On first request the view: + 1. Creates a FileUpload stub in the database (is_public=True, source_url set). + 2. Fires a background thread to download and save the file. + 3. Immediately redirects to the original URL so the image is visible right away. + On subsequent requests, once the file is saved locally, the view redirects to + the stored public URL instead — no more dependency on the original host. + """ + import hashlib + import posixpath + from threading import Thread + from .tasks import download_and_save_image + + # Build the canonical source URL (https preferred) + source_url = f'https://{image_url}' + + url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20] + filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}' + + # Derive extension from filename; fall back to .jpg for bare names + _, ext = posixpath.splitext(filename) + if not ext: + ext = '.jpg' + filename = f'{filename}{ext}' + + stored_name = f'import_{url_hash}{ext}' + + # Try to find an existing record for this URL (idempotent) + record = FileUpload.objects.filter(source_url=source_url).first() + + if record is None: + # Create the stub immediately so we have a stable public URL + record = FileUpload.objects.create( + name=filename, + stored_name=stored_name, + mime_type=f'image/{ext.lstrip(".") or "jpeg"}', + size=0, + is_public=True, + source_url=source_url, + ) + logger.info(f"import_image_view: created FileUpload {record.pk} for {source_url}") + + # If the file is already on disk, serve from local storage + if os.path.exists(record.file_path): + return redirect(record.public_url) + + # File not yet saved — kick off (or re-kick) the background download + thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) + thread.start() + + # Redirect to the original URL as a temporary placeholder while download runs + return redirect(source_url) diff --git a/links/migrations/0044_add_source_url_to_fileupload.py b/links/migrations/0044_add_source_url_to_fileupload.py new file mode 100644 index 0000000..ffe2275 --- /dev/null +++ b/links/migrations/0044_add_source_url_to_fileupload.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-22 03:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0043_add_screenshot_job_timeout_to_sitesettings'), + ] + + operations = [ + migrations.AddField( + model_name='fileupload', + name='source_url', + field=models.URLField(blank=True, db_index=True, max_length=2048, null=True, verbose_name='Source URL'), + ), + ] diff --git a/links/models.py b/links/models.py index 0aa3999..fa9a414 100644 --- a/links/models.py +++ b/links/models.py @@ -350,6 +350,7 @@ class FileUpload(models.Model): is_public = models.BooleanField(_('Is Public'), default=False, db_index=True) public_token = models.CharField(_('Public Token'), max_length=64, unique=True, null=True, blank=True) expires_at = models.DateTimeField(_('Expires At'), null=True, blank=True) + source_url = models.URLField(_('Source URL'), max_length=2048, null=True, blank=True, db_index=True) download_count = models.PositiveIntegerField(_('Download Count'), default=0) created_at = models.DateTimeField(_('Created At'), auto_now_add=True) updated_at = models.DateTimeField(_('Updated At'), auto_now=True) diff --git a/links/tasks.py b/links/tasks.py index 0e2ac1b..de934d3 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -444,3 +444,59 @@ def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0): logger.info(f"Scheduled Crawl4AI retry {retry_count + 1} for page {page_id} in {retry_delay} seconds") except Exception as e: logger.error(f"Unexpected error fetching webpage content for page {page_id}: {e}", exc_info=True) + + +def download_and_save_image(file_upload_id): + """Background task: download an external image and save it to FILE_UPLOADS_FOLDER. + + The FileUpload record must already exist with source_url set. This task fills in + the actual file content, size, and mime_type once the download completes. + """ + from .models import FileUpload + + try: + record = FileUpload.objects.get(pk=file_upload_id) + except FileUpload.DoesNotExist: + logger.error(f"download_and_save_image: FileUpload {file_upload_id} not found") + return + + source_url = record.source_url + if not source_url: + logger.error(f"download_and_save_image: FileUpload {file_upload_id} has no source_url") + return + + dest_path = record.file_path + if os.path.exists(dest_path): + logger.info(f"download_and_save_image: {dest_path} already exists, skipping download") + return + + logger.info(f"download_and_save_image: downloading {source_url} → {dest_path}") + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + response = requests.get(source_url, headers=headers, timeout=60, stream=True) + response.raise_for_status() + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + content = response.content + + with open(dest_path, 'wb') as f: + f.write(content) + + mime_type = response.headers.get('Content-Type', 'application/octet-stream').split(';')[0].strip() + FileUpload.objects.filter(pk=file_upload_id).update( + size=len(content), + mime_type=mime_type, + ) + logger.info(f"download_and_save_image: saved {len(content)} bytes for FileUpload {file_upload_id}") + + except Exception as exc: + logger.error(f"download_and_save_image: failed for {source_url}: {exc}", exc_info=True) + # Clean up partial file if it exists + if os.path.exists(dest_path): + try: + os.remove(dest_path) + except OSError: + pass diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html index f590f55..e2b55d9 100644 --- a/links/templates/links/files/list.html +++ b/links/templates/links/files/list.html @@ -73,7 +73,7 @@ + @click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))"> {% if file.is_image %} @@ -321,6 +321,112 @@
+ + + + + + {% endblock %} diff --git a/static/openapi.yaml b/static/openapi.yaml index e30e958..1093137 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.0.3 info: title: GoLinks API description: API documentation for GoLinks service @@ -396,6 +396,56 @@ paths: description: File content (inline for images, attachment for others) '404': description: File not found, is private, or link has expired + + /import/images/{image_url}: + get: + summary: Import and cache an external image + description: | + Imports an external image by URL and saves it to the local file store. + + The `image_url` path parameter is the image URL **without** the `https://` scheme prefix + (e.g. `example.com/path/to/image.jpg`). The server always tries `https://` first. + + **Worker mode** — this endpoint is non-blocking: + - On the **first request** for a given URL the server creates a `FileUpload` record + (marked `is_public: true`) and kicks off a background thread to download and save the file. + The response immediately redirects (HTTP 302) to the original `https://` source URL so the + image is visible right away. + - On **subsequent requests**, once the background download has completed, the response + redirects to the locally-saved public file URL (`/public/files/{uuid}-{filename}`) so the + original host is no longer needed. + + **Idempotent** — the same external URL always resolves to the same `FileUpload` record; + the file is only downloaded once. + + **Use in Markdown / posts:** + ``` + ![Alt text](http://your-domain/import/images/example.com/path/to/image.jpg) + ``` + tags: + - Files + parameters: + - in: path + name: image_url + required: true + schema: + type: string + description: | + External image URL without the scheme prefix. + Example: `graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg` + example: graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg + responses: + '302': + description: | + Redirect to either the original source URL (while background download is in progress) + or the locally-saved public file URL (once download has completed). + headers: + Location: + schema: + type: string + description: URL to redirect to (original source or local `/public/files/…`) + +components: schemas: Page: type: object @@ -633,6 +683,14 @@ paths: download_count: type: integer readOnly: true + source_url: + type: string + format: uri + nullable: true + description: | + Original external URL this file was imported from via `/import/images/…`. + `null` for files uploaded directly. + example: https://example.com/path/to/image.jpg created_at: type: string format: date-time From 8cfe7fd77535b79e5f6a8d9dd5a434e4b942295b Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 15:18:45 +1100 Subject: [PATCH 23/95] Add random image API docs to OpenAPI spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- static/openapi.yaml | 132 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/static/openapi.yaml b/static/openapi.yaml index 1093137..0670aa8 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -397,6 +397,138 @@ paths: '404': description: File not found, is private, or link has expired + /api/images/random/: + get: + summary: Get a random image + description: | + Returns a randomly selected image from the server's image library, resized to the + default dimensions. Each request returns a different image. + + **Cache busting:** Add a `?v={timestamp}` query parameter to prevent browsers from + caching repeated calls (the server ignores this parameter). + tags: + - Images + parameters: + - in: query + name: fit + schema: + type: string + enum: [clip, crop, fill, scale] + default: scale + description: | + How to fit the image into the requested dimensions. + - `scale` — scale to fit, maintaining aspect ratio (may add letterbox) + - `crop` — crop to exact dimensions + - `clip` — clip to fit, no upscaling + - `fill` — fill exact dimensions, may distort + - in: query + name: v + schema: + type: integer + description: Cache-busting value (e.g. Unix timestamp). Ignored by the server. + example: 1774152070 + responses: + '200': + description: Image file (JPEG or PNG) + headers: + Cache-Control: + schema: + type: string + description: Caching directives (e.g. `public, max-age=3600`) + X-Image-Source: + schema: + type: string + description: Relative path of the source image that was served + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + '404': + description: | + No images available, folder not configured, or invalid parameters. + + /api/images/random/{width}/{height}/: + get: + summary: Get a random image at specific dimensions + description: | + Returns a randomly selected image resized to `{width}×{height}` pixels. + Minimum 300 px and maximum 4096 px for each dimension. + + **Cache busting:** Add a `?v={timestamp}` query parameter to prevent browsers from + caching repeated calls (the server ignores this parameter). + + **Use in Markdown / posts:** + ``` + ![Random image](http://your-domain/api/images/random/300/450/?fit=crop&v=1234567890) + ``` + tags: + - Images + parameters: + - in: path + name: width + required: true + schema: + type: integer + minimum: 300 + maximum: 4096 + description: Target width in pixels + example: 300 + - in: path + name: height + required: true + schema: + type: integer + minimum: 300 + maximum: 4096 + description: Target height in pixels + example: 450 + - in: query + name: fit + schema: + type: string + enum: [clip, crop, fill, scale] + default: scale + description: | + How to fit the image into the requested dimensions. + - `scale` — scale to fit, maintaining aspect ratio (may add letterbox) + - `crop` — crop to exact dimensions + - `clip` — clip to fit, no upscaling + - `fill` — fill exact dimensions, may distort + - in: query + name: v + schema: + type: integer + description: Cache-busting value (e.g. Unix timestamp). Ignored by the server. + example: 1774152070 + responses: + '200': + description: Image file (JPEG or PNG) at the requested dimensions + headers: + Cache-Control: + schema: + type: string + X-Image-Source: + schema: + type: string + description: Relative path of the source image that was served + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + '404': + description: | + No images available, folder not configured, invalid dimensions, or invalid fit mode. + /import/images/{image_url}: get: summary: Import and cache an external image From 9d0f1fc87c5f2f54e966c1ec6af261fbf067ad22 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 15:23:51 +1100 Subject: [PATCH 24/95] Make API docs LLM-friendly - Add operationId to all 20 API operations - Add tag groups (Pages, Posts, Collections, Images, Files) with descriptions - Enrich info.description with concept table, usage patterns, and examples - Add /llms.txt (llmstxt.org standard) with plain-text API overview - Add /.well-known/ai-plugin.json (OpenAI plugin manifest) - Add URL routes for llms.txt and ai-plugin.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/urls.py | 5 + static/.well-known/ai-plugin.json | 18 ++++ static/llms.txt | 65 +++++++++++++ static/openapi.yaml | 157 +++++++++++++++++++++++++----- 4 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 static/.well-known/ai-plugin.json create mode 100644 static/llms.txt diff --git a/core/urls.py b/core/urls.py index 5fe99a1..a3d1287 100644 --- a/core/urls.py +++ b/core/urls.py @@ -3,6 +3,7 @@ from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from django.views.static import serve +from django.views.generic import TemplateView from links.views import LinkDetailView, LinkUpdateView, CustomLinkView from links.file_views import PublicFileView, import_image_view from django.urls import path, include, re_path @@ -41,6 +42,10 @@ urlpatterns = [ name='public-file', ), + # LLM-friendly discovery endpoints + path('llms.txt', serve, {'document_root': settings.STATIC_ROOT, 'path': 'llms.txt'}), + path('.well-known/ai-plugin.json', serve, {'document_root': settings.STATIC_ROOT, 'path': '.well-known/ai-plugin.json'}), + # Include main app URLs with locale path('', include('links.urls')), ] diff --git a/static/.well-known/ai-plugin.json b/static/.well-known/ai-plugin.json new file mode 100644 index 0000000..729b826 --- /dev/null +++ b/static/.well-known/ai-plugin.json @@ -0,0 +1,18 @@ +{ + "schema_version": "v1", + "name_for_human": "GoLinks", + "name_for_model": "golinks", + "description_for_human": "Manage short links, bookmarked pages, image collections, file uploads, and blog posts on your self-hosted GoLinks server.", + "description_for_model": "GoLinks is a self-hosted link and content management API. Use it to: bookmark URLs (server auto-extracts title/description/screenshot), create Markdown blog posts with tags, manage image collections with bulk upload, upload and share files via public token URLs, serve random images at any dimensions from a personal library, and import external images for self-hosting. All list endpoints are paginated with page/page_size params. No authentication required.", + "auth": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "/static/openapi.yaml", + "is_user_authenticated": false + }, + "logo_url": "/static/images/logo.png", + "contact_email": "", + "legal_info_url": "" +} diff --git a/static/llms.txt b/static/llms.txt new file mode 100644 index 0000000..e344a46 --- /dev/null +++ b/static/llms.txt @@ -0,0 +1,65 @@ +# GoLinks API + +> GoLinks is a self-hosted link and content management system. It lets you create short links, bookmark pages with auto-extracted metadata, manage image collections, upload files, write Markdown blog posts, and serve random images from a personal library. + +## What you can do with this API + +- **Bookmark pages** (`/api/pages`) — POST a URL and the server auto-fetches the title, description and screenshot in the background +- **Write blog posts** (`/api/posts`) — Create Markdown posts with tag categorisation +- **Manage image collections** (`/api/collections`) — Group images into named albums; upload multiple images at once +- **Upload & share files** (`/api/files`) — Upload any file, make it public with a shareable token URL, set expiry dates +- **Random image endpoint** (`/api/images/random/{width}/{height}/`) — Returns a random image from the server's library at any size; add `?fit=crop` to crop to exact dimensions +- **Import external images** (`/import/images/{url-without-scheme}`) — Reference an external image URL; the server downloads and self-hosts it in the background + +## API reference + +- Full OpenAPI 3.0.3 spec: /static/openapi.yaml +- Interactive docs: /ui/api-docs/ + +## Key patterns + +### Embedding images in Markdown +```markdown +![placeholder](https://your-domain/api/images/random/400/300/?fit=crop&v=1234567890) +![photo](https://your-domain/import/images/example.com/path/to/photo.jpg) +``` + +### File sharing workflow +1. Upload a file: `POST /api/files` (multipart/form-data, field: `files`) +2. Make it public: `POST /api/files/{id}/toggle-public` → get `public_url` +3. Share the URL: `/public/files/{token}/` — no authentication required + +### Pagination +All list endpoints accept `?page=` (1-based) and `?page_size=`. Responses are enveloped: +```json +{ "count": 42, "next": "...", "previous": null, "results": [...] } +``` + +## Authentication + +No authentication is currently required. + +## Operations quick reference + +| operationId | Method | Path | Description | +|---|---|---|---| +| listPages | GET | /api/pages | List bookmarked pages | +| createPage | POST | /api/pages | Bookmark a new URL | +| listPosts | GET | /api/posts | List blog posts | +| createPost | POST | /api/posts | Create a blog post | +| listCollections | GET | /api/collections | List image collections | +| createCollection | POST | /api/collections | Create a collection | +| deleteCollection | DELETE | /api/collections/{id} | Delete a collection | +| uploadImagesToCollection | POST | /api/collections/{id}/upload_images | Upload images | +| deleteImage | DELETE | /api/images/{id} | Delete an image | +| listFiles | GET | /api/files | List uploaded files | +| uploadFiles | POST | /api/files | Upload files | +| getFile | GET | /api/files/{id} | Get file metadata | +| deleteFile | DELETE | /api/files/{id} | Delete a file | +| downloadFile | GET | /api/files/{id}/download | Download/view file | +| toggleFilePublic | POST | /api/files/{id}/toggle-public | Toggle public sharing | +| setFileExpiry | POST | /api/files/{id}/set-expiry | Set expiry date | +| getPublicFile | GET | /public/files/{token}/ | Access public file | +| getRandomImage | GET | /api/images/random/ | Random image (default size) | +| getRandomImageSized | GET | /api/images/random/{w}/{h}/ | Random image at W×H | +| importExternalImage | GET | /import/images/{url} | Import & cache external image | diff --git a/static/openapi.yaml b/static/openapi.yaml index 0670aa8..f84d7d4 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -1,18 +1,80 @@ openapi: 3.0.3 info: title: GoLinks API - description: API documentation for GoLinks service + description: | + # GoLinks — Link & Content Management API + + GoLinks is a self-hosted link management system. It lets you create short links, + bookmark pages with auto-extracted metadata, manage image collections, upload files, + write blog posts, and serve random images from a personal library. + + ## Key concepts + + | Resource | What it is | + |---|---| + | **Page** | A bookmarked URL. When created, the server asynchronously fetches its title, description, and a screenshot. | + | **Post** | A Markdown blog post, optionally tagged. | + | **Collection** | A named group of uploaded images (e.g. a photo album). | + | **Image** | An image file belonging to a Collection. | + | **File** | A generic uploaded file. Files can be made public via a shareable token URL. | + | **Random image** | Serves a random image from the server's local image library, resized on the fly. Great for placeholder images. | + + ## Common patterns + + ### Pagination + All list endpoints accept `?page=` (1-based) and `?page_size=` query parameters. + Responses follow the envelope `{ count, next, previous, results[] }`. + + ### Image embedding + Use the random image endpoint as a live placeholder: + ``` + ![placeholder](https://your-domain/api/images/random/400/300/?fit=crop&v=1234567890) + ``` + Or import and self-host an external image: + ``` + ![photo](https://your-domain/import/images/example.com/path/photo.jpg) + ``` + + ### File sharing + Upload a file, call `POST /api/files/{id}/toggle-public` to get a public token, + then share `/public/files/{token}/` — no authentication required for the recipient. + + ## Authentication + No authentication is currently required on any endpoint. version: 1.0.0 + x-logo: + url: /static/images/logo.png servers: - url: http://localhost:8000 description: Local development server +tags: + - name: Pages + description: | + Bookmarked web pages. Creating a page triggers an async background job that fetches + the page title, description, and screenshot automatically. + - name: Posts + description: Markdown blog posts with optional tag categorisation. + - name: Collections + description: Named groups of images. Each collection can hold many uploaded image files. + - name: Images + description: | + Image management and random image serving. + The random image endpoint is useful as a live placeholder — pass `?v={timestamp}` + to bust the browser cache on each render. + - name: Files + description: | + Generic file uploads with optional public sharing via tokenised URLs. + Files can be made public/private and given an expiry date. + paths: /api/pages: get: + operationId: listPages + tags: [Pages] summary: List all pages - description: Retrieve a paginated list of all pages + description: Retrieve a paginated list of all bookmarked pages. parameters: - in: query name: page @@ -33,8 +95,13 @@ paths: $ref: '#/components/schemas/PageList' post: + operationId: createPage + tags: [Pages] summary: Create a new page - description: Create a new page with the provided data + description: | + Bookmark a URL. After creation the server starts an async background job that fetches + the page title, description, content, and a screenshot. + Poll `GET /api/pages` or check `processing_status` to see when it completes. requestBody: required: true content: @@ -51,8 +118,10 @@ paths: /api/posts: get: + operationId: listPosts + tags: [Posts] summary: List all posts - description: Retrieve a paginated list of all posts + description: Retrieve a paginated list of all Markdown blog posts. parameters: - in: query name: page @@ -73,8 +142,10 @@ paths: $ref: '#/components/schemas/PostList' post: + operationId: createPost + tags: [Posts] summary: Create a new post - description: Create a new post with the provided data + description: Create a Markdown blog post. Pass tag slugs in the `tags` array to categorise it. requestBody: required: true content: @@ -91,8 +162,10 @@ paths: /api/collections: get: + operationId: listCollections + tags: [Collections] summary: List all collections - description: Retrieve a paginated list of all image collections + description: Retrieve a paginated list of all image collections. parameters: - in: query name: page @@ -113,8 +186,10 @@ paths: $ref: '#/components/schemas/CollectionList' post: + operationId: createCollection + tags: [Collections] summary: Create a new collection - description: Create a new image collection + description: Create a new named image collection. requestBody: required: true content: @@ -131,8 +206,10 @@ paths: /api/collections/{collection_id}: delete: + operationId: deleteCollection + tags: [Collections] summary: Delete a collection - description: Delete a collection and all its images + description: Permanently delete a collection and all its images. parameters: - in: path name: collection_id @@ -151,8 +228,12 @@ paths: /api/collections/{collection_id}/upload_images: post: + operationId: uploadImagesToCollection + tags: [Collections] summary: Upload images to a collection - description: Upload one or more images to a collection with optional descriptions + description: | + Upload one or more images to a collection. Only `image/*` content types are accepted. + Optionally provide a parallel `descriptions[]` array — each entry maps to the image at the same index. parameters: - in: path name: collection_id @@ -203,8 +284,10 @@ paths: /api/images/{image_id}: delete: + operationId: deleteImage + tags: [Images] summary: Delete an image - description: Delete a specific image from a collection + description: Permanently delete a single image from its collection. parameters: - in: path name: image_id @@ -223,8 +306,10 @@ paths: /api/files: get: + operationId: listFiles + tags: [Files] summary: List all uploaded files - description: Retrieve a paginated list of all uploaded files + description: Retrieve a paginated list of all uploaded files. parameters: - in: query name: page @@ -240,8 +325,12 @@ paths: $ref: '#/components/schemas/FileList' post: + operationId: uploadFiles + tags: [Files] summary: Upload one or more files - description: Upload one or multiple files. Files are stored in FILE_UPLOADS_FOLDER on the server. + description: | + Upload one or multiple files via `multipart/form-data`. Files are stored server-side + in the configured `FILE_UPLOADS_FOLDER`. Returns an array of `FileUpload` objects. requestBody: required: true content: @@ -271,7 +360,10 @@ paths: /api/files/{id}: get: + operationId: getFile + tags: [Files] summary: Get file details + description: Fetch metadata for a single file by its UUID. parameters: - in: path name: id @@ -290,8 +382,10 @@ paths: description: File not found delete: + operationId: deleteFile + tags: [Files] summary: Delete a file - description: Deletes the file record and removes it from disk + description: Permanently delete the file record and remove it from disk. parameters: - in: path name: id @@ -307,8 +401,12 @@ paths: /api/files/{id}/download: get: + operationId: downloadFile + tags: [Files] summary: Download or view a file - description: Serves the file content. Images are served inline; other files as attachments. + description: | + Serve the raw file bytes. Images are returned with `Content-Disposition: inline` + (viewable in browser); all other files use `attachment` (triggers download). parameters: - in: path name: id @@ -324,8 +422,14 @@ paths: /api/files/{id}/toggle-public: post: + operationId: toggleFilePublic + tags: [Files] summary: Toggle public/private visibility - description: Makes a private file public (generating a public token) or makes a public file private immediately. + description: | + Toggle a file between public and private: + - **Private → Public**: generates a `public_token` and returns a `public_url` + (`/public/files/{token}/`) that anyone can access without authentication. + - **Public → Private**: immediately revokes the token; existing `public_url` links stop working. parameters: - in: path name: id @@ -353,7 +457,12 @@ paths: /api/files/{id}/set-expiry: post: + operationId: setFileExpiry + tags: [Files] summary: Set or clear expiry date for a public file + description: | + Set an expiry datetime for a public file. After the expiry the `public_url` returns 404. + Send `expires_at: null` (or omit the field) to remove the expiry and make the link permanent. parameters: - in: path name: id @@ -383,8 +492,12 @@ paths: /public/files/{token}/: get: + operationId: getPublicFile + tags: [Files] summary: Access a public file by token - description: Serves a publicly shared file. Returns 404 if the file is private or the link has expired. + description: | + Unauthenticated endpoint. Serves a publicly shared file by its opaque token. + Returns 404 if the file has been made private or if the expiry date has passed. parameters: - in: path name: token @@ -399,6 +512,8 @@ paths: /api/images/random/: get: + operationId: getRandomImage + tags: [Images] summary: Get a random image description: | Returns a randomly selected image from the server's image library, resized to the @@ -406,8 +521,6 @@ paths: **Cache busting:** Add a `?v={timestamp}` query parameter to prevent browsers from caching repeated calls (the server ignores this parameter). - tags: - - Images parameters: - in: query name: fit @@ -454,6 +567,8 @@ paths: /api/images/random/{width}/{height}/: get: + operationId: getRandomImageSized + tags: [Images] summary: Get a random image at specific dimensions description: | Returns a randomly selected image resized to `{width}×{height}` pixels. @@ -466,8 +581,6 @@ paths: ``` ![Random image](http://your-domain/api/images/random/300/450/?fit=crop&v=1234567890) ``` - tags: - - Images parameters: - in: path name: width @@ -531,6 +644,8 @@ paths: /import/images/{image_url}: get: + operationId: importExternalImage + tags: [Files] summary: Import and cache an external image description: | Imports an external image by URL and saves it to the local file store. @@ -554,8 +669,6 @@ paths: ``` ![Alt text](http://your-domain/import/images/example.com/path/to/image.jpg) ``` - tags: - - Files parameters: - in: path name: image_url From f6f115690b50d4c142757c8f147f791c0a0a64e6 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 15:29:52 +1100 Subject: [PATCH 25/95] Fix llms.txt/ai-plugin.json URL routing - Move routes to top of urlpatterns (before catch-all alias resolver) - Serve directly from committed static/ dir instead of staticfiles/ (STATIC_ROOT) so they work without collectstatic being run - Eliminates APPEND_SLASH redirect to alias catch-all when file not in staticfiles/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/urls.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/core/urls.py b/core/urls.py index a3d1287..54f3c3d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,12 +4,30 @@ from django.conf import settings from django.conf.urls.static import static from django.views.static import serve from django.views.generic import TemplateView +from django.http import FileResponse, Http404 from links.views import LinkDetailView, LinkUpdateView, CustomLinkView from links.file_views import PublicFileView, import_image_view from django.urls import path, include, re_path from django.conf.urls.i18n import i18n_patterns +import os + + +def _serve_static_file(filename, content_type): + """Serve a file directly from the committed static/ directory.""" + def view(request): + path = os.path.join(settings.BASE_DIR, 'static', filename) + try: + return FileResponse(open(path, 'rb'), content_type=content_type) + except FileNotFoundError: + raise Http404 + return view + urlpatterns = [ + # LLM-friendly discovery endpoints — must be FIRST before any catch-all routes + path('llms.txt', _serve_static_file('llms.txt', 'text/plain; charset=utf-8'), name='llms-txt'), + path('.well-known/ai-plugin.json', _serve_static_file('.well-known/ai-plugin.json', 'application/json'), name='ai-plugin-json'), + path('admin/', admin.site.urls), # Add API URLs before locale URLs path('api/', include('links.api_urls')), # New line for API routes @@ -42,10 +60,6 @@ urlpatterns = [ name='public-file', ), - # LLM-friendly discovery endpoints - path('llms.txt', serve, {'document_root': settings.STATIC_ROOT, 'path': 'llms.txt'}), - path('.well-known/ai-plugin.json', serve, {'document_root': settings.STATIC_ROOT, 'path': '.well-known/ai-plugin.json'}), - # Include main app URLs with locale path('', include('links.urls')), ] From f4d36e69534d9abc1dc7966a4b1cf8f7e7b10daf Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 16:15:53 +1100 Subject: [PATCH 26/95] Add image imports --- core/apps.py | 11 ++- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/api_urls.py | 1 + links/api_views.py | 30 +++++++- links/file_views.py | 13 +++- links/serializers.py | 15 +++- links/tasks.py | 41 +++++++++++ links/templates/links/jobs.html | 97 +++++++++++++++++++++++-- links/views.py | 56 ++++++++++++++- static/llms.txt | 4 ++ static/openapi.yaml | 121 ++++++++++++++++++++++++++++++++ 11 files changed, 377 insertions(+), 12 deletions(-) diff --git a/core/apps.py b/core/apps.py index eceb15f..0125765 100644 --- a/core/apps.py +++ b/core/apps.py @@ -16,7 +16,7 @@ class CoreConfig(AppConfig): Initialize APScheduler when Django starts """ from core.scheduler import scheduler, start_scheduler - from links.tasks import schedule_pending_pages, schedule_pending_screenshots + from links.tasks import schedule_pending_pages, schedule_pending_screenshots, retry_stuck_image_imports from apscheduler.triggers.interval import IntervalTrigger # Start the scheduler @@ -49,3 +49,12 @@ class CoreConfig(AppConfig): replace_existing=True, ) logger.info(f"Scheduled periodic task: schedule_pending_screenshots (every {screenshots_interval}s)") + + # Add periodic job for retrying stuck image imports (every 5 minutes) + scheduler.add_job( + retry_stuck_image_imports, + trigger=IntervalTrigger(seconds=300), + id='retry_stuck_image_imports', + replace_existing=True, + ) + logger.info("Scheduled periodic task: retry_stuck_image_imports (every 300s)") diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 1df67b1cef2c51dfec07779af731e36b949943d4..093307bb20ae5281d8e4942665a830f5db394cfd 100644 GIT binary patch delta 3180 zcmb_eU2GiH6`t9>v$M0Cotqz;K!}r=(Bc%vdw2fF5TTWv#8TPdJft>FkaK6|?vL3Y zXLpRFzAR&*Ril)5Osal~BwGk+Y4G1LE2P&aT2nivmq5>)pksK$qLPA0zaA!A8 zc5Meom9@I(YIW|p_ni68`ObOfbl)?l`(D_(F*d+3%rspO(0{l&Ea8%~E zKd2~U$ME<}HY@)oS9~;It5>kMXL73Wiz=>=Jr+X8=}PVcKI|AYz4FuE@v$ee*}X-# zzRaME>jjeVBVd+ii=H>t7)qxdQYLl<=Sz6dsa4W5Q<77wdZg;5r>Dv_>^9OelGSuZ zPLpz(NO@eXmZ}A*Q7lzSL&Bcq66}`gA8D#qZ+dlH@}!10?UrhxvQYDixFX>hTz4B% zc$1VFqyY$rfp6XPp-{)aE#V3-(5*^V^J8!5$G#hWjFt)>E|nWn!@*@T;D(pSL6l+n zP{cyPg#v^k8j46X$_Q_cM)^@T-in`&UVD5aFD4lH4m<%5i2oH|5f6%q^I7ywYX9D0 zj@j~fmPYqf10hwNz^*&ds7==$GBI5*Pk6P7e5p)2ABqO2OGuGqc_&haRDCF;4Qi$? zYgX&v_2_F~_Qj%15*W<7qWEg+-q=_0Nb-yT_|w8!U~t>$gZF@@Zzsf@@~i$R&7ycV z>%+X`$c~yl}wOBD))H$hYlWr&=&bW7J=*Pt8e zsBwX$C#Ogua_#PJpS7XP&$S^14a&&S6|_WkQ)6iD)Vz~S^W9`mG@Dec$(AM~TO*e0 zW?VaCE3)HeOhva96_b(So`mm;#@3Lf<{drb*a$n8tc)1>b|RH%)^x&QhaX$f=ZtL8 zUl%wu&`vZ|qZyrOdfr`DH#Z6`eunvyqO&8u=IHEO22Wu{kM9H-r6(hn#nR>g-vX)J z*jRrw&M<5?%LK~%!?m8J+weqTEIQbfI z<`ZNYj%B(?b~6+^N=k~c1ii>tXOqhyQp`b9H!VfEoklYj!Mj~2!v9w*y3G;5Q5SnP387?bnSxq;s>xZxc3S;z(#|~D?oz> zqN$uj4+u`Dq8?lT&#|fC!U9kMZTjy9UoU{S*83>Zd(YplfND^>0yYQeD%i2!@8?pv z2WVkZifO-7xe6v|GW_!@Ft;YfNk)7_{7jsLyWj}C2wSj1M~B~u&(p!-F|m1nG{>$+ zvB~hzYF#A}9$GEch44@}HQcolH?+RL4glN7{hmoO?=$gp@eS+#$h^;g$e#k=MZL%u z$xX4ZU)s){Nj|&T-^_23B=+P)YmVa<*liE(yzf@8y>I8!xb75jeLzO4xx4kR2={gb z2>aP1tv_(wIJ?)s$M5I29FkanPpRsX%Vj23+4MDE&ttEaDI z6x29wYqHC;)^j{}e0*yX&UMDRzr%Oo1$Z8wgRjEBz&ZF2ijEIqIC~^RkqCv*I#=U< zeUw^#(6L$w2SGX#UV@*&kKu>#18VhGxDiB!&`Joc1XiHpD$f2-c0SufhF(UuEN*8z?)sOX6VmhuUIE!nL|@{W6+x^RSNByvqIbw xPA?g$R&Jo}I~%vZPwu(!j;4#?Pcnc120A=ywBrpSZ4l{tM$uZI$owz+egsL{!Jz;E delta 832 zcmaiyO>7Kd7{}jt=AE6{&g?ubTTR4vx2;5qY2TUI`PhoYMril2+Qvs+(B17;A}9$- z4-Tf9_CkW-jSJ<_;LxC{so=oHMItUj(x{IENv+ZZiH|Z{4sH(r%kz7lJpbpP=jcf6 z=t%73(nZ1?Lg;XyHGwJu&74|oaKhY;?u*TM{Qw=Jc!+YNl&@SA2`H*W2tK1d@EyLu zr}3)p*P!IXID{e?W3IpdRtGP3;IJ=^@ITrsLux02yD$Ws)KBWD+8Ok(RsGI+{`#vL zXh)(fpvZgmLS&hehn@0OS*Xa3r)j2Qmsck#+2*ZrUP36GPUnr3+hgQx+brnGoSRJ9 zNz-tW`Bbl4aC%Y>r8;$7OWUL6HNEM;{@tckH0*-w6kXeL^L#!EL%^xmO%|$qo!u)37Tp#{}O{z!>bb&7M>v&D=p6sDcP>Ab!AghI?X0$ z$TjdB5)``@ih)yh@E1w2=v&go_P!-a@Upy;)ql3zh2mHk!UV=C9 z0_NZyyeppsJmIC`scFHVnhrH~h+GD7&$tXzsCXiZkhie`P@k5Gq|*^pC!utN9Z~uH zYy$X9^w^I2D%9mw4>tUR+ct#a?995*P03RrjH converter only captures the path component; query params + # like ?format=jpg&name=900x900 end up in QUERY_STRING and must be re-attached. + query_string = request.META.get('QUERY_STRING', '') source_url = f'https://{image_url}' + if query_string: + source_url = f'{source_url}?{query_string}' url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20] + # Strip query string for filename derivation filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}' # Derive extension from filename; fall back to .jpg for bare names diff --git a/links/serializers.py b/links/serializers.py index b41ded0..b41098c 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -1,5 +1,18 @@ from rest_framework import serializers -from .models import Page, Post, ImageCollection, Image, Tag, FileUpload +from .models import Link, Page, Post, ImageCollection, Image, Tag, FileUpload + + +class LinkSerializer(serializers.ModelSerializer): + tags = serializers.SerializerMethodField() + + class Meta: + model = Link + fields = ['id', 'alias', 'original_url', 'description', 'link_type', + 'click_count', 'tags', 'created_at', 'updated_at'] + read_only_fields = ['id', 'click_count', 'created_at', 'updated_at'] + + def get_tags(self, obj): + return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()] class PageSerializer(serializers.ModelSerializer): class Meta: diff --git a/links/tasks.py b/links/tasks.py index de934d3..26e24d2 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -500,3 +500,44 @@ def download_and_save_image(file_upload_id): os.remove(dest_path) except OSError: pass + # Remove the stub record so a size=0 ghost doesn't appear in the file list. + # The next import request for the same URL will create a fresh stub and retry. + try: + FileUpload.objects.filter(pk=file_upload_id, size=0).delete() + logger.info(f"download_and_save_image: deleted stub FileUpload {file_upload_id} after failed download") + except Exception as del_exc: + logger.error(f"download_and_save_image: could not delete stub {file_upload_id}: {del_exc}") + + +def retry_stuck_image_imports(): + """Periodic task: retry imported images that are stuck with size=0 and no file on disk. + + A stub FileUpload (size=0, source_url set) can be left behind when the background + download thread is killed mid-flight (e.g. server restart). This task finds those + orphaned stubs and re-kicks the download, provided the stub is old enough that we + are confident it is not a currently in-progress download (>5 minutes since last update). + """ + from .models import FileUpload + + threshold = timezone.now() - timedelta(minutes=5) + stuck = FileUpload.objects.filter( + size=0, + source_url__isnull=False, + updated_at__lt=threshold, + ).exclude(source_url='') + + count = stuck.count() + if count: + logger.info(f"retry_stuck_image_imports: found {count} stuck import stub(s) — retrying") + + for record in stuck: + if os.path.exists(record.file_path): + # File landed on disk but DB wasn't updated — fix it now + size = os.path.getsize(record.file_path) + FileUpload.objects.filter(pk=record.pk, size=0).update(size=size) + logger.info(f"retry_stuck_image_imports: fixed size for FileUpload {record.pk} ({size} bytes)") + continue + + logger.info(f"retry_stuck_image_imports: re-queuing download for FileUpload {record.pk} ({record.source_url})") + thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) + thread.start() diff --git a/links/templates/links/jobs.html b/links/templates/links/jobs.html index 6285383..e52dd06 100644 --- a/links/templates/links/jobs.html +++ b/links/templates/links/jobs.html @@ -147,10 +147,19 @@ {{ scheduled_jobs|length }} + + {% trans "Image Imports" %} + + {{ stats.image_imports.total }} + + - {% if tab != 'scheduler' %} + {% if tab == 'screenshots' or tab == 'pages' %}
{% for s, label, pill_active_style, pill_inactive_style in filter_options %} {% endfor %}
+ {% elif tab == 'image_imports' %} + {% endif %} @@ -176,7 +194,7 @@ style="background:#1d4ed8;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;"> ↺ {% trans "Retry" %} - @@ -367,6 +385,71 @@ {% else %}
{% trans "No pages match this filter." %}
{% endif %} + + {% elif tab == 'image_imports' %} + + {% if page_obj.object_list %} +
+ + + + + + + + + + + + + + {% for imp in page_obj.object_list %} + + + + + + + + + + {% endfor %} + +
+ + ID{% trans "Filename" %}{% trans "Source URL" %}{% trans "Status" %}{% trans "Size" %}{% trans "Updated" %}
+ + {{ imp.id|truncatechars:12 }} + + {{ imp.name|truncatechars:40 }} + + + + {{ imp.source_url|truncatechars:50 }} + + + {% if imp.size > 0 %} + + {% trans "done" %} + + {% else %} + + {% trans "pending" %} + + {% endif %} + + {% if imp.size > 0 %}{{ imp.formatted_size }}{% else %}{% endif %} + {{ imp.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No image imports match this filter." %}
+ {% endif %} {% endif %} @@ -461,9 +544,13 @@ function jobsManager() { if (this.selectedIds.length === 0) return; const tab = this.tab; const actionMap = { - retry: tab === 'screenshots' ? 'bulk_retry_screenshots' : 'bulk_retry_pages', - fail: tab === 'screenshots' ? 'bulk_fail_screenshots' : 'bulk_fail_pages', - delete: tab === 'screenshots' ? 'bulk_delete_screenshots': 'bulk_delete_pages', + retry: tab === 'screenshots' ? 'bulk_retry_screenshots' + : tab === 'image_imports' ? 'bulk_retry_image_imports' + : 'bulk_retry_pages', + fail: tab === 'screenshots' ? 'bulk_fail_screenshots' : 'bulk_fail_pages', + delete: tab === 'screenshots' ? 'bulk_delete_screenshots' + : tab === 'image_imports' ? 'bulk_delete_image_imports' + : 'bulk_delete_pages', }; if (type === 'delete' && !confirm(`Delete ${this.selectedIds.length} item(s)?`)) return; document.getElementById('bulk-action-input').value = actionMap[type]; diff --git a/links/views.py b/links/views.py index 1ff1280..1a5fe0c 100644 --- a/links/views.py +++ b/links/views.py @@ -586,7 +586,8 @@ class JobsView(View): PAGE_SIZE = 50 def _get_stats(self): - from .models import Screenshot, Page + from .models import Screenshot, Page, FileUpload + imports_qs = FileUpload.objects.filter(source_url__isnull=False).exclude(source_url='') return { 'screenshots': { 'total': Screenshot.objects.count(), @@ -602,10 +603,15 @@ class JobsView(View): 'completed': Page.objects.filter(process_status=Page.ProcessStatus.COMPLETED).count(), 'failed': Page.objects.filter(process_status=Page.ProcessStatus.FAILED).count(), }, + 'image_imports': { + 'total': imports_qs.count(), + 'pending': imports_qs.filter(size=0).count(), + 'completed': imports_qs.filter(size__gt=0).count(), + }, } def get(self, request): - from .models import Screenshot, Page, SiteSettings as SS + from .models import Screenshot, Page, FileUpload, SiteSettings as SS from core.scheduler import scheduler from django.core.paginator import Paginator @@ -623,6 +629,15 @@ class JobsView(View): if status_filter != 'all': pg_qs = pg_qs.filter(process_status=status_filter) + # Build image imports queryset + import_qs = FileUpload.objects.filter( + source_url__isnull=False, + ).exclude(source_url='').order_by('-updated_at') + if status_filter == 'pending': + import_qs = import_qs.filter(size=0) + elif status_filter == 'completed': + import_qs = import_qs.filter(size__gt=0) + # Paginate the active tab's queryset if tab == 'screenshots': paginator = Paginator(ss_qs, self.PAGE_SIZE) @@ -632,6 +647,10 @@ class JobsView(View): paginator = Paginator(pg_qs, self.PAGE_SIZE) page_obj = paginator.get_page(page_num) all_ids = [str(obj.id) for obj in page_obj.object_list] + elif tab == 'image_imports': + paginator = Paginator(import_qs, self.PAGE_SIZE) + page_obj = paginator.get_page(page_num) + all_ids = [str(obj.id) for obj in page_obj.object_list] else: page_obj = None all_ids = [] @@ -655,6 +674,11 @@ class JobsView(View): ('completed', 'Completed', 'background:#16a34a;color:#fff;', 'background:#f0fdf4;color:#166534;'), ('failed', 'Failed', 'background:#dc2626;color:#fff;', 'background:#fff1f2;color:#991b1b;'), ] + import_filter_options = [ + ('all', 'All', 'background:#1f2937;color:#fff;', 'background:#f3f4f6;color:#374151;'), + ('pending', 'Pending', 'background:#d97706;color:#fff;', 'background:#fef3c7;color:#92400e;'), + ('completed', 'Done', 'background:#16a34a;color:#fff;', 'background:#f0fdf4;color:#166534;'), + ] return render(request, self.template_name, { 'stats': self._get_stats(), @@ -666,6 +690,7 @@ class JobsView(View): 'scheduler_running': scheduler.running, 'site_settings': SS.get(), 'filter_options': filter_options, + 'import_filter_options': import_filter_options, }) def post(self, request): @@ -708,6 +733,33 @@ class JobsView(View): n = qs.delete()[0] messages.success(request, _(f'Deleted {n} page(s).')) + # ── Bulk image import actions ───────────────────────────────────── + elif action == 'bulk_retry_image_imports': + from .models import FileUpload + from .tasks import download_and_save_image + from threading import Thread as _Thread + qs = FileUpload.objects.filter(pk__in=ids) if ids else FileUpload.objects.none() + n = 0 + for record in qs: + _Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True).start() + n += 1 + messages.success(request, _(f'Retrying {n} image import(s).')) + + elif action == 'bulk_delete_image_imports': + from .models import FileUpload + import os as _os + qs = FileUpload.objects.filter(pk__in=ids) if ids else FileUpload.objects.none() + n = 0 + for record in qs: + if _os.path.exists(record.file_path): + try: + _os.remove(record.file_path) + except OSError: + pass + record.delete() + n += 1 + messages.success(request, _(f'Deleted {n} image import record(s).')) + else: messages.error(request, _('Unknown action.')) diff --git a/static/llms.txt b/static/llms.txt index e344a46..b3e9cd1 100644 --- a/static/llms.txt +++ b/static/llms.txt @@ -4,6 +4,8 @@ ## What you can do with this API +- **List short links** (`/api/links`) — GET a paginated list of all short links with their aliases, target URLs, tags, and click counts +- **Most visited links** (`/api/links/most-visited`) — GET links sorted by visit count descending (only links with at least one click) - **Bookmark pages** (`/api/pages`) — POST a URL and the server auto-fetches the title, description and screenshot in the background - **Write blog posts** (`/api/posts`) — Create Markdown posts with tag categorisation - **Manage image collections** (`/api/collections`) — Group images into named albums; upload multiple images at once @@ -43,6 +45,8 @@ No authentication is currently required. | operationId | Method | Path | Description | |---|---|---|---| +| listLinks | GET | /api/links | List all short links | +| listMostVisitedLinks | GET | /api/links/most-visited | Links sorted by visit count | | listPages | GET | /api/pages | List bookmarked pages | | createPage | POST | /api/pages | Bookmark a new URL | | listPosts | GET | /api/posts | List blog posts | diff --git a/static/openapi.yaml b/static/openapi.yaml index f84d7d4..e267e35 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -50,6 +50,8 @@ servers: description: Local development server tags: + - name: Links + description: Short links with click tracking. Supports listing all links and retrieving the most visited ones sorted by click count. - name: Pages description: | Bookmarked web pages. Creating a page triggers an async background job that fetches @@ -69,6 +71,62 @@ tags: Files can be made public/private and given an expiry date. paths: + /api/links: + get: + operationId: listLinks + tags: [Links] + summary: List all links + description: Retrieve a paginated list of all short links ordered by creation date (newest first). + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + description: Page number (1-based). + - in: query + name: page_size + schema: + type: integer + default: 10 + maximum: 100 + description: Number of results per page. + responses: + '200': + description: Paginated list of links. + content: + application/json: + schema: + $ref: '#/components/schemas/LinkList' + + /api/links/most-visited: + get: + operationId: listMostVisitedLinks + tags: [Links] + summary: Most visited links + description: Return links that have at least one click, sorted by click count descending. + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + description: Page number (1-based). + - in: query + name: page_size + schema: + type: integer + default: 10 + maximum: 100 + description: Number of results per page. + responses: + '200': + description: Paginated list of links sorted by click_count descending. + content: + application/json: + schema: + $ref: '#/components/schemas/LinkList' + /api/pages: get: operationId: listPages @@ -969,3 +1027,66 @@ components: enum: [success] message: type: string + + TagSummary: + type: object + properties: + id: + type: integer + name: + type: string + slug: + type: string + + Link: + type: object + properties: + id: + type: integer + readOnly: true + alias: + type: string + description: Unique slug used as the short-link identifier (e.g. /gh → github.com). + original_url: + type: string + description: Target URL. May contain template parameters like `{param,default=value}`. + description: + type: string + nullable: true + link_type: + type: string + enum: [LINK, CUSTOM] + description: LINK redirects to original_url; CUSTOM renders Markdown content. + click_count: + type: integer + readOnly: true + description: Number of times the short link has been visited. + tags: + type: array + readOnly: true + items: + $ref: '#/components/schemas/TagSummary' + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + + LinkList: + type: object + properties: + count: + type: integer + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/Link' From 7a7dedab88a119c6a0ea762c6d24f5b5db96e3fa Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 16:29:38 +1100 Subject: [PATCH 27/95] Update --- core/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/urls.py b/core/urls.py index 54f3c3d..762c147 100644 --- a/core/urls.py +++ b/core/urls.py @@ -50,8 +50,9 @@ urlpatterns = [ path('ui/netscan/', include('netscan.urls')), path('ui/files/', include('links.file_urls')), - # Import external image by URL — /import/images/ + # Import external image by URL — /import/images/ (also plural alias) path('import/images/', import_image_view, name='import-image'), + path('imports/images/', import_image_view, name='imports-image'), # Public file access — /public/files/{uuid}-{filename} re_path( From e0c1e75b45286e9aff39478ca63ce91fcdc1ef52 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 22 Mar 2026 16:34:34 +1100 Subject: [PATCH 28/95] Fix shareable link! --- links/templates/links/files/list.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html index e2b55d9..924e7e5 100644 --- a/links/templates/links/files/list.html +++ b/links/templates/links/files/list.html @@ -601,7 +601,7 @@ function fileRow(pk, isPublic, expiresAt, publicUrl) { }, formatExpiry(dt) { return dt ? new Date(dt).toLocaleDateString() : ''; }, copyPublicUrl() { - const url = location.origin + this.publicUrl; + const url = this.publicUrl.startsWith('http') ? this.publicUrl : location.origin + this.publicUrl; const el = document.createElement('textarea'); el.value = url; el.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); From fb005c563bb61ca4073d927fcd2d21f0b58d47bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:28:45 +0000 Subject: [PATCH 29/95] Bump requests from 2.32.4 to 2.33.0 Bumps [requests](https://github.com/psf/requests) from 2.32.4 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 89748cf..b095853 100644 --- a/uv.lock +++ b/uv.lock @@ -1096,7 +1096,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.4" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1104,9 +1104,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] From 67f451f9e9b5c441102d9c995cbc19cec763c411 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:11:03 +0000 Subject: [PATCH 30/95] Bump yaml from 2.5.1 to 2.8.3 in /new_theme/static_src Bumps [yaml](https://github.com/eemeli/yaml) from 2.5.1 to 2.8.3. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.5.1...v2.8.3) --- updated-dependencies: - dependency-name: yaml dependency-version: 2.8.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- new_theme/static_src/package-lock.json | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/new_theme/static_src/package-lock.json b/new_theme/static_src/package-lock.json index d68ad73..3160e05 100644 --- a/new_theme/static_src/package-lock.json +++ b/new_theme/static_src/package-lock.json @@ -1523,15 +1523,18 @@ } }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } } }, @@ -2575,9 +2578,9 @@ } }, "yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true } } From 20745ae2163688d8c8acaba186687e1a55edc328 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:00:04 +0000 Subject: [PATCH 31/95] Bump yaml from 2.6.0 to 2.8.3 Bumps [yaml](https://github.com/eemeli/yaml) from 2.6.0 to 2.8.3. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.6.0...v2.8.3) --- updated-dependencies: - dependency-name: yaml dependency-version: 2.8.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19520aa..353c636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1318,14 +1318,18 @@ } }, "node_modules/yaml": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", - "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } } } From 3c57f4afb9761f3d19a48d1ed93e1b092456461d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:17:34 +0000 Subject: [PATCH 32/95] Allow static Unicode text mixed with template params in template URLs Agent-Logs-Url: https://github.com/wahyd4/links/sessions/b1b352f0-55a8-4973-9df9-5e1f6a8e3768 Co-authored-by: wahyd4 <836576+wahyd4@users.noreply.github.com> --- links/forms.py | 8 ++++- tests/test_links_api.py | 74 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/links/forms.py b/links/forms.py index 6406f34..ddd969f 100644 --- a/links/forms.py +++ b/links/forms.py @@ -3,6 +3,7 @@ from .models import Link, Page, Post, ImageCollection, Image, Tag from simplemde.fields import SimpleMDEField from django.utils.translation import gettext_lazy as _ from django.core.validators import URLValidator +from urllib.parse import quote import re class LinkForm(forms.ModelForm): @@ -32,8 +33,13 @@ class LinkForm(forms.ModelForm): temp_url = temp_url.replace(param_full, 'template-param') # Validate the URL with placeholders + # Encode non-ASCII characters and spaces so that static Unicode text + # (e.g. Chinese characters) mixed with template params passes URLValidator. + # The safe string keeps all standard URL characters intact and includes '%' + # to avoid double-encoding any already percent-encoded sequences. try: - URLValidator()(temp_url) + encoded_url = quote(temp_url, safe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%") + URLValidator()(encoded_url) except forms.ValidationError: raise forms.ValidationError(_("Please enter a valid URL. Template parameters are allowed in the format {param_name,default=value}.")) diff --git a/tests/test_links_api.py b/tests/test_links_api.py index fcf3089..e84820c 100644 --- a/tests/test_links_api.py +++ b/tests/test_links_api.py @@ -63,3 +63,77 @@ class TestLinksUI: response = client.get("/wiki/Django/") assert response.status_code == 302 assert "Django" in response["Location"] + + +@pytest.mark.django_db +class TestTemplateLinkFormValidation: + """Tests for template URL validation in LinkForm, including static Unicode text.""" + + def test_create_link_with_unicode_prefix_in_template_url(self, client): + """Static Chinese text before a template param should be accepted.""" + original_url = 'https://chatgpt.com/?q=翻译下面的段落为中文: {query,default=Cool beans}' + response = client.post( + "/create/", + { + "alias": "chatgpt-translate", + "original_url": original_url, + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert response.status_code == 200 + link = Link.objects.get(alias="chatgpt-translate") + assert link.original_url == original_url + + def test_create_link_with_unicode_suffix_in_template_url(self, client): + """Static Chinese text after a template param should be accepted.""" + original_url = 'https://chatgpt.com/?q={query,default=Cool beans},保证真实性' + response = client.post( + "/create/", + { + "alias": "chatgpt-verify", + "original_url": original_url, + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert response.status_code == 200 + link = Link.objects.get(alias="chatgpt-verify") + assert link.original_url == original_url + + def test_create_link_with_unicode_around_template_param(self, client): + """Static Unicode text both before and after a template param should be accepted.""" + original_url = 'https://chatgpt.com/?q=翻译: {query,default=hello} 保证真实性' + response = client.post( + "/create/", + { + "alias": "chatgpt-both", + "original_url": original_url, + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert response.status_code == 200 + link = Link.objects.get(alias="chatgpt-both") + assert link.original_url == original_url + + def test_create_link_with_invalid_url_still_rejected(self, client): + """A completely invalid URL (no scheme/host) must still be rejected.""" + response = client.post( + "/create/", + { + "alias": "bad-url", + "original_url": "not-a-url-at-all", + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert not Link.objects.filter(alias="bad-url").exists() From 62553b5414c45829826649880b0a41f229be3b1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:29:32 +0000 Subject: [PATCH 33/95] Fix is_valid_alias to accept slug characters (hyphens and underscores) Agent-Logs-Url: https://github.com/wahyd4/links/sessions/6f09a469-cf40-4dfe-bafb-9382998551ad Co-authored-by: wahyd4 <836576+wahyd4@users.noreply.github.com> --- links/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/links/views.py b/links/views.py index 1a5fe0c..69b7d90 100644 --- a/links/views.py +++ b/links/views.py @@ -137,7 +137,7 @@ class LinkCreateView(CreateView): return super().form_valid(form) def is_valid_alias(self, alias): - return alias.isalnum() + return bool(re.match(r'^[a-zA-Z0-9_-]+$', alias)) class LinkUpdateView(UpdateView): model = Link @@ -218,7 +218,7 @@ class LinkUpdateView(UpdateView): return reverse('link_detail', kwargs={'pk': self.object.pk}) def is_valid_alias(self, alias): - return alias.isalnum() + return bool(re.match(r'^[a-zA-Z0-9_-]+$', alias)) class LinkDeleteView(DeleteView): model = Link From 675ad330c010f120c1e6b242bf58fe3efebc2d89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:32:56 +0000 Subject: [PATCH 34/95] Bump brace-expansion, sucrase and rimraf in /new_theme/static_src Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) to 5.0.5 and updates ancestor dependencies [brace-expansion](https://github.com/juliangruber/brace-expansion), [sucrase](https://github.com/alangpierce/sucrase) and [rimraf](https://github.com/isaacs/rimraf). These dependencies need to be updated together. Updates `brace-expansion` from 2.0.2 to 5.0.5 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v5.0.5) Updates `sucrase` from 3.35.0 to 3.35.1 - [Changelog](https://github.com/alangpierce/sucrase/blob/main/CHANGELOG.md) - [Commits](https://github.com/alangpierce/sucrase/commits) Updates `rimraf` from 5.0.10 to 6.1.3 - [Changelog](https://github.com/isaacs/rimraf/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/rimraf/compare/v5.0.10...v6.1.3) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.5 dependency-type: indirect - dependency-name: sucrase dependency-version: 3.35.1 dependency-type: indirect - dependency-name: rimraf dependency-version: 6.1.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- new_theme/static_src/package-lock.json | 938 +++++++------------------ new_theme/static_src/package.json | 2 +- 2 files changed, 236 insertions(+), 704 deletions(-) diff --git a/new_theme/static_src/package-lock.json b/new_theme/static_src/package-lock.json index 3160e05..38bbdc5 100644 --- a/new_theme/static_src/package-lock.json +++ b/new_theme/static_src/package-lock.json @@ -18,7 +18,7 @@ "postcss-import": "^15.1.0", "postcss-nested": "^6.0.1", "postcss-simple-vars": "^7.0.1", - "rimraf": "^5.0.5", + "rimraf": "^6.1.3", "tailwindcss": "^3.4.0" } }, @@ -34,23 +34,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -134,16 +117,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@tailwindcss/aspect-ratio": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz", @@ -180,30 +153,6 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" } }, - "node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -229,12 +178,6 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -247,15 +190,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -313,24 +247,6 @@ "node": ">= 6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -396,18 +312,6 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -457,22 +361,6 @@ "node": ">=8" } }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -496,26 +384,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -576,15 +444,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -612,21 +471,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", @@ -669,12 +513,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -706,25 +544,10 @@ "mini-svg-data-uri": "cli.js" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -787,9 +610,9 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, "node_modules/path-key": { @@ -807,22 +630,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1112,16 +919,98 @@ } }, "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "dependencies": { - "glob": "^10.3.7" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -1170,18 +1059,6 @@ "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1191,114 +1068,18 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -1392,6 +1173,51 @@ "node": ">=0.8" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1431,97 +1257,6 @@ "node": ">= 8" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -1545,20 +1280,6 @@ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true }, - "@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "requires": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - } - }, "@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -1624,13 +1345,6 @@ "fastq": "^1.6.0" } }, - "@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true - }, "@tailwindcss/aspect-ratio": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz", @@ -1659,18 +1373,6 @@ "postcss-selector-parser": "6.0.10" } }, - "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true - }, - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, "any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1693,27 +1395,12 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, "binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true }, - "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, "braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1756,21 +1443,6 @@ } } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1815,18 +1487,6 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, - "eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, "fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -1869,16 +1529,6 @@ "to-regex-range": "^5.0.1" } }, - "foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - } - }, "fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1892,20 +1542,6 @@ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true }, - "glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - } - }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1948,12 +1584,6 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1975,16 +1605,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "requires": { - "@isaacs/cliui": "^8.0.2", - "@pkgjs/parseargs": "^0.11.0" - } - }, "jiti": { "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", @@ -2021,12 +1641,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2049,19 +1663,10 @@ "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", "dev": true }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - }, "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true }, "mz": { @@ -2100,9 +1705,9 @@ "dev": true }, "package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, "path-key": { @@ -2117,16 +1722,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "requires": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - } - }, "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2286,12 +1881,66 @@ "dev": true }, "rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "requires": { - "glob": "^10.3.7" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true + }, + "minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.2" + } + }, + "path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + } } }, "run-parallel": { @@ -2318,101 +1967,24 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true - }, "source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, - "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "requires": { - "ansi-regex": "^6.0.1" - } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - } - } - }, "sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" } }, @@ -2482,6 +2054,31 @@ "thenify": ">= 3.1.0 < 4" } }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2512,71 +2109,6 @@ "isexe": "^2.0.0" } }, - "wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "requires": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - } - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, "yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/new_theme/static_src/package.json b/new_theme/static_src/package.json index 8e267de..dda7fe1 100644 --- a/new_theme/static_src/package.json +++ b/new_theme/static_src/package.json @@ -22,7 +22,7 @@ "postcss-import": "^15.1.0", "postcss-nested": "^6.0.1", "postcss-simple-vars": "^7.0.1", - "rimraf": "^5.0.5", + "rimraf": "^6.1.3", "tailwindcss": "^3.4.0", "cross-spawn": ">=7.0.5" } From 609df9ddb5a1d56aa0abb0050a0ad6b5c91cfc9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:36:00 +0000 Subject: [PATCH 35/95] Run tests on pull requests; skip deploy for non-push events Agent-Logs-Url: https://github.com/wahyd4/links/sessions/11e06463-5bc6-44f9-831d-da156cb4977f Co-authored-by: wahyd4 <836576+wahyd4@users.noreply.github.com> --- .github/workflows/build-and-deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 549f4f8..0df0764 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -3,6 +3,7 @@ name: Build and Deploy to Kubernetes on: push: branches: [ main ] + pull_request: env: REGISTRY: ghcr.io @@ -37,6 +38,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest needs: test + if: github.event_name == 'push' permissions: contents: read packages: write From 22d33f4e93696ae39c4e4cc27867a2f798e322a9 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Fri, 27 Mar 2026 11:46:47 +1100 Subject: [PATCH 36/95] Upgrade GitHub Actions to latest versions Updated GitHub Actions to use newer versions of actions for checkout, setup-uv, login, metadata, and build-push. --- .github/workflows/build-and-deploy.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 0df0764..1c7e301 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v7 with: version: "latest" @@ -45,10 +45,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -56,13 +56,13 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # Build and push main application image - name: Build and push Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile From 5607bb6366294fdf3a6e8a74e3ddacdc793fdf5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:47:25 +0000 Subject: [PATCH 37/95] Bump picomatch from 2.3.1 to 2.3.2 in /new_theme/static_src Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- new_theme/static_src/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/new_theme/static_src/package-lock.json b/new_theme/static_src/package-lock.json index 3160e05..4041a80 100644 --- a/new_theme/static_src/package-lock.json +++ b/new_theme/static_src/package-lock.json @@ -830,9 +830,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" @@ -2134,9 +2134,9 @@ "dev": true }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, "pify": { From 6d9d08d13296ea2a0d3c5d89d494f6cf905d7391 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Fri, 27 Mar 2026 11:51:29 +1100 Subject: [PATCH 38/95] Update checkout action to version 6 --- .github/workflows/build-and-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 1c7e301..97bc195 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 From 768ab6c5d8cf3efcff7162d849722fb132c51321 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:12:04 +0000 Subject: [PATCH 39/95] feat: add build time and version info to Docker image and page footer Agent-Logs-Url: https://github.com/wahyd4/links/sessions/514d1f3d-d003-4f16-9a54-d221dba306f7 Co-authored-by: wahyd4 <836576+wahyd4@users.noreply.github.com> --- .github/workflows/build-and-deploy.yml | 7 +++++++ Dockerfile | 6 ++++++ core/context_processors.py | 8 ++++++++ core/settings.py | 5 +++++ templates/base.html | 5 +++++ 5 files changed, 31 insertions(+) create mode 100644 core/context_processors.py diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 97bc195..738ac1f 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -60,6 +60,10 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Set build timestamp + id: build_time + run: echo "value=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT + # Build and push main application image - name: Build and push Docker image uses: docker/build-push-action@v6 @@ -72,6 +76,9 @@ jobs: ghcr.io/wahyd4/links:1.0.${{ github.run_number }} labels: |- ${{ steps.meta.outputs.labels }} + build-args: |- + BUILD_TIME=${{ steps.build_time.outputs.value }} + BUILD_VERSION=1.0.${{ github.run_number }} - name: Deploy to Kubernetes uses: wahyd4/kubectl-helm-action@master diff --git a/Dockerfile b/Dockerfile index 9ffbc46..d156831 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,6 +73,10 @@ RUN rm -rf /tmp/.cache/uv ~/.npm ~/.cache \ # Production stage - use Python 3.12 slim FROM python:3.12-slim AS production +# Build-time metadata +ARG BUILD_TIME="" +ARG BUILD_VERSION="" + # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -81,6 +85,8 @@ ENV VIRTUAL_ENV=/app/.venv ENV PATH="/app/.venv/bin:$PATH" ENV DEBIAN_FRONTEND=noninteractive ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright +ENV BUILD_TIME=${BUILD_TIME} +ENV BUILD_VERSION=${BUILD_VERSION} # Set work directory WORKDIR /app diff --git a/core/context_processors.py b/core/context_processors.py new file mode 100644 index 0000000..d9287ef --- /dev/null +++ b/core/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def build_info(request): + return { + 'BUILD_TIME': getattr(settings, 'BUILD_TIME', ''), + 'BUILD_VERSION': getattr(settings, 'BUILD_VERSION', ''), + } diff --git a/core/settings.py b/core/settings.py index 8a7dc44..54281b7 100644 --- a/core/settings.py +++ b/core/settings.py @@ -36,6 +36,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'core.context_processors.build_info', ], }, }, @@ -207,6 +208,10 @@ R2_CUSTOM_DOMAIN = os.environ.get('R2_CUSTOM_DOMAIN') CRAWL4AI_API_URL = os.environ.get('CRAWL4AI_API_URL', 'https://crawl-api.junv.cc') CRAWL4AI_ENABLED = os.environ.get('CRAWL4AI_ENABLED', 'True').lower() in ('true', '1', 'yes') +# Build metadata (injected at Docker image build time) +BUILD_TIME = os.environ.get('BUILD_TIME', '') +BUILD_VERSION = os.environ.get('BUILD_VERSION', '') + # For debugging LOGGING = { 'version': 1, diff --git a/templates/base.html b/templates/base.html index 3b6fc21..f42ad21 100644 --- a/templates/base.html +++ b/templates/base.html @@ -252,5 +252,10 @@ }); {% block extra_js %}{% endblock %} + {% if BUILD_TIME %} +
+ Built at: {{ BUILD_TIME }} with ver: {{ BUILD_VERSION }} +
+ {% endif %} From c26c620f78baea459d8d1565d4215f1fde9264bf Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 29 Mar 2026 10:23:42 +1100 Subject: [PATCH 40/95] Add redis caching --- core/apps.py | 14 ++++- core/settings.py | 18 ++++++ k8s/manifest.yaml | 2 + links/page_views.py | 15 +++-- links/search_views.py | 16 +++++- links/tasks.py | 100 ++++++++++++++++++++++++++++++---- links/views.py | 124 ++++++++++++++++++++++++++---------------- pyproject.toml | 1 + tests/conftest.py | 11 ++++ uv.lock | 24 ++++++++ 10 files changed, 260 insertions(+), 65 deletions(-) diff --git a/core/apps.py b/core/apps.py index 0125765..ea6bcce 100644 --- a/core/apps.py +++ b/core/apps.py @@ -16,7 +16,10 @@ class CoreConfig(AppConfig): Initialize APScheduler when Django starts """ from core.scheduler import scheduler, start_scheduler - from links.tasks import schedule_pending_pages, schedule_pending_screenshots, retry_stuck_image_imports + from links.tasks import ( + schedule_pending_pages, schedule_pending_screenshots, + retry_stuck_image_imports, flush_click_buffer, + ) from apscheduler.triggers.interval import IntervalTrigger # Start the scheduler @@ -58,3 +61,12 @@ class CoreConfig(AppConfig): replace_existing=True, ) logger.info("Scheduled periodic task: retry_stuck_image_imports (every 300s)") + + # Flush Redis-buffered click counts to the database (every 60 seconds) + scheduler.add_job( + flush_click_buffer, + trigger=IntervalTrigger(seconds=60), + id='flush_click_buffer', + replace_existing=True, + ) + logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") diff --git a/core/settings.py b/core/settings.py index 54281b7..0d18a76 100644 --- a/core/settings.py +++ b/core/settings.py @@ -104,6 +104,24 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',') +# Redis cache +REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + 'TIMEOUT': 300, + } +} + +# Store sessions in Redis instead of the database +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +SESSION_CACHE_ALIAS = 'default' + # SimpleMDE 配置 SIMPLEMDE_OPTIONS = { 'placeholder': 'Type here...', diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index a6afb2f..84e6c27 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -158,6 +158,8 @@ spec: secretKeyRef: name: r2-credentials key: key_id + - name: REDIS_URL + value: "redis://redis.db.svc.cluster.local:6379/0" - name: CRAWL4AI_API_URL value: "http://crawl4ai.ai.svc.cluster.local:80" - name: CRAWL4AI_ENABLED diff --git a/links/page_views.py b/links/page_views.py index 27b08aa..50eb334 100644 --- a/links/page_views.py +++ b/links/page_views.py @@ -6,6 +6,7 @@ from django.contrib import messages from django.utils.translation import gettext_lazy as _ from django.core.files.base import ContentFile from django.conf import settings +from django.core.cache import cache from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response @@ -142,6 +143,13 @@ def fetch_page_info(request): url = url.lstrip('@') + # Return cached metadata if available (24-hour TTL) + import hashlib as _hashlib + cache_key = 'pageinfo:' + _hashlib.md5(url.encode()).hexdigest() + cached = cache.get(cache_key) + if cached is not None: + return JsonResponse(cached) + try: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', @@ -193,10 +201,9 @@ def fetch_page_info(request): description = re.sub(r'\s+', ' ', description.strip()) description = description[:500] + '...' if len(description) > 500 else description - return JsonResponse({ - 'title': title, - 'summary': description - }) + result = {'title': title, 'summary': description} + cache.set(cache_key, result, timeout=86400) + return JsonResponse(result) except Exception as e: return JsonResponse({ diff --git a/links/search_views.py b/links/search_views.py index 13c06fe..1a18c9f 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -10,6 +10,8 @@ from .models import Link, Page, Post from .search_backend import search_backend from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import csrf_exempt +from django.core.cache import cache +import hashlib import json import requests import os @@ -62,7 +64,7 @@ def search_vector_api(request): 'score': hit.score, 'tags': p.get('tags', []) }) - + return JsonResponse({'results': results, 'total': len(results)}) except Exception as e: logger.error(f"Vector search error: {e}") @@ -154,6 +156,14 @@ def search_api_v2(request): per_page = int(request.GET.get('per_page', 20)) if not query: return JsonResponse({'results': [], 'total': 0, 'page': page, 'per_page': per_page, 'has_next': False, 'has_prev': False}) + + cache_key = 'search:v2:' + hashlib.md5( + f'{query}:{type_filter}:{sort_by}:{page}:{per_page}'.encode() + ).hexdigest() + cached = cache.get(cache_key) + if cached is not None: + return JsonResponse(cached) + try: search_results = search_backend.search(query_string=query, model_type=type_filter if type_filter else None, page=page, per_page=per_page, sort_by=sort_by) enriched_results = [] @@ -173,7 +183,9 @@ def search_api_v2(request): except Exception as e: logger.error(f"Error enriching {model_type} {model_id}: {e}") continue - return JsonResponse({'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']}) + result_payload = {'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']} + cache.set(cache_key, result_payload, timeout=300) + return JsonResponse(result_payload) except Exception as e: logger.error(f"Search API error: {e}", exc_info=True) return JsonResponse({'error': str(e), 'results': [], 'total': 0}, status=500) diff --git a/links/tasks.py b/links/tasks.py index 26e24d2..9e6130b 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -295,7 +295,7 @@ def process_page(page_id, retry_count=0): else: page.process_status = Page.ProcessStatus.PENDING page.save() - + # Schedule retry using APScheduler retry_delay = fibonacci(retry_count) from core.scheduler import scheduler @@ -390,43 +390,43 @@ def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0): Fetch webpage content using Crawl4AI /md endpoint """ from .models import Page - + if not settings.CRAWL4AI_ENABLED: logger.info("Crawl4AI is disabled, skipping webpage content extraction") return - + try: page = Page.objects.get(id=page_id) logger.info(f"Fetching webpage content for page {page_id} from Crawl4AI") - + # Call Crawl4AI /md endpoint api_url = f"{settings.CRAWL4AI_API_URL}/md" payload = { "url": page.url } - + response = requests.post( api_url, json=payload, timeout=60 ) response.raise_for_status() - + data = response.json() - + # Extract markdown content from response markdown_content = data.get('markdown', '') - + if markdown_content: page.content = markdown_content page.save() logger.info(f"Successfully fetched webpage content for page {page_id}") else: logger.warning(f"No markdown content returned for page {page_id}") - + except requests.RequestException as e: logger.error(f"Failed to fetch webpage content for page {page_id}: {e}") - + # Retry logic if retry_count < MAX_RETRIES: retry_delay = fibonacci(retry_count + 1) @@ -509,6 +509,86 @@ def download_and_save_image(file_upload_id): logger.error(f"download_and_save_image: could not delete stub {file_upload_id}: {del_exc}") +def flush_click_buffer(): + """Periodic task: flush Redis-buffered click counts into the database. + + redirect_to_original increments Redis keys of the form 'clicks:{link_id}:{date}' + instead of writing a ClickLog row per hit. This task drains those keys every ~60s + and bulk-inserts the ClickLog records plus updates Link.click_count in one shot. + """ + from .models import Link, ClickLog + from django_redis import get_redis_connection + + try: + redis_conn = get_redis_connection('default') + except Exception as e: + logger.error(f'flush_click_buffer: cannot get Redis connection: {e}') + return + + cursor = 0 + pattern = 'clicks:*' + # django-redis stores keys with a prefix like ':1:' — match it broadly + prefix = redis_conn.connection_pool.connection_kwargs.get('db', 0) + all_keys = [] + while True: + cursor, keys = redis_conn.scan(cursor, match=f'*clicks:*', count=200) + all_keys.extend(keys) + if cursor == 0: + break + + if not all_keys: + return + + logs_to_create = [] + link_count_deltas = {} # {link_id: total_clicks} + + pipeline = redis_conn.pipeline() + for key in all_keys: + pipeline.getdel(key) + counts = pipeline.execute() + + for raw_key, count_bytes in zip(all_keys, counts): + if not count_bytes: + continue + count = int(count_bytes) + if count <= 0: + continue + try: + # Key format: 'clicks:{link_id}:{date}' + key_str = raw_key.decode() if isinstance(raw_key, bytes) else raw_key + # Strip any cache key prefix (':1:clicks:...' → 'clicks:...') + parts = key_str.rsplit('clicks:', 1) + if len(parts) != 2: + continue + remainder = parts[1] # '{link_id}:{date}' + link_id_str, date_str = remainder.split(':', 1) + link_id = int(link_id_str) + except (ValueError, IndexError): + logger.warning(f'flush_click_buffer: unrecognised key format: {raw_key}') + continue + + link_count_deltas[link_id] = link_count_deltas.get(link_id, 0) + count + try: + click_date = timezone.datetime.strptime(date_str, '%Y-%m-%d').replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError: + click_date = timezone.now() + + for _ in range(count): + logs_to_create.append(ClickLog(link_id=link_id, clicked_at=click_date)) + + if logs_to_create: + ClickLog.objects.bulk_create(logs_to_create, ignore_conflicts=True) + logger.info(f'flush_click_buffer: inserted {len(logs_to_create)} ClickLog rows') + + for link_id, delta in link_count_deltas.items(): + Link.objects.filter(pk=link_id).update(click_count=F('click_count') + delta) + + if link_count_deltas: + logger.info(f'flush_click_buffer: updated click_count for {len(link_count_deltas)} link(s)') + + def retry_stuck_image_imports(): """Periodic task: retry imported images that are stuck with size=0 and no file on disk. diff --git a/links/views.py b/links/views.py index 69b7d90..f4418ac 100644 --- a/links/views.py +++ b/links/views.py @@ -6,6 +6,7 @@ from django.db.models import F, Count, Q, Case, When, Value, IntegerField from django.db.models.functions import TruncDate from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings from .forms import LinkForm, PageForm +from django.core.cache import cache import json from django.core.serializers.json import DjangoJSONEncoder from django.contrib import messages @@ -195,6 +196,9 @@ class LinkUpdateView(UpdateView): response = super().form_valid(form) + # Invalidate the alias cache so the redirect hot-path sees the updated link + cache.delete(f"link:alias:{form.instance.alias.lower()}") + new_url = form.cleaned_data['original_url'] if self.original_url != new_url: logger.info(f"URL changed from {self.original_url} to {new_url}") @@ -225,6 +229,10 @@ class LinkDeleteView(DeleteView): template_name = 'links/link_confirm_delete.html' success_url = reverse_lazy('link_list') + def delete(self, request, *args, **kwargs): + cache.delete(f"link:alias:{self.get_object().alias.lower()}") + return super().delete(request, *args, **kwargs) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['object'] = self.get_object() @@ -240,10 +248,21 @@ def redirect_to_original(request, alias, param=None): processed_alias = ''.join(e for e in alias.lower() if e.isalnum()) try: - link = Link.objects.get(alias=processed_alias) - link.click_count = F('click_count') + 1 - link.save() - ClickLog.objects.create(link=link) + cache_key = f'link:alias:{processed_alias}' + link = cache.get(cache_key) + if link is None: + link = Link.objects.get(alias=processed_alias) + cache.set(cache_key, link, timeout=3600) + + # Buffer click in Redis (atomic increment). A periodic task flushes to DB. + from django.utils import timezone + date_str = timezone.now().strftime('%Y-%m-%d') + click_key = f'clicks:{link.id}:{date_str}' + try: + cache.incr(click_key) + except ValueError: + # Key doesn't exist yet — set it then give it a 48-hour safety-net TTL + cache.set(click_key, 1, timeout=172800) try: # 如果是模板 URL 并且提供了参数 @@ -271,6 +290,8 @@ def redirect_to_original(request, alias, param=None): return redirect('link_list') except Link.DoesNotExist: + # Also clear any stale cache entry that might be causing the miss + cache.delete(f'link:alias:{processed_alias}') messages.warning(request, _("The alias '{}' doesn't exist. Do you want to create a new one?").format(processed_alias)) return redirect(reverse('link_create') + f'?alias={processed_alias}') @@ -329,56 +350,63 @@ class LinkDetailView(DetailView): period_name = "3 Months" interval_days = 1 # Daily - # Get click stats from database - click_stats = ClickLog.objects.filter( - link=self.object, - clicked_at__date__gte=start_date, - clicked_at__date__lte=end_date - ).annotate( - date=TruncDate('clicked_at') - ).values('date').annotate(count=Count('id')).order_by('date') + # Get click stats — cache per link+period to avoid repeated ClickLog full-scans + stats_cache_key = f'link:stats:{self.object.pk}:{period}' + click_stats_list = cache.get(stats_cache_key) - # Convert to dictionary for easy lookup - click_dict = {item['date']: item['count'] for item in click_stats} + if click_stats_list is None: + # Get click stats from database + click_stats = ClickLog.objects.filter( + link=self.object, + clicked_at__date__gte=start_date, + clicked_at__date__lte=end_date + ).annotate( + date=TruncDate('clicked_at') + ).values('date').annotate(count=Count('id')).order_by('date') - # Create complete dataset with appropriate intervals - click_stats_list = [] - current_date = start_date + # Convert to dictionary for easy lookup + click_dict = {item['date']: item['count'] for item in click_stats} - if interval_days == 1: - # Daily intervals - while current_date <= end_date: - click_stats_list.append({ - 'date': current_date.strftime('%Y-%m-%d'), - 'count': click_dict.get(current_date, 0) - }) - current_date += timedelta(days=1) - else: - # Weekly, bi-weekly, or monthly intervals - while current_date <= end_date: - interval_end = min(current_date + timedelta(days=interval_days - 1), end_date) + # Create complete dataset with appropriate intervals + click_stats_list = [] + current_date = start_date - # Sum clicks for this interval - interval_count = 0 - temp_date = current_date - while temp_date <= interval_end: - interval_count += click_dict.get(temp_date, 0) - temp_date += timedelta(days=1) + if interval_days == 1: + # Daily intervals + while current_date <= end_date: + click_stats_list.append({ + 'date': current_date.strftime('%Y-%m-%d'), + 'count': click_dict.get(current_date, 0) + }) + current_date += timedelta(days=1) + else: + # Weekly, bi-weekly, or monthly intervals + while current_date <= end_date: + interval_end = min(current_date + timedelta(days=interval_days - 1), end_date) - # Format label based on interval - if interval_days == 7: # Weekly - label = f"{current_date.strftime('%m/%d')}" - elif interval_days == 14: # Bi-weekly - label = f"{current_date.strftime('%m/%d')}" - else: # Monthly - label = f"{calendar.month_abbr[current_date.month]} {current_date.year}" + # Sum clicks for this interval + interval_count = 0 + temp_date = current_date + while temp_date <= interval_end: + interval_count += click_dict.get(temp_date, 0) + temp_date += timedelta(days=1) - click_stats_list.append({ - 'date': current_date.strftime('%Y-%m-%d'), - 'count': interval_count, - 'label': label - }) - current_date += timedelta(days=interval_days) + # Format label based on interval + if interval_days == 7: # Weekly + label = f"{current_date.strftime('%m/%d')}" + elif interval_days == 14: # Bi-weekly + label = f"{current_date.strftime('%m/%d')}" + else: # Monthly + label = f"{calendar.month_abbr[current_date.month]} {current_date.year}" + + click_stats_list.append({ + 'date': current_date.strftime('%Y-%m-%d'), + 'count': interval_count, + 'label': label + }) + current_date += timedelta(days=interval_days) + + cache.set(stats_cache_key, click_stats_list, timeout=600) context['click_stats'] = json.dumps(click_stats_list, cls=DjangoJSONEncoder) context['current_period'] = period diff --git a/pyproject.toml b/pyproject.toml index 2fa5dc5..ab74c7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "python-magic>=0.4.27", "pillow~=12.1.1", "whoosh==2.7.4", + "django-redis>=5.4.0", "qdrant-client>=1.13.2", "cryptography>=42.0.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index 23b6c19..fa7dbe4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,3 +47,14 @@ def use_tmp_upload_dir(settings, tmp_path): """Redirect FILE_UPLOADS_FOLDER to a temp dir for each test.""" settings.FILE_UPLOADS_FOLDER = str(tmp_path) yield + + +@pytest.fixture(autouse=True) +def use_locmem_cache(settings): + """Override Redis cache with in-process LocMemCache so tests don't need a Redis server.""" + settings.CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } + } + yield diff --git a/uv.lock b/uv.lock index 35f30a2..c590c98 100644 --- a/uv.lock +++ b/uv.lock @@ -311,6 +311,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, ] +[[package]] +name = "django-redis" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" }, +] + [[package]] name = "django-simplemde" version = "0.1.4" @@ -587,6 +600,7 @@ dependencies = [ { name = "boto3" }, { name = "cryptography" }, { name = "django" }, + { name = "django-redis" }, { name = "django-simplemde" }, { name = "django-tailwind" }, { name = "django-widget-tweaks" }, @@ -628,6 +642,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.35.0" }, { name = "cryptography", specifier = ">=42.0.0" }, { name = "django", specifier = ">=5.2.9" }, + { name = "django-redis", specifier = ">=5.4.0" }, { name = "django-simplemde", specifier = "==0.1.4" }, { name = "django-tailwind", specifier = "==3.8.0" }, { name = "django-widget-tweaks", specifier = "==1.5.0" }, @@ -1131,6 +1146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + [[package]] name = "requests" version = "2.33.0" From 3a20fcb63a2cbbe2eb98680998c5967fa89d92a7 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 29 Mar 2026 11:10:37 +1100 Subject: [PATCH 41/95] Optimize frontend --- core/settings.py | 2 +- justfile | 18 +- links/collection_views.py | 5 + links/page_views.py | 5 + links/post_views.py | 5 + links/templates/links/collection_detail.html | 12 +- links/templates/links/collection_form.html | 5 +- links/templates/links/collection_list.html | 89 +- links/templates/links/files/list.html | 1 - .../links/includes/collection_list_items.html | 89 + .../links/includes/page_list_items.html | 133 ++ .../templates/links/includes/pagination.html | 8 + .../links/includes/post_list_items.html | 80 + links/templates/links/jobs.html | 2 - links/templates/links/link_detail.html | 3 +- links/templates/links/link_form.html | 2 +- .../links/mini_apps/fire_planning.html | 4 +- .../links/mini_apps/image_gallery.html | 2 +- links/templates/links/page_detail.html | 3 +- links/templates/links/page_form.html | 12 +- links/templates/links/page_list.html | 121 +- links/templates/links/post_form.html | 14 +- links/templates/links/post_list.html | 79 +- links/templates/links/public_post.html | 2 +- links/templates/links/search_react.html | 478 +---- links/templates/links/tag_list.html | 20 +- package-lock.json | 1870 ++++++++++++++++- package.json | 26 +- scripts/copy-vendor.js | 53 + static/dist/search.js | 40 + static/vendor/alpinejs/alpine.min.js | 5 + static/vendor/chartjs/chart.min.js | 14 + static/vendor/dropzone/dropzone.min.css | 1 + static/vendor/dropzone/dropzone.min.js | 2 + .../vendor/echarts/echarts-wordcloud.min.js | 3 + static/vendor/echarts/echarts.min.js | 45 + static/vendor/fontawesome/css/all.min.css | 9 + .../fontawesome/webfonts/fa-brands-400.ttf | Bin 0 -> 210792 bytes .../fontawesome/webfonts/fa-brands-400.woff2 | Bin 0 -> 118684 bytes .../fontawesome/webfonts/fa-regular-400.ttf | Bin 0 -> 68064 bytes .../fontawesome/webfonts/fa-regular-400.woff2 | Bin 0 -> 25472 bytes .../fontawesome/webfonts/fa-solid-900.ttf | Bin 0 -> 426112 bytes .../fontawesome/webfonts/fa-solid-900.woff2 | Bin 0 -> 158220 bytes .../webfonts/fa-v4compatibility.ttf | Bin 0 -> 10836 bytes .../webfonts/fa-v4compatibility.woff2 | Bin 0 -> 4796 bytes static/vendor/htmx/htmx.min.js | 1 + static/vendor/jquery/jquery.min.js | 2 + static/vendor/select2/css/select2.min.css | 1 + static/vendor/select2/js/select2.min.js | 2 + static_src/search.jsx | 475 +++++ tailwind.config.js | 6 +- templates/base.html | 7 +- vite.config.js | 20 + 53 files changed, 2936 insertions(+), 840 deletions(-) create mode 100644 links/templates/links/includes/collection_list_items.html create mode 100644 links/templates/links/includes/page_list_items.html create mode 100644 links/templates/links/includes/post_list_items.html create mode 100644 scripts/copy-vendor.js create mode 100644 static/dist/search.js create mode 100644 static/vendor/alpinejs/alpine.min.js create mode 100644 static/vendor/chartjs/chart.min.js create mode 100644 static/vendor/dropzone/dropzone.min.css create mode 100644 static/vendor/dropzone/dropzone.min.js create mode 100644 static/vendor/echarts/echarts-wordcloud.min.js create mode 100644 static/vendor/echarts/echarts.min.js create mode 100644 static/vendor/fontawesome/css/all.min.css create mode 100644 static/vendor/fontawesome/webfonts/fa-brands-400.ttf create mode 100644 static/vendor/fontawesome/webfonts/fa-brands-400.woff2 create mode 100644 static/vendor/fontawesome/webfonts/fa-regular-400.ttf create mode 100644 static/vendor/fontawesome/webfonts/fa-regular-400.woff2 create mode 100644 static/vendor/fontawesome/webfonts/fa-solid-900.ttf create mode 100644 static/vendor/fontawesome/webfonts/fa-solid-900.woff2 create mode 100644 static/vendor/fontawesome/webfonts/fa-v4compatibility.ttf create mode 100644 static/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 create mode 100644 static/vendor/htmx/htmx.min.js create mode 100644 static/vendor/jquery/jquery.min.js create mode 100644 static/vendor/select2/css/select2.min.css create mode 100644 static/vendor/select2/js/select2.min.js create mode 100644 static_src/search.jsx create mode 100644 vite.config.js diff --git a/core/settings.py b/core/settings.py index 0d18a76..c837795 100644 --- a/core/settings.py +++ b/core/settings.py @@ -105,7 +105,7 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',') # Redis cache -REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') +REDIS_URL = os.environ.get('REDIS_URL', 'redis://192.168.1.2:6379/0') CACHES = { 'default': { diff --git a/justfile b/justfile index 2a13b19..eccf33e 100644 --- a/justfile +++ b/justfile @@ -27,14 +27,14 @@ dev: #!/usr/bin/env bash set -eu source .venv/bin/activate - uv run manage.py tailwind start & + npm run dev & TAILWIND_PID=$! trap "kill $TAILWIND_PID 2>/dev/null" EXIT uv run manage.py runserver 0.0.0.0:8000 # Run Tailwind CSS in watch mode (standalone) tailwind: - source .venv/bin/activate && uv run manage.py tailwind start + npm run dev # ── Database ────────────────────────────────────────────────────────────────── @@ -60,9 +60,21 @@ superuser: # ── Static Assets ───────────────────────────────────────────────────────────── +# Copy vendor JS/CSS from node_modules to static/vendor/ +vendor: + node scripts/copy-vendor.js + # Build Tailwind CSS (minified, for production) build-css: - source .venv/bin/activate && uv run manage.py tailwind build + npm run build:css + +# Build the React search component with Vite +build-search: + npm run build:search + +# Build all frontend assets: vendor copy + Tailwind + Vite (full production build) +build: + npm run build # Collect all static files into staticfiles/ collectstatic: diff --git a/links/collection_views.py b/links/collection_views.py index 8a08bca..80b88b3 100644 --- a/links/collection_views.py +++ b/links/collection_views.py @@ -11,6 +11,11 @@ class CollectionListView(ListView): context_object_name = 'collections' paginate_by = 12 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/collection_list_items.html'] + return [self.template_name] + class CollectionDetailView(DetailView): model = ImageCollection template_name = 'links/collection_detail.html' diff --git a/links/page_views.py b/links/page_views.py index 50eb334..e7af0e9 100644 --- a/links/page_views.py +++ b/links/page_views.py @@ -68,6 +68,11 @@ class PageListView(ListView): context_object_name = 'pages' paginate_by = 10 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/page_list_items.html'] + return [self.template_name] + class PageDetailView(DetailView): model = Page template_name = 'links/page_detail.html' diff --git a/links/post_views.py b/links/post_views.py index a11e8a7..f6ed2e1 100644 --- a/links/post_views.py +++ b/links/post_views.py @@ -20,6 +20,11 @@ class PostListView(ListView): context_object_name = 'posts' paginate_by = 10 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/post_list_items.html'] + return [self.template_name] + def get_queryset(self): queryset = Post.objects.all().order_by('-created_at') diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html index 70914de..8dacef7 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -3,7 +3,8 @@ {% load static %} {% block extra_css %} - + + {% endblock %} @@ -561,4 +560,3 @@ function jobsManager() { } {% endblock %} - diff --git a/links/templates/links/link_detail.html b/links/templates/links/link_detail.html index 5579d11..895ec52 100644 --- a/links/templates/links/link_detail.html +++ b/links/templates/links/link_detail.html @@ -223,7 +223,8 @@ {% endblock %} {% block extra_js %} - + + - + {# jQuery and Select2 are loaded globally from vendor in base.html #} + + @@ -99,7 +98,7 @@
-

{% trans "Tags" %}

+

{% trans "Tags" %}

{% for tag in page.tags.all %} diff --git a/links/templates/links/page_form.html b/links/templates/links/page_form.html index befbe16..bdac839 100644 --- a/links/templates/links/page_form.html +++ b/links/templates/links/page_form.html @@ -3,10 +3,8 @@ {% load static %} {% block extra_css %} - - - - + +{# Select2 CSS is loaded globally from vendor in base.html #} ")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/static/vendor/jquery/jquery.min.js b/static/vendor/jquery/jquery.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/static/vendor/jquery/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--group{padding:0}.select2-container--default .select2-results__option--disabled{color:#999}.select2-container--default .select2-results__option--selected{background-color:#ddd}.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0;padding-bottom:5px;padding-right:5px}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;display:inline-block;margin-left:5px;margin-top:5px;padding:0}.select2-container--classic .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#888;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555;outline:none}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option--group{padding:0}.select2-container--classic .select2-results__option--disabled{color:grey}.select2-container--classic .select2-results__option--highlighted.select2-results__option--selectable{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/vendor/select2/js/select2.min.js b/static/vendor/select2/js/select2.min.js new file mode 100644 index 0000000..cc9a83f --- /dev/null +++ b/static/vendor/select2/js/select2.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.1.0-rc.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(t){var e,n,s,p,r,o,h,f,g,m,y,v,i,a,_,s=((u=t&&t.fn&&t.fn.select2&&t.fn.select2.amd?t.fn.select2.amd:u)&&u.requirejs||(u?n=u:u={},g={},m={},y={},v={},i=Object.prototype.hasOwnProperty,a=[].slice,_=/\.js$/,h=function(e,t){var n,s,i=c(e),r=i[0],t=t[1];return e=i[1],r&&(n=x(r=l(r,t))),r?e=n&&n.normalize?n.normalize(e,(s=t,function(e){return l(e,s)})):l(e,t):(r=(i=c(e=l(e,t)))[0],e=i[1],r&&(n=x(r))),{f:r?r+"!"+e:e,n:e,pr:r,p:n}},f={require:function(e){return w(e)},exports:function(e){var t=g[e];return void 0!==t?t:g[e]={}},module:function(e){return{id:e,uri:"",exports:g[e],config:(t=e,function(){return y&&y.config&&y.config[t]||{}})};var t}},r=function(e,t,n,s){var i,r,o,a,l,c=[],u=typeof n,d=A(s=s||e);if("undefined"==u||"function"==u){for(t=!t.length&&n.length?["require","exports","module"]:t,a=0;a":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},s.__cache={};var n=0;return s.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null!=t||(t=e.id?"select2-data-"+e.id:"select2-data-"+(++n).toString()+"-"+s.generateChars(4),e.setAttribute("data-select2-id",t)),t},s.StoreData=function(e,t,n){e=s.GetUniqueElementId(e);s.__cache[e]||(s.__cache[e]={}),s.__cache[e][t]=n},s.GetData=function(e,t){var n=s.GetUniqueElementId(e);return t?s.__cache[n]&&null!=s.__cache[n][t]?s.__cache[n][t]:r(e).data(t):s.__cache[n]},s.RemoveData=function(e){var t=s.GetUniqueElementId(e);null!=s.__cache[t]&&delete s.__cache[t],e.removeAttribute("data-select2-id")},s.copyNonInternalCssClasses=function(e,t){var n=(n=e.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0===e.indexOf("select2-")}),t=(t=t.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0!==e.indexOf("select2-")}),t=n.concat(t);e.setAttribute("class",t.join(" "))},s}),u.define("select2/results",["jquery","./utils"],function(d,p){function s(e,t,n){this.$element=e,this.data=n,this.options=t,s.__super__.constructor.call(this)}return p.Extend(s,p.Observable),s.prototype.render=function(){var e=d('
    ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},s.prototype.clear=function(){this.$results.empty()},s.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=d(''),s=this.options.get("translations").get(e.message);n.append(t(s(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},s.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},s.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested",role:"none"});i.append(l),o.append(a),o.append(i)}else this.template(e,t);return p.StoreData(t,"data",e),t},s.prototype.bind=function(t,e){var i=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){i.clear(),i.append(e.data),t.isOpen()&&(i.setClasses(),i.highlightFirstItem())}),t.on("results:append",function(e){i.append(e.data),t.isOpen()&&i.setClasses()}),t.on("query",function(e){i.hideMessages(),i.showLoading(e)}),t.on("select",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("open",function(){i.$results.attr("aria-expanded","true"),i.$results.attr("aria-hidden","false"),i.setClasses(),i.ensureHighlightVisible()}),t.on("close",function(){i.$results.attr("aria-expanded","false"),i.$results.attr("aria-hidden","true"),i.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=i.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e,t=i.getHighlightedResults();0!==t.length&&(e=p.GetData(t[0],"data"),t.hasClass("select2-results__option--selected")?i.trigger("close",{}):i.trigger("select",{data:e}))}),t.on("results:previous",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t);s<=0||(e=s-1,0===t.length&&(e=0),(s=n.eq(e)).trigger("mouseenter"),t=i.$results.offset().top,n=s.offset().top,s=i.$results.scrollTop()+(n-t),0===e?i.$results.scrollTop(0):n-t<0&&i.$results.scrollTop(s))}),t.on("results:next",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t)+1;s>=n.length||((e=n.eq(s)).trigger("mouseenter"),t=i.$results.offset().top+i.$results.outerHeight(!1),n=e.offset().top+e.outerHeight(!1),e=i.$results.scrollTop()+n-t,0===s?i.$results.scrollTop(0):tthis.$results.outerHeight()||s<0)&&this.$results.scrollTop(n))},s.prototype.template=function(e,t){var n=this.options.get("templateResult"),s=this.options.get("escapeMarkup"),e=n(e,t);null==e?t.style.display="none":"string"==typeof e?t.innerHTML=s(e):d(t).append(e)},s}),u.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),u.define("select2/selection/base",["jquery","../utils","../keys"],function(n,s,i){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return s.Extend(r,s.Observable),r.prototype.render=function(){var e=n('');return this._tabindex=0,null!=s.GetData(this.$element[0],"old-tabindex")?this._tabindex=s.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},r.prototype.bind=function(e,t){var n=this,s=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",s),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},r.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},r.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&s.GetData(this,"element").select2("close")})})},r.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},r.prototype.position=function(e,t){t.find(".selection").append(e)},r.prototype.destroy=function(){this._detachCloseHandler(this.container)},r.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},r.prototype.isEnabled=function(){return!this.isDisabled()},r.prototype.isDisabled=function(){return this.options.get("disabled")},r}),u.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,s){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e[0].classList.add("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var s=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",s),this.$selection.attr("aria-controls",s),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){var t,n;0!==e.length?(n=e[0],t=this.$selection.find(".select2-selection__rendered"),e=this.display(n,t),t.empty().append(e),(n=n.title||n.text)?t.attr("title",n):t.removeAttr("title")):this.clear()},i}),u.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,c){function r(e,t){r.__super__.constructor.apply(this,arguments)}return c.Extend(r,e),r.prototype.render=function(){var e=r.__super__.render.call(this);return e[0].classList.add("select2-selection--multiple"),e.html('
      '),e},r.prototype.bind=function(e,t){var n=this;r.__super__.bind.apply(this,arguments);var s=e.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s),this.$selection.on("click",function(e){n.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){var t;n.isDisabled()||(t=i(this).parent(),t=c.GetData(t[0],"data"),n.trigger("unselect",{originalEvent:e,data:t}))}),this.$selection.on("keydown",".select2-selection__choice__remove",function(e){n.isDisabled()||e.stopPropagation()})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return i('
    • ')},r.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=this.$selection.find(".select2-selection__rendered").attr("id")+"-choice-",s=0;s')).attr("title",s()),e.attr("aria-label",s()),e.attr("aria-describedby",n),a.StoreData(e[0],"data",t),this.$selection.prepend(e),this.$selection[0].classList.add("select2-selection--clearable"))},e}),u.define("select2/selection/search",["jquery","../utils","../keys"],function(s,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=this.options.get("translations").get("search"),n=s('');this.$searchContainer=n,this.$search=n.find("textarea"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",t());e=e.call(this);return this._transferTabIndex(),e.append(this.$searchContainer),e},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results",r=t.id+"-container";e.call(this,t,n),s.$search.attr("aria-describedby",r),t.on("open",function(){s.$search.attr("aria-controls",i),s.$search.trigger("focus")}),t.on("close",function(){s.$search.val(""),s.resizeSearch(),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.trigger("focus")}),t.on("enable",function(){s.$search.prop("disabled",!1),s._transferTabIndex()}),t.on("disable",function(){s.$search.prop("disabled",!0)}),t.on("focus",function(e){s.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){s.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){s._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){var t;e.stopPropagation(),s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented(),e.which!==l.BACKSPACE||""!==s.$search.val()||0<(t=s.$selection.find(".select2-selection__choice").last()).length&&(t=a.GetData(t[0],"data"),s.searchRemoveChoice(t),e.preventDefault())}),this.$selection.on("click",".select2-search--inline",function(e){s.$search.val()&&e.stopPropagation()});var t=document.documentMode,o=t&&t<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(e){o?s.$selection.off("input.search input.searchcheck"):s.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(e){var t;o&&"input"===e.type?s.$selection.off("input.search input.searchcheck"):(t=e.which)!=l.SHIFT&&t!=l.CTRL&&t!=l.ALT&&t!=l.TAB&&s.handleSearch(e)})},e.prototype._transferTabIndex=function(e){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},e.prototype.createPlaceholder=function(e,t){this.$search.attr("placeholder",t.text)},e.prototype.update=function(e,t){var n=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),e.call(this,t),this.resizeSearch(),n&&this.$search.trigger("focus")},e.prototype.handleSearch=function(){var e;this.resizeSearch(),this._keyUpPrevented||(e=this.$search.val(),this.trigger("query",{term:e})),this._keyUpPrevented=!1},e.prototype.searchRemoveChoice=function(e,t){this.trigger("unselect",{data:t}),this.$search.val(t.text),this.handleSearch()},e.prototype.resizeSearch=function(){this.$search.css("width","25px");var e="100%";""===this.$search.attr("placeholder")&&(e=.75*(this.$search.val().length+1)+"em"),this.$search.css("width",e)},e}),u.define("select2/selection/selectionCss",["../utils"],function(n){function e(){}return e.prototype.render=function(e){var t=e.call(this),e=this.options.get("selectionCssClass")||"";return-1!==e.indexOf(":all:")&&(e=e.replace(":all:",""),n.copyNonInternalCssClasses(t[0],this.$element[0])),t.addClass(e),t},e}),u.define("select2/selection/eventRelay",["jquery"],function(o){function e(){}return e.prototype.bind=function(e,t,n){var s=this,i=["open","opening","close","closing","select","selecting","unselect","unselecting","clear","clearing"],r=["opening","closing","selecting","unselecting","clearing"];e.call(this,t,n),t.on("*",function(e,t){var n;-1!==i.indexOf(e)&&(t=t||{},n=o.Event("select2:"+e,{params:t}),s.$element.trigger(n),-1!==r.indexOf(e)&&(t.prevented=n.isDefaultPrevented()))})},e}),u.define("select2/translation",["jquery","require"],function(t,n){function s(e){this.dict=e||{}}return s.prototype.all=function(){return this.dict},s.prototype.get=function(e){return this.dict[e]},s.prototype.extend=function(e){this.dict=t.extend({},e.all(),this.dict)},s._cache={},s.loadPath=function(e){var t;return e in s._cache||(t=n(e),s._cache[e]=t),new s(s._cache[e])},s}),u.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Œ":"OE","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","œ":"oe","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ώ":"ω","ς":"σ","’":"'"}}),u.define("select2/data/base",["../utils"],function(n){function s(e,t){s.__super__.constructor.call(this)}return n.Extend(s,n.Observable),s.prototype.current=function(e){throw new Error("The `current` method must be defined in child classes.")},s.prototype.query=function(e,t){throw new Error("The `query` method must be defined in child classes.")},s.prototype.bind=function(e,t){},s.prototype.destroy=function(){},s.prototype.generateResultId=function(e,t){e=e.id+"-result-";return e+=n.generateChars(4),null!=t.id?e+="-"+t.id.toString():e+="-"+n.generateChars(4),e},s}),u.define("select2/data/select",["./base","../utils","jquery"],function(e,a,l){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return a.Extend(n,e),n.prototype.current=function(e){var t=this;e(Array.prototype.map.call(this.$element[0].querySelectorAll(":checked"),function(e){return t.item(l(e))}))},n.prototype.select=function(i){var e,r=this;if(i.selected=!0,null!=i.element&&"option"===i.element.tagName.toLowerCase())return i.element.selected=!0,void this.$element.trigger("input").trigger("change");this.$element.prop("multiple")?this.current(function(e){var t=[];(i=[i]).push.apply(i,e);for(var n=0;nthis.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),u.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("select",function(){s._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var s=this;this._checkIfMaximumSelected(function(){e.call(s,t,n)})},e.prototype._checkIfMaximumSelected=function(e,t){var n=this;this.current(function(e){e=null!=e?e.length:0;0=n.maximumSelectionLength?n.trigger("results:message",{message:"maximumSelected",args:{maximum:n.maximumSelectionLength}}):t&&t()})},e}),u.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),u.define("select2/dropdown/search",["jquery"],function(r){function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("translations").get("search"),e=r('');return this.$searchContainer=e,this.$search=e.find("input"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",n()),t.prepend(e),t},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){r(this).off("keyup")}),this.$search.on("keyup input",function(e){s.handleSearch(e)}),t.on("open",function(){s.$search.attr("tabindex",0),s.$search.attr("aria-controls",i),s.$search.trigger("focus"),window.setTimeout(function(){s.$search.trigger("focus")},0)}),t.on("close",function(){s.$search.attr("tabindex",-1),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.val(""),s.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||s.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(s.showSearch(e)?s.$searchContainer[0].classList.remove("select2-search--hide"):s.$searchContainer[0].classList.add("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")})},e.prototype.handleSearch=function(e){var t;this._keyUpPrevented||(t=this.$search.val(),this.trigger("query",{term:t})),this._keyUpPrevented=!1},e.prototype.showSearch=function(e,t){return!0},e}),u.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,s){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,s)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return t="string"==typeof t?{id:"",text:t}:t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),s=t.length-1;0<=s;s--){var i=t[s];this.placeholder.id===i.id&&n.splice(s,1)}return n},e}),u.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,s){this.lastParams={},e.call(this,t,n,s),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("query",function(e){s.lastParams=e,s.loading=!0}),t.on("query:append",function(e){s.lastParams=e,s.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);!this.loading&&e&&(e=this.$results.offset().top+this.$results.outerHeight(!1),this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=e+50&&this.loadMore())},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
    • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),u.define("select2/dropdown/attachBody",["jquery","../utils"],function(u,o){function e(e,t,n){this.$dropdownParent=u(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("open",function(){s._showDropdown(),s._attachPositioningHandler(t),s._bindContainerResultHandlers(t)}),t.on("close",function(){s._hideDropdown(),s._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t[0].classList.remove("select2"),t[0].classList.add("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=u(""),e=e.call(this);return t.append(e),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){var n;this._containerResultsHandlersBound||(n=this,t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0)},e.prototype._attachPositioningHandler=function(e,t){var n=this,s="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id,t=this.$container.parents().filter(o.hasScroll);t.each(function(){o.StoreData(this,"select2-scroll-position",{x:u(this).scrollLeft(),y:u(this).scrollTop()})}),t.on(s,function(e){var t=o.GetData(this,"select2-scroll-position");u(this).scrollTop(t.y)}),u(window).on(s+" "+i+" "+r,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,s="resize.select2."+t.id,t="orientationchange.select2."+t.id;this.$container.parents().filter(o.hasScroll).off(n),u(window).off(n+" "+s+" "+t)},e.prototype._positionDropdown=function(){var e=u(window),t=this.$dropdown[0].classList.contains("select2-dropdown--above"),n=this.$dropdown[0].classList.contains("select2-dropdown--below"),s=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var r={height:this.$container.outerHeight(!1)};r.top=i.top,r.bottom=i.top+r.height;var o=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+o,a={left:i.left,top:r.bottom},l=this.$dropdownParent;"static"===l.css("position")&&(l=l.offsetParent());i={top:0,left:0};(u.contains(document.body,l[0])||l[0].isConnected)&&(i=l.offset()),a.top-=i.top,a.left-=i.left,t||n||(s="below"),e||!c||t?!c&&e&&t&&(s="below"):s="above",("above"==s||t&&"below"!==s)&&(a.top=r.top-i.top-o),null!=s&&(this.$dropdown[0].classList.remove("select2-dropdown--below"),this.$dropdown[0].classList.remove("select2-dropdown--above"),this.$dropdown[0].classList.add("select2-dropdown--"+s),this.$container[0].classList.remove("select2-container--below"),this.$container[0].classList.remove("select2-container--above"),this.$container[0].classList.add("select2-container--"+s)),this.$dropdownContainer.css(a)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),u.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,s){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,s)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,s=0;s');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container[0].classList.add("select2-container--"+this.options.get("theme")),r.StoreData(e[0],"element",this.$element),e},o}),u.define("jquery-mousewheel",["jquery"],function(e){return e}),u.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,r,t,o){var a;return null==i.fn.select2&&(a=["open","close","destroy"],i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new r(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,s=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=o.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,s)}),-1 { + const variants = { + default: "bg-gray-100 text-gray-800", + link: "bg-blue-100 text-blue-800", + page: "bg-green-100 text-green-800", + post: "bg-purple-100 text-purple-800" + }; + return ( + + {children} + + ); +}; + +// Card Component +const Card = ({ children, className = "", hover = false }) => ( +
      + {children} +
      +); + +// Input Component +const Input = ({ className = "", ...props }) => ( + +); + +// Select Component +const Select = ({ children, className = "", ...props }) => ( + +); + +// Button Component +const Button = ({ children, variant = "default", className = "", ...props }) => { + const variants = { + default: "bg-blue-600 text-white hover:bg-blue-700", + outline: "border-2 border-gray-300 bg-white hover:bg-gray-50", + ghost: "hover:bg-gray-100" + }; + return ( + + ); +}; + +// Skeleton Loader +const Skeleton = ({ className = "" }) => ( +
      +); + +// Search Result Item Component +const SearchResultItem = ({ result }) => { + const typeIcons = { + link: "fa-link", + page: "fa-file-alt", + post: "fa-newspaper" + }; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + return ( + +
      +
      +
      + +
      +
      + +
      +
      + + {result.title} + + + {result.type} + +
      + + {result.type === 'link' && result.original_url && ( + + + {result.original_url} + + )} + + {(result.description || result.summary) && ( +

      + {result.description || result.summary} +

      + )} + +
      + + + {formatDate(result.created_at)} + + {result.click_count !== undefined && ( + + + {result.click_count} clicks + + )} +
      + + {result.tags && result.tags.length > 0 && ( +
      + {result.tags.map(tag => ( + + + {tag.name} + + ))} +
      + )} +
      + +
      + + + +
      +
      +
      + ); +}; + +// Main Search App Component +const SearchApp = () => { + const [query, setQuery] = useState(''); + const [type, setType] = useState(''); + const [sort, setSort] = useState('relevance'); + const [isVector, setIsVector] = useState(false); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(null); + const perPage = 20; + + const performSearch = useCallback(async (searchQuery, searchType, searchSort, searchPage) => { + if (!searchQuery.trim()) { + setResults([]); + setTotal(0); + return; + } + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + q: searchQuery, + type: searchType, + sort: searchSort, + page: searchPage, + per_page: perPage + }); + + const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/"; + const response = await fetch(`${endpoint}?${params}`); + const data = await response.json(); + + if (response.ok) { + setResults(data.results); + setTotal(data.total); + } else { + setError(data.error || 'Search failed'); + setResults([]); + } + } catch (err) { + setError('Network error. Please try again.'); + setResults([]); + } finally { + setLoading(false); + } + }, [isVector]); + + const handleSearch = useCallback((e) => { + e.preventDefault(); + setPage(1); + performSearch(query, type, sort, 1); + }, [query, type, sort, performSearch]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlQuery = params.get('q') || ''; + const urlType = params.get('type') || ''; + const urlSort = params.get('sort') || 'relevance'; + + setQuery(urlQuery); + setType(urlType); + setSort(urlSort); + + if (urlQuery) { + performSearch(urlQuery, urlType, urlSort, 1); + } + }, [performSearch]); + + useEffect(() => { + if (query) { + const params = new URLSearchParams({ + q: query, + ...(type && { type }), + ...(sort !== 'relevance' && { sort }) + }); + window.history.replaceState({}, '', `?${params}`); + } + }, [query, type, sort]); + + const handlePageChange = (newPage) => { + setPage(newPage); + performSearch(query, type, sort, newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const totalPages = Math.ceil(total / perPage); + + return ( +
      +
      + {/* Header */} +
      + +

      + + Advanced Search +

      +
      +

      Search through all Links, Pages, and Posts

      +
      + + {/* Search Form */} + +
      + {/* Search Input */} +
      + +
      + + setQuery(e.target.value)} + placeholder="Enter keywords to search..." + className="pl-10" + /> +
      +
      + + {/* Filters */} +
      +
      + + +
      + +
      + +
      + + setIsVector(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> +
      + +
      +
      + + {/* Search Button */} + +
      +
      + + {/* Results */} + {query && ( +
      + {/* Results Header */} +
      +

      + {loading ? ( + 'Searching...' + ) : error ? ( + + + {error} + + ) : ( + <> + Search results for: "{query}" + {total > 0 && ( + + ({total} {total === 1 ? 'result' : 'results'}) + + )} + + )} +

      +
      + + {/* Loading Skeletons */} + {loading && ( +
      + {[1, 2, 3].map(i => ( + +
      + +
      + + + +
      +
      +
      + ))} +
      + )} + + {/* Results List */} + {!loading && !error && results.length > 0 && ( +
      + {results.map(result => ( + + ))} +
      + )} + + {/* No Results */} + {!loading && !error && results.length === 0 && query && ( + + +

      No results found

      +

      Try adjusting your search terms or filters

      +
      + )} + + {/* Pagination */} + {!loading && totalPages > 1 && ( +
      + + +
      + {[...Array(Math.min(5, totalPages))].map((_, i) => { + let pageNum; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (page <= 3) { + pageNum = i + 1; + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = page - 2 + i; + } + + return ( + + ); + })} +
      + + +
      + )} +
      + )} + + {/* Empty State */} + {!query && ( + + +

      Start Searching

      +

      + Enter a search query to find links, pages, and posts. + You can search by title, content, URL, or tags. +

      +
      + )} +
      +
      + ); +}; + +// Mount app +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/tailwind.config.js b/tailwind.config.js index 988616d..7e75998 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,10 @@ module.exports = { content: [ - // ... + './templates/**/*.html', + './links/templates/**/*.html', + './new_theme/templates/**/*.html', + './static_src/**/*.{js,jsx}', + './**/*.js', ], theme: { extend: {}, diff --git a/templates/base.html b/templates/base.html index f42ad21..3c5dcda 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,7 +7,7 @@ {% trans "GoLinks" %} - + {% endblock %} @@ -357,8 +481,21 @@ - -
      + +
      + + + + + +

      {{ post.title }}

      @@ -555,6 +692,31 @@ {% trans "Append" %}
      +
      +
      + + + + + + + + +
      +
      +
      +

      {% trans "Contents" %}

      + +
      +
      {% endblock %} @@ -952,5 +1114,145 @@ function regenerateWithVoice() { regenerateBtn.textContent = originalText; }, 2000); } + +// ============================================================ +// Table of Contents +// ============================================================ +(function() { + function buildTOC() { + const article = document.querySelector('article.prose'); + if (!article) return; + + const headings = article.querySelectorAll('h2, h3, h4'); + if (headings.length < 2) { + // Not enough headings - hide TOC sidebar and mobile button + const sidebar = document.getElementById('toc-sidebar'); + const mobileBtn = document.getElementById('toc-mobile-btn'); + if (sidebar) sidebar.style.display = 'none'; + if (mobileBtn) mobileBtn.style.display = 'none'; + return; + } + + function createLinks(navEl) { + navEl.innerHTML = ''; + headings.forEach(function(heading) { + // Ensure heading has an id (toc extension adds these, but fallback just in case) + if (!heading.id) { + heading.id = heading.textContent.trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + const level = parseInt(heading.tagName[1]); // 2, 3, or 4 + const link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent; + link.classList.add('toc-link', 'toc-h' + level); + link.setAttribute('data-heading-id', heading.id); + + // Visual indentation by heading level + link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px'; + + link.addEventListener('click', function(e) { + e.preventDefault(); + closeMobileDrawer(); + const target = document.getElementById(heading.id); + if (target) { + const nav = document.getElementById('main-nav'); + const navH = nav ? nav.offsetHeight : 70; + const y = target.getBoundingClientRect().top + window.scrollY - navH - 16; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + navEl.appendChild(link); + }); + } + + const tocNav = document.getElementById('toc-nav'); + const tocNavMobile = document.getElementById('toc-nav-mobile'); + if (tocNav) createLinks(tocNav); + if (tocNavMobile) createLinks(tocNavMobile); + + // Scroll-spy: find the heading whose top is closest above the viewport threshold + var _rafPending = false; + function onScroll() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(function() { + _rafPending = false; + var nav = document.getElementById('main-nav'); + var navH = nav ? nav.offsetHeight : 64; + var threshold = navH + 24; // a bit below the navbar + var activeId = null; + // Walk headings in reverse: pick the last one whose top <= threshold + for (var i = headings.length - 1; i >= 0; i--) { + var rect = headings[i].getBoundingClientRect(); + if (rect.top <= threshold) { + activeId = headings[i].id; + break; + } + } + // If none is above threshold, highlight the first heading + if (!activeId && headings.length > 0) activeId = headings[0].id; + highlightTOC(activeId); + // Auto-scroll the active TOC link into view within the sidebar + var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active'); + if (activeLinkInSidebar) { + var sidebarInner = document.getElementById('toc-sidebar-inner'); + if (sidebarInner) { + var linkTop = activeLinkInSidebar.offsetTop; + var linkH = activeLinkInSidebar.offsetHeight; + var scrollTop = sidebarInner.scrollTop; + var sidebarH = sidebarInner.clientHeight; + if (linkTop < scrollTop) { + sidebarInner.scrollTop = linkTop - 8; + } else if (linkTop + linkH > scrollTop + sidebarH) { + sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8; + } + } + } + }); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); // run once on load + + function highlightTOC(id) { + document.querySelectorAll('.toc-link').forEach(function(a) { + if (id && a.getAttribute('data-heading-id') === id) { + a.classList.add('toc-active'); + } else { + a.classList.remove('toc-active'); + } + }); + } + } + + function openMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(0)'; + if (backdrop) backdrop.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function closeMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(-100%)'; + if (backdrop) backdrop.classList.add('hidden'); + document.body.style.overflow = ''; + } + + document.addEventListener('DOMContentLoaded', function() { + buildTOC(); + const mobileBtn = document.getElementById('toc-mobile-btn'); + const mobileClose = document.getElementById('toc-mobile-close'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer); + if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer); + if (backdrop) backdrop.addEventListener('click', closeMobileDrawer); + }); +})(); {% endblock %} diff --git a/links/templates/links/public_post.html b/links/templates/links/public_post.html index 1c8bdb7..5d99aba 100644 --- a/links/templates/links/public_post.html +++ b/links/templates/links/public_post.html @@ -75,34 +75,143 @@ .think-icon.expanded { transform: rotate(180deg); } + + /* ===== Table of Contents Layout ===== */ + #post-toc-layout { + max-width: 80rem; + margin: 2.5rem auto 0; + padding: 0.5rem 1rem; + } + #toc-sidebar { display: none; } + @media (min-width: 1024px) { + #post-toc-layout { display: flex; align-items: stretch; gap: 1.5rem; } + #toc-sidebar { display: block; width: 16rem; flex-shrink: 0; } + #toc-sidebar-inner { position: sticky; top: 1.5rem; max-height: calc(100vh - 4rem); overflow-y: auto; } + #post-main-content { flex: 1 1 0%; min-width: 0; } + #toc-mobile-btn { display: none !important; } + } + #toc-sidebar-inner > div { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,.07); + } + #toc-sidebar .toc-link { + display: block; + font-size: 0.8rem; + line-height: 1.6; + color: #6b7280; + text-decoration: none; + padding: 0.2rem 0.5rem; + border-left: 2px solid transparent; + transition: color 0.15s, border-color 0.15s, background-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + border-radius: 0 4px 4px 0; + } + #toc-sidebar .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-sidebar .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; background-color: #eff6ff; } + #toc-sidebar .toc-h2 { font-weight: 500; color: #374151; } + #toc-sidebar .toc-h3, #toc-sidebar .toc-h4 { font-size: 0.775rem; } + + /* Mobile button */ + #toc-mobile-btn { + position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 40; + background: #2563eb; color: #fff; border: none; border-radius: 9999px; + width: 3rem; height: 3rem; cursor: pointer; + box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1); + display: flex; align-items: center; justify-content: center; + transition: background-color 0.15s; + } + #toc-mobile-btn:hover { background: #1d4ed8; } + #toc-mobile-backdrop { + display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; + } + #toc-mobile-drawer { + position: fixed; top: 0; left: 0; height: 100%; + width: 18rem; max-width: 80vw; background: #fff; z-index: 50; + box-shadow: 0 25px 50px -12px rgba(0,0,0,.25); overflow-y: auto; + transform: translateX(-100%); transition: transform 0.3s ease; + } + #toc-nav-mobile .toc-link { + display: block; font-size: 0.875rem; line-height: 1.7; + color: #374151; text-decoration: none; padding: 0.3rem 0.5rem; + border-left: 2px solid transparent; transition: color 0.15s, border-color 0.15s; + border-radius: 0 4px 4px 0; + } + #toc-nav-mobile .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-nav-mobile .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; } -
      - -
      -
      -
      -

      {{ post.title }}

      -
      - {{ post.created_at|date:"Y-m-d H:i" }} -
      + +
      + + +
      + - -
      - {% markdown_with_tasks post.content post.task_states %} + +
      + +
      +
      +
      +

      {{ post.title }}

      +
      + {{ post.created_at|date:"Y-m-d H:i" }} +
      +
      +
      + {% if post.tags.exists %} +
      + {% for tag in post.tags.all %} + + {{ tag.name }} + + {% endfor %} +
      + {% endif %} +
      + + +
      + {% markdown_with_tasks post.content post.task_states %} +
      +
      +
      + + + + + + + + +
      +
      +
      +

      Contents

      + +
      +
      @@ -146,6 +255,130 @@ }); }); }); + + // ============================================================ + // Table of Contents + // ============================================================ + (function() { + function buildTOC() { + const proseEl = document.querySelector('.prose'); + if (!proseEl) return; + + const headings = proseEl.querySelectorAll('h2, h3, h4'); + if (headings.length < 2) { + const sidebar = document.getElementById('toc-sidebar'); + const mobileBtn = document.getElementById('toc-mobile-btn'); + if (sidebar) sidebar.style.display = 'none'; + if (mobileBtn) mobileBtn.style.display = 'none'; + return; + } + + function createLinks(navEl) { + navEl.innerHTML = ''; + headings.forEach(function(heading) { + if (!heading.id) { + heading.id = heading.textContent.trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + const level = parseInt(heading.tagName[1]); + const link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent; + link.classList.add('toc-link', 'toc-h' + level); + link.setAttribute('data-heading-id', heading.id); + link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px'; + link.addEventListener('click', function(e) { + e.preventDefault(); + closeMobileDrawer(); + const target = document.getElementById(heading.id); + if (target) { + const y = target.getBoundingClientRect().top + window.scrollY - 24; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + navEl.appendChild(link); + }); + } + + const tocNav = document.getElementById('toc-nav'); + const tocNavMobile = document.getElementById('toc-nav-mobile'); + if (tocNav) createLinks(tocNav); + if (tocNavMobile) createLinks(tocNavMobile); + + // Scroll-spy: find heading closest above the viewport threshold + var _rafPending = false; + function onScroll() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(function() { + _rafPending = false; + var threshold = 40; + var activeId = null; + for (var i = headings.length - 1; i >= 0; i--) { + if (headings[i].getBoundingClientRect().top <= threshold) { + activeId = headings[i].id; + break; + } + } + if (!activeId && headings.length > 0) activeId = headings[0].id; + highlightTOC(activeId); + var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active'); + if (activeLinkInSidebar) { + var sidebarInner = document.getElementById('toc-sidebar-inner'); + if (sidebarInner) { + var linkTop = activeLinkInSidebar.offsetTop; + var linkH = activeLinkInSidebar.offsetHeight; + var scrollTop = sidebarInner.scrollTop; + var sidebarH = sidebarInner.clientHeight; + if (linkTop < scrollTop) sidebarInner.scrollTop = linkTop - 8; + else if (linkTop + linkH > scrollTop + sidebarH) sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8; + } + } + }); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + + function highlightTOC(id) { + document.querySelectorAll('.toc-link').forEach(function(a) { + if (id && a.getAttribute('data-heading-id') === id) { + a.classList.add('toc-active'); + } else { + a.classList.remove('toc-active'); + } + }); + } + } + + function openMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(0)'; + if (backdrop) backdrop.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function closeMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(-100%)'; + if (backdrop) backdrop.classList.add('hidden'); + document.body.style.overflow = ''; + } + + document.addEventListener('DOMContentLoaded', function() { + buildTOC(); + const mobileBtn = document.getElementById('toc-mobile-btn'); + const mobileClose = document.getElementById('toc-mobile-close'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer); + if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer); + if (backdrop) backdrop.addEventListener('click', closeMobileDrawer); + }); + })(); diff --git a/links/templatetags/markdown_extras.py b/links/templatetags/markdown_extras.py index 76f8155..ce66158 100644 --- a/links/templatetags/markdown_extras.py +++ b/links/templatetags/markdown_extras.py @@ -17,6 +17,7 @@ def markdown(value): return mark_safe(md.markdown(value, extensions=['markdown.extensions.fenced_code', 'markdown.extensions.tables', 'markdown.extensions.nl2br', + 'markdown.extensions.toc', think_markdown.ThinkExtension()])) @@ -37,6 +38,7 @@ def markdown_with_tasks(content, task_states=None): 'markdown.extensions.fenced_code', 'markdown.extensions.tables', 'markdown.extensions.nl2br', + 'markdown.extensions.toc', think_markdown.ThinkExtension(), tasklist_markdown.TaskListExtension(task_states=task_states) ] From 20c7857302a69742eee89f5f08507fb5a9f7bcc6 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Mar 2026 18:06:46 +1100 Subject: [PATCH 46/95] Add building graph --- core/apps.py | 13 +- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/knowledge_graph_urls.py | 30 ++ links/knowledge_graph_views.py | 113 +++++ links/llm_client.py | 111 +++++ links/migrations/0045_knowledge_graph.py | 88 ++++ links/models.py | 98 +++++ links/tasks.py | 288 ++++++++++++ links/templates/links/knowledge_graph.html | 485 +++++++++++++++++++++ links/templates/links/settings.html | 187 ++++++++ links/urls.py | 2 + links/views.py | 31 ++ templates/base.html | 10 + 13 files changed, 1455 insertions(+), 1 deletion(-) create mode 100644 links/knowledge_graph_urls.py create mode 100644 links/knowledge_graph_views.py create mode 100644 links/llm_client.py create mode 100644 links/migrations/0045_knowledge_graph.py create mode 100644 links/templates/links/knowledge_graph.html diff --git a/core/apps.py b/core/apps.py index ea6bcce..46757f1 100644 --- a/core/apps.py +++ b/core/apps.py @@ -18,7 +18,7 @@ class CoreConfig(AppConfig): from core.scheduler import scheduler, start_scheduler from links.tasks import ( schedule_pending_pages, schedule_pending_screenshots, - retry_stuck_image_imports, flush_click_buffer, + retry_stuck_image_imports, flush_click_buffer, schedule_kg_build, ) from apscheduler.triggers.interval import IntervalTrigger @@ -31,9 +31,11 @@ class CoreConfig(AppConfig): ss = SiteSettings.get() pages_interval = ss.schedule_pending_pages_interval or 120 screenshots_interval = ss.schedule_pending_screenshots_interval or 120 + kg_interval = ss.kg_auto_schedule_interval or 3600 except Exception: pages_interval = 120 screenshots_interval = 120 + kg_interval = 3600 # Add periodic job for checking pending pages scheduler.add_job( @@ -70,3 +72,12 @@ class CoreConfig(AppConfig): replace_existing=True, ) logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") + + # Add periodic job for knowledge graph auto-build + scheduler.add_job( + schedule_kg_build, + trigger=IntervalTrigger(seconds=kg_interval), + id='schedule_kg_build', + replace_existing=True, + ) + logger.info(f"Scheduled periodic task: schedule_kg_build (every {kg_interval}s)") diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 5a19c3afac694f48f719647a7abd9dab02db4c52..7ffae6652ffeba39ba77849347c3e33590a6d3a1 100644 GIT binary patch delta 3232 zcmd5;eQX;=7T?+RdL75R6PLo$(k4^4kof92>%?|rmvV7SDCMJ}grW+OuGij)vt+$C z_Bt(8kQ%C_QtzZg7Fr3!DLTZ5ga9FPHQXI)fwp%?rIH{{hma~!B#W-JQC=+Doh3M&$C3s z!jFig?t$mwIrtU)1b+0={iD!JR6EM6h}=|eA+kMRJqLvr<_dwXa3}!iYC<~2uR}L6 z{Xo}@(V{$R_6Br=TY6f-d26;XUDqfSzoXgf++^#u~mCb}d#D*Bt?Zy$1l#6G>&^`HIk2 z1>aN>QupS*A(jG(TSY}x7Xi0LF%Wr+ZoU8=ZMCtZ!5_g_qlx2!hd53Rxrt12GZpT( zS6!}+E|L41n;{`+;>OoFz!o5mX?V{fUrDZYfUQ7Ov5rl=h>hH^NslN-W-*)H$N>^y z{&qaGf<08dd)Kpjm?VF^!cD*G;{VUj@F)4>1m}02D_I5rFSq3Y?=?H@9t z&}MN*K_@M=5%aVBnl{lviLv_8E*$<^-zjkH5ynfevbZFD4fvlGOw!L-NTIK{zBtC* z%#m`-GtajURCJ3x;!*pBi-zhrwna2($4bM{WEIe2BCQn1r!>k6WlBTPgk)fNI z6-~p6Mw01plHvMR(8Syz!_Hy*H$_I0jA#u_F=yJ=I-a9=I!m!9*bR<0l0yF!4ti!h zkGnfv?{KZo2k9((BT0x*ZRb(uPKSdm{FC+KG3MX`P%Q%WZ?y+cFcbdeE`FMV|AG^6 zyYQ9pw6H^P^V1|Wuki8SewGp=iF#}#DIEFfOyzp+cT3&TPN}D}M?z9G9*M-eHw0s$ zXt+Bv>HeOnxxaUO3)%<_H|wRGTiF*_FMnP0I`@a}DYw(LpZk=~(-{Kz+Li;|<#tZo zxDib5GnJH%D|}FV^dVLRsYf4U1GQW;8w8sI$Tymim6Dm0i|G-pmUJv*!!}oaD3#0U zX#2n&X#37vZdt(68t|~K>&be}Nb4mP%k@n))*Wz`JDdb2MIBgc;|PVo5uxQ+XXH;(kyb(hL>vU5b037e4o2k`am#c|)C8!I@zVCtA*|H%{5=N4Oz2QitXG3{8@K7u4 z73Hg4iA19$_C2u87*ljjMZ+4_Rb(hx+)?zSj5dZ1q#3A4cqVPi1*|J3R^zBWuN1Ih zwhso;mXVy6#zjOFanxbt4D6rx^>DnqHyDfcM5O4?-^7AaPbeIV@gyYxe4c{OlZ`zI z2X}yF6b}HPU33$9I6f9xLGRrQ0z%6lKOU|>Bc_>;cXjExw4#sXie@|%3ipKX-Ghyu zPMl3))h_(F6pW^Fx~^oEmz|ClW{CQx!u9>*QquWb!eQhDdHZ&AG)+=4&Fh}$nkJq1 zk;=|ETPC{7!Fs_RD$OmpxVy=a$WM&z}XVF0sXBETL(y}>H zz~si(32PLvGKV#EJu54DO&-PfSqZ?d^w^4&Qp9qppw~44Ta(R^JI;s3lmfZO3LPOy z@=yHXLO>^w{f}_Sq0t71B!6!lGB;xR-#n&G7h`Ijd7BX-!Dnmoh1{5?;(}Fvow?$! zUq^hu$a~{+c<*9YH=jX$Xka@*w}d+%>hLva*XMt@SoU#Ze?OSe>Q%PHx>j?Em!@o| z(DG$J$7mg4*qF7nQ=wfQ8oer27{WwlgLOf6@~SyFA9Vd4xqr~H4Jz^ZgE zo45NvRaQ(NB5<&&WpU$c)|XE9iNjM)fNW-EV1RILhiUh* yb_2R-BYT5Pd~6+Dx(BtrG@8`ees<33h;P{4aI|EjYCCEe%yu-#&(qV+{XYN$3=Y`< delta 8946 zcmeHNeQXrR6`$F?+qutOpN;K;zm^cdj(uM~-x=H3_=p=4HK_o_hE%59;#=^+eQfW} zwMm=UhXFgPLU3$nloErZrfHQ*O{ptR^g}@kI2B1#w@MMMptPuev_(ZVil~%GQD<*` z?BN=N)iZzH*|RtA&CHwUdGmYo-mC{E%LXRPj+a=f0RTOuG?Ahs1EWS_7 z+Ep!dlO9jL#uQV9EmP}keh529zhi$A7L9&ludZk_a8^*ndI5Kn`;hw+_bYCa3$Qym z9eb0#%)a+M-ocLYzBN9dJ0efMAFMg!qfzThMEZnCk=J)ODf>zBk+LVrPtt#_SfgzOX7FpE`H`lA{LQ~$ zhfTjToiJE+zenZFi&Tg-T^hCcoXPfcbcg1SQp0BdBUNPbwqmgwCiY?P1-k5xW_p-> zahg6_m5*2h;C{&+<-W?!vTw87SOd}XQ}dAF3p#1|Lh~QEMmq+KU>fLtth1O-f@$F3VmX*DwpAbq*dldu)d zauqbBf!R!526}K3z(X((oHUt@C#b#X>+n;0HCczll(Y_yB&b#R9S(I+(%KX;{(Ytc zzmlM0uv%#p8M(1aA!ZDyLxa8qp2C+0sB%2Upt3@j$L4W*9JL!Z4aIBp0IWO~@_NVL zMdkfA{%Ii==f!9=5^dx+Mv z5#qc(8i~a1wl1NoTeOE_Rw*bP?2QKYb;s>p-O)%W&=cC-Eeigi7>n(Wb&J8E)#=<& zuQZC*V4y$T(`UcChvEA8*!A(_59hZ(tJ7ej`w0JZByu3e2LlI0e(Rm};CqD_zrTkp zTOb^Z3&Ei1=Oa6@0n&qklXuj`T9Vr&ObfD+*KLP-q!$v(hG z8B{I9+U@c-9jWIA+9_m#7uVq!C@Bf5vF?YG}<`VAAXIHEXHQ zs9~xIz6ETs7W{+!%=<`gMcged>R15)eFUsdd6j6SiyYC!vAl**b-6sf+gV6}zEP@jeZ84UbD6MQT5hN=bDJv5UkZlvJw_+mTE-72*ORjE5>6vd0>1 zR{{}JW*{>D8?+UFTuyJt)eu$UCv>RBIJDoM@^H^(CvYLjgx#hdZ4Vf(sL@J)I-q%| z;flJK`tNgaEWZDg)}{M3HCj;+p7sV0D_l{1&0QWQt%lsJ+H@Jhm3z!T$4;xUeBnxV z?f)Qq7HpWnV>;wE4#jP`XhdD0Tr{HMCZm>yi_BV9)ZXPQG89m%X*HG)wb`}5 zy027ku=rtc|0Lsvf?8`1wd(3-*J?{YYjalv{{z(wMh>+Tcr&>gIKwkgsxrO_EX)hQ z@J)k8|EiuRx2qe`MYNMU#+5QJuzvF`(@pb$@t>w|lk3>`wYJ2rjWW^?Z6X&6dxZeG zi`p*6qX*^tr9hZ>#`sDh9v4GWoOjw?RU{hUm6sZl*r|+jr(*DP`3_H`qqecG*49v4 z?`o)TlNyu&|D9_LiT3YG3NrGJc^QIB8FMXlhncN(!C>kHPk~h8f}1tSo>J8*tLoZN z>#B3KNnUx{m6)T@jz`Qsz$=}lJBd)Oqt;_}xU98K-XU#PI==zY4ocdjDE!J5rbEMC z0{n=#l#*H%W+`D(Qj0=3#1(%7YW|F^~jpkv@7lT z7YgN;|0qxct!$*-YKWoAT|9tOTNg{a2wKsexpzC+L2K^qPNuDvj@w)am&WZv-^-8N z?1u1}d*Q+A50-cl+|s9WxK*9p@vkl2F)m_|4BWOm@QfZ+C!f?IJIp8lY*4DaRnw(k kt2L8EnDD_L=JDlgEt!989$!wJW&W0Vd^t3KdxjbKFTrI-zW@LL diff --git a/links/knowledge_graph_urls.py b/links/knowledge_graph_urls.py new file mode 100644 index 0000000..0190944 --- /dev/null +++ b/links/knowledge_graph_urls.py @@ -0,0 +1,30 @@ +from django.urls import path +from . import knowledge_graph_views + +urlpatterns = [ + path( + "ui/knowledge-graph/", + knowledge_graph_views.KnowledgeGraphPageView.as_view(), + name="knowledge-graph", + ), + path( + "api/knowledge-graph/data/", + knowledge_graph_views.KnowledgeGraphDataView.as_view(), + name="knowledge-graph-data", + ), + path( + "api/knowledge-graph/build/", + knowledge_graph_views.KnowledgeGraphBuildView.as_view(), + name="knowledge-graph-build", + ), + path( + "api/knowledge-graph/status/", + knowledge_graph_views.KnowledgeGraphStatusView.as_view(), + name="knowledge-graph-status", + ), + path( + "api/knowledge-graph/test-llm/", + knowledge_graph_views.LLMTestView.as_view(), + name="knowledge-graph-test-llm", + ), +] diff --git a/links/knowledge_graph_views.py b/links/knowledge_graph_views.py new file mode 100644 index 0000000..f305e5f --- /dev/null +++ b/links/knowledge_graph_views.py @@ -0,0 +1,113 @@ +""" +Views for the Knowledge Graph feature. +""" +import json +import logging + +from django.http import JsonResponse +from django.shortcuts import render +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from .models import KnowledgeGraphSnapshot, SiteSettings + +logger = logging.getLogger(__name__) + + +class KnowledgeGraphPageView(View): + template_name = "links/knowledge_graph.html" + + def get(self, request): + snapshot = KnowledgeGraphSnapshot.get_latest() + latest_building = KnowledgeGraphSnapshot.objects.filter( + status=KnowledgeGraphSnapshot.Status.BUILDING + ).first() + ss = SiteSettings.get() + return render(request, self.template_name, { + "snapshot": snapshot, + "latest_building": latest_building, + "site_settings": ss, + }) + + +class KnowledgeGraphDataView(View): + def get(self, request): + snapshot = KnowledgeGraphSnapshot.get_latest() + if not snapshot: + return JsonResponse({"error": "No ready snapshot found."}, status=404) + return JsonResponse(snapshot.graph_data, safe=False) + + +class KnowledgeGraphBuildView(View): + def post(self, request): + try: + body = json.loads(request.body or "{}") + except json.JSONDecodeError: + body = {} + + ss = SiteSettings.get() + use_llm_requested = body.get("use_llm", False) + # Only use LLM if a provider is configured + use_llm = use_llm_requested and ss.llm_provider not in ("none", "") + + snapshot = KnowledgeGraphSnapshot.objects.create( + status=KnowledgeGraphSnapshot.Status.BUILDING, + used_llm=use_llm, + ) + + from threading import Thread + from .tasks import build_knowledge_graph + thread = Thread( + target=build_knowledge_graph, + kwargs={"snapshot_id": snapshot.pk, "use_llm": use_llm}, + daemon=True, + ) + thread.start() + + return JsonResponse({ + "snapshot_id": snapshot.pk, + "status": snapshot.status, + "use_llm": use_llm, + }) + + +class KnowledgeGraphStatusView(View): + def get(self, request): + # Latest snapshot regardless of status (so the UI can poll while building) + snapshot = KnowledgeGraphSnapshot.objects.first() + if not snapshot: + return JsonResponse({"status": "none"}) + return JsonResponse({ + "snapshot_id": snapshot.pk, + "status": snapshot.status, + "node_count": snapshot.node_count, + "edge_count": snapshot.edge_count, + "used_llm": snapshot.used_llm, + "build_duration_ms": snapshot.build_duration_ms, + "error_message": snapshot.error_message, + "created_at": snapshot.created_at.isoformat() if snapshot.created_at else None, + "completed_at": snapshot.completed_at.isoformat() if snapshot.completed_at else None, + }) + + +class LLMTestView(View): + def post(self, request): + try: + body = json.loads(request.body or "{}") + except json.JSONDecodeError: + body = {} + + # Accept inline params from the UI test modal, or fall back to SiteSettings + from .llm_client import LLMClient + provider = body.get("provider") or SiteSettings.get().llm_provider + base_url = body.get("base_url") or SiteSettings.get().llm_base_url + model = body.get("model") or SiteSettings.get().llm_model + api_key = body.get("api_key") or SiteSettings.get().llm_api_key + + if provider in ("none", ""): + return JsonResponse({"success": False, "error": "No LLM provider configured."}) + + client = LLMClient(provider=provider, base_url=base_url, model=model, api_key=api_key) + success, error = client.test_connection() + return JsonResponse({"success": success, "error": error}) diff --git a/links/llm_client.py b/links/llm_client.py new file mode 100644 index 0000000..26f034b --- /dev/null +++ b/links/llm_client.py @@ -0,0 +1,111 @@ +""" +LLM / embedding client for the knowledge graph builder. + +Supports: + - Ollama (local, free — recommended for manual builds) + - OpenRouter (cloud, requires API key) +""" +import logging +import math +import requests + +logger = logging.getLogger(__name__) + +EMBED_TIMEOUT = 15 # seconds per request + + +class LLMClientError(Exception): + pass + + +class LLMClient: + """Thin wrapper for embedding generation via Ollama or OpenRouter.""" + + def __init__(self, provider: str, base_url: str, model: str, api_key: str = ""): + self.provider = provider + self.base_url = base_url.rstrip("/") + self.model = model + self.api_key = api_key + + @classmethod + def from_settings(cls) -> "LLMClient": + from links.models import SiteSettings + ss = SiteSettings.get() + return cls( + provider=ss.llm_provider, + base_url=ss.llm_base_url, + model=ss.llm_model, + api_key=ss.llm_api_key, + ) + + def get_embedding(self, text: str) -> list[float]: + """Return a float vector for *text*. Raises LLMClientError on failure.""" + if not text or not text.strip(): + raise LLMClientError("Empty text passed to get_embedding") + + if self.provider == "ollama": + return self._ollama_embed(text) + elif self.provider == "openrouter": + return self._openrouter_embed(text) + else: + raise LLMClientError(f"Unsupported provider: {self.provider}") + + def _ollama_embed(self, text: str) -> list[float]: + url = f"{self.base_url}/api/embeddings" + try: + resp = requests.post( + url, + json={"model": self.model, "prompt": text}, + timeout=EMBED_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + vec = data.get("embedding") + if not vec: + raise LLMClientError(f"Ollama returned no embedding. Response: {data}") + return vec + except requests.RequestException as exc: + raise LLMClientError(f"Ollama request failed: {exc}") from exc + + def _openrouter_embed(self, text: str) -> list[float]: + url = "https://openrouter.ai/api/v1/embeddings" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + try: + resp = requests.post( + url, + headers=headers, + json={"model": self.model, "input": text}, + timeout=EMBED_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + try: + vec = data["data"][0]["embedding"] + except (KeyError, IndexError) as exc: + raise LLMClientError(f"Unexpected OpenRouter response shape: {data}") from exc + return vec + except requests.RequestException as exc: + raise LLMClientError(f"OpenRouter request failed: {exc}") from exc + + def test_connection(self) -> tuple[bool, str]: + """Returns (success: bool, error_message: str).""" + try: + vec = self.get_embedding("ping") + if not vec: + return False, "Got empty embedding vector" + return True, "" + except LLMClientError as exc: + return False, str(exc) + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + """Compute cosine similarity between two vectors.""" + dot = sum(x * y for x, y in zip(a, b)) + mag_a = math.sqrt(sum(x * x for x in a)) + mag_b = math.sqrt(sum(x * x for x in b)) + if mag_a == 0 or mag_b == 0: + return 0.0 + return dot / (mag_a * mag_b) diff --git a/links/migrations/0045_knowledge_graph.py b/links/migrations/0045_knowledge_graph.py new file mode 100644 index 0000000..d815871 --- /dev/null +++ b/links/migrations/0045_knowledge_graph.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.12 on 2026-03-30 06:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0044_add_source_url_to_fileupload'), + ] + + operations = [ + migrations.CreateModel( + name='KnowledgeGraphSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('building', 'Building'), ('ready', 'Ready'), ('failed', 'Failed')], db_index=True, default='building', max_length=20, verbose_name='Status')), + ('graph_data', models.JSONField(default=dict, help_text='Graphology-compatible serialization: {nodes: [...], edges: [...]}', verbose_name='Graph Data')), + ('node_count', models.IntegerField(default=0, verbose_name='Node Count')), + ('edge_count', models.IntegerField(default=0, verbose_name='Edge Count')), + ('used_llm', models.BooleanField(default=False, verbose_name='Used LLM')), + ('error_message', models.TextField(blank=True, verbose_name='Error Message')), + ('build_duration_ms', models.IntegerField(default=0, verbose_name='Build Duration (ms)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), + ], + options={ + 'verbose_name': 'Knowledge Graph Snapshot', + 'verbose_name_plural': 'Knowledge Graph Snapshots', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='sitesettings', + name='kg_auto_schedule_enabled', + field=models.BooleanField(default=False, help_text='When enabled, the knowledge graph is rebuilt on the configured interval.', verbose_name='Auto-rebuild Knowledge Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_auto_schedule_interval', + field=models.IntegerField(default=3600, help_text='How often (in seconds) to auto-rebuild the knowledge graph. Minimum 60.', verbose_name='Knowledge Graph Rebuild Interval (seconds)'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_links', + field=models.BooleanField(default=True, verbose_name='Include Links in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_pages', + field=models.BooleanField(default=True, verbose_name='Include Pages in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_posts', + field=models.BooleanField(default=True, verbose_name='Include Posts in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_tags', + field=models.BooleanField(default=True, verbose_name='Include Tags in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_semantic_threshold', + field=models.FloatField(default=0.7, help_text='Minimum cosine similarity (0.0–1.0) required to draw a semantic edge between two items.', verbose_name='Semantic Similarity Threshold'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_api_key', + field=models.CharField(blank=True, default='', help_text='API key for OpenRouter (not needed for Ollama).', max_length=255, verbose_name='LLM API Key'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_base_url', + field=models.CharField(blank=True, default='http://localhost:11434', help_text='Base URL for the Ollama server (e.g. http://192.168.1.2:11434).', max_length=255, verbose_name='LLM Base URL'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_model', + field=models.CharField(blank=True, default='nomic-embed-text', help_text='Model name used for embeddings (e.g. nomic-embed-text for Ollama).', max_length=100, verbose_name='LLM Embedding Model'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_provider', + field=models.CharField(choices=[('none', 'None (no LLM)'), ('ollama', 'Ollama (local)'), ('openrouter', 'OpenRouter')], default='none', help_text='LLM/embedding provider used to generate semantic edges in the knowledge graph.', max_length=20, verbose_name='LLM Provider'), + ), + ] diff --git a/links/models.py b/links/models.py index fa9a414..8e0594d 100644 --- a/links/models.py +++ b/links/models.py @@ -415,6 +415,12 @@ class SiteSettings(models.Model): Singleton model for app-wide configuration. Always use SiteSettings.get() to retrieve the instance. """ + + class LLMProvider(models.TextChoices): + NONE = 'none', _('None (no LLM)') + OLLAMA = 'ollama', _('Ollama (local)') + OPENROUTER = 'openrouter', _('OpenRouter') + public_sharing_domain = models.CharField( _('Public Sharing Domain'), max_length=255, @@ -452,6 +458,57 @@ class SiteSettings(models.Model): ), ) + # ── Knowledge Graph: LLM settings ──────────────────────────────────── + llm_provider = models.CharField( + _('LLM Provider'), + max_length=20, + choices=LLMProvider.choices, + default=LLMProvider.NONE, + help_text=_('LLM/embedding provider used to generate semantic edges in the knowledge graph.'), + ) + llm_base_url = models.CharField( + _('LLM Base URL'), + max_length=255, + blank=True, + default='http://localhost:11434', + help_text=_('Base URL for the Ollama server (e.g. http://192.168.1.2:11434).'), + ) + llm_model = models.CharField( + _('LLM Embedding Model'), + max_length=100, + blank=True, + default='nomic-embed-text', + help_text=_('Model name used for embeddings (e.g. nomic-embed-text for Ollama).'), + ) + llm_api_key = models.CharField( + _('LLM API Key'), + max_length=255, + blank=True, + default='', + help_text=_('API key for OpenRouter (not needed for Ollama).'), + ) + + # ── Knowledge Graph: schedule & build options ───────────────────────── + kg_auto_schedule_enabled = models.BooleanField( + _('Auto-rebuild Knowledge Graph'), + default=False, + help_text=_('When enabled, the knowledge graph is rebuilt on the configured interval.'), + ) + kg_auto_schedule_interval = models.IntegerField( + _('Knowledge Graph Rebuild Interval (seconds)'), + default=3600, + help_text=_('How often (in seconds) to auto-rebuild the knowledge graph. Minimum 60.'), + ) + kg_semantic_threshold = models.FloatField( + _('Semantic Similarity Threshold'), + default=0.70, + help_text=_('Minimum cosine similarity (0.0–1.0) required to draw a semantic edge between two items.'), + ) + kg_include_links = models.BooleanField(_('Include Links in Graph'), default=True) + kg_include_pages = models.BooleanField(_('Include Pages in Graph'), default=True) + kg_include_posts = models.BooleanField(_('Include Posts in Graph'), default=True) + kg_include_tags = models.BooleanField(_('Include Tags in Graph'), default=True) + class Meta: verbose_name = _('Site Settings') @@ -466,3 +523,44 @@ class SiteSettings(models.Model): def save(self, *args, **kwargs): self.pk = 1 super().save(*args, **kwargs) + + +class KnowledgeGraphSnapshot(models.Model): + """Stores a point-in-time serialized knowledge graph for immediate front-end consumption.""" + + class Status(models.TextChoices): + BUILDING = 'building', _('Building') + READY = 'ready', _('Ready') + FAILED = 'failed', _('Failed') + + status = models.CharField( + _('Status'), + max_length=20, + choices=Status.choices, + default=Status.BUILDING, + db_index=True, + ) + graph_data = models.JSONField( + _('Graph Data'), + default=dict, + help_text=_('Graphology-compatible serialization: {nodes: [...], edges: [...]}'), + ) + node_count = models.IntegerField(_('Node Count'), default=0) + edge_count = models.IntegerField(_('Edge Count'), default=0) + used_llm = models.BooleanField(_('Used LLM'), default=False) + error_message = models.TextField(_('Error Message'), blank=True) + build_duration_ms = models.IntegerField(_('Build Duration (ms)'), default=0) + created_at = models.DateTimeField(_('Created At'), auto_now_add=True) + completed_at = models.DateTimeField(_('Completed At'), null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = _('Knowledge Graph Snapshot') + verbose_name_plural = _('Knowledge Graph Snapshots') + + def __str__(self): + return f"KG Snapshot [{self.status}] {self.node_count} nodes / {self.edge_count} edges @ {self.created_at}" + + @classmethod + def get_latest(cls): + return cls.objects.filter(status=cls.Status.READY).first() diff --git a/links/tasks.py b/links/tasks.py index 1bfd6b9..2879a98 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -622,3 +622,291 @@ def retry_stuck_image_imports(): logger.info(f"retry_stuck_image_imports: re-queuing download for FileUpload {record.pk} ({record.source_url})") thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) thread.start() + + +# ── Knowledge Graph ─────────────────────────────────────────────────────────── + +def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False): + """ + Build a graphology-compatible knowledge graph from all Links, Pages, Posts and Tags. + + Nodes are coloured by type: + Link → #4B9CD3 (blue) + Page → #22C55E (green) + Post → #F97316 (orange) + Tag → #8B5CF6 (purple) + + Edges (always): + has_tag item → tag (#94A3B8, structural) + same_domain items sharing netloc (#FCD34D, domain) + + Edges (with LLM): + semantic cosine-sim ≥ threshold (#F472B6) + """ + import time + import random + from urllib.parse import urlparse + from django.utils import timezone as tz + from .models import KnowledgeGraphSnapshot, SiteSettings, Link, Page, Post, Tag + + start_ms = int(time.time() * 1000) + + # Resolve / create the snapshot record + if snapshot_id: + try: + snapshot = KnowledgeGraphSnapshot.objects.get(pk=snapshot_id) + except KnowledgeGraphSnapshot.DoesNotExist: + logger.error(f"build_knowledge_graph: snapshot {snapshot_id} not found") + return + else: + snapshot = KnowledgeGraphSnapshot.objects.create( + status=KnowledgeGraphSnapshot.Status.BUILDING, + used_llm=use_llm, + ) + + snapshot.status = KnowledgeGraphSnapshot.Status.BUILDING + snapshot.used_llm = use_llm + snapshot.save(update_fields=["status", "used_llm"]) + + try: + ss = SiteSettings.get() + nodes = [] + edges = [] + edge_key_counter = [0] + + def next_edge_key(): + edge_key_counter[0] += 1 + return f"e{edge_key_counter[0]}" + + def rand_pos(): + return round(random.uniform(0, 100), 2) + + # ── Collect nodes ──────────────────────────────────────────────── + # Links + if ss.kg_include_links: + for link in Link.objects.prefetch_related("tags").all(): + size = max(6, min(20, 6 + link.click_count // 5)) + nodes.append({ + "key": f"link-{link.id}", + "attributes": { + "label": link.alias, + "color": "#4B9CD3", + "size": size, + "node_type": "link", + "item_id": link.id, + "item_url": f"/detail/{link.pk}/", + "original_url": link.original_url or "", + "description": link.description or "", + "tag_ids": [t.id for t in link.tags.all()], + "x": rand_pos(), + "y": rand_pos(), + }, + }) + + # Pages + if ss.kg_include_pages: + for page in Page.objects.prefetch_related("tags").all(): + nodes.append({ + "key": f"page-{page.id}", + "attributes": { + "label": (page.title or page.url)[:80], + "color": "#22C55E", + "size": 8, + "node_type": "page", + "item_id": page.id, + "item_url": f"/ui/pages/{page.pk}/", + "original_url": page.url, + "description": page.summary or "", + "tag_ids": [t.id for t in page.tags.all()], + "x": rand_pos(), + "y": rand_pos(), + }, + }) + + # Posts + if ss.kg_include_posts: + for post in Post.objects.prefetch_related("tags").all(): + nodes.append({ + "key": f"post-{post.id}", + "attributes": { + "label": post.title[:80], + "color": "#F97316", + "size": 8, + "node_type": "post", + "item_id": post.id, + "item_url": f"/ui/posts/{post.pk}/", + "original_url": "", + "description": post.summary or "", + "tag_ids": [t.id for t in post.tags.all()], + "x": rand_pos(), + "y": rand_pos(), + }, + }) + + # Tags + if ss.kg_include_tags: + for tag in Tag.objects.all(): + nodes.append({ + "key": f"tag-{tag.id}", + "attributes": { + "label": tag.name, + "color": "#8B5CF6", + "size": 14, + "node_type": "tag", + "item_id": tag.id, + "item_url": f"/ui/tags/{tag.slug}/", + "original_url": "", + "description": tag.description or "", + "x": rand_pos(), + "y": rand_pos(), + }, + }) + + # Build a set of existing node keys for fast membership checks + node_keys = {n["key"] for n in nodes} + + # ── Structural edges ───────────────────────────────────────────── + for node in nodes: + ntype = node["attributes"]["node_type"] + nkey = node["key"] + + if ntype in ("link", "page", "post") and ss.kg_include_tags: + for tag_id in node["attributes"].get("tag_ids", []): + tag_key = f"tag-{tag_id}" + if tag_key in node_keys: + edges.append({ + "key": next_edge_key(), + "source": nkey, + "target": tag_key, + "attributes": { + "edge_type": "has_tag", + "color": "#94A3B8", + "size": 1, + }, + }) + + if ntype == "tag": + pass # Tag has no parent field; hierarchy edges skipped + + # ── Domain edges ───────────────────────────────────────────────── + domain_map: dict[str, list[str]] = {} + for node in nodes: + url = node["attributes"].get("original_url", "") + if url: + try: + netloc = urlparse(url).netloc + if netloc: + domain_map.setdefault(netloc, []).append(node["key"]) + except Exception: + pass + + domain_edge_count = 0 + for netloc, keys in domain_map.items(): + if len(keys) < 2: + continue + for i in range(len(keys)): + for j in range(i + 1, len(keys)): + if domain_edge_count >= 100: + break + edges.append({ + "key": next_edge_key(), + "source": keys[i], + "target": keys[j], + "attributes": { + "edge_type": "same_domain", + "color": "#FCD34D", + "size": 0.5, + "domain": netloc, + }, + }) + domain_edge_count += 1 + if domain_edge_count >= 100: + break + + # ── Semantic edges (LLM) ───────────────────────────────────────── + if use_llm and ss.llm_provider != "none": + from .llm_client import LLMClient, LLMClientError, cosine_similarity + client = LLMClient.from_settings() + threshold = ss.kg_semantic_threshold + + # Build text corpus and compute embeddings + embed_nodes = [n for n in nodes if n["attributes"]["node_type"] != "tag"] + embeddings: dict[str, list[float]] = {} + + for node in embed_nodes: + text_parts = [node["attributes"].get("label", "")] + desc = node["attributes"].get("description", "") + if desc: + text_parts.append(desc[:500]) + text = " ".join(text_parts).strip() + try: + embeddings[node["key"]] = client.get_embedding(text) + except LLMClientError as exc: + logger.warning(f"build_knowledge_graph: embedding failed for {node['key']}: {exc}") + + # Compare all pairs + keys_with_embeds = list(embeddings.keys()) + for i in range(len(keys_with_embeds)): + for j in range(i + 1, len(keys_with_embeds)): + ka, kb = keys_with_embeds[i], keys_with_embeds[j] + sim = cosine_similarity(embeddings[ka], embeddings[kb]) + if sim >= threshold: + edges.append({ + "key": next_edge_key(), + "source": ka, + "target": kb, + "attributes": { + "edge_type": "semantic", + "color": "#F472B6", + "size": round(sim, 3), + "similarity": round(sim, 3), + }, + }) + + # ── Save snapshot ───────────────────────────────────────────────── + graph_data = { + "attributes": {"title": "Knowledge Graph"}, + "nodes": nodes, + "edges": edges, + } + elapsed_ms = int(time.time() * 1000) - start_ms + + snapshot.graph_data = graph_data + snapshot.node_count = len(nodes) + snapshot.edge_count = len(edges) + snapshot.status = KnowledgeGraphSnapshot.Status.READY + snapshot.build_duration_ms = elapsed_ms + snapshot.completed_at = tz.now() + snapshot.save() + logger.info( + f"build_knowledge_graph: done — {len(nodes)} nodes, {len(edges)} edges " + f"(LLM={use_llm}) in {elapsed_ms}ms" + ) + + except Exception as exc: + logger.error(f"build_knowledge_graph: failed — {exc}", exc_info=True) + snapshot.status = KnowledgeGraphSnapshot.Status.FAILED + snapshot.error_message = str(exc) + snapshot.completed_at = tz.now() + snapshot.save(update_fields=["status", "error_message", "completed_at"]) + + +def schedule_kg_build(): + """Periodic task: trigger a knowledge graph rebuild if auto-schedule is enabled.""" + from .models import SiteSettings, KnowledgeGraphSnapshot + + ss = SiteSettings.get() + if not ss.kg_auto_schedule_enabled: + return + + # Don't start a new build if one is already running + if KnowledgeGraphSnapshot.objects.filter( + status=KnowledgeGraphSnapshot.Status.BUILDING + ).exists(): + logger.debug("schedule_kg_build: skipped — a build is already in progress") + return + + logger.info("schedule_kg_build: starting auto knowledge graph build") + use_llm = ss.llm_provider != "none" + thread = Thread(target=build_knowledge_graph, kwargs={"use_llm": use_llm}, daemon=True) + thread.start() diff --git a/links/templates/links/knowledge_graph.html b/links/templates/links/knowledge_graph.html new file mode 100644 index 0000000..753cd1e --- /dev/null +++ b/links/templates/links/knowledge_graph.html @@ -0,0 +1,485 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      + + +
      + + + + + + + + + + + +
      + + +
      + + +
      +
      +

      {% trans "Node Details" %}

      + +
      +
      + + + + + + + +

      + + +

      + + +
      +

      {% trans "URL" %}

      + +
      + + + + + + + {% trans "View Item" %} + + + +
      +

      + {% trans "Connected" %} () +

      +
        + +
      • + + {% trans "more" %} +
      • +
      +
      +
      +
      + + +
      + + + +

      {% trans "No knowledge graph yet." %}

      + +
      + + +
      + + + + +

      {% trans "Building graph…" %}

      +
      + + +
      +
      +

      {% trans "Build Knowledge Graph" %}

      + +
      + +

      + {% trans "Semantic edges (pink) connect items with similar meaning using embeddings. Without LLM, the graph uses tag and domain edges only." %} +

      +
      + +
      + + +
      +
      +
      + +
      +{% endblock %} + +{% block extra_js %} + + + + + + +{% endblock %} diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html index 8bb9b3b..0f0bed2 100644 --- a/links/templates/links/settings.html +++ b/links/templates/links/settings.html @@ -106,6 +106,155 @@

      {% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}

      + +
      +

      {% trans "Knowledge Graph — LLM Provider" %}

      +

      + {% trans "Configure an LLM/embedding provider to generate semantic similarity edges in the knowledge graph. Select Ollama to use a local model for free." %} +

      + +
      + +
      + + +
      + + +
      + + +

      {% trans "E.g. http://localhost:11434 or your LAN Ollama address." %}

      +
      + + +
      + + +

      + {% trans "Ollama: nomic-embed-text, mxbai-embed-large, etc." %} •  + {% trans "OpenRouter: any embedding model slug." %} +

      +
      + + +
      + + +
      + + +
      + + +
      +
      +
      + + +
      +

      {% trans "Knowledge Graph — Auto Rebuild" %}

      +

      + {% trans "Optionally rebuild the knowledge graph on a schedule. You can also trigger a manual build from the " %} + {% trans "Knowledge Graph page" %}. +

      + +
      + + + + +
      + +
      + + {% trans "seconds" %} +
      +

      {% trans "Minimum 60 (1 minute). Default 3600 (1 hour)." %}

      +
      + + +
      + + +
      + 0.50 ({% trans "more edges" %}) + 0.95 ({% trans "fewer, tighter edges" %}) +
      +
      + + +
      +

      {% trans "Include in graph:" %}

      +
      + + + + +
      +
      +
      +
      +
      @@ -121,3 +270,41 @@
      {% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/links/urls.py b/links/urls.py index d9dc17c..2c0a6e3 100644 --- a/links/urls.py +++ b/links/urls.py @@ -72,6 +72,8 @@ urlpatterns = [ path('', include('links.collection_urls')), # Include tag URLs path('', include('links.tag_urls')), + # Include knowledge graph URLs + path('', include('links.knowledge_graph_urls')), # Aliases - these should always be last path('/', views.redirect_to_original, name='redirect_to_original'), diff --git a/links/views.py b/links/views.py index f4418ac..f8f38d0 100644 --- a/links/views.py +++ b/links/views.py @@ -590,6 +590,32 @@ class SiteSettingsView(View): site_settings.schedule_pending_screenshots_interval = max(10, min(screenshots_interval, 3600)) except (ValueError, TypeError): pass + + # ── Knowledge Graph LLM settings ────────────────────────────────── + llm_provider = request.POST.get('llm_provider', 'none').strip() + if llm_provider in ('none', 'ollama', 'openrouter'): + site_settings.llm_provider = llm_provider + site_settings.llm_base_url = request.POST.get('llm_base_url', '').strip() or 'http://localhost:11434' + site_settings.llm_model = request.POST.get('llm_model', '').strip() or 'nomic-embed-text' + site_settings.llm_api_key = request.POST.get('llm_api_key', '').strip() + + # ── Knowledge Graph schedule settings ───────────────────────────── + site_settings.kg_auto_schedule_enabled = bool(request.POST.get('kg_auto_schedule_enabled')) + try: + kg_interval = int(request.POST.get('kg_auto_schedule_interval', 3600)) + site_settings.kg_auto_schedule_interval = max(60, min(kg_interval, 86400)) + except (ValueError, TypeError): + pass + try: + threshold = float(request.POST.get('kg_semantic_threshold', 0.70)) + site_settings.kg_semantic_threshold = max(0.0, min(threshold, 1.0)) + except (ValueError, TypeError): + pass + site_settings.kg_include_links = bool(request.POST.get('kg_include_links')) + site_settings.kg_include_pages = bool(request.POST.get('kg_include_pages')) + site_settings.kg_include_posts = bool(request.POST.get('kg_include_posts')) + site_settings.kg_include_tags = bool(request.POST.get('kg_include_tags')) + site_settings.save() # Reschedule periodic jobs with the new intervals @@ -602,6 +628,11 @@ class SiteSettingsView(View): 'schedule_pending_screenshots', trigger=IntervalTrigger(seconds=site_settings.schedule_pending_screenshots_interval), ) + # Reschedule knowledge graph job + scheduler.reschedule_job( + 'schedule_kg_build', + trigger=IntervalTrigger(seconds=site_settings.kg_auto_schedule_interval), + ) except Exception: pass # Scheduler may not be running in test/CLI context diff --git a/templates/base.html b/templates/base.html index 3c5dcda..25499d7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -187,6 +187,16 @@
      + +
      + + + + {% trans "Knowledge Graph" %} +
      +
      +
      From 029ed423c90cab1ab5aed12cbece430217b3d601 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Mar 2026 18:25:52 +1100 Subject: [PATCH 47/95] Add graph --- data/db.sqlite3 | Bin 479232 -> 774144 bytes links/knowledge_graph_views.py | 37 ++++++- links/migrations/0046_kg_progress_data.py | 18 ++++ links/models.py | 6 ++ links/tasks.py | 70 ++++++++++-- links/templates/links/knowledge_graph.html | 117 +++++++++++++++++++-- templates/base.html | 20 ++-- 7 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 links/migrations/0046_kg_progress_data.py diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 7ffae6652ffeba39ba77849347c3e33590a6d3a1..ffedfdd6992a4ea1b5a67b44979c08252c26507f 100644 GIT binary patch literal 774144 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

      #2Oy!9WxJR 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 o>$>UYV?>Xol2KyhjBGBQiW|wKY3ir@-wR diff --git a/links/knowledge_graph_views.py b/links/knowledge_graph_views.py index f305e5f..b873744 100644 --- a/links/knowledge_graph_views.py +++ b/links/knowledge_graph_views.py @@ -3,9 +3,11 @@ Views for the Knowledge Graph feature. """ import json import logging +from datetime import timedelta from django.http import JsonResponse from django.shortcuts import render +from django.utils import timezone from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt @@ -14,6 +16,9 @@ from .models import KnowledgeGraphSnapshot, SiteSettings logger = logging.getLogger(__name__) +# A BUILDING snapshot with no progress after this many minutes is considered stuck +STALE_BUILD_MINUTES = 5 + class KnowledgeGraphPageView(View): template_name = "links/knowledge_graph.html" @@ -46,6 +51,20 @@ class KnowledgeGraphBuildView(View): except json.JSONDecodeError: body = {} + # Cancel any stuck BUILDING snapshots before starting a new one + stale_cutoff = timezone.now() - timedelta(minutes=STALE_BUILD_MINUTES) + stuck = KnowledgeGraphSnapshot.objects.filter( + status=KnowledgeGraphSnapshot.Status.BUILDING, + created_at__lt=stale_cutoff, + ) + if stuck.exists(): + stuck.update( + status=KnowledgeGraphSnapshot.Status.FAILED, + error_message="Build was cancelled (process was killed or server restarted)", + completed_at=timezone.now(), + ) + logger.info("Marked %d stale BUILDING snapshot(s) as FAILED", stuck.count()) + ss = SiteSettings.get() use_llm_requested = body.get("use_llm", False) # Only use LLM if a provider is configured @@ -78,14 +97,30 @@ class KnowledgeGraphStatusView(View): snapshot = KnowledgeGraphSnapshot.objects.first() if not snapshot: return JsonResponse({"status": "none"}) + + # Auto-detect stale BUILDING snapshots (e.g. server restarted, thread was killed) + status = snapshot.status + if status == KnowledgeGraphSnapshot.Status.BUILDING: + progress = snapshot.progress_data or {} + age_s = (timezone.now() - snapshot.created_at).total_seconds() + last_pct = progress.get("pct", 0) + # Mark stuck if: no progress at all after 5 min, or no change for 10 min + if age_s > STALE_BUILD_MINUTES * 60 and last_pct == 0: + snapshot.status = KnowledgeGraphSnapshot.Status.FAILED + snapshot.error_message = "Build timed out — the worker thread was likely killed (server restart). Click Build Graph to try again." + snapshot.completed_at = timezone.now() + snapshot.save(update_fields=["status", "error_message", "completed_at"]) + status = KnowledgeGraphSnapshot.Status.FAILED + return JsonResponse({ "snapshot_id": snapshot.pk, - "status": snapshot.status, + "status": status, "node_count": snapshot.node_count, "edge_count": snapshot.edge_count, "used_llm": snapshot.used_llm, "build_duration_ms": snapshot.build_duration_ms, "error_message": snapshot.error_message, + "progress_data": snapshot.progress_data or {}, "created_at": snapshot.created_at.isoformat() if snapshot.created_at else None, "completed_at": snapshot.completed_at.isoformat() if snapshot.completed_at else None, }) diff --git a/links/migrations/0046_kg_progress_data.py b/links/migrations/0046_kg_progress_data.py new file mode 100644 index 0000000..cca3948 --- /dev/null +++ b/links/migrations/0046_kg_progress_data.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-30 07:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0045_knowledge_graph'), + ] + + operations = [ + migrations.AddField( + model_name='knowledgegraphsnapshot', + name='progress_data', + field=models.JSONField(blank=True, default=dict, help_text='Live build progress: {pct, step, logs}', verbose_name='Progress Data'), + ), + ] diff --git a/links/models.py b/links/models.py index 8e0594d..4d8b632 100644 --- a/links/models.py +++ b/links/models.py @@ -550,6 +550,12 @@ class KnowledgeGraphSnapshot(models.Model): used_llm = models.BooleanField(_('Used LLM'), default=False) error_message = models.TextField(_('Error Message'), blank=True) build_duration_ms = models.IntegerField(_('Build Duration (ms)'), default=0) + progress_data = models.JSONField( + _('Progress Data'), + default=dict, + blank=True, + help_text=_('Live build progress: {pct, step, logs}'), + ) created_at = models.DateTimeField(_('Created At'), auto_now_add=True) completed_at = models.DateTimeField(_('Completed At'), null=True, blank=True) diff --git a/links/tasks.py b/links/tasks.py index 2879a98..95b625e 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -666,13 +666,29 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) snapshot.status = KnowledgeGraphSnapshot.Status.BUILDING snapshot.used_llm = use_llm - snapshot.save(update_fields=["status", "used_llm"]) + snapshot.progress_data = {"pct": 0, "step": "Starting…", "logs": []} + snapshot.save(update_fields=["status", "used_llm", "progress_data"]) try: ss = SiteSettings.get() nodes = [] edges = [] edge_key_counter = [0] + _log_entries: list[dict] = [] + + def _elapsed_s() -> float: + return round((time.time() * 1000 - start_ms) / 1000, 1) + + def emit(pct: int, msg: str) -> None: + """Save a progress update to the snapshot so the UI can poll it.""" + _log_entries.append({"elapsed_s": _elapsed_s(), "msg": msg}) + snapshot.progress_data = { + "pct": pct, + "step": msg, + "logs": list(_log_entries), + } + snapshot.save(update_fields=["progress_data"]) + logger.info(f"build_knowledge_graph [{pct}%]: {msg}") def next_edge_key(): edge_key_counter[0] += 1 @@ -681,10 +697,13 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) def rand_pos(): return round(random.uniform(0, 100), 2) + emit(2, "Build started") + # ── Collect nodes ──────────────────────────────────────────────── # Links if ss.kg_include_links: - for link in Link.objects.prefetch_related("tags").all(): + link_qs = list(Link.objects.prefetch_related("tags").all()) + for link in link_qs: size = max(6, min(20, 6 + link.click_count // 5)) nodes.append({ "key": f"link-{link.id}", @@ -702,10 +721,12 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) "y": rand_pos(), }, }) + emit(15, f"Collected {len(link_qs)} links") # Pages if ss.kg_include_pages: - for page in Page.objects.prefetch_related("tags").all(): + page_qs = list(Page.objects.prefetch_related("tags").all()) + for page in page_qs: nodes.append({ "key": f"page-{page.id}", "attributes": { @@ -722,10 +743,12 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) "y": rand_pos(), }, }) + emit(28, f"Collected {len(page_qs)} pages") # Posts if ss.kg_include_posts: - for post in Post.objects.prefetch_related("tags").all(): + post_qs = list(Post.objects.prefetch_related("tags").all()) + for post in post_qs: nodes.append({ "key": f"post-{post.id}", "attributes": { @@ -742,10 +765,12 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) "y": rand_pos(), }, }) + emit(38, f"Collected {len(post_qs)} posts") # Tags if ss.kg_include_tags: - for tag in Tag.objects.all(): + tag_qs = list(Tag.objects.all()) + for tag in tag_qs: nodes.append({ "key": f"tag-{tag.id}", "attributes": { @@ -761,11 +786,13 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) "y": rand_pos(), }, }) + emit(45, f"Collected {len(tag_qs)} tags — {len(nodes)} total nodes") # Build a set of existing node keys for fast membership checks node_keys = {n["key"] for n in nodes} # ── Structural edges ───────────────────────────────────────────── + emit(48, "Building tag edges…") for node in nodes: ntype = node["attributes"]["node_type"] nkey = node["key"] @@ -788,7 +815,10 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) if ntype == "tag": pass # Tag has no parent field; hierarchy edges skipped + emit(55, f"Built {len(edges)} tag edges") + # ── Domain edges ───────────────────────────────────────────────── + emit(57, "Building domain edges…") domain_map: dict[str, list[str]] = {} for node in nodes: url = node["attributes"].get("original_url", "") @@ -823,17 +853,21 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) if domain_edge_count >= 100: break + emit(65, f"Built {domain_edge_count} domain edges across {len(domain_map)} domains") + # ── Semantic edges (LLM) ───────────────────────────────────────── if use_llm and ss.llm_provider != "none": from .llm_client import LLMClient, LLMClientError, cosine_similarity client = LLMClient.from_settings() threshold = ss.kg_semantic_threshold - # Build text corpus and compute embeddings embed_nodes = [n for n in nodes if n["attributes"]["node_type"] != "tag"] - embeddings: dict[str, list[float]] = {} + total_embed = len(embed_nodes) + emit(67, f"Computing embeddings for {total_embed} nodes via {ss.llm_provider}…") - for node in embed_nodes: + embeddings: dict[str, list[float]] = {} + emit_every = max(1, total_embed // 10) # emit ~10 progress steps + for idx, node in enumerate(embed_nodes): text_parts = [node["attributes"].get("label", "")] desc = node["attributes"].get("description", "") if desc: @@ -843,8 +877,12 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) embeddings[node["key"]] = client.get_embedding(text) except LLMClientError as exc: logger.warning(f"build_knowledge_graph: embedding failed for {node['key']}: {exc}") + if (idx + 1) % emit_every == 0 or (idx + 1) == total_embed: + pct = 67 + int(18 * (idx + 1) / total_embed) + emit(pct, f"Embedded {idx + 1}/{total_embed} nodes…") - # Compare all pairs + emit(85, f"Got {len(embeddings)} embeddings — computing pairwise similarity…") + semantic_count = 0 keys_with_embeds = list(embeddings.keys()) for i in range(len(keys_with_embeds)): for j in range(i + 1, len(keys_with_embeds)): @@ -862,8 +900,14 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) "similarity": round(sim, 3), }, }) + semantic_count += 1 + + emit(90, f"Built {semantic_count} semantic edges (threshold={threshold})") + else: + emit(65, "Skipping LLM semantic edges (no provider configured)") # ── Save snapshot ───────────────────────────────────────────────── + emit(92, f"Saving snapshot — {len(nodes)} nodes, {len(edges)} edges…") graph_data = { "attributes": {"title": "Knowledge Graph"}, "nodes": nodes, @@ -871,12 +915,18 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) } elapsed_ms = int(time.time() * 1000) - start_ms + _log_entries.append({"elapsed_s": _elapsed_s(), "msg": f"Done in {elapsed_ms / 1000:.1f}s"}) snapshot.graph_data = graph_data snapshot.node_count = len(nodes) snapshot.edge_count = len(edges) snapshot.status = KnowledgeGraphSnapshot.Status.READY snapshot.build_duration_ms = elapsed_ms snapshot.completed_at = tz.now() + snapshot.progress_data = { + "pct": 100, + "step": f"Done — {len(nodes)} nodes, {len(edges)} edges in {elapsed_ms / 1000:.1f}s", + "logs": list(_log_entries), + } snapshot.save() logger.info( f"build_knowledge_graph: done — {len(nodes)} nodes, {len(edges)} edges " @@ -888,7 +938,7 @@ def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False) snapshot.status = KnowledgeGraphSnapshot.Status.FAILED snapshot.error_message = str(exc) snapshot.completed_at = tz.now() - snapshot.save(update_fields=["status", "error_message", "completed_at"]) + snapshot.save(update_fields=["status", "error_message", "completed_at", "progress_data"]) def schedule_kg_build(): diff --git a/links/templates/links/knowledge_graph.html b/links/templates/links/knowledge_graph.html index 753cd1e..b925cc0 100644 --- a/links/templates/links/knowledge_graph.html +++ b/links/templates/links/knowledge_graph.html @@ -182,15 +182,64 @@

      - +
      - - - - -

      {% trans "Building graph…" %}

      + class="absolute inset-0 flex items-center justify-center p-6"> +
      + + +
      + + + + + {% trans "Building Knowledge Graph" %} +
      + + +
      +
      + + +
      +
      +
      +
      +
      + + +
      + ⏱ {% trans "Elapsed" %}: + + {% trans "Est. remaining" %}: + +
      + + +
      + +
      + +
      {% trans "No log entries yet…" %}
      +
      +
      + +
      @@ -254,6 +303,13 @@ function kgApp() { connectedNodes: [], statusText: '{% trans "No graph" %}', pollTimer: null, + progress: { pct: 0, step: '', logs: [] }, + buildLogs: [], + logsExpanded: false, + elapsedSecs: 0, + estRemaining: 0, + _buildStartMs: null, + _elapsedTimer: null, filters: [ { type: 'link', label: '{% trans "Links" %}', color: '#4B9CD3', active: true, count: 0 }, { type: 'page', label: '{% trans "Pages" %}', color: '#22C55E', active: true, count: 0 }, @@ -435,6 +491,12 @@ function kgApp() { async startBuild() { this.showBuildModal = false; this.building = true; + this.progress = { pct: 0, step: '{% trans "Starting…" %}', logs: [] }; + this.buildLogs = []; + this.elapsedSecs = 0; + this.estRemaining = 0; + this._buildStartMs = Date.now(); + this._startElapsedTimer(this._buildStartMs); this.statusText = '{% trans "Building…" %}'; try { const resp = await fetch('/api/knowledge-graph/build/', { @@ -447,10 +509,29 @@ function kgApp() { } } catch(e) { this.building = false; + clearInterval(this._elapsedTimer); this.statusText = '{% trans "Build failed" %}'; } }, + // ── progress helpers ─────────────────────────────────────────────── + fmtSecs(s) { + if (s < 60) return Math.round(s) + 's'; + return Math.floor(s / 60) + 'm ' + (Math.round(s) % 60) + 's'; + }, + + _startElapsedTimer(startMs) { + this._buildStartMs = startMs; + clearInterval(this._elapsedTimer); + this._elapsedTimer = setInterval(() => { + this.elapsedSecs = (Date.now() - this._buildStartMs) / 1000; + const pct = this.progress.pct || 0; + if (pct > 5 && pct < 100) { + this.estRemaining = (this.elapsedSecs / pct) * (100 - pct); + } + }, 500); + }, + // ── status polling ───────────────────────────────────────────────── pollStatus() { clearInterval(this.pollTimer); @@ -458,12 +539,32 @@ function kgApp() { try { const resp = await fetch('/api/knowledge-graph/status/'); const data = await resp.json(); + + // Update live progress + if (data.progress_data && data.progress_data.pct !== undefined) { + this.progress = data.progress_data; + this.buildLogs = data.progress_data.logs || []; + if (this.logsExpanded && this.buildLogs.length) { + this.$nextTick(() => { + const el = document.querySelector('.font-mono.overflow-y-auto'); + if (el) el.scrollTop = el.scrollHeight; + }); + } + } + + // Start elapsed timer from server-reported created_at + if (data.status === 'building' && data.created_at && !this._buildStartMs) { + this._startElapsedTimer(new Date(data.created_at).getTime()); + } + if (data.status === 'ready') { clearInterval(this.pollTimer); + clearInterval(this._elapsedTimer); this.building = false; await this.loadGraph(); } else if (data.status === 'failed') { clearInterval(this.pollTimer); + clearInterval(this._elapsedTimer); this.building = false; this.statusText = '{% trans "Build failed" %}: ' + (data.error_message || '?'); } else if (data.status === 'building') { diff --git a/templates/base.html b/templates/base.html index 25499d7..a0823c5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -178,20 +178,12 @@
      - -
      - - - - {% trans "Settings" %} -
      -
      + d="M4 6a2 2 0 100-4 2 2 0 000 4zm16 0a2 2 0 100-4 2 2 0 000 4zM4 20a2 2 0 100-4 2 2 0 000 4zm16 0a2 2 0 100-4 2 2 0 000 4zm-8-8a2 2 0 100-4 2 2 0 000 4zM8.5 8.5l-3 3m13-3l-3 3m-7 0l3 3m1 0l3-3"/> {% trans "Knowledge Graph" %}
      @@ -201,11 +193,19 @@
      + d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/> {% trans "Jobs" %}
      + +
      + + + + {% trans "Settings" %} +
      +
      From bd37986b2182c6a21256000763052e61dcf9c67a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Mar 2026 19:51:12 +1100 Subject: [PATCH 48/95] Update jobs --- links/apps.py | 172 ++++++++++++++++ links/job_registry.py | 50 +++++ links/templates/links/jobs.html | 299 ++++++++++++++-------------- links/templates/links/settings.html | 4 +- links/views.py | 153 +++++++------- netscan/apps.py | 69 +++++++ qdrant_sync.py | 12 +- 7 files changed, 524 insertions(+), 235 deletions(-) create mode 100644 links/job_registry.py diff --git a/links/apps.py b/links/apps.py index e133061..702d4fc 100644 --- a/links/apps.py +++ b/links/apps.py @@ -8,3 +8,175 @@ class LinksConfig(AppConfig): def ready(self): # Import signals to register them import links.signals + self._register_job_types() + + def _register_job_types(self): + from . import job_registry + if job_registry.all_types(): + return # already registered (e.g. double-init in test runner) + + from .models import Screenshot, Page, FileUpload + from django.urls import reverse + + # ── Screenshots ────────────────────────────────────────────────── + def ss_stats(): + return { + 'total': Screenshot.objects.count(), + 'pending': Screenshot.objects.filter(status=Screenshot.Status.PENDING).count(), + 'processing': Screenshot.objects.filter(status=Screenshot.Status.PROCESSING).count(), + 'completed': Screenshot.objects.filter(status=Screenshot.Status.COMPLETED).count(), + 'failed': Screenshot.objects.filter(status=Screenshot.Status.FAILED).count(), + } + + def ss_queryset(sf): + qs = Screenshot.objects.select_related('page').order_by('-updated_at') + return qs if sf == 'all' else qs.filter(status=sf) + + def ss_serialize(obj): + return { + 'id': str(obj.id), + 'title': (obj.page.title or obj.page.url) if obj.page else '?', + 'detail_url': reverse('page-detail', args=[obj.page.pk]) if obj.page else '#', + 'status': obj.status, + 'retry': obj.retry_count, + 'retry_max': 3, + 'error': obj.error or '', + 'updated_at': obj.updated_at, + 'extra': {}, + } + + job_registry.register({ + 'id': 'screenshots', + 'label': 'Screenshots', + 'icon_color': 'text-indigo-500', + 'icon_path': ( + 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86' + 'a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2' + ' 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z' + ), + 'title_label': 'Page', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'), + ('completed', 'Completed'), ('failed', 'Failed'), + ], + 'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'], + 'get_stats': ss_stats, + 'get_queryset': ss_queryset, + 'serialize': ss_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_screenshots', + 'fail': 'bulk_fail_screenshots', + 'delete': 'bulk_delete_screenshots', + }, + }) + + # ── Page Processing ───────────────────────────────────────────── + def pg_stats(): + return { + 'total': Page.objects.count(), + 'pending': Page.objects.filter(process_status=Page.ProcessStatus.PENDING).count(), + 'processing': Page.objects.filter(process_status=Page.ProcessStatus.PROCESSING).count(), + 'completed': Page.objects.filter(process_status=Page.ProcessStatus.COMPLETED).count(), + 'failed': Page.objects.filter(process_status=Page.ProcessStatus.FAILED).count(), + } + + def pg_queryset(sf): + qs = Page.objects.order_by('-updated_at') + return qs if sf == 'all' else qs.filter(process_status=sf) + + def pg_serialize(obj): + return { + 'id': str(obj.id), + 'title': obj.title or obj.url, + 'detail_url': reverse('page-detail', args=[obj.pk]), + 'status': obj.process_status, + 'retry': obj.retry_count, + 'retry_max': 3, + 'error': obj.error_message or '', + 'updated_at': obj.updated_at, + 'extra': {}, + } + + job_registry.register({ + 'id': 'pages', + 'label': 'Page Processing', + 'icon_color': 'text-purple-500', + 'icon_path': ( + 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2' + 'V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2' + ), + 'title_label': 'Title / URL', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'), + ('completed', 'Completed'), ('failed', 'Failed'), + ], + 'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'], + 'get_stats': pg_stats, + 'get_queryset': pg_queryset, + 'serialize': pg_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_pages', + 'fail': 'bulk_fail_pages', + 'delete': 'bulk_delete_pages', + }, + }) + + # ── Image Imports ────────────────────────────────────────────── + def ii_stats(): + base = FileUpload.objects.filter(source_url__isnull=False).exclude(source_url='') + return { + 'total': base.count(), + 'pending': base.filter(size=0).count(), + 'completed': base.filter(size__gt=0).count(), + } + + def ii_queryset(sf): + base = FileUpload.objects.filter( + source_url__isnull=False, + ).exclude(source_url='').order_by('-updated_at') + if sf == 'pending': + return base.filter(size=0) + if sf == 'completed': + return base.filter(size__gt=0) + return base + + def ii_serialize(obj): + return { + 'id': str(obj.id), + 'title': obj.name, + 'detail_url': getattr(obj, 'download_url', '#') or '#', + 'status': 'completed' if obj.size > 0 else 'pending', + 'retry': None, + 'retry_max': None, + 'error': '', + 'updated_at': obj.updated_at, + 'extra': { + 'source_url': obj.source_url or '', + 'formatted_size': ( + getattr(obj, 'formatted_size', '') if obj.size > 0 else '' + ), + }, + } + + job_registry.register({ + 'id': 'image_imports', + 'label': 'Image Imports', + 'icon_color': 'text-green-500', + 'icon_path': ( + 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828' + ' 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12' + 'a2 2 0 002 2z' + ), + 'title_label': 'Filename', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('completed', 'Done'), + ], + 'columns': ['id', 'title', 'source_url', 'status', 'size', 'updated'], + 'get_stats': ii_stats, + 'get_queryset': ii_queryset, + 'serialize': ii_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_image_imports', + 'delete': 'bulk_delete_image_imports', + }, + }) diff --git a/links/job_registry.py b/links/job_registry.py new file mode 100644 index 0000000..a4a0349 --- /dev/null +++ b/links/job_registry.py @@ -0,0 +1,50 @@ +""" +Job type registry for the /ui/jobs/ page. + +To add a new job tab: + 1. Call ``register(job_type_dict)`` from ``LinksConfig.ready()`` in links/apps.py. + 2. Add the corresponding bulk action handler in ``JobsView.post()``. + +Each job type dict must have: + id (str) unique tab slug, used as ?tab= value + label (str) display name shown in the tab bar + icon_color (str) Tailwind text-colour class, e.g. 'text-indigo-500' + icon_path (str) SVG value for the stats card icon + title_label (str) column header for the title/name column + status_choices (list) [(value, label), ...]; first entry should be ('all', 'All') + columns (list[str]) ordered column ids from: + ['id','title','source_url','status','retry','error','size','updated'] + get_stats (callable) () -> dict with 'total' plus any of: + pending / processing / completed / failed + (omit keys that do not apply to this type) + get_queryset (callable) (status_filter: str) -> QuerySet + serialize (callable) (obj) -> dict with keys: + id, title, detail_url, status, + retry (int|None), retry_max (int|None), + error (str), updated_at (datetime), + extra (dict – for source_url, formatted_size, etc.) + bulk_actions (dict) subset of {'retry': action_name, + 'fail': action_name, + 'delete': action_name} +""" +import logging + +logger = logging.getLogger(__name__) + +_registry: list[dict] = [] + + +def register(job_type: dict) -> None: + """Register a job type. Call from LinksConfig.ready().""" + _registry.append(job_type) + logger.debug("Registered job type: %s", job_type.get("id")) + + +def all_types() -> list[dict]: + """Return all registered job types in registration order.""" + return list(_registry) + + +def get_type(type_id: str) -> dict | None: + """Return the job type dict with the given id, or None.""" + return next((j for j in _registry if j["id"] == type_id), None) diff --git a/links/templates/links/jobs.html b/links/templates/links/jobs.html index 58ff95d..dea09cb 100644 --- a/links/templates/links/jobs.html +++ b/links/templates/links/jobs.html @@ -41,102 +41,67 @@ {% endif %} -
      - +
      + {% for stat in job_type_stats %}
      - + - - {% if tab == 'screenshots' or tab == 'pages' %} + + {% if status_choices %}
      - {% for s, label, pill_active_style, pill_inactive_style in filter_options %} - - {{ label }} - - {% endfor %} -
      - {% elif tab == 'image_imports' %} -
      - {% for s, label, pill_active_style, pill_inactive_style in import_filter_options %} - + {% for value, label in status_choices %} + {{ label }} {% endfor %} @@ -193,7 +155,7 @@ style="background:#1d4ed8;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;"> ↺ {% trans "Retry" %} - @@ -220,6 +182,19 @@ {% if tab == 'scheduler' %} +
      + + + + ( {% trans "job(s)" %}) + +
      {% if scheduled_jobs %}
      @@ -232,26 +207,28 @@ - {% for job in scheduled_jobs %} - - - - - - - {% endfor %} +
      {{ job.id }}{{ job.name }}{{ job.trigger }} - {% if job.next_run %}{{ job.next_run }}{% else %}—{% endif %} -
      +
      + {% trans "No jobs match this filter." %} +
      {% else %}
      {% trans "No scheduled jobs." %}
      {% endif %} - {% elif tab == 'screenshots' %} - - {% if page_obj.object_list %} + {% else %} + + {% if page_rows %}
      @@ -262,64 +239,87 @@ :checked="allIds.length > 0 && selectedIds.length === allIds.length"> - - - - + + {% if 'source_url' in current_tab_config.columns %}{% endif %} + {% if 'status' in current_tab_config.columns %}{% endif %} + {% if 'retry' in current_tab_config.columns %}{% endif %} + {% if 'error' in current_tab_config.columns %}{% endif %} + {% if 'size' in current_tab_config.columns %}{% endif %} - {% for ss in page_obj.object_list %} - + {% for row in page_rows %} + - + + {% if 'source_url' in current_tab_config.columns %} + + {% endif %} + {% if 'status' in current_tab_config.columns %} - + {% endif %} + {% if 'retry' in current_tab_config.columns %} + {% endif %} + {% if 'error' in current_tab_config.columns %} + - + {% endif %} + {% if 'size' in current_tab_config.columns %} + + {% endif %} + {% endfor %}
      ID{% trans "Page" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{{ current_tab_config.title_label }}{% trans "Source URL" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{% trans "Size" %}{% trans "Updated" %}
      + value="{{ row.id }}" + @change="toggle('{{ row.id }}')" + :checked="selectedIds.includes('{{ row.id }}')"> {{ ss.id }}{{ row.id|truncatechars:12 }} - - {{ ss.page.title|default:ss.page.url|truncatechars:55 }} + {% if row.detail_url and row.detail_url != '#' %} + + {{ row.title|truncatechars:55 }} + + {% else %} + {{ row.title|truncatechars:55 }} + {% endif %} + + + {{ row.extra.source_url|truncatechars:50 }} - {{ ss.status }} + {{ row.status }} {{ ss.retry_count }}/3 - {% if ss.error %} + {% if row.retry is not None %}{{ row.retry }}/{{ row.retry_max }}{% else %}{% endif %} + + {% if row.error %} {% else %}{% endif %} {{ ss.updated_at|timesince }} {% trans "ago" %} + {% if row.extra.formatted_size %}{{ row.extra.formatted_size }}{% else %}{% endif %} + {{ row.updated_at|timesince }} {% trans "ago" %}
      {% else %} -
      {% trans "No screenshots match this filter." %}
      +
      {% trans "No items match this filter." %}
      {% endif %} - {% elif tab == 'pages' %} - - {% if page_obj.object_list %} + {% comment %}pages table removed — unified table above handles all types{% endcomment %} + {% if False %}
      @@ -385,8 +385,8 @@
      {% trans "No pages match this filter." %}
      {% endif %} - {% elif tab == 'image_imports' %} - + {% comment %}image_imports table removed — unified table above handles all types{% endcomment %} + {% if False %} {% if page_obj.object_list %}
      @@ -450,6 +450,7 @@
      {% trans "No image imports match this filter." %}
      {% endif %} {% endif %} + {% endif %} {% if page_obj.has_other_pages %} @@ -510,6 +511,8 @@ {{ all_ids|json_script:"jobs-all-ids" }} +{{ scheduled_jobs|json_script:"scheduled-jobs-data" }} +{{ bulk_actions|json_script:"bulk-actions-data" }} + @@ -302,10 +402,13 @@ + +
      + + +
      +
      +

      + + Nginx Monitor + + Live + +

      +

      {{ nginx_settings.source_display }}

      +
      + {% if nginx_settings.last_fetch_at %}Last: {{ nginx_settings.last_fetch_at|timesince }} ago{% else %}No ingestion yet{% endif %} + · + +
      +
      +
      + +
      + {% for rkey, rlabel in all_ranges %} + + {{ rlabel|slice:"5:" }} + + {% endfor %} +
      + +
      +
      + +
      + {% csrf_token %} + + +
      + {% csrf_token %} + + + + New Alert Profile + + + Settings + +
      +
      +
      + + + {% for msg in messages %} +
      + {{ msg }} +
      + {% endfor %} + + +
      + {% with s=stats %} +
      +
      Requests
      +
      {{ s.total }}
      +
      +
      +
      Errors
      +
      {{ s.errors }}
      +
      +
      +
      Unique IPs
      +
      {{ s.unique_ips }}
      +
      +
      +
      Avg Response
      +
      {{ s.avg_resp_ms }} ms
      +
      + {% endwith %} +
      + + +
      +
      +
      {{ range_label }}
      +
      +
      +
      +
      Status codes — {{ range_label }}
      +
      +
      +
      + + +
      +
      +
      Top IPs — {{ range_label }}
      +
      + + + + + + + + + + {% for ip in top_ips %} + + + + + + + {% empty %} + + {% endfor %} + +
      IPLocationReqsErrors
      + + + {% if ip.city or ip.country %}{{ ip.city }}{% if ip.city and ip.country %}, {% endif %}{{ ip.country }}{% else %}—{% endif %} + {{ ip.count }}{{ ip.errors }}
      No data yet
      +
      + +
      +
      Top Services — {{ range_label }}
      + + + + + + + + + {% for svc in top_services %} + + + + + {% empty %} + + {% endfor %} + +
      ServiceRequests
      + {% if svc.service %}{% else %}—{% endif %} + {{ svc.count }}
      No data yet
      +
      +
      + + + {% if profiles %} +
      +
      +
      Alert Profiles
      + + New +
      +
      + {% for p in profiles %} +
      +
      + {{ p.name }} + + window {{ p.alert_window_seconds }}s • max {{ p.alert_max_requests }} req • {{ p.alert_max_error_rate|floatformat:0 }}% err rate + +
      +
      +
      + {% csrf_token %} + +
      + Edit + Delete +
      +
      + {% endfor %} +
      +
      + {% endif %} + + +
      +
      + + Threat Alerts + {% if active_alerts %}{{ active_alerts }}{% endif %} +
      +
      + {% include 'nginxmon/_alerts.html' %} +
      +
      + + +
      +
      +
      + + Live Access Log +
      + Auto-refreshes every 5s • last 60 entries +
      +
      + + + + + + + + + + + + + + + {% include 'nginxmon/_live_logs.html' %} + +
      Time IP Method PathStatus
      +
      +
      + +
      + + + + + + +{% endblock %} diff --git a/nginxmon/templates/nginxmon/profile_confirm_delete.html b/nginxmon/templates/nginxmon/profile_confirm_delete.html new file mode 100644 index 0000000..0e6b897 --- /dev/null +++ b/nginxmon/templates/nginxmon/profile_confirm_delete.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block content %} +
      +
      +
      +

      + Delete Alert Profile +

      +
      +
      +

      + Delete alert profile {{ object.name }}? + Its alerts history will also be removed. +

      +
      + {% csrf_token %} + + + Cancel + +
      +
      +
      +
      +{% endblock %} diff --git a/nginxmon/templates/nginxmon/profile_form.html b/nginxmon/templates/nginxmon/profile_form.html new file mode 100644 index 0000000..2a9d584 --- /dev/null +++ b/nginxmon/templates/nginxmon/profile_form.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block content %} +
      +
      +
      +

      + {% if object %}Edit Alert Profile — {{ object.name }}{% else %}New Alert Profile{% endif %} +

      +

      + Alert thresholds and Telegram notification settings for this monitoring rule. +

      +
      + +
      + {% csrf_token %} + {% for field in form %} +
      + + {{ field }} + {% if field.help_text %}

      {{ field.help_text }}

      {% endif %} + {% for error in field.errors %}

      {{ error }}

      {% endfor %} +
      + {% endfor %} + +
      + + Cancel +
      +
      +
      +
      +{% endblock %} diff --git a/nginxmon/templates/nginxmon/settings.html b/nginxmon/templates/nginxmon/settings.html new file mode 100644 index 0000000..0bff8c3 --- /dev/null +++ b/nginxmon/templates/nginxmon/settings.html @@ -0,0 +1,103 @@ +{% extends 'base.html' %} + +{% block content %} +
      + + +
      + + + +
      +

      + Nginx Monitor Settings +

      +

      Cluster connection & local testing

      +
      +
      + + {% for msg in messages %} +
      + {{ msg }} +
      + {% endfor %} + + +
      +
      +

      Cluster Connection

      +

      + Configured once. The scheduler uses these to run + kubectl logs on a timer. +

      +
      +
      + {% csrf_token %} + {% for field in form %} +
      + + {{ field }} + {% if field.help_text %}

      {{ field.help_text }}

      {% endif %} + {% for error in field.errors %}

      {{ error }}

      {% endfor %} +
      + {% endfor %} +
      + +
      +
      +
      + + +
      +
      + +
      +

      Test Locally — Paste Log Lines

      +

      + No kubectl needed. Paste raw nginx ingress log lines and they'll be parsed and stored + immediately, so you can preview the dashboard with real data. +

      +
      +
      +
      + {% csrf_token %} + {% for field in paste_form %} +
      + +
      {{ field }}
      + {% for error in field.errors %}

      {{ error }}

      {% endfor %} +
      + {% endfor %} +
      + + Duplicates are automatically skipped. +
      +
      + + +
      +

      Or via the management command:

      +
      # From a file
      +python manage.py nginxmon_ingest --file /path/to/nginx.log
      +
      +# Pipe from kubectl directly
      +kubectl logs -n ingress-nginx \
      +  -l app.kubernetes.io/name=ingress-nginx \
      +  --container controller | python manage.py nginxmon_ingest
      +
      +# Re-run the configured source (kubectl or file)
      +python manage.py nginxmon_ingest --from-settings
      +
      +
      + +
      +{% endblock %} diff --git a/nginxmon/urls.py b/nginxmon/urls.py new file mode 100644 index 0000000..44ad6a0 --- /dev/null +++ b/nginxmon/urls.py @@ -0,0 +1,29 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # Main dashboard + path('', views.DashboardView.as_view(), name='nginxmon-dashboard'), + + # One-off settings (k8s pod config + paste-logs test) + path('settings/', views.SettingsView.as_view(), name='nginxmon-settings'), + + # Alert profiles + path('profiles/new/', views.ProfileCreateView.as_view(), name='nginxmon-profile-create'), + path('profiles//edit/', views.ProfileUpdateView.as_view(), name='nginxmon-profile-edit'), + path('profiles//delete/', views.ProfileDeleteView.as_view(), name='nginxmon-profile-delete'), + path('profiles//test-telegram/', views.TestTelegramView.as_view(), name='nginxmon-test-telegram'), + + # Manual actions + path('actions/fetch/', views.TriggerFetchView.as_view(), name='nginxmon-fetch'), + path('actions/detect/', views.TriggerDetectView.as_view(), name='nginxmon-detect'), + path('alert//dismiss/', views.DismissAlertView.as_view(), name='nginxmon-dismiss-alert'), + + # HTMX partials & APIs + path('api/live-logs/', views.LiveLogsPartialView.as_view(), name='nginxmon-live-logs'), + path('api/alerts/', views.AlertsPartialView.as_view(), name='nginxmon-alerts-partial'), + path('api/chart-data/', views.ChartDataView.as_view(), name='nginxmon-chart-data'), + path('api/ingest/', views.IngestLogsView.as_view(), name='nginxmon-ingest'), + path('api/toggle-ingestion/', views.ToggleIngestionView.as_view(), name='nginxmon-toggle'), + path('api/status-stream/', views.StatusStreamView.as_view(), name='nginxmon-status-stream'), +] diff --git a/nginxmon/views.py b/nginxmon/views.py new file mode 100644 index 0000000..7ae5e2c --- /dev/null +++ b/nginxmon/views.py @@ -0,0 +1,384 @@ +import json +import logging +import threading +import time +from datetime import timedelta + +from django.contrib import messages +from django.db.models import Avg, Count, Q +from django.db.models.functions import TruncDay, TruncHour, TruncMinute +from django.http import JsonResponse, StreamingHttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy +from django.utils import timezone +from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView, View + +from .detector import detect_threats as run_detect +from .fetcher import fetch_and_store, ingest_raw +from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm +from .models import NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert +from .notifications import send_test_telegram +from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile + +logger = logging.getLogger(__name__) + +# ── Time range config ───────────────────────────────────────────────────────── + +_RANGES = { + '5m': {'delta': timedelta(minutes=5), 'trunc': TruncMinute, 'group_n': 1, 'label': 'Last 5 min', 'fmt': '%H:%M'}, + '30m': {'delta': timedelta(minutes=30), 'trunc': TruncMinute, 'group_n': 2, 'label': 'Last 30 min', 'fmt': '%H:%M'}, + '6h': {'delta': timedelta(hours=6), 'trunc': TruncMinute, 'group_n': 15, 'label': 'Last 6 hours', 'fmt': '%H:%M'}, + '1d': {'delta': timedelta(hours=24), 'trunc': TruncHour, 'group_n': 1, 'label': 'Last 24 hours', 'fmt': '%H:%M'}, + '7d': {'delta': timedelta(days=7), 'trunc': TruncHour, 'group_n': 4, 'label': 'Last 7 days', 'fmt': '%m/%d %H:%M'}, + '30d': {'delta': timedelta(days=30), 'trunc': TruncDay, 'group_n': 1, 'label': 'Last 30 days', 'fmt': '%m/%d'}, + '90d': {'delta': timedelta(days=90), 'trunc': TruncDay, 'group_n': 3, 'label': 'Last 90 days', 'fmt': '%m/%d'}, +} +_DEFAULT_RANGE = '6h' + + +def _get_range(request): + key = request.GET.get('range', _DEFAULT_RANGE) + if key not in _RANGES: + key = _DEFAULT_RANGE + cfg = _RANGES[key] + return key, cfg, timezone.now() - cfg['delta'] + + +def _floor_bucket_key(dt, trunc_fn, group_n, fmt): + """Return format string for the bucket boundary dt falls into.""" + if trunc_fn is TruncMinute: + m = (dt.minute // group_n) * group_n + aligned = dt.replace(minute=m, second=0, microsecond=0) + elif trunc_fn is TruncHour: + h = (dt.hour // group_n) * group_n + aligned = dt.replace(hour=h, minute=0, second=0, microsecond=0) + else: # TruncDay + if group_n <= 1: + aligned = dt.replace(hour=0, minute=0, second=0, microsecond=0) + else: + from datetime import date + epoch = date(2020, 1, 1) + d = dt.date() if hasattr(dt, 'date') else dt + days = (d - epoch).days + floored_date = epoch + timedelta(days=(days // group_n) * group_n) + aligned = dt.replace( + year=floored_date.year, month=floored_date.month, + day=floored_date.day, hour=0, minute=0, second=0, microsecond=0, + ) + return aligned.strftime(fmt) + + +# ── Dashboard — live monitoring ─────────────────────────────────────────────── + +class DashboardView(TemplateView): + template_name = 'nginxmon/dashboard.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + range_key, cfg, since = _get_range(self.request) + + logs = NginxAccessLog.objects.filter(timestamp__gte=since) + stats = { + 'total': logs.count(), + 'errors': logs.filter(status__gte=400).count(), + 'unique_ips': logs.values('remote_addr').distinct().count(), + 'avg_resp_ms': round( + (logs.aggregate(a=Avg('request_time'))['a'] or 0) * 1000, 1 + ), + } + + top_services = list( + logs.values('service') + .annotate(count=Count('id')) + .order_by('-count')[:10] + ) + status_dist = list( + logs.values('status') + .annotate(count=Count('id')) + .order_by('status') + ) + top_ips = list( + logs.values('remote_addr', 'country', 'city') + .annotate(count=Count('id'), errors=Count('id', filter=Q(status__gte=400))) + .order_by('-count')[:10] + ) + active_alerts = ThreatAlert.objects.filter(dismissed=False).count() + profiles = NginxAlertProfile.objects.filter(enabled=True) + + ctx.update({ + 'nginx_settings': NginxSettings.get(), + 'stats': stats, + 'top_services': top_services, + 'status_dist': status_dist, + 'top_ips': top_ips, + 'active_alerts': active_alerts, + 'profiles': profiles, + 'status_dist_json': json.dumps(status_dist), + 'current_range': range_key, + 'range_label': cfg['label'], + 'all_ranges': [(k, v['label']) for k, v in _RANGES.items()], + }) + return ctx + + +# ── Settings (one-off k8s config + paste test logs) ────────────────────────── + +class SettingsView(View): + template_name = 'nginxmon/settings.html' + + def _get_settings(self): + return NginxSettings.get() + + def get(self, request): + obj = self._get_settings() + return render(request, self.template_name, { + 'form': NginxSettingsForm(instance=obj), + 'paste_form': PasteLogsForm(), + 'nginx_settings': obj, + }) + + def post(self, request): + obj = self._get_settings() + if 'save_settings' in request.POST: + form = NginxSettingsForm(request.POST, instance=obj) + if form.is_valid(): + saved = form.save() + stop_fetch_job() + if saved.enabled: + start_fetch_job(saved.fetch_interval_seconds) + messages.success(request, 'Settings saved.') + return redirect('nginxmon-settings') + return render(request, self.template_name, { + 'form': form, + 'paste_form': PasteLogsForm(), + 'nginx_settings': obj, + }) + + if 'paste_logs' in request.POST: + paste_form = PasteLogsForm(request.POST) + if paste_form.is_valid(): + count = ingest_raw(paste_form.cleaned_data['log_text']) + messages.success(request, f'Ingested {count} new log entries.') + return redirect('nginxmon-settings') + return render(request, self.template_name, { + 'form': NginxSettingsForm(instance=obj), + 'paste_form': paste_form, + 'nginx_settings': obj, + }) + + return redirect('nginxmon-settings') + + +# ── Alert profiles ──────────────────────────────────────────────────────────── + +class ProfileCreateView(CreateView): + model = NginxAlertProfile + form_class = NginxAlertProfileForm + template_name = 'nginxmon/profile_form.html' + success_url = reverse_lazy('nginxmon-dashboard') + + def form_valid(self, form): + response = super().form_valid(form) + if self.object.enabled: + schedule_profile(self.object) + messages.success(self.request, f'Alert profile "{self.object.name}" created.') + return response + + +class ProfileUpdateView(UpdateView): + model = NginxAlertProfile + form_class = NginxAlertProfileForm + template_name = 'nginxmon/profile_form.html' + success_url = reverse_lazy('nginxmon-dashboard') + + def form_valid(self, form): + response = super().form_valid(form) + unschedule_profile(self.object) + if self.object.enabled: + schedule_profile(self.object) + messages.success(self.request, f'Alert profile "{self.object.name}" updated.') + return response + + +class ProfileDeleteView(DeleteView): + model = NginxAlertProfile + template_name = 'nginxmon/profile_confirm_delete.html' + success_url = reverse_lazy('nginxmon-dashboard') + + def form_valid(self, form): + unschedule_profile(self.object) + return super().form_valid(form) + + +class TestTelegramView(View): + def post(self, request, pk): + profile = get_object_or_404(NginxAlertProfile, pk=pk) + result = send_test_telegram(profile) + if result['ok']: + messages.success(request, 'Telegram test message sent!') + else: + messages.error(request, f'Telegram error: {result["error"]}') + return redirect('nginxmon-dashboard') + + +# ── HTMX partials ───────────────────────────────────────────────────────────── + +class LiveLogsPartialView(View): + _SORT_MAP = { + 'timestamp': 'timestamp', + 'ip': 'remote_addr', + 'status': 'status', + 'method': 'method', + 'service': 'service', + 'time': 'request_time', + 'bytes': 'body_bytes_sent', + } + + def get(self, request): + qs = NginxAccessLog.objects.all() + + ip_f = request.GET.get('ip', '').strip() + status_f = request.GET.get('status', '').strip() + service_f = request.GET.get('service', '').strip() + sort_col = request.GET.get('sort', 'timestamp') + sort_ord = request.GET.get('order', 'desc') + + if ip_f: + qs = qs.filter(remote_addr=ip_f) + if status_f: + try: + qs = qs.filter(status=int(status_f)) + except ValueError: + pass + if service_f: + qs = qs.filter(service=service_f) + + db_col = self._SORT_MAP.get(sort_col, 'timestamp') + qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}') + + return render(request, 'nginxmon/_live_logs.html', {'logs': qs[:60]}) + + +class AlertsPartialView(View): + def get(self, request): + alerts = ThreatAlert.objects.filter(dismissed=False).select_related('profile')[:20] + return render(request, 'nginxmon/_alerts.html', {'alerts': alerts}) + + +# ── Action views ────────────────────────────────────────────────────────────── + +class TriggerFetchView(View): + def post(self, request): + threading.Thread(target=fetch_and_store, daemon=True).start() + messages.success(request, 'Log fetch triggered in the background.') + return redirect('nginxmon-dashboard') + + +class TriggerDetectView(View): + def post(self, request): + for profile in NginxAlertProfile.objects.filter(enabled=True): + threading.Thread(target=run_detect, args=[profile.pk], daemon=True).start() + messages.success(request, 'Threat detection triggered for all profiles.') + return redirect('nginxmon-dashboard') + + +class DismissAlertView(View): + def post(self, request, pk): + alert = get_object_or_404(ThreatAlert, pk=pk) + alert.dismissed = True + alert.save(update_fields=['dismissed']) + if request.headers.get('HX-Request'): + return JsonResponse({'ok': True}) + return redirect('nginxmon-dashboard') + + +# ── Chart & ingest API ──────────────────────────────────────────────────────── + +class ChartDataView(View): + def get(self, request): + range_key, cfg, since = _get_range(request) + trunc_fn = cfg['trunc'] + group_n = cfg['group_n'] + fmt = cfg['fmt'] + + # Aggregate in DB using the timestamp index + trunc function. + # Returns at most ~360 rows (6h/minute) — then Python merges into final buckets. + rows = list( + NginxAccessLog.objects + .filter(timestamp__gte=since) + .annotate(bucket=trunc_fn('timestamp')) + .values('bucket') + .annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400))) + .order_by('bucket') + ) + + # Merge adjacent DB buckets into the desired bucket size. + # Dict insertion order is preserved (Python 3.7+) so labels stay sorted. + buckets: dict = {} + for row in rows: + bkt = row['bucket'] + if bkt is None: + continue + key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt) + if key not in buckets: + buckets[key] = {'total': 0, 'errors': 0} + buckets[key]['total'] += row['total'] + buckets[key]['errors'] += row['errors'] + + return JsonResponse({ + 'labels': list(buckets), + 'total': [v['total'] for v in buckets.values()], + 'errors': [v['errors'] for v in buckets.values()], + 'range': range_key, + 'label': cfg['label'], + }) + + +class IngestLogsView(View): + """POST endpoint for the paste-logs form (used from SettingsView).""" + def post(self, request): + text = request.POST.get('log_text', '') + count = ingest_raw(text) + return JsonResponse({'inserted': count}) + + +# ── Ingestion toggle & SSE status stream ───────────────────────────────────── + +class ToggleIngestionView(View): + """POST — flip NginxSettings.enabled and restart/stop the fetch job.""" + def post(self, request): + settings = NginxSettings.get() + settings.enabled = not settings.enabled + settings.save(update_fields=['enabled']) + stop_fetch_job() + if settings.enabled: + start_fetch_job(settings.fetch_interval_seconds) + return JsonResponse({'enabled': settings.enabled}) + + +class StatusStreamView(View): + """SSE endpoint — pushes ingestion status JSON every 2 seconds.""" + def get(self, request): + def event_stream(): + while True: + try: + settings = NginxSettings.get() + total = NginxAccessLog.objects.count() + last_fetch = ( + settings.last_fetch_at.isoformat() + if settings.last_fetch_at else None + ) + payload = json.dumps({ + 'enabled': settings.enabled, + 'last_fetch_at': last_fetch, + 'total_logs': total, + }) + yield f'data: {payload}\n\n' + except Exception: + pass + time.sleep(2) + + response = StreamingHttpResponse(event_stream(), content_type='text/event-stream') + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response diff --git a/tailwind.config.js b/tailwind.config.js index 7e75998..7ef8fc2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,6 +3,8 @@ module.exports = { './templates/**/*.html', './links/templates/**/*.html', './new_theme/templates/**/*.html', + './netscan/templates/**/*.html', + './nginxmon/templates/**/*.html', './static_src/**/*.{js,jsx}', './**/*.js', ], diff --git a/templates/base.html b/templates/base.html index a0823c5..8bf7060 100644 --- a/templates/base.html +++ b/templates/base.html @@ -139,6 +139,18 @@
      + +
      + + + + + {% trans "Nginx Monitor" %} +
      +
      +
      From 0b564ec19c4e6edf6092847b628fec73132a0d5c Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 15:54:07 +1100 Subject: [PATCH 52/95] Update --- data/db.sqlite3 | Bin 1118208 -> 1138688 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 4cf1b0daae955c7b875a5e868a7452a8c0f01f85..94e3328f30b540351a8a03f8613800c4db6be6ff 100644 GIT binary patch delta 9624 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 XIsEet{F6-Ew2hL_)vPWGOl1EL1=Jx> delta 3309 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 VO=Ni!-G!{&yxGBmau(H#{vSu_wrKzW From ccee1f408e92177750dffe697f7af72c553540d5 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 15:57:02 +1100 Subject: [PATCH 53/95] ignore db sqlite file --- .gitignore | 1 + data/db.sqlite3 | Bin 1138688 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 data/db.sqlite3 diff --git a/.gitignore b/.gitignore index ee7dc05..e64b67d 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ DerivedData/ # Swift Package Manager .swiftpm/ .build/ +data/db.sqlite3 diff --git a/data/db.sqlite3 b/data/db.sqlite3 deleted file mode 100644 index 94e3328f30b540351a8a03f8613800c4db6be6ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1138688 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

      q$fyl9tpBr= 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 UdmW`cj?!*NX_uok>nQE~|GxhfW&i*H From cc4e66b945e591fa3fe0e4e5d8c8e7c2a492524e Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 18:44:29 +1100 Subject: [PATCH 54/95] Add geo --- nginxmon/templates/nginxmon/dashboard.html | 167 +++++++++++++++++++++ nginxmon/urls.py | 1 + nginxmon/views.py | 38 ++++- 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/nginxmon/templates/nginxmon/dashboard.html b/nginxmon/templates/nginxmon/dashboard.html index 3763314..c9c8292 100644 --- a/nginxmon/templates/nginxmon/dashboard.html +++ b/nginxmon/templates/nginxmon/dashboard.html @@ -2,6 +2,8 @@ {% block content %} + +

      + +
      +
      +
      Traffic by Region — {{ range_label }}
      +
      +
      +
      +
      +
      @@ -413,4 +424,160 @@ function _applyToggleUI(enabled) { es.onerror = function() { es.close(); setTimeout(startStatusStream, 5000); }; })(); + + {% endblock %} diff --git a/nginxmon/urls.py b/nginxmon/urls.py index 44ad6a0..5903c31 100644 --- a/nginxmon/urls.py +++ b/nginxmon/urls.py @@ -26,4 +26,5 @@ urlpatterns = [ path('api/ingest/', views.IngestLogsView.as_view(), name='nginxmon-ingest'), path('api/toggle-ingestion/', views.ToggleIngestionView.as_view(), name='nginxmon-toggle'), path('api/status-stream/', views.StatusStreamView.as_view(), name='nginxmon-status-stream'), + path('api/geo-stats/', views.GeoStatsView.as_view(), name='nginxmon-geo-stats'), ] diff --git a/nginxmon/views.py b/nginxmon/views.py index 7ae5e2c..73c023a 100644 --- a/nginxmon/views.py +++ b/nginxmon/views.py @@ -16,7 +16,7 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie from .detector import detect_threats as run_detect from .fetcher import fetch_and_store, ingest_raw from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm -from .models import NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert +from .models import IPGeoCache, NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert from .notifications import send_test_telegram from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile @@ -334,6 +334,42 @@ class ChartDataView(View): }) +class GeoStatsView(View): + """Returns country-level aggregates + IP bubble data for the geo map.""" + def get(self, request): + range_key, cfg, since = _get_range(request) + countries = list( + NginxAccessLog.objects + .filter(timestamp__gte=since).exclude(country_code='') + .values('country_code', 'country') + .annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400))) + .order_by('-total') + ) + ip_agg = list( + NginxAccessLog.objects.filter(timestamp__gte=since) + .values('remote_addr') + .annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400))) + .order_by('-total')[:300] + ) + ip_set = [r['remote_addr'] for r in ip_agg] + geo_cache = { + c.ip: (c.lat, c.lon, c.country, c.city) + for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False) + } + bubbles = [] + for row in ip_agg: + c = geo_cache.get(row['remote_addr']) + if c and c[0] is not None and c[1] is not None: + bubbles.append({ + 'lat': round(float(c[0]), 2), + 'lon': round(float(c[1]), 2), + 'total': row['total'], + 'errors': row['errors'], + 'label': c[3] or c[2] or row['remote_addr'], + }) + return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key}) + + class IngestLogsView(View): """POST endpoint for the paste-logs form (used from SettingsView).""" def post(self, request): From 26d3ac960782d90c5049cc4341b023fc1aaf4b5d Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 18:58:00 +1100 Subject: [PATCH 55/95] Fix docker build --- Dockerfile | 2 + nginxmon/templates/nginxmon/_live_logs.html | 4 +- nginxmon/templates/nginxmon/dashboard.html | 53 ++++++++++++++++++++- nginxmon/views.py | 10 ++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d156831..2dfd93b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ COPY manage.py ./ COPY core/ ./core/ COPY links/ ./links/ COPY netscan/ ./netscan/ +COPY nginxmon/ ./nginxmon/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ @@ -140,6 +141,7 @@ COPY --chown=appuser:appuser manage.py ./ COPY --chown=appuser:appuser core/ ./core/ COPY --chown=appuser:appuser links/ ./links/ COPY --chown=appuser:appuser netscan/ ./netscan/ +COPY --chown=appuser:appuser nginxmon/ ./nginxmon/ COPY --chown=appuser:appuser new_theme/ ./new_theme/ COPY --chown=appuser:appuser templates/ ./templates/ COPY --chown=appuser:appuser static/ ./static/ diff --git a/nginxmon/templates/nginxmon/_live_logs.html b/nginxmon/templates/nginxmon/_live_logs.html index a038ae7..db3b304 100644 --- a/nginxmon/templates/nginxmon/_live_logs.html +++ b/nginxmon/templates/nginxmon/_live_logs.html @@ -10,7 +10,9 @@ {% if log.method == 'GET' %}text-green-700{% elif log.method == 'POST' %}text-blue-700 {% elif log.method == 'DELETE' %}text-red-700{% else %}text-gray-600{% endif %}">{{ log.method }} - {{ log.request_uri }} + + + {% if log.service %}{% else %}—{% endif %} diff --git a/nginxmon/templates/nginxmon/dashboard.html b/nginxmon/templates/nginxmon/dashboard.html index c9c8292..e9d4e15 100644 --- a/nginxmon/templates/nginxmon/dashboard.html +++ b/nginxmon/templates/nginxmon/dashboard.html @@ -113,6 +113,12 @@
      + +
      +
      Top Paths — {{ range_label }}
      +
      +
      +
      @@ -304,18 +310,61 @@ statusChart.setOption({ }); statusChart.on('click', p => setFilter('status', p.name)); window.addEventListener('resize', () => statusChart.resize()); + +// ── Top paths bar chart ─────────────────────────────────────────────────────── +const _pathsData = {{ top_paths_json|safe }}; +const pathsChart = echarts.init(document.getElementById('pathsChart')); +(function() { + const rows = _pathsData.slice().reverse(); // bottom-to-top in ECharts yAxis + const labels = rows.map(r => r.request_uri || '(empty)'); + const totals = rows.map(r => r.count - r.errors); + const errors = rows.map(r => r.errors); + pathsChart.setOption({ + tooltip: { + trigger: 'axis', axisPointer: { type: 'shadow' }, + formatter: params => { + const ok = params.find(p => p.seriesName === 'OK'); + const err = params.find(p => p.seriesName === 'Errors'); + const total = (ok ? ok.value : 0) + (err ? err.value : 0); + return `
      ${params[0].name}
      ` + + `
      Total: ${total} Errors: ${err ? err.value : 0}
      `; + }, + }, + grid: { left: 8, right: 24, top: 4, bottom: 4, containLabel: true }, + xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } }, + axisLabel: { fontSize: 10, color: '#94a3b8' } }, + yAxis: { + type: 'category', data: labels, + axisLabel: { + fontSize: 10, color: '#475569', width: 260, overflow: 'truncate', + formatter: v => v.length > 52 ? v.slice(0, 50) + '…' : v, + }, + }, + series: [ + { name: 'OK', type: 'bar', stack: 'total', data: totals, + itemStyle: { color: '#6366f1', borderRadius: [0,0,0,0] }, + emphasis: { itemStyle: { color: '#4f46e5' } } }, + { name: 'Errors', type: 'bar', stack: 'total', data: errors, + itemStyle: { color: '#f87171', borderRadius: [0,2,2,0] }, + emphasis: { itemStyle: { color: '#ef4444' } } }, + ], + }); + pathsChart.on('click', p => setFilter('path', p.name)); +})(); +window.addEventListener('resize', () => pathsChart.resize()); +{% endblock %} diff --git a/links/urls.py b/links/urls.py index 3c481f6..11e7aba 100644 --- a/links/urls.py +++ b/links/urls.py @@ -56,6 +56,7 @@ urlpatterns = [ path('ui/mini-apps/image-gallery/', mini_apps_views.ImageGalleryView.as_view(), name='mini-apps-image-gallery'), path('ui/mini-apps/tts/', mini_apps_views.TTSView.as_view(), name='mini-apps-tts'), path('ui/mini-apps/fire-planning/', mini_apps_views.FIREPlanningView.as_view(), name='mini-apps-fire-planning'), + path('ui/mini-apps/ip-ban/', mini_apps_views.NginxIPBanView.as_view(), name='mini-apps-ip-ban'), # TTS API path('ui/tts-api/', views.generate_tts_api, name='tts-api'), From 57516a3c9c01e1541c6ce2a14d2ac0dd1bc8fdc1 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 20:20:25 +1100 Subject: [PATCH 61/95] minor fix! --- nginxmon/fetcher.py | 2 ++ nginxmon/views.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/nginxmon/fetcher.py b/nginxmon/fetcher.py index 67ee20f..8e28998 100644 --- a/nginxmon/fetcher.py +++ b/nginxmon/fetcher.py @@ -46,6 +46,8 @@ def fetch_and_store() -> int: new_logs, seen = [], set() for e in entries: + if excluder(e['remote_addr']): + continue key = e['request_id'] or ( f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}" ) diff --git a/nginxmon/views.py b/nginxmon/views.py index 336c528..a65e527 100644 --- a/nginxmon/views.py +++ b/nginxmon/views.py @@ -33,7 +33,7 @@ _RANGES = { '30d': {'delta': timedelta(days=30), 'trunc': TruncDay, 'group_n': 1, 'label': 'Last 30 days', 'fmt': '%m/%d'}, '90d': {'delta': timedelta(days=90), 'trunc': TruncDay, 'group_n': 3, 'label': 'Last 90 days', 'fmt': '%m/%d'}, } -_DEFAULT_RANGE = '6h' +_DEFAULT_RANGE = '30m' def _get_range(request): From 70fbb78a546b06792657b6a98c1a76de913affd5 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 20:27:01 +1100 Subject: [PATCH 62/95] ui update --- nginxmon/templates/nginxmon/settings.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nginxmon/templates/nginxmon/settings.html b/nginxmon/templates/nginxmon/settings.html index 0bff8c3..73188b5 100644 --- a/nginxmon/templates/nginxmon/settings.html +++ b/nginxmon/templates/nginxmon/settings.html @@ -1,13 +1,10 @@ {% extends 'base.html' %} {% block content %} -
      +
      - - -

      Nginx Monitor Settings From 0d489e658e6c5e3d0227292c8549d47616b0ab12 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 21:06:22 +1100 Subject: [PATCH 63/95] Update mini app --- links/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/links/views.py b/links/views.py index 4c99fc8..1fa2082 100644 --- a/links/views.py +++ b/links/views.py @@ -90,7 +90,7 @@ class LinkListView(ListView): mini_apps = [ {'name': 'Image Gallery', 'description': 'Browse Images', 'url': 'mini-apps-image-gallery'}, {'name': 'TTS', 'description': 'Convert text to speech', 'url': 'mini-apps-tts'}, - {'name': 'Coming Soon', 'description': 'New App', 'url': '#'} + {'name': 'IP Ban', 'description': 'Block IP addresses', 'url': 'mini-apps-ip-ban'}, ] context['mini_apps'] = [ {**app, 'color': random.choice(colors)} From e89825a114b2d5be9b3e7dd06c5bd430a2639888 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 21:33:07 +1100 Subject: [PATCH 64/95] Add auto ban feature! --- .gitignore | 2 + nginxmon/fetcher.py | 267 ++++++++++++++++---- nginxmon/forms.py | 3 + nginxmon/migrations/0004_auto_ban_fields.py | 33 +++ nginxmon/migrations/0005_banned_ip_model.py | 29 +++ nginxmon/models.py | 41 +++ nginxmon/templates/nginxmon/_live_logs.html | 3 + nginxmon/templates/nginxmon/dashboard.html | 63 +++++ nginxmon/urls.py | 1 + nginxmon/views.py | 25 +- scripts/analyze_ips.py | 27 ++ scripts/run_autoban.py | 25 ++ 12 files changed, 472 insertions(+), 47 deletions(-) create mode 100644 nginxmon/migrations/0004_auto_ban_fields.py create mode 100644 nginxmon/migrations/0005_banned_ip_model.py create mode 100644 scripts/analyze_ips.py create mode 100644 scripts/run_autoban.py diff --git a/.gitignore b/.gitignore index e64b67d..73cfdab 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ DerivedData/ .swiftpm/ .build/ data/db.sqlite3 + +.playwright-mcp/ diff --git a/nginxmon/fetcher.py b/nginxmon/fetcher.py index 8e28998..8ccb525 100644 --- a/nginxmon/fetcher.py +++ b/nginxmon/fetcher.py @@ -78,6 +78,10 @@ def fetch_and_store() -> int: NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True) logger.info('nginxmon: inserted %d new log entries', len(new_logs)) enrich_geo_batch(new_logs) + if settings.auto_ban_enabled: + auto_ban_auth_scanners(settings) + auto_ban_php_scanners(settings) + auto_ban_404_flood(settings) _touch(settings) return len(new_logs) @@ -132,10 +136,219 @@ def ingest_raw(text: str) -> int: NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True) logger.info('nginxmon: ingested %d log entries from raw text', len(new_logs)) enrich_geo_batch(new_logs) + if settings.auto_ban_enabled: + auto_ban_auth_scanners(settings) + auto_ban_php_scanners(settings) + auto_ban_404_flood(settings) return len(new_logs) +# Auth-probe paths that signal someone trying to log in / brute-force OAuth +_AUTH_PROBE_PREFIXES = ('/oauth2/', '/authorize') + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _push_bans(settings: NginxSettings, candidate_ips: list[str], + ban_source: str, make_reason, make_note, + window_start) -> list[str]: + """ + Common routine: + 1. Filter out excluded / private IPs. + 2. Load ConfigMap, find new offenders, write back. + 3. Create BannedIP records. + 4. Annotate matching log rows. + Returns list of newly banned IPs. + """ + from django.db.models import Q + from .models import BannedIP + + excluder = _build_excluder(settings.excluded_ips) + candidate_ips = [ip for ip in candidate_ips + if not excluder(ip) and not _is_private_ip(ip)] + if not candidate_ips: + return [] + + try: + from links.mini_apps_views import _read_blocked_ips, _write_blocked_ips + blocked = _read_blocked_ips() + except Exception as exc: + logger.error('nginxmon auto_ban: could not read ConfigMap: %s', exc) + return [] + + already_in_db = set( + BannedIP.objects.filter(ip__in=candidate_ips).values_list('ip', flat=True) + ) + newly_banned = [ip for ip in candidate_ips + if ip not in blocked and ip not in already_in_db] + if not newly_banned: + return [] + + # Push to ConfigMap + try: + _write_blocked_ips(blocked + newly_banned) + logger.info('nginxmon auto_ban [%s]: banned %d IPs: %s', + ban_source, len(newly_banned), newly_banned) + except Exception as exc: + logger.error('nginxmon auto_ban: could not write ConfigMap: %s', exc) + return [] + + # Persist BannedIP records and annotate log rows + for ip in newly_banned: + reason = make_reason(ip, window_start) + note = f'Auto banned by system. Reason: {reason}.' + + # Geo from cache + from .models import IPGeoCache + geo = IPGeoCache.objects.filter(ip=ip).first() + BannedIP.objects.get_or_create( + ip=ip, + defaults=dict( + ban_source=ban_source, + reason=reason, + request_count=NginxAccessLog.objects.filter( + remote_addr=ip, timestamp__gte=window_start).count(), + country=geo.country if geo else '', + city=geo.city if geo else '', + ), + ) + + # Annotate all matching log rows for this IP in the window + NginxAccessLog.objects.filter( + remote_addr=ip, timestamp__gte=window_start, + ).update(note=note) + + return newly_banned + + +# --------------------------------------------------------------------------- +# Detector: repeated auth-probe failures +# --------------------------------------------------------------------------- + +def auto_ban_auth_scanners(settings: NginxSettings) -> list[str]: + """ + Ban IPs with > threshold *failed* (4xx/5xx) requests to /oauth2/ or /authorize + within the rolling window. + """ + from django.db.models import Count, Q + from datetime import timedelta + + window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours) + + probe_filter = Q() + for prefix in _AUTH_PROBE_PREFIXES: + probe_filter |= Q(request_uri__startswith=prefix) + + candidates = list( + NginxAccessLog.objects + .filter(probe_filter, timestamp__gte=window_start, status__gte=400) + .values('remote_addr') + .annotate(cnt=Count('id')) + .filter(cnt__gt=settings.auto_ban_threshold) + .values_list('remote_addr', flat=True) + ) + + def make_reason(ip, ws): + paths = list( + NginxAccessLog.objects.filter( + probe_filter, remote_addr=ip, timestamp__gte=ws, status__gte=400, + ).values_list('request_uri', flat=True)[:20] + ) + hit = sorted({p for prefix in _AUTH_PROBE_PREFIXES for p in paths if p.startswith(prefix)}) + return 'repeated failed auth-probe requests to: ' + (', '.join(hit) or ', '.join(_AUTH_PROBE_PREFIXES)) + + return _push_bans(settings, candidates, 'auto_auth_probe', make_reason, None, window_start) + + +# --------------------------------------------------------------------------- +# Detector: PHP webshell / backdoor scanner +# --------------------------------------------------------------------------- + +def auto_ban_php_scanners(settings: NginxSettings) -> list[str]: + """ + Ban IPs probing random .php paths (classic webshell/backdoor scanners). + Threshold reuses auto_ban_threshold (default 5). Any IP making > threshold + requests to *.php paths (excluding the known auth endpoints) gets banned. + """ + from django.db.models import Count, Q + from datetime import timedelta + + window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours) + + # Match paths ending in .php or containing .php? — but NOT auth endpoints + auth_filter = Q() + for prefix in _AUTH_PROBE_PREFIXES: + auth_filter |= Q(request_uri__startswith=prefix) + + php_filter = Q(request_uri__iregex=r'\.php(\?|$|/)') + + candidates = list( + NginxAccessLog.objects + .filter(php_filter, timestamp__gte=window_start) + .exclude(auth_filter) + .values('remote_addr') + .annotate(cnt=Count('id')) + .filter(cnt__gt=settings.auto_ban_threshold) + .values_list('remote_addr', flat=True) + ) + + def make_reason(ip, ws): + sample = list( + NginxAccessLog.objects.filter( + php_filter, remote_addr=ip, timestamp__gte=ws, + ).values_list('request_uri', flat=True)[:5] + ) + cnt = NginxAccessLog.objects.filter( + php_filter, remote_addr=ip, timestamp__gte=ws, + ).count() + return (f'PHP webshell/backdoor scanner — {cnt} probes to .php paths, ' + f'e.g.: {", ".join(sample[:3])}') + + return _push_bans(settings, candidates, 'auto_php_scan', make_reason, None, window_start) + + +# --------------------------------------------------------------------------- +# Detector: 404 flood (scraper / content scanner) +# --------------------------------------------------------------------------- + +def auto_ban_404_flood(settings: NginxSettings) -> list[str]: + """ + Ban IPs generating an abnormally high number of 404s (badge scrapers, + content scanners, etc.). Threshold = 5 × auto_ban_threshold. + """ + from django.db.models import Count + from datetime import timedelta + + window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours) + threshold = settings.auto_ban_threshold * 5 # harsher: 25 by default + + candidates = list( + NginxAccessLog.objects + .filter(status=404, timestamp__gte=window_start) + .values('remote_addr') + .annotate(cnt=Count('id')) + .filter(cnt__gt=threshold) + .values_list('remote_addr', flat=True) + ) + + def make_reason(ip, ws): + cnt = NginxAccessLog.objects.filter( + remote_addr=ip, status=404, timestamp__gte=ws, + ).count() + sample = list( + NginxAccessLog.objects.filter( + remote_addr=ip, status=404, timestamp__gte=ws, + ).values_list('request_uri', flat=True)[:3] + ) + return (f'404 flood — {cnt} consecutive 404 responses, ' + f'e.g.: {", ".join(sample)}') + + return _push_bans(settings, candidates, 'auto_404_flood', make_reason, None, window_start) + + def _k8s_logs(settings: NginxSettings) -> str | None: """Fetch pod logs via the Kubernetes Python client (works in-cluster and locally).""" since = settings.fetch_interval_seconds + _OVERLAP @@ -207,6 +420,14 @@ def cleanup_old_logs(days: int = 7): logger.info('nginxmon: pruned %d old log entries (>%d days)', deleted, days) +def _is_private_ip(ip: str) -> bool: + """Return True for RFC-1918, loopback, link-local, and other private ranges.""" + try: + return ipaddress.ip_address(ip).is_private + except ValueError: + return False + + def _build_excluder(excluded_ips_text: str): """ Build and return a callable(ip: str) -> bool that returns True when the @@ -251,49 +472,3 @@ def _build_excluder(excluded_ips_text: str): return False return _is_excluded - - -def _build_excluder(excluded_ips_text: str): - """ - Build and return a callable(ip: str) -> bool that returns True when the - given IP should be excluded from ingestion. - - Supported entry formats (one per line): - 192.168.1.218 exact IP - 192.168.1.x wildcard — any IP whose first 3 octets match - 192.168.1.0/24 CIDR range - """ - exact: set[str] = set() - wildcards: list[str] = [] # prefixes like '192.168.1.' - networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] - - for raw_line in (excluded_ips_text or '').splitlines(): - entry = raw_line.strip() - if not entry or entry.startswith('#'): - continue - if entry.endswith('.x') or entry.endswith('.*'): - # Wildcard: treat everything before .x as a prefix - wildcards.append(entry[:-1]) # keep trailing '.' - elif '/' in entry: - try: - networks.append(ipaddress.ip_network(entry, strict=False)) - except ValueError: - logger.warning('nginxmon: invalid CIDR in excluded_ips: %r', entry) - else: - exact.add(entry) - - def _is_excluded(ip: str) -> bool: - if ip in exact: - return True - for prefix in wildcards: - if ip.startswith(prefix): - return True - if networks: - try: - addr = ipaddress.ip_address(ip) - return any(addr in net for net in networks) - except ValueError: - pass - return False - - return _is_excluded diff --git a/nginxmon/forms.py b/nginxmon/forms.py index 8f33368..562997e 100644 --- a/nginxmon/forms.py +++ b/nginxmon/forms.py @@ -13,6 +13,9 @@ class NginxSettingsForm(forms.ModelForm): 'log_file_path', 'enabled', 'excluded_ips', + 'auto_ban_enabled', + 'auto_ban_threshold', + 'auto_ban_window_hours', ] widgets = { 'excluded_ips': forms.Textarea(attrs={ diff --git a/nginxmon/migrations/0004_auto_ban_fields.py b/nginxmon/migrations/0004_auto_ban_fields.py new file mode 100644 index 0000000..da6f1ce --- /dev/null +++ b/nginxmon/migrations/0004_auto_ban_fields.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.12 on 2026-04-01 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nginxmon', '0003_add_excluded_ips'), + ] + + operations = [ + migrations.AddField( + model_name='nginxaccesslog', + name='note', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='nginxsettings', + name='auto_ban_enabled', + field=models.BooleanField(default=True, help_text='Automatically ban IPs that repeatedly probe auth endpoints.'), + ), + migrations.AddField( + model_name='nginxsettings', + name='auto_ban_threshold', + field=models.IntegerField(default=5, help_text='Number of auth-probe requests from one IP within the window before auto-ban.'), + ), + migrations.AddField( + model_name='nginxsettings', + name='auto_ban_window_hours', + field=models.IntegerField(default=24, help_text='Rolling window (hours) used to count auth-probe requests.'), + ), + ] diff --git a/nginxmon/migrations/0005_banned_ip_model.py b/nginxmon/migrations/0005_banned_ip_model.py new file mode 100644 index 0000000..0c37886 --- /dev/null +++ b/nginxmon/migrations/0005_banned_ip_model.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-04-01 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nginxmon', '0004_auto_ban_fields'), + ] + + operations = [ + migrations.CreateModel( + name='BannedIP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip', models.GenericIPAddressField(db_index=True, unique=True)), + ('ban_source', models.CharField(choices=[('manual', 'Manual'), ('auto_auth_probe', 'Auto: Auth Probe'), ('auto_php_scan', 'Auto: PHP Scanner'), ('auto_404_flood', 'Auto: 404 Flood')], default='manual', max_length=30)), + ('reason', models.TextField()), + ('request_count', models.IntegerField(default=0)), + ('banned_at', models.DateTimeField(auto_now_add=True)), + ('country', models.CharField(blank=True, max_length=100)), + ('city', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['-banned_at'], + }, + ), + ] diff --git a/nginxmon/models.py b/nginxmon/models.py index 1d1556e..401dd3c 100644 --- a/nginxmon/models.py +++ b/nginxmon/models.py @@ -39,6 +39,18 @@ class NginxSettings(models.Model): 'Matching requests will not be ingested.' ), ) + auto_ban_enabled = models.BooleanField( + default=True, + help_text='Automatically ban IPs that repeatedly probe auth endpoints.', + ) + auto_ban_threshold = models.IntegerField( + default=5, + help_text='Number of auth-probe requests from one IP within the window before auto-ban.', + ) + auto_ban_window_hours = models.IntegerField( + default=24, + help_text='Rolling window (hours) used to count auth-probe requests.', + ) class Meta: verbose_name = 'Nginx Settings' @@ -107,6 +119,8 @@ class NginxAccessLog(models.Model): upstream_response_time = models.FloatField(null=True, blank=True) upstream_status = models.IntegerField(null=True, blank=True) request_id = models.CharField(max_length=100, blank=True, db_index=True) + # Auto-ban annotation + note = models.TextField(blank=True, default='') # Geo (populated after insert) country = models.CharField(max_length=100, blank=True) country_code = models.CharField(max_length=10, blank=True) @@ -176,3 +190,30 @@ class ThreatAlert(models.Model): @property def error_rate(self): return self.error_count / self.request_count if self.request_count else 0 + + +class BannedIP(models.Model): + """ + Permanent record of every IP banned by the system (auto or manual). + The IP is also pushed to the Kubernetes nginx ConfigMap block list. + """ + BAN_SOURCES = [ + ('manual', 'Manual'), + ('auto_auth_probe', 'Auto: Auth Probe'), + ('auto_php_scan', 'Auto: PHP Scanner'), + ('auto_404_flood', 'Auto: 404 Flood'), + ] + + ip = models.GenericIPAddressField(unique=True, db_index=True) + ban_source = models.CharField(max_length=30, choices=BAN_SOURCES, default='manual') + reason = models.TextField() + request_count = models.IntegerField(default=0) + banned_at = models.DateTimeField(auto_now_add=True) + country = models.CharField(max_length=100, blank=True) + city = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['-banned_at'] + + def __str__(self): + return f'{self.ip} [{self.get_ban_source_display()}] @ {self.banned_at:%Y-%m-%d %H:%M}' diff --git a/nginxmon/templates/nginxmon/_live_logs.html b/nginxmon/templates/nginxmon/_live_logs.html index db3b304..5e509ed 100644 --- a/nginxmon/templates/nginxmon/_live_logs.html +++ b/nginxmon/templates/nginxmon/_live_logs.html @@ -25,6 +25,9 @@ {{ log.request_time|floatformat:3 }}s + + {% if log.note %} {{ log.note }}{% endif %} + {% empty %} No logs yet — use "Fetch Now" or wait for the scheduler. diff --git a/nginxmon/templates/nginxmon/dashboard.html b/nginxmon/templates/nginxmon/dashboard.html index be2773e..44fc8b9 100644 --- a/nginxmon/templates/nginxmon/dashboard.html +++ b/nginxmon/templates/nginxmon/dashboard.html @@ -251,6 +251,7 @@ Service Status Time(s) + Note @@ -260,6 +261,68 @@

      + +
      +
      +
      + +

      Banned IPs

      + {{ banned_ips|length }} +
      +
      + {% if banned_ips %} +
      + + + + + + + + + + + + + + {% for ban in banned_ips %} + + + + + + + + + + {% endfor %} + +
      IPSourceReasonRequests
      + + + + {{ ban.get_ban_source_display }} + + {{ ban.reason }}{{ ban.request_count }} +
      + {% csrf_token %} + +
      +
      +
      + {% else %} +
      No IPs banned yet.
      + {% endif %} +
      +
      {% block extra_js %}{% endblock %} From bf11b675e82b4113c67500948fba441e92826c88 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 1 Apr 2026 21:52:29 +1100 Subject: [PATCH 66/95] Update ui --- .../migrations/0006_banned_ip_unbanned_at.py | 18 +++++++++++++ nginxmon/models.py | 5 ++++ nginxmon/templates/nginxmon/dashboard.html | 27 ++++++++++++++----- nginxmon/views.py | 8 +++--- 4 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 nginxmon/migrations/0006_banned_ip_unbanned_at.py diff --git a/nginxmon/migrations/0006_banned_ip_unbanned_at.py b/nginxmon/migrations/0006_banned_ip_unbanned_at.py new file mode 100644 index 0000000..8903e81 --- /dev/null +++ b/nginxmon/migrations/0006_banned_ip_unbanned_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-04-01 10:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nginxmon', '0005_banned_ip_model'), + ] + + operations = [ + migrations.AddField( + model_name='bannedip', + name='unbanned_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/nginxmon/models.py b/nginxmon/models.py index 401dd3c..530b045 100644 --- a/nginxmon/models.py +++ b/nginxmon/models.py @@ -209,11 +209,16 @@ class BannedIP(models.Model): reason = models.TextField() request_count = models.IntegerField(default=0) banned_at = models.DateTimeField(auto_now_add=True) + unbanned_at = models.DateTimeField(null=True, blank=True) country = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True) class Meta: ordering = ['-banned_at'] + @property + def is_unbanned(self): + return self.unbanned_at is not None + def __str__(self): return f'{self.ip} [{self.get_ban_source_display()}] @ {self.banned_at:%Y-%m-%d %H:%M}' diff --git a/nginxmon/templates/nginxmon/dashboard.html b/nginxmon/templates/nginxmon/dashboard.html index 44fc8b9..445e165 100644 --- a/nginxmon/templates/nginxmon/dashboard.html +++ b/nginxmon/templates/nginxmon/dashboard.html @@ -286,11 +286,12 @@ {% for ban in banned_ips %} - - - + + + - + {% if ban.city or ban.country %}{{ ban.city }}{% if ban.city and ban.country %}, {% endif %}{{ ban.country }}{% else %}—{% endif %} @@ -302,16 +303,28 @@ {{ ban.get_ban_source_display }} - {{ ban.reason }} - {{ ban.request_count }} - {{ ban.banned_at|date:"m/d H:i" }} + {{ ban.reason }} + {{ ban.request_count }} + + {{ ban.banned_at|date:"m/d H:i" }} + {% if ban.is_unbanned %} +
      ✓ unbanned {{ ban.unbanned_at|date:"m/d H:i" }} + {% endif %} + + {% if ban.is_unbanned %} + + Unbanned + + {% else %}
      {% csrf_token %}
      + {% endif %} {% endfor %} diff --git a/nginxmon/views.py b/nginxmon/views.py index 24139f1..ef3e5f3 100644 --- a/nginxmon/views.py +++ b/nginxmon/views.py @@ -305,7 +305,8 @@ class DismissAlertView(View): class UnbanIPView(View): - """Remove an IP from both the BannedIP table and the ConfigMap block list.""" + """Mark an IP as manually unbanned — removes it from the ConfigMap block list + but keeps the BannedIP record so auto-ban never re-bans it.""" def post(self, request, pk): ban = get_object_or_404(BannedIP, pk=pk) @@ -318,8 +319,9 @@ class UnbanIPView(View): _write_blocked_ips(blocked) except Exception as exc: logger.warning('nginxmon unban: could not update ConfigMap for %s: %s', ip, exc) - ban.delete() - # Remove notes from log rows for this IP + ban.unbanned_at = timezone.now() + ban.save(update_fields=['unbanned_at']) + # Clear ban notes from log rows NginxAccessLog.objects.filter(remote_addr=ip).update(note='') messages.success(request, f'{ip} has been unbanned.') return redirect('nginxmon-dashboard') From 19ea20485dd823b1e9d4f4b10ac0e58685ac5640 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:41:28 +0000 Subject: [PATCH 67/95] Bump pillow from 12.1.1 to 12.2.0 Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.1 to 12.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.1.1...12.2.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.2.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 128 ++++++++++++++++++++++++------------------------- 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f600969..8b1e7a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "playwright>=1.40.0", "boto3>=1.35.0", "python-magic>=0.4.27", - "pillow~=12.1.1", + "pillow~=12.2.0", "whoosh==2.7.4", "django-redis>=5.4.0", "qdrant-client>=1.13.2", diff --git a/uv.lock b/uv.lock index 73b248c..c15afd7 100644 --- a/uv.lock +++ b/uv.lock @@ -683,7 +683,7 @@ requires-dist = [ { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "kubernetes", specifier = ">=29.0.0" }, { name = "markdown", specifier = "==3.8.1" }, - { name = "pillow", specifier = "~=12.1.1" }, + { name = "pillow", specifier = "~=12.2.0" }, { name = "playwright", specifier = ">=1.40.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.5" }, @@ -819,71 +819,71 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] From d3aa50794a18934e1d2a53acc7268f8568833bf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:22:04 +0000 Subject: [PATCH 68/95] Bump pytest from 8.3.4 to 9.0.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.4 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.4...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- pyproject.toml | 4 ++-- uv.lock | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f600969..807753d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ allow-direct-references = true [project.optional-dependencies] dev = [ - "pytest>=7.0", + "pytest>=9.0.3", "pytest-django>=4.5", "black>=23.0", "isort>=5.0", @@ -68,6 +68,6 @@ line_length = 100 [dependency-groups] dev = [ - "pytest>=8.3.4", + "pytest>=9.0.3", "pytest-django>=4.10.0", ] diff --git a/uv.lock b/uv.lock index 73b248c..6129e1b 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ requires-dist = [ { name = "markdown", specifier = "==3.8.1" }, { name = "pillow", specifier = "~=12.1.1" }, { name = "playwright", specifier = ">=1.40.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.5" }, { name = "python-magic", specifier = ">=0.4.27" }, { name = "qdrant-client", specifier = ">=1.13.2" }, @@ -698,7 +698,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-django", specifier = ">=4.10.0" }, ] @@ -1075,19 +1075,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pytest" -version = "8.3.4" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 049d417cc8b4cbf44ea6eeb0aeaded4feccefce4 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Tue, 14 Apr 2026 12:04:44 +1000 Subject: [PATCH 69/95] Remove knowledge graph feature The knowledge graph feature (links graph) was not functioning correctly and was slowing down the application. This commit removes it entirely: - Delete knowledge_graph_urls.py, knowledge_graph_views.py, llm_client.py - Delete knowledge_graph.html template - Remove KnowledgeGraphSnapshot model and all llm_*/kg_* fields from SiteSettings (migration 0048) - Remove build_knowledge_graph() and schedule_kg_build() from tasks.py - Remove KG settings save logic and bulk delete action from views.py - Remove knowledge_graph_urls include from links/urls.py - Remove schedule_kg_build scheduler job from core/apps.py - Remove KG snapshot job registry from links/apps.py - Remove Knowledge Graph nav item from base.html - Remove KG settings cards and llmSettingsHelper JS from settings.html --- core/apps.py | 13 +- links/apps.py | 72 -- links/knowledge_graph_urls.py | 30 - links/knowledge_graph_views.py | 148 --- links/llm_client.py | 111 --- .../migrations/0048_remove_knowledge_graph.py | 58 ++ links/models.py | 103 --- links/tasks.py | 338 ------- links/templates/links/knowledge_graph.html | 847 ------------------ links/templates/links/settings.html | 183 ---- links/urls.py | 2 - links/views.py | 37 - templates/base.html | 10 - 13 files changed, 59 insertions(+), 1893 deletions(-) delete mode 100644 links/knowledge_graph_urls.py delete mode 100644 links/knowledge_graph_views.py delete mode 100644 links/llm_client.py create mode 100644 links/migrations/0048_remove_knowledge_graph.py delete mode 100644 links/templates/links/knowledge_graph.html diff --git a/core/apps.py b/core/apps.py index 46757f1..ea6bcce 100644 --- a/core/apps.py +++ b/core/apps.py @@ -18,7 +18,7 @@ class CoreConfig(AppConfig): from core.scheduler import scheduler, start_scheduler from links.tasks import ( schedule_pending_pages, schedule_pending_screenshots, - retry_stuck_image_imports, flush_click_buffer, schedule_kg_build, + retry_stuck_image_imports, flush_click_buffer, ) from apscheduler.triggers.interval import IntervalTrigger @@ -31,11 +31,9 @@ class CoreConfig(AppConfig): ss = SiteSettings.get() pages_interval = ss.schedule_pending_pages_interval or 120 screenshots_interval = ss.schedule_pending_screenshots_interval or 120 - kg_interval = ss.kg_auto_schedule_interval or 3600 except Exception: pages_interval = 120 screenshots_interval = 120 - kg_interval = 3600 # Add periodic job for checking pending pages scheduler.add_job( @@ -72,12 +70,3 @@ class CoreConfig(AppConfig): replace_existing=True, ) logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") - - # Add periodic job for knowledge graph auto-build - scheduler.add_job( - schedule_kg_build, - trigger=IntervalTrigger(seconds=kg_interval), - id='schedule_kg_build', - replace_existing=True, - ) - logger.info(f"Scheduled periodic task: schedule_kg_build (every {kg_interval}s)") diff --git a/links/apps.py b/links/apps.py index e4581f1..c1bca6b 100644 --- a/links/apps.py +++ b/links/apps.py @@ -181,75 +181,3 @@ class LinksConfig(AppConfig): }, }) - # ── Knowledge Graph Snapshots ──────────────────────────────────── - from .models import KnowledgeGraphSnapshot - - def kg_stats(): - return { - 'total': KnowledgeGraphSnapshot.objects.count(), - 'processing': KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.BUILDING - ).count(), - 'completed': KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.READY - ).count(), - 'failed': KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.FAILED - ).count(), - } - - def kg_queryset(sf): - qs = KnowledgeGraphSnapshot.objects.order_by('-created_at') - if sf == 'processing': - return qs.filter(status=KnowledgeGraphSnapshot.Status.BUILDING) - if sf == 'completed': - return qs.filter(status=KnowledgeGraphSnapshot.Status.READY) - if sf == 'failed': - return qs.filter(status=KnowledgeGraphSnapshot.Status.FAILED) - return qs - - def kg_serialize(obj): - if obj.status == KnowledgeGraphSnapshot.Status.BUILDING: - pct = obj.progress_data.get('pct', 0) if isinstance(obj.progress_data, dict) else 0 - title = f'Building… {pct}%' - else: - title = f'{obj.node_count} nodes / {obj.edge_count} edges' - if obj.used_llm: - title += ' (LLM)' - return { - 'id': str(obj.id), - 'title': title, - 'detail_url': reverse('knowledge-graph'), - 'status': obj.status, - 'retry': None, - 'retry_max': None, - 'error': obj.error_message or '', - 'updated_at': obj.completed_at or obj.created_at, - 'extra': { - 'duration': ( - f'{obj.build_duration_ms // 1000}s' if obj.build_duration_ms else '' - ), - }, - } - - job_registry.register({ - 'id': 'knowledge_graph', - 'label': 'Knowledge Graph', - 'icon_color': 'text-pink-500', - 'icon_path': ( - 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101' - 'm-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' - ), - 'title_label': 'Snapshot', - 'status_choices': [ - ('all', 'All'), ('processing', 'Building'), ('completed', 'Ready'), - ('failed', 'Failed'), - ], - 'columns': ['id', 'title', 'status', 'error', 'updated'], - 'get_stats': kg_stats, - 'get_queryset': kg_queryset, - 'serialize': kg_serialize, - 'bulk_actions': { - 'delete': 'bulk_delete_knowledge_graph_snapshots', - }, - }) diff --git a/links/knowledge_graph_urls.py b/links/knowledge_graph_urls.py deleted file mode 100644 index 0190944..0000000 --- a/links/knowledge_graph_urls.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.urls import path -from . import knowledge_graph_views - -urlpatterns = [ - path( - "ui/knowledge-graph/", - knowledge_graph_views.KnowledgeGraphPageView.as_view(), - name="knowledge-graph", - ), - path( - "api/knowledge-graph/data/", - knowledge_graph_views.KnowledgeGraphDataView.as_view(), - name="knowledge-graph-data", - ), - path( - "api/knowledge-graph/build/", - knowledge_graph_views.KnowledgeGraphBuildView.as_view(), - name="knowledge-graph-build", - ), - path( - "api/knowledge-graph/status/", - knowledge_graph_views.KnowledgeGraphStatusView.as_view(), - name="knowledge-graph-status", - ), - path( - "api/knowledge-graph/test-llm/", - knowledge_graph_views.LLMTestView.as_view(), - name="knowledge-graph-test-llm", - ), -] diff --git a/links/knowledge_graph_views.py b/links/knowledge_graph_views.py deleted file mode 100644 index b873744..0000000 --- a/links/knowledge_graph_views.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Views for the Knowledge Graph feature. -""" -import json -import logging -from datetime import timedelta - -from django.http import JsonResponse -from django.shortcuts import render -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.csrf import csrf_exempt - -from .models import KnowledgeGraphSnapshot, SiteSettings - -logger = logging.getLogger(__name__) - -# A BUILDING snapshot with no progress after this many minutes is considered stuck -STALE_BUILD_MINUTES = 5 - - -class KnowledgeGraphPageView(View): - template_name = "links/knowledge_graph.html" - - def get(self, request): - snapshot = KnowledgeGraphSnapshot.get_latest() - latest_building = KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.BUILDING - ).first() - ss = SiteSettings.get() - return render(request, self.template_name, { - "snapshot": snapshot, - "latest_building": latest_building, - "site_settings": ss, - }) - - -class KnowledgeGraphDataView(View): - def get(self, request): - snapshot = KnowledgeGraphSnapshot.get_latest() - if not snapshot: - return JsonResponse({"error": "No ready snapshot found."}, status=404) - return JsonResponse(snapshot.graph_data, safe=False) - - -class KnowledgeGraphBuildView(View): - def post(self, request): - try: - body = json.loads(request.body or "{}") - except json.JSONDecodeError: - body = {} - - # Cancel any stuck BUILDING snapshots before starting a new one - stale_cutoff = timezone.now() - timedelta(minutes=STALE_BUILD_MINUTES) - stuck = KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.BUILDING, - created_at__lt=stale_cutoff, - ) - if stuck.exists(): - stuck.update( - status=KnowledgeGraphSnapshot.Status.FAILED, - error_message="Build was cancelled (process was killed or server restarted)", - completed_at=timezone.now(), - ) - logger.info("Marked %d stale BUILDING snapshot(s) as FAILED", stuck.count()) - - ss = SiteSettings.get() - use_llm_requested = body.get("use_llm", False) - # Only use LLM if a provider is configured - use_llm = use_llm_requested and ss.llm_provider not in ("none", "") - - snapshot = KnowledgeGraphSnapshot.objects.create( - status=KnowledgeGraphSnapshot.Status.BUILDING, - used_llm=use_llm, - ) - - from threading import Thread - from .tasks import build_knowledge_graph - thread = Thread( - target=build_knowledge_graph, - kwargs={"snapshot_id": snapshot.pk, "use_llm": use_llm}, - daemon=True, - ) - thread.start() - - return JsonResponse({ - "snapshot_id": snapshot.pk, - "status": snapshot.status, - "use_llm": use_llm, - }) - - -class KnowledgeGraphStatusView(View): - def get(self, request): - # Latest snapshot regardless of status (so the UI can poll while building) - snapshot = KnowledgeGraphSnapshot.objects.first() - if not snapshot: - return JsonResponse({"status": "none"}) - - # Auto-detect stale BUILDING snapshots (e.g. server restarted, thread was killed) - status = snapshot.status - if status == KnowledgeGraphSnapshot.Status.BUILDING: - progress = snapshot.progress_data or {} - age_s = (timezone.now() - snapshot.created_at).total_seconds() - last_pct = progress.get("pct", 0) - # Mark stuck if: no progress at all after 5 min, or no change for 10 min - if age_s > STALE_BUILD_MINUTES * 60 and last_pct == 0: - snapshot.status = KnowledgeGraphSnapshot.Status.FAILED - snapshot.error_message = "Build timed out — the worker thread was likely killed (server restart). Click Build Graph to try again." - snapshot.completed_at = timezone.now() - snapshot.save(update_fields=["status", "error_message", "completed_at"]) - status = KnowledgeGraphSnapshot.Status.FAILED - - return JsonResponse({ - "snapshot_id": snapshot.pk, - "status": status, - "node_count": snapshot.node_count, - "edge_count": snapshot.edge_count, - "used_llm": snapshot.used_llm, - "build_duration_ms": snapshot.build_duration_ms, - "error_message": snapshot.error_message, - "progress_data": snapshot.progress_data or {}, - "created_at": snapshot.created_at.isoformat() if snapshot.created_at else None, - "completed_at": snapshot.completed_at.isoformat() if snapshot.completed_at else None, - }) - - -class LLMTestView(View): - def post(self, request): - try: - body = json.loads(request.body or "{}") - except json.JSONDecodeError: - body = {} - - # Accept inline params from the UI test modal, or fall back to SiteSettings - from .llm_client import LLMClient - provider = body.get("provider") or SiteSettings.get().llm_provider - base_url = body.get("base_url") or SiteSettings.get().llm_base_url - model = body.get("model") or SiteSettings.get().llm_model - api_key = body.get("api_key") or SiteSettings.get().llm_api_key - - if provider in ("none", ""): - return JsonResponse({"success": False, "error": "No LLM provider configured."}) - - client = LLMClient(provider=provider, base_url=base_url, model=model, api_key=api_key) - success, error = client.test_connection() - return JsonResponse({"success": success, "error": error}) diff --git a/links/llm_client.py b/links/llm_client.py deleted file mode 100644 index 26f034b..0000000 --- a/links/llm_client.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -LLM / embedding client for the knowledge graph builder. - -Supports: - - Ollama (local, free — recommended for manual builds) - - OpenRouter (cloud, requires API key) -""" -import logging -import math -import requests - -logger = logging.getLogger(__name__) - -EMBED_TIMEOUT = 15 # seconds per request - - -class LLMClientError(Exception): - pass - - -class LLMClient: - """Thin wrapper for embedding generation via Ollama or OpenRouter.""" - - def __init__(self, provider: str, base_url: str, model: str, api_key: str = ""): - self.provider = provider - self.base_url = base_url.rstrip("/") - self.model = model - self.api_key = api_key - - @classmethod - def from_settings(cls) -> "LLMClient": - from links.models import SiteSettings - ss = SiteSettings.get() - return cls( - provider=ss.llm_provider, - base_url=ss.llm_base_url, - model=ss.llm_model, - api_key=ss.llm_api_key, - ) - - def get_embedding(self, text: str) -> list[float]: - """Return a float vector for *text*. Raises LLMClientError on failure.""" - if not text or not text.strip(): - raise LLMClientError("Empty text passed to get_embedding") - - if self.provider == "ollama": - return self._ollama_embed(text) - elif self.provider == "openrouter": - return self._openrouter_embed(text) - else: - raise LLMClientError(f"Unsupported provider: {self.provider}") - - def _ollama_embed(self, text: str) -> list[float]: - url = f"{self.base_url}/api/embeddings" - try: - resp = requests.post( - url, - json={"model": self.model, "prompt": text}, - timeout=EMBED_TIMEOUT, - ) - resp.raise_for_status() - data = resp.json() - vec = data.get("embedding") - if not vec: - raise LLMClientError(f"Ollama returned no embedding. Response: {data}") - return vec - except requests.RequestException as exc: - raise LLMClientError(f"Ollama request failed: {exc}") from exc - - def _openrouter_embed(self, text: str) -> list[float]: - url = "https://openrouter.ai/api/v1/embeddings" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - try: - resp = requests.post( - url, - headers=headers, - json={"model": self.model, "input": text}, - timeout=EMBED_TIMEOUT, - ) - resp.raise_for_status() - data = resp.json() - try: - vec = data["data"][0]["embedding"] - except (KeyError, IndexError) as exc: - raise LLMClientError(f"Unexpected OpenRouter response shape: {data}") from exc - return vec - except requests.RequestException as exc: - raise LLMClientError(f"OpenRouter request failed: {exc}") from exc - - def test_connection(self) -> tuple[bool, str]: - """Returns (success: bool, error_message: str).""" - try: - vec = self.get_embedding("ping") - if not vec: - return False, "Got empty embedding vector" - return True, "" - except LLMClientError as exc: - return False, str(exc) - - -def cosine_similarity(a: list[float], b: list[float]) -> float: - """Compute cosine similarity between two vectors.""" - dot = sum(x * y for x, y in zip(a, b)) - mag_a = math.sqrt(sum(x * x for x in a)) - mag_b = math.sqrt(sum(x * x for x in b)) - if mag_a == 0 or mag_b == 0: - return 0.0 - return dot / (mag_a * mag_b) diff --git a/links/migrations/0048_remove_knowledge_graph.py b/links/migrations/0048_remove_knowledge_graph.py new file mode 100644 index 0000000..caf2d13 --- /dev/null +++ b/links/migrations/0048_remove_knowledge_graph.py @@ -0,0 +1,58 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0047_add_is_public_to_post'), + ] + + operations = [ + migrations.DeleteModel( + name='KnowledgeGraphSnapshot', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_provider', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_base_url', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_model', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_api_key', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_auto_schedule_enabled', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_auto_schedule_interval', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_semantic_threshold', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_links', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_pages', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_posts', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_tags', + ), + ] diff --git a/links/models.py b/links/models.py index 36b0537..f2b6a7f 100644 --- a/links/models.py +++ b/links/models.py @@ -417,11 +417,6 @@ class SiteSettings(models.Model): Always use SiteSettings.get() to retrieve the instance. """ - class LLMProvider(models.TextChoices): - NONE = 'none', _('None (no LLM)') - OLLAMA = 'ollama', _('Ollama (local)') - OPENROUTER = 'openrouter', _('OpenRouter') - public_sharing_domain = models.CharField( _('Public Sharing Domain'), max_length=255, @@ -459,57 +454,6 @@ class SiteSettings(models.Model): ), ) - # ── Knowledge Graph: LLM settings ──────────────────────────────────── - llm_provider = models.CharField( - _('LLM Provider'), - max_length=20, - choices=LLMProvider.choices, - default=LLMProvider.NONE, - help_text=_('LLM/embedding provider used to generate semantic edges in the knowledge graph.'), - ) - llm_base_url = models.CharField( - _('LLM Base URL'), - max_length=255, - blank=True, - default='http://localhost:11434', - help_text=_('Base URL for the Ollama server (e.g. http://192.168.1.2:11434).'), - ) - llm_model = models.CharField( - _('LLM Embedding Model'), - max_length=100, - blank=True, - default='nomic-embed-text', - help_text=_('Model name used for embeddings (e.g. nomic-embed-text for Ollama).'), - ) - llm_api_key = models.CharField( - _('LLM API Key'), - max_length=255, - blank=True, - default='', - help_text=_('API key for OpenRouter (not needed for Ollama).'), - ) - - # ── Knowledge Graph: schedule & build options ───────────────────────── - kg_auto_schedule_enabled = models.BooleanField( - _('Auto-rebuild Knowledge Graph'), - default=False, - help_text=_('When enabled, the knowledge graph is rebuilt on the configured interval.'), - ) - kg_auto_schedule_interval = models.IntegerField( - _('Knowledge Graph Rebuild Interval (seconds)'), - default=3600, - help_text=_('How often (in seconds) to auto-rebuild the knowledge graph. Minimum 60.'), - ) - kg_semantic_threshold = models.FloatField( - _('Semantic Similarity Threshold'), - default=0.70, - help_text=_('Minimum cosine similarity (0.0–1.0) required to draw a semantic edge between two items.'), - ) - kg_include_links = models.BooleanField(_('Include Links in Graph'), default=True) - kg_include_pages = models.BooleanField(_('Include Pages in Graph'), default=True) - kg_include_posts = models.BooleanField(_('Include Posts in Graph'), default=True) - kg_include_tags = models.BooleanField(_('Include Tags in Graph'), default=True) - class Meta: verbose_name = _('Site Settings') @@ -524,50 +468,3 @@ class SiteSettings(models.Model): def save(self, *args, **kwargs): self.pk = 1 super().save(*args, **kwargs) - - -class KnowledgeGraphSnapshot(models.Model): - """Stores a point-in-time serialized knowledge graph for immediate front-end consumption.""" - - class Status(models.TextChoices): - BUILDING = 'building', _('Building') - READY = 'ready', _('Ready') - FAILED = 'failed', _('Failed') - - status = models.CharField( - _('Status'), - max_length=20, - choices=Status.choices, - default=Status.BUILDING, - db_index=True, - ) - graph_data = models.JSONField( - _('Graph Data'), - default=dict, - help_text=_('Graphology-compatible serialization: {nodes: [...], edges: [...]}'), - ) - node_count = models.IntegerField(_('Node Count'), default=0) - edge_count = models.IntegerField(_('Edge Count'), default=0) - used_llm = models.BooleanField(_('Used LLM'), default=False) - error_message = models.TextField(_('Error Message'), blank=True) - build_duration_ms = models.IntegerField(_('Build Duration (ms)'), default=0) - progress_data = models.JSONField( - _('Progress Data'), - default=dict, - blank=True, - help_text=_('Live build progress: {pct, step, logs}'), - ) - created_at = models.DateTimeField(_('Created At'), auto_now_add=True) - completed_at = models.DateTimeField(_('Completed At'), null=True, blank=True) - - class Meta: - ordering = ['-created_at'] - verbose_name = _('Knowledge Graph Snapshot') - verbose_name_plural = _('Knowledge Graph Snapshots') - - def __str__(self): - return f"KG Snapshot [{self.status}] {self.node_count} nodes / {self.edge_count} edges @ {self.created_at}" - - @classmethod - def get_latest(cls): - return cls.objects.filter(status=cls.Status.READY).first() diff --git a/links/tasks.py b/links/tasks.py index 95b625e..1bfd6b9 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -622,341 +622,3 @@ def retry_stuck_image_imports(): logger.info(f"retry_stuck_image_imports: re-queuing download for FileUpload {record.pk} ({record.source_url})") thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) thread.start() - - -# ── Knowledge Graph ─────────────────────────────────────────────────────────── - -def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False): - """ - Build a graphology-compatible knowledge graph from all Links, Pages, Posts and Tags. - - Nodes are coloured by type: - Link → #4B9CD3 (blue) - Page → #22C55E (green) - Post → #F97316 (orange) - Tag → #8B5CF6 (purple) - - Edges (always): - has_tag item → tag (#94A3B8, structural) - same_domain items sharing netloc (#FCD34D, domain) - - Edges (with LLM): - semantic cosine-sim ≥ threshold (#F472B6) - """ - import time - import random - from urllib.parse import urlparse - from django.utils import timezone as tz - from .models import KnowledgeGraphSnapshot, SiteSettings, Link, Page, Post, Tag - - start_ms = int(time.time() * 1000) - - # Resolve / create the snapshot record - if snapshot_id: - try: - snapshot = KnowledgeGraphSnapshot.objects.get(pk=snapshot_id) - except KnowledgeGraphSnapshot.DoesNotExist: - logger.error(f"build_knowledge_graph: snapshot {snapshot_id} not found") - return - else: - snapshot = KnowledgeGraphSnapshot.objects.create( - status=KnowledgeGraphSnapshot.Status.BUILDING, - used_llm=use_llm, - ) - - snapshot.status = KnowledgeGraphSnapshot.Status.BUILDING - snapshot.used_llm = use_llm - snapshot.progress_data = {"pct": 0, "step": "Starting…", "logs": []} - snapshot.save(update_fields=["status", "used_llm", "progress_data"]) - - try: - ss = SiteSettings.get() - nodes = [] - edges = [] - edge_key_counter = [0] - _log_entries: list[dict] = [] - - def _elapsed_s() -> float: - return round((time.time() * 1000 - start_ms) / 1000, 1) - - def emit(pct: int, msg: str) -> None: - """Save a progress update to the snapshot so the UI can poll it.""" - _log_entries.append({"elapsed_s": _elapsed_s(), "msg": msg}) - snapshot.progress_data = { - "pct": pct, - "step": msg, - "logs": list(_log_entries), - } - snapshot.save(update_fields=["progress_data"]) - logger.info(f"build_knowledge_graph [{pct}%]: {msg}") - - def next_edge_key(): - edge_key_counter[0] += 1 - return f"e{edge_key_counter[0]}" - - def rand_pos(): - return round(random.uniform(0, 100), 2) - - emit(2, "Build started") - - # ── Collect nodes ──────────────────────────────────────────────── - # Links - if ss.kg_include_links: - link_qs = list(Link.objects.prefetch_related("tags").all()) - for link in link_qs: - size = max(6, min(20, 6 + link.click_count // 5)) - nodes.append({ - "key": f"link-{link.id}", - "attributes": { - "label": link.alias, - "color": "#4B9CD3", - "size": size, - "node_type": "link", - "item_id": link.id, - "item_url": f"/detail/{link.pk}/", - "original_url": link.original_url or "", - "description": link.description or "", - "tag_ids": [t.id for t in link.tags.all()], - "x": rand_pos(), - "y": rand_pos(), - }, - }) - emit(15, f"Collected {len(link_qs)} links") - - # Pages - if ss.kg_include_pages: - page_qs = list(Page.objects.prefetch_related("tags").all()) - for page in page_qs: - nodes.append({ - "key": f"page-{page.id}", - "attributes": { - "label": (page.title or page.url)[:80], - "color": "#22C55E", - "size": 8, - "node_type": "page", - "item_id": page.id, - "item_url": f"/ui/pages/{page.pk}/", - "original_url": page.url, - "description": page.summary or "", - "tag_ids": [t.id for t in page.tags.all()], - "x": rand_pos(), - "y": rand_pos(), - }, - }) - emit(28, f"Collected {len(page_qs)} pages") - - # Posts - if ss.kg_include_posts: - post_qs = list(Post.objects.prefetch_related("tags").all()) - for post in post_qs: - nodes.append({ - "key": f"post-{post.id}", - "attributes": { - "label": post.title[:80], - "color": "#F97316", - "size": 8, - "node_type": "post", - "item_id": post.id, - "item_url": f"/ui/posts/{post.pk}/", - "original_url": "", - "description": post.summary or "", - "tag_ids": [t.id for t in post.tags.all()], - "x": rand_pos(), - "y": rand_pos(), - }, - }) - emit(38, f"Collected {len(post_qs)} posts") - - # Tags - if ss.kg_include_tags: - tag_qs = list(Tag.objects.all()) - for tag in tag_qs: - nodes.append({ - "key": f"tag-{tag.id}", - "attributes": { - "label": tag.name, - "color": "#8B5CF6", - "size": 14, - "node_type": "tag", - "item_id": tag.id, - "item_url": f"/ui/tags/{tag.slug}/", - "original_url": "", - "description": tag.description or "", - "x": rand_pos(), - "y": rand_pos(), - }, - }) - emit(45, f"Collected {len(tag_qs)} tags — {len(nodes)} total nodes") - - # Build a set of existing node keys for fast membership checks - node_keys = {n["key"] for n in nodes} - - # ── Structural edges ───────────────────────────────────────────── - emit(48, "Building tag edges…") - for node in nodes: - ntype = node["attributes"]["node_type"] - nkey = node["key"] - - if ntype in ("link", "page", "post") and ss.kg_include_tags: - for tag_id in node["attributes"].get("tag_ids", []): - tag_key = f"tag-{tag_id}" - if tag_key in node_keys: - edges.append({ - "key": next_edge_key(), - "source": nkey, - "target": tag_key, - "attributes": { - "edge_type": "has_tag", - "color": "#94A3B8", - "size": 1, - }, - }) - - if ntype == "tag": - pass # Tag has no parent field; hierarchy edges skipped - - emit(55, f"Built {len(edges)} tag edges") - - # ── Domain edges ───────────────────────────────────────────────── - emit(57, "Building domain edges…") - domain_map: dict[str, list[str]] = {} - for node in nodes: - url = node["attributes"].get("original_url", "") - if url: - try: - netloc = urlparse(url).netloc - if netloc: - domain_map.setdefault(netloc, []).append(node["key"]) - except Exception: - pass - - domain_edge_count = 0 - for netloc, keys in domain_map.items(): - if len(keys) < 2: - continue - for i in range(len(keys)): - for j in range(i + 1, len(keys)): - if domain_edge_count >= 100: - break - edges.append({ - "key": next_edge_key(), - "source": keys[i], - "target": keys[j], - "attributes": { - "edge_type": "same_domain", - "color": "#FCD34D", - "size": 0.5, - "domain": netloc, - }, - }) - domain_edge_count += 1 - if domain_edge_count >= 100: - break - - emit(65, f"Built {domain_edge_count} domain edges across {len(domain_map)} domains") - - # ── Semantic edges (LLM) ───────────────────────────────────────── - if use_llm and ss.llm_provider != "none": - from .llm_client import LLMClient, LLMClientError, cosine_similarity - client = LLMClient.from_settings() - threshold = ss.kg_semantic_threshold - - embed_nodes = [n for n in nodes if n["attributes"]["node_type"] != "tag"] - total_embed = len(embed_nodes) - emit(67, f"Computing embeddings for {total_embed} nodes via {ss.llm_provider}…") - - embeddings: dict[str, list[float]] = {} - emit_every = max(1, total_embed // 10) # emit ~10 progress steps - for idx, node in enumerate(embed_nodes): - text_parts = [node["attributes"].get("label", "")] - desc = node["attributes"].get("description", "") - if desc: - text_parts.append(desc[:500]) - text = " ".join(text_parts).strip() - try: - embeddings[node["key"]] = client.get_embedding(text) - except LLMClientError as exc: - logger.warning(f"build_knowledge_graph: embedding failed for {node['key']}: {exc}") - if (idx + 1) % emit_every == 0 or (idx + 1) == total_embed: - pct = 67 + int(18 * (idx + 1) / total_embed) - emit(pct, f"Embedded {idx + 1}/{total_embed} nodes…") - - emit(85, f"Got {len(embeddings)} embeddings — computing pairwise similarity…") - semantic_count = 0 - keys_with_embeds = list(embeddings.keys()) - for i in range(len(keys_with_embeds)): - for j in range(i + 1, len(keys_with_embeds)): - ka, kb = keys_with_embeds[i], keys_with_embeds[j] - sim = cosine_similarity(embeddings[ka], embeddings[kb]) - if sim >= threshold: - edges.append({ - "key": next_edge_key(), - "source": ka, - "target": kb, - "attributes": { - "edge_type": "semantic", - "color": "#F472B6", - "size": round(sim, 3), - "similarity": round(sim, 3), - }, - }) - semantic_count += 1 - - emit(90, f"Built {semantic_count} semantic edges (threshold={threshold})") - else: - emit(65, "Skipping LLM semantic edges (no provider configured)") - - # ── Save snapshot ───────────────────────────────────────────────── - emit(92, f"Saving snapshot — {len(nodes)} nodes, {len(edges)} edges…") - graph_data = { - "attributes": {"title": "Knowledge Graph"}, - "nodes": nodes, - "edges": edges, - } - elapsed_ms = int(time.time() * 1000) - start_ms - - _log_entries.append({"elapsed_s": _elapsed_s(), "msg": f"Done in {elapsed_ms / 1000:.1f}s"}) - snapshot.graph_data = graph_data - snapshot.node_count = len(nodes) - snapshot.edge_count = len(edges) - snapshot.status = KnowledgeGraphSnapshot.Status.READY - snapshot.build_duration_ms = elapsed_ms - snapshot.completed_at = tz.now() - snapshot.progress_data = { - "pct": 100, - "step": f"Done — {len(nodes)} nodes, {len(edges)} edges in {elapsed_ms / 1000:.1f}s", - "logs": list(_log_entries), - } - snapshot.save() - logger.info( - f"build_knowledge_graph: done — {len(nodes)} nodes, {len(edges)} edges " - f"(LLM={use_llm}) in {elapsed_ms}ms" - ) - - except Exception as exc: - logger.error(f"build_knowledge_graph: failed — {exc}", exc_info=True) - snapshot.status = KnowledgeGraphSnapshot.Status.FAILED - snapshot.error_message = str(exc) - snapshot.completed_at = tz.now() - snapshot.save(update_fields=["status", "error_message", "completed_at", "progress_data"]) - - -def schedule_kg_build(): - """Periodic task: trigger a knowledge graph rebuild if auto-schedule is enabled.""" - from .models import SiteSettings, KnowledgeGraphSnapshot - - ss = SiteSettings.get() - if not ss.kg_auto_schedule_enabled: - return - - # Don't start a new build if one is already running - if KnowledgeGraphSnapshot.objects.filter( - status=KnowledgeGraphSnapshot.Status.BUILDING - ).exists(): - logger.debug("schedule_kg_build: skipped — a build is already in progress") - return - - logger.info("schedule_kg_build: starting auto knowledge graph build") - use_llm = ss.llm_provider != "none" - thread = Thread(target=build_knowledge_graph, kwargs={"use_llm": use_llm}, daemon=True) - thread.start() diff --git a/links/templates/links/knowledge_graph.html b/links/templates/links/knowledge_graph.html deleted file mode 100644 index da0e6c9..0000000 --- a/links/templates/links/knowledge_graph.html +++ /dev/null @@ -1,847 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% load static %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      - - -
      - - - - - {% if snapshot %}{% with built_at=snapshot.completed_at|default:snapshot.created_at %} - - {% endwith %}{% endif %} -
      - - -
      - - -
      - - - - -

      -

      {% trans "Computing force-directed layout…" %}

      -
      - - -
      - - - - -
      - - -
      - - - -
      - - -
      -
      -

      {% trans "Node Details" %}

      - -
      -
      - - - - -

      -

      -
      -

      {% trans "URL" %}

      - -
      - - - - - {% trans "View Item" %} - -
      -

      - {% trans "Connected" %} () -

      -
        - -
      • - + {% trans "more" %} -
      • -
      -
      -
      -
      - - -
      - - - -

      {% trans "No knowledge graph yet." %}

      - -
      - - -
      -
      -
      - - - - - {% trans "Building Knowledge Graph" %} -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - ⏱ {% trans "Elapsed" %}: - - {% trans "Est. remaining" %}: - -
      -
      - -
      - -
      {% trans "No log entries yet…" %}
      -
      -
      -
      -
      - - -
      -
      -

      {% trans "Build Knowledge Graph" %}

      -
      - -

      - {% trans "Semantic edges (pink) connect items with similar meaning using embeddings. Without LLM, the graph uses tag and domain edges only." %} -

      -
      -
      - - -
      -
      -
      - -
      -{% endblock %} - -{% block extra_js %} - - - - - - -{% endblock %} diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html index dae48d7..76650e6 100644 --- a/links/templates/links/settings.html +++ b/links/templates/links/settings.html @@ -106,155 +106,6 @@

      {% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}

      - -
      -

      {% trans "Knowledge Graph — LLM Provider" %}

      -

      - {% trans "Configure an LLM/embedding provider to generate semantic similarity edges in the knowledge graph. Select Ollama to use a local model for free." %} -

      - -
      - -
      - - -
      - - -
      - - -

      {% trans "E.g. http://localhost:11434 or your LAN Ollama address." %}

      -
      - - -
      - - -

      - {% trans "Ollama: qwen3-embedding:0.6b, mxbai-embed-large, etc." %} •  - {% trans "OpenRouter: any embedding model slug." %} -

      -
      - - -
      - - -
      - - -
      - - -
      -
      -
      - - -
      -

      {% trans "Knowledge Graph — Auto Rebuild" %}

      -

      - {% trans "Optionally rebuild the knowledge graph on a schedule. You can also trigger a manual build from the " %} - {% trans "Knowledge Graph page" %}. -

      - -
      - - - - -
      - -
      - - {% trans "seconds" %} -
      -

      {% trans "Minimum 60 (1 minute). Default 3600 (1 hour)." %}

      -
      - - -
      - - -
      - 0.50 ({% trans "more edges" %}) - 0.95 ({% trans "fewer, tighter edges" %}) -
      -
      - - -
      -

      {% trans "Include in graph:" %}

      -
      - - - - -
      -
      -
      -
      -
      @@ -369,39 +220,5 @@ function sharedPostsMgr() { }, }; } - -function llmSettingsHelper() { - return { - provider: '{{ site_settings.llm_provider }}', - testing: false, - testResult: null, - testMessage: '', - async testConnection() { - this.testing = true; - this.testResult = null; - const provider = document.getElementById('llm_provider').value; - const base_url = document.getElementById('llm_base_url')?.value || ''; - const model = document.getElementById('llm_model')?.value || ''; - const api_key = document.getElementById('llm_api_key')?.value || ''; - try { - const resp = await fetch('/api/knowledge-graph/test-llm/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '', - }, - body: JSON.stringify({ provider, base_url, model, api_key }), - }); - const data = await resp.json(); - this.testResult = data.success; - this.testMessage = data.success ? '✓ {% trans "Connection OK" %}' : '✗ ' + (data.error || '{% trans "Failed" %}'); - } catch(e) { - this.testResult = false; - this.testMessage = '✗ ' + e.message; - } - this.testing = false; - }, - }; -} {% endblock %} diff --git a/links/urls.py b/links/urls.py index 11e7aba..a499227 100644 --- a/links/urls.py +++ b/links/urls.py @@ -74,8 +74,6 @@ urlpatterns = [ path('', include('links.collection_urls')), # Include tag URLs path('', include('links.tag_urls')), - # Include knowledge graph URLs - path('', include('links.knowledge_graph_urls')), # Aliases - these should always be last path('/', views.redirect_to_original, name='redirect_to_original'), diff --git a/links/views.py b/links/views.py index 1fa2082..c7d2538 100644 --- a/links/views.py +++ b/links/views.py @@ -594,32 +594,6 @@ class SiteSettingsView(View): site_settings.schedule_pending_screenshots_interval = max(10, min(screenshots_interval, 3600)) except (ValueError, TypeError): pass - - # ── Knowledge Graph LLM settings ────────────────────────────────── - llm_provider = request.POST.get('llm_provider', 'none').strip() - if llm_provider in ('none', 'ollama', 'openrouter'): - site_settings.llm_provider = llm_provider - site_settings.llm_base_url = request.POST.get('llm_base_url', '').strip() or 'http://localhost:11434' - site_settings.llm_model = request.POST.get('llm_model', '').strip() or 'qwen3-embedding:0.6b' - site_settings.llm_api_key = request.POST.get('llm_api_key', '').strip() - - # ── Knowledge Graph schedule settings ───────────────────────────── - site_settings.kg_auto_schedule_enabled = bool(request.POST.get('kg_auto_schedule_enabled')) - try: - kg_interval = int(request.POST.get('kg_auto_schedule_interval', 3600)) - site_settings.kg_auto_schedule_interval = max(60, min(kg_interval, 86400)) - except (ValueError, TypeError): - pass - try: - threshold = float(request.POST.get('kg_semantic_threshold', 0.70)) - site_settings.kg_semantic_threshold = max(0.0, min(threshold, 1.0)) - except (ValueError, TypeError): - pass - site_settings.kg_include_links = bool(request.POST.get('kg_include_links')) - site_settings.kg_include_pages = bool(request.POST.get('kg_include_pages')) - site_settings.kg_include_posts = bool(request.POST.get('kg_include_posts')) - site_settings.kg_include_tags = bool(request.POST.get('kg_include_tags')) - site_settings.save() # Reschedule periodic jobs with the new intervals @@ -632,11 +606,6 @@ class SiteSettingsView(View): 'schedule_pending_screenshots', trigger=IntervalTrigger(seconds=site_settings.schedule_pending_screenshots_interval), ) - # Reschedule knowledge graph job - scheduler.reschedule_job( - 'schedule_kg_build', - trigger=IntervalTrigger(seconds=site_settings.kg_auto_schedule_interval), - ) except Exception: pass # Scheduler may not be running in test/CLI context @@ -817,12 +786,6 @@ class JobsView(View): messages.success(request, _(f'Deleted {n} netscan run(s).')) elif action == 'bulk_delete_knowledge_graph_snapshots': - from .models import KnowledgeGraphSnapshot - qs = KnowledgeGraphSnapshot.objects.filter(pk__in=ids) if ids else KnowledgeGraphSnapshot.objects.none() - n = qs.delete()[0] - messages.success(request, _(f'Deleted {n} knowledge graph snapshot(s).')) - - else: messages.error(request, _('Unknown action.')) # Preserve tab/status after POST diff --git a/templates/base.html b/templates/base.html index 28313da..f081645 100644 --- a/templates/base.html +++ b/templates/base.html @@ -191,16 +191,6 @@ - -
      - - - - {% trans "Knowledge Graph" %} -
      -
      -
      From a04aa1b828813279ab37ae28fa16c6f6304527bd Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Tue, 14 Apr 2026 14:32:30 +1000 Subject: [PATCH 70/95] chore: add manifest template and update deploy step to commit rendered manifest --- .github/workflows/build-and-deploy.yml | 14 +- k8s/manifest.template.yaml | 320 +++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 k8s/manifest.template.yaml diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 738ac1f..39a769d 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -40,7 +40,7 @@ jobs: needs: test if: github.event_name == 'push' permissions: - contents: read + contents: write packages: write steps: @@ -86,7 +86,15 @@ jobs: KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} with: args: |- - # Update both deployments with new image tags - sed -i 's|image: .*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:1.0.${{ github.run_number }}|' k8s/manifest.yaml + # Render template → manifest with the actual image tag + sed 's|__IMAGE_TAG__|1.0.${{ github.run_number }}|g' k8s/manifest.template.yaml > k8s/manifest.yaml # Apply Kubernetes manifests kubectl --insecure-skip-tls-verify apply -n ${{ env.K8S_NAMESPACE }} -f k8s/manifest.yaml + + - name: Commit updated manifest + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add k8s/manifest.yaml + git commit -m "chore: update manifest image to 1.0.${{ github.run_number }} [skip ci]" + git push diff --git a/k8s/manifest.template.yaml b/k8s/manifest.template.yaml new file mode 100644 index 0000000..e842553 --- /dev/null +++ b/k8s/manifest.template.yaml @@ -0,0 +1,320 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: feiniu-images-nfs-apps +spec: + capacity: + storage: 100Gi + accessModes: + - ReadWriteMany + nfs: + server: 192.168.1.5 + path: "/fs/1000/nfs/data/images" + +--- + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "feiniu-images-nfs-apps" +spec: + storageClassName: "" + volumeName: feiniu-images-nfs-apps + accessModes: + - ReadWriteMany + resources: + requests: + storage: 100Gi + +--- + +apiVersion: v1 +kind: PersistentVolume +metadata: + name: uploads-nfs-apps +spec: + capacity: + storage: 1000Gi + accessModes: + - ReadWriteMany + nfs: + server: 192.168.1.5 + path: "/fs/1000/nfs/uploads" + +--- + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "uploads-nfs-apps" +spec: + storageClassName: "" + volumeName: uploads-nfs-apps + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1000Gi + +# --- +# apiVersion: v1 +# kind: PersistentVolumeClaim +# metadata: +# name: links-pvc +# spec: +# accessModes: +# - ReadWriteOnce +# storageClassName: nfs-client +# resources: +# requests: +# storage: 2Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: links + labels: + tags.datadoghq.com/env: "prod" + tags.datadoghq.com/service: "links" +spec: + selector: + matchLabels: + app: links + replicas: 1 + template: + metadata: + labels: + app: links + tags.datadoghq.com/env: "prod" + tags.datadoghq.com/service: "links" + spec: + serviceAccountName: links + volumes: + - name: downloads + persistentVolumeClaim: + claimName: feiniu-images-nfs-apps + - name: data + persistentVolumeClaim: + claimName: links-local-pvc + - name: uploads + persistentVolumeClaim: + claimName: uploads-nfs-apps + - name: cache + emptyDir: {} + initContainers: + - name: links-init + image: "ghcr.io/wahyd4/links:__IMAGE_TAG__" + command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] + volumeMounts: + - name: data + mountPath: /app/data + containers: + - name: links + image: "ghcr.io/wahyd4/links:__IMAGE_TAG__" + securityContext: + runAsUser: 1000 + imagePullPolicy: Always + volumeMounts: + - name: data + mountPath: /app/data + - name: cache + mountPath: /app/.cache + - name: downloads + mountPath: /images + - name: uploads + mountPath: /uploads + env: + - name : DEBUG + value: "false" + - name: FILE_UPLOADS_FOLDER + value: "/uploads" + - name: R2_CUSTOM_DOMAIN + value: home-links-prod.junv.cc + - name: DB_HOST + value: new-postgres-postgresql.db.svc.cluster.local + - name: DB_NAME + value: badges + - name: DB_USERNAME + value: postgres + # - name: DB_PASSWORD + # valueFrom: + # { secretKeyRef: { name: database-credentials, key: password } } + - name: CSRF_TRUSTED_ORIGINS + value: "https://to.junv.cc,https://go.junv.cc,http://go" + - name: UV_CACHE_DIR + value: "/app/.cache/uv" + - name: R2_BUCKET_NAME + value: "home-links-prod" + - name: R2_ENDPOINT_URL + value: https://d39b5aca439164602c01f7af2a58d4bf.r2.cloudflarestorage.com + - name: IMAGES_FOLDER + value: "/images" + - name: R2_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: r2-credentials + key: access_key + - name: R2_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: r2-credentials + key: key_id + - name: REDIS_URL + value: "redis://redis.db.svc.cluster.local:6379/0" + - name: CRAWL4AI_API_URL + value: "http://crawl4ai.ai.svc.cluster.local:80" + - name: CRAWL4AI_ENABLED + value: "true" + - name: QDRANT_SYNC_ENABLED + value: "true" + - name: QDRANT_HOST + value: "192.168.1.2" + - name: OLLAMA_URL + value: "http://ollama-service.ollama.svc.cluster.local:11434" + ports: + - containerPort: 8000 + name: links-port + protocol: TCP + resources: + requests: + cpu: 200m + memory: 400Mi + limits: + cpu: 1200m + memory: 2Gi + imagePullSecrets: + - name: github-image-pull-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: links + labels: + app: links +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: links-port + protocol: TCP + name: web + selector: + app: links + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: 1024m + nginx.ingress.kubernetes.io/auth-url: "https://pass.junv.cc/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://pass.junv.cc/oauth2/start?rd=https://$host$escaped_request_uri" + # Skip authentication for /public, but looks like doesn't work + # nginx.ingress.kubernetes.io/auth-snippet: | + # if ($request_uri ~ "/public") { + # return 200; + # } +spec: + ingressClassName: nginx + tls: + - hosts: + - to.junv.cc + - go.junv.cc + secretName: links-tls + rules: + - host: go.junv.cc + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - host: to.junv.cc + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: links + port: + number: 80 +--- + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: go-links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "false" + nginx.ingress.kubernetes.io/proxy-body-size: 1024m +spec: + ingressClassName: nginx + rules: + - host: go + http: + paths: + - backend: + service: + name: links + port: + number: 80 + path: / + pathType: Prefix + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: public-links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + tls: + - hosts: + - xgo.junv.cc + secretName: public-links-tls + rules: + - host: xgo.junv.cc + http: + paths: + - path: /public + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - path: /static + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - path: /favicon.ico + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + +--- +# ServiceAccount for the links pod +apiVersion: v1 +kind: ServiceAccount +metadata: + name: links + namespace: apps From 6a82b598631e0c2b4c6674c912dec13e5881a9f0 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Tue, 14 Apr 2026 14:33:39 +1000 Subject: [PATCH 71/95] ci: skip workflow when only k8s/ files change to prevent commit loop --- .github/workflows/build-and-deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 39a769d..0a5b869 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -3,7 +3,11 @@ name: Build and Deploy to Kubernetes on: push: branches: [ main ] + paths-ignore: + - 'k8s/**' pull_request: + paths-ignore: + - 'k8s/**' env: REGISTRY: ghcr.io From 04aad674e47df307b0e7c1de897e5f5ce0fa7553 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Tue, 14 Apr 2026 14:42:54 +1000 Subject: [PATCH 72/95] Test --- .github/workflows/build-and-deploy.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 0a5b869..bebf22b 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -84,19 +84,10 @@ jobs: BUILD_TIME=${{ steps.build_time.outputs.value }} BUILD_VERSION=1.0.${{ github.run_number }} - - name: Deploy to Kubernetes - uses: wahyd4/kubectl-helm-action@master - env: - KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} - with: - args: |- - # Render template → manifest with the actual image tag - sed 's|__IMAGE_TAG__|1.0.${{ github.run_number }}|g' k8s/manifest.template.yaml > k8s/manifest.yaml - # Apply Kubernetes manifests - kubectl --insecure-skip-tls-verify apply -n ${{ env.K8S_NAMESPACE }} -f k8s/manifest.yaml - - name: Commit updated manifest run: | + # Render template → manifest with the actual image tag + sed 's|__IMAGE_TAG__|1.0.${{ github.run_number }}|g' k8s/manifest.template.yaml > k8s/manifest.yaml git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add k8s/manifest.yaml From a07b0b181e25b9fc9968e3c4dab32ef7034f0a0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 04:47:49 +0000 Subject: [PATCH 73/95] chore: update manifest image to 1.0.280 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 4c0c009..e21906a 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -103,14 +103,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:main" + image: "ghcr.io/wahyd4/links:1.0.280" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:main" + image: "ghcr.io/wahyd4/links:1.0.280" securityContext: runAsUser: 1000 imagePullPolicy: Always From 864166ecbd740bdfc62658385b2c78cc4324b406 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Tue, 14 Apr 2026 14:54:48 +1000 Subject: [PATCH 74/95] =?UTF-8?q?ci:=20fix=20Python=20setup=20=E2=80=94=20?= =?UTF-8?q?use=20setup-uv=20python-version=20instead=20of=20separate=20ins?= =?UTF-8?q?tall=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-and-deploy.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index bebf22b..4464beb 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -26,9 +26,8 @@ jobs: uses: astral-sh/setup-uv@v7 with: version: "latest" - - - name: Set up Python - run: uv python install 3.12 + python-version: "3.12" + enable-cache: true - name: Install dependencies (including dev) run: uv sync --dev From b3b6f77c84a0baa2101c6c9c133f2c7cbf125f46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 04:58:25 +0000 Subject: [PATCH 75/95] chore: update manifest image to 1.0.283 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index e21906a..b4099e4 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -103,14 +103,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.280" + image: "ghcr.io/wahyd4/links:1.0.283" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.280" + image: "ghcr.io/wahyd4/links:1.0.283" securityContext: runAsUser: 1000 imagePullPolicy: Always From 5b1df2ee5ea045fb1f65c572c9bf163feea806a2 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:01:43 +1000 Subject: [PATCH 76/95] Add router monitor --- core/apps.py | 20 + core/settings.py | 1 + core/urls.py | 1 + k8s/manifest.template.yaml | 4 + k8s/manifest.yaml | 4 + routermon/__init__.py | 0 routermon/admin.py | 22 + routermon/apps.py | 10 + routermon/forms.py | 20 + routermon/management/__init__.py | 0 routermon/management/commands/__init__.py | 0 routermon/migrations/0001_initial.py | 62 +++ routermon/migrations/__init__.py | 0 routermon/models.py | 87 ++++ routermon/parser.py | 156 +++++++ routermon/receiver.py | 301 +++++++++++++ routermon/tasks.py | 23 + .../templates/routermon/_live_queries.html | 45 ++ routermon/templates/routermon/dashboard.html | 413 ++++++++++++++++++ routermon/templates/routermon/settings.html | 183 ++++++++ routermon/urls.py | 15 + routermon/views.py | 307 +++++++++++++ templates/base.html | 10 + 23 files changed, 1684 insertions(+) create mode 100644 routermon/__init__.py create mode 100644 routermon/admin.py create mode 100644 routermon/apps.py create mode 100644 routermon/forms.py create mode 100644 routermon/management/__init__.py create mode 100644 routermon/management/commands/__init__.py create mode 100644 routermon/migrations/0001_initial.py create mode 100644 routermon/migrations/__init__.py create mode 100644 routermon/models.py create mode 100644 routermon/parser.py create mode 100644 routermon/receiver.py create mode 100644 routermon/tasks.py create mode 100644 routermon/templates/routermon/_live_queries.html create mode 100644 routermon/templates/routermon/dashboard.html create mode 100644 routermon/templates/routermon/settings.html create mode 100644 routermon/urls.py create mode 100644 routermon/views.py diff --git a/core/apps.py b/core/apps.py index ea6bcce..68bd57f 100644 --- a/core/apps.py +++ b/core/apps.py @@ -70,3 +70,23 @@ class CoreConfig(AppConfig): replace_existing=True, ) logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") + + # ── routermon ────────────────────────────────────────────────────────── + try: + from routermon.tasks import cleanup_old_queries + from routermon.models import RouterMonSettings + from routermon.receiver import start_receiver + + rm_settings = RouterMonSettings.get() + if rm_settings.enabled: + start_receiver(rm_settings.syslog_port) + + scheduler.add_job( + cleanup_old_queries, + trigger=IntervalTrigger(hours=6), + id='routermon_cleanup', + replace_existing=True, + ) + logger.info("routermon: scheduled cleanup job (every 6h)") + except Exception as exc: + logger.warning("routermon: startup error (non-fatal): %s", exc) diff --git a/core/settings.py b/core/settings.py index e253962..fc9ee78 100644 --- a/core/settings.py +++ b/core/settings.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ 'markdown', # 只需要基本的markdown包 'netscan', 'nginxmon', + 'routermon', ] ROOT_URLCONF = 'core.urls' diff --git a/core/urls.py b/core/urls.py index b09f086..0c82af8 100644 --- a/core/urls.py +++ b/core/urls.py @@ -49,6 +49,7 @@ urlpatterns = [ # Include netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them path('ui/netscan/', include('netscan.urls')), path('ui/nginxmon/', include('nginxmon.urls')), + path('ui/routermon/', include('routermon.urls')), path('ui/files/', include('links.file_urls')), # Import external image by URL — /import/images/ (also plural alias) diff --git a/k8s/manifest.template.yaml b/k8s/manifest.template.yaml index e842553..20646c8 100644 --- a/k8s/manifest.template.yaml +++ b/k8s/manifest.template.yaml @@ -175,6 +175,10 @@ spec: - containerPort: 8000 name: links-port protocol: TCP + - containerPort: 5514 + name: syslog-udp + protocol: UDP + hostPort: 5514 resources: requests: cpu: 200m diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index b4099e4..3c1008b 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -175,6 +175,10 @@ spec: - containerPort: 8000 name: links-port protocol: TCP + - containerPort: 5514 + name: syslog-udp + protocol: UDP + hostPort: 5514 resources: requests: cpu: 200m diff --git a/routermon/__init__.py b/routermon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routermon/admin.py b/routermon/admin.py new file mode 100644 index 0000000..490e917 --- /dev/null +++ b/routermon/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from .models import RouterMonSettings, DnsQuery, DhcpLease + + +@admin.register(RouterMonSettings) +class RouterMonSettingsAdmin(admin.ModelAdmin): + list_display = ['syslog_port', 'enabled', 'retention_days', 'last_received_at'] + + +@admin.register(DnsQuery) +class DnsQueryAdmin(admin.ModelAdmin): + list_display = ['timestamp', 'client_ip', 'client_name', 'domain', 'query_type', 'is_nxdomain', 'resolved_ip', 'country'] + list_filter = ['is_nxdomain', 'query_type'] + search_fields = ['domain', 'client_ip', 'client_name'] + ordering = ['-timestamp'] + + +@admin.register(DhcpLease) +class DhcpLeaseAdmin(admin.ModelAdmin): + list_display = ['ip', 'mac', 'hostname', 'last_seen'] + search_fields = ['ip', 'mac', 'hostname'] + ordering = ['-last_seen'] diff --git a/routermon/apps.py b/routermon/apps.py new file mode 100644 index 0000000..5408942 --- /dev/null +++ b/routermon/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +import logging + +logger = logging.getLogger(__name__) + + +class RouterMonConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'routermon' + verbose_name = 'Router Monitor' diff --git a/routermon/forms.py b/routermon/forms.py new file mode 100644 index 0000000..5c08c86 --- /dev/null +++ b/routermon/forms.py @@ -0,0 +1,20 @@ +from django import forms +from .models import RouterMonSettings + + +class RouterMonSettingsForm(forms.ModelForm): + class Meta: + model = RouterMonSettings + fields = ['enabled', 'syslog_port', 'excluded_clients', 'retention_days'] + widgets = { + 'excluded_clients': forms.Textarea(attrs={'rows': 4, 'class': 'font-mono text-sm'}), + } + help_texts = { + 'syslog_port': ( + 'UDP port the server listens on (default 5514). ' + 'Configure your router to send syslog here. ' + 'If your router only supports port 514, add an iptables redirect on the k3s node.' + ), + 'excluded_clients': 'One LAN IP per line. Queries from these devices will be ignored.', + 'retention_days': 'DNS query log records older than this will be automatically deleted.', + } diff --git a/routermon/management/__init__.py b/routermon/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routermon/management/commands/__init__.py b/routermon/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routermon/migrations/0001_initial.py b/routermon/migrations/0001_initial.py new file mode 100644 index 0000000..6e818e1 --- /dev/null +++ b/routermon/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.12 on 2026-04-16 02:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DhcpLease', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip', models.GenericIPAddressField(db_index=True, unique=True)), + ('mac', models.CharField(blank=True, max_length=17)), + ('hostname', models.CharField(blank=True, max_length=200)), + ('last_seen', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['ip'], + }, + ), + migrations.CreateModel( + name='RouterMonSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=True)), + ('syslog_port', models.IntegerField(default=5514, help_text='UDP port to listen on for syslog datagrams. Default: 5514 (non-privileged). If your router only supports port 514, add an iptables redirect on the k3s node: iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port 5514')), + ('excluded_clients', models.TextField(blank=True, default='', help_text='One LAN IP per line. DNS queries from these clients will not be stored.')), + ('retention_days', models.IntegerField(default=7, help_text='Delete DNS query records older than this many days.')), + ('last_received_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Router Monitor Settings', + 'verbose_name_plural': 'Router Monitor Settings', + }, + ), + migrations.CreateModel( + name='DnsQuery', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(db_index=True)), + ('client_ip', models.GenericIPAddressField(db_index=True)), + ('client_name', models.CharField(blank=True, help_text='Hostname from DHCP lease at query time', max_length=200)), + ('domain', models.CharField(db_index=True, max_length=500)), + ('query_type', models.CharField(default='A', max_length=20)), + ('is_nxdomain', models.BooleanField(db_index=True, default=False)), + ('resolved_ip', models.GenericIPAddressField(blank=True, null=True)), + ('country', models.CharField(blank=True, max_length=100)), + ('country_code', models.CharField(blank=True, max_length=10)), + ('city', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['client_ip', 'timestamp'], name='routermon_d_client__2d31fb_idx'), models.Index(fields=['domain', 'timestamp'], name='routermon_d_domain_ca0a82_idx'), models.Index(fields=['timestamp', 'is_nxdomain'], name='routermon_d_timesta_74c854_idx')], + }, + ), + ] diff --git a/routermon/migrations/__init__.py b/routermon/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routermon/models.py b/routermon/models.py new file mode 100644 index 0000000..64ed561 --- /dev/null +++ b/routermon/models.py @@ -0,0 +1,87 @@ +from django.db import models + + +class RouterMonSettings(models.Model): + """Singleton (pk=1) — router syslog receiver configuration.""" + + enabled = models.BooleanField(default=True) + syslog_port = models.IntegerField( + default=5514, + help_text=( + 'UDP port to listen on for syslog datagrams. Default: 5514 (non-privileged). ' + 'If your router only supports port 514, add an iptables redirect on the k3s node: ' + 'iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port 5514' + ), + ) + excluded_clients = models.TextField( + blank=True, + default='', + help_text='One LAN IP per line. DNS queries from these clients will not be stored.', + ) + retention_days = models.IntegerField( + default=7, + help_text='Delete DNS query records older than this many days.', + ) + last_received_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = 'Router Monitor Settings' + verbose_name_plural = 'Router Monitor Settings' + + def __str__(self): + status = 'enabled' if self.enabled else 'disabled' + return f'RouterMon Settings ({status}, port {self.syslog_port})' + + @classmethod + def get(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + +class DnsQuery(models.Model): + """One DNS query event received from the router's dnsmasq syslog.""" + + timestamp = models.DateTimeField(db_index=True) + client_ip = models.GenericIPAddressField(db_index=True) + client_name = models.CharField(max_length=200, blank=True, help_text='Hostname from DHCP lease at query time') + domain = models.CharField(max_length=500, db_index=True) + query_type = models.CharField(max_length=20, default='A') + is_nxdomain = models.BooleanField(default=False, db_index=True) + # Best-effort: first public A/AAAA record from the corresponding reply line. + resolved_ip = models.GenericIPAddressField(null=True, blank=True) + # Geo for resolved_ip (populated asynchronously) + country = models.CharField(max_length=100, blank=True) + country_code = models.CharField(max_length=10, blank=True) + city = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['client_ip', 'timestamp']), + models.Index(fields=['domain', 'timestamp']), + models.Index(fields=['timestamp', 'is_nxdomain']), + ] + + def __str__(self): + flag = ' [NX]' if self.is_nxdomain else '' + return f'{self.client_ip} → {self.domain} ({self.query_type}){flag} @ {self.timestamp:%Y-%m-%d %H:%M:%S}' + + @property + def geo_display(self): + parts = [p for p in [self.city, self.country] if p] + return ', '.join(parts) if parts else '—' + + +class DhcpLease(models.Model): + """Latest-known DHCP lease: maps LAN IP to MAC + hostname.""" + + ip = models.GenericIPAddressField(unique=True, db_index=True) + mac = models.CharField(max_length=17, blank=True) + hostname = models.CharField(max_length=200, blank=True) + last_seen = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['ip'] + + def __str__(self): + return f'{self.ip} ({self.hostname or self.mac or "unknown"})' diff --git a/routermon/parser.py b/routermon/parser.py new file mode 100644 index 0000000..f1a5656 --- /dev/null +++ b/routermon/parser.py @@ -0,0 +1,156 @@ +""" +Parse syslog datagrams from AsusWRT-Merlin's dnsmasq. + +Handles RFC 3164 syslog envelope: + Mmm DD HH:MM:SS hostname dnsmasq[PID]: message + or with PRI prefix: Mmm DD HH:MM:SS hostname dnsmasq[PID]: message + +Recognised message formats: + query[TYPE] domain from IP — DNS query event + reply domain is IP — resolved to an IP + reply domain is NXDOMAIN — domain does not exist + reply domain is NODATA — exists but no records of requested type + DHCPACK(iface) IP MAC [hostname] — DHCP lease acknowledged +""" +import re +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +logger = logging.getLogger(__name__) + +# ── Compiled regexes ────────────────────────────────────────────────────────── + +# Strip optional RFC 3164 PRI prefix: +_RE_PRI = re.compile(r'^<\d+>') + +# Full syslog line: optional PRI + "Mmm DD HH:MM:SS hostname process[pid]: msg" +# We capture the process token and the message body. +_RE_SYSLOG = re.compile( + r'(?:<\d+>)?' # optional PRI + r'\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}' # timestamp (Mmm DD HH:MM:SS) + r'\s+\S+' # hostname + r'\s+(dnsmasq(?:-dhcp)?)\[\d+\]:' # process (group 1) + r'\s*(.*)', # message body (group 2) + re.DOTALL, +) + +# dnsmasq DNS query: query[TYPE] domain from IP +_RE_QUERY = re.compile(r'^query\[(\w+)\]\s+([\w.\-]+)\s+from\s+([\d.a-fA-F:]+)') + +# dnsmasq reply: reply domain is ANSWER +# ANSWER may be: an IP address, NXDOMAIN, NODATA, NODATA-IPv4, NODATA-IPv6, etc. +_RE_REPLY = re.compile(r'^reply\s+([\w.\-]+)\s+is\s+(\S+)') + +# dnsmasq DHCP ack: DHCPACK(iface) IP MAC [hostname] +_RE_DHCP = re.compile(r'^DHCPACK\(\S+\)\s+([\d.]+)\s+([\da-fA-F:]+)\s*(\S*)') + +# Simple IPv4/IPv6 check (enough to distinguish from NXDOMAIN/NODATA strings) +_RE_IP = re.compile(r'^[\d.a-fA-F:]+$') + +# Private IP ranges to skip for geo (same check as nginxmon) +import ipaddress +_PRIVATE_NETS = ( + ipaddress.ip_network('10.0.0.0/8'), + ipaddress.ip_network('172.16.0.0/12'), + ipaddress.ip_network('192.168.0.0/16'), + ipaddress.ip_network('127.0.0.0/8'), + ipaddress.ip_network('::1/128'), + ipaddress.ip_network('fc00::/7'), +) + + +def _is_public_ip(addr: str) -> bool: + try: + ip = ipaddress.ip_address(addr) + return not any(ip in net for net in _PRIVATE_NETS) + except ValueError: + return False + + +# ── Result dataclasses ──────────────────────────────────────────────────────── + +@dataclass +class QueryEvent: + client_ip: str + domain: str + query_type: str + + +@dataclass +class ReplyEvent: + domain: str + answer: str # raw answer (IP, NXDOMAIN, NODATA, …) + is_nxdomain: bool + resolved_ip: Optional[str] # public IP only, or None + + +@dataclass +class DhcpEvent: + ip: str + mac: str + hostname: str + + +@dataclass +class ParseResult: + query: Optional[QueryEvent] = None + reply: Optional[ReplyEvent] = None + dhcp: Optional[DhcpEvent] = None + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def parse_line(raw: str) -> Optional[ParseResult]: + """ + Parse a single syslog datagram string. + Returns a ParseResult if a recognised event was found, else None. + """ + raw = raw.strip() + if not raw: + return None + + m = _RE_SYSLOG.match(raw) + if not m: + # Some Merlin builds omit the standard syslog prefix; try matching + # dnsmasq message body directly. + body = raw + else: + body = m.group(2).strip() + + # DNS query + mq = _RE_QUERY.match(body) + if mq: + return ParseResult(query=QueryEvent( + client_ip=mq.group(3), + domain=mq.group(2).rstrip('.'), + query_type=mq.group(1), + )) + + # DNS reply + mr = _RE_REPLY.match(body) + if mr: + domain = mr.group(1).rstrip('.') + answer = mr.group(2) + is_nxdomain = answer.upper() in ('NXDOMAIN', 'NODATA', 'NODATA-IPV4', 'NODATA-IPV6') + resolved_ip = None + if not is_nxdomain and _RE_IP.match(answer) and _is_public_ip(answer): + resolved_ip = answer + return ParseResult(reply=ReplyEvent( + domain=domain, + answer=answer, + is_nxdomain=is_nxdomain, + resolved_ip=resolved_ip, + )) + + # DHCP ack + md = _RE_DHCP.match(body) + if md: + return ParseResult(dhcp=DhcpEvent( + ip=md.group(1), + mac=md.group(2).lower(), + hostname=md.group(3), + )) + + return None diff --git a/routermon/receiver.py b/routermon/receiver.py new file mode 100644 index 0000000..fd7004c --- /dev/null +++ b/routermon/receiver.py @@ -0,0 +1,301 @@ +""" +UDP syslog receiver for routermon. + +Architecture: + - One background thread owns the UDP socket and enqueues parsed events. + - A separate writer thread drains the queue and bulk-saves to the DB every + FLUSH_INTERVAL seconds (or when the queue reaches FLUSH_SIZE). + - Only one Gunicorn worker binds the socket (EADDRINUSE is silently ignored + in the others — matches the project's existing in-process APScheduler style). + +dnsmasq reply correlation: + - A small in-memory dict `_pending` maps `domain` → (pk, timestamp) for + outstanding query rows awaiting a reply. Entries expire after REPLY_TTL + seconds so stale pending records don't accumulate. +""" +import errno +import logging +import queue +import socket +import threading +import time +from datetime import timedelta + +from django.utils import timezone + +logger = logging.getLogger(__name__) + +FLUSH_INTERVAL = 2 # seconds between writer flushes +FLUSH_SIZE = 100 # flush immediately when queue reaches this size +REPLY_TTL = 10 # seconds to keep a query in _pending for reply matching +MAX_QUEUE = 10_000 # drop oldest items if queue exceeds this (backpressure) + +_receiver_thread: threading.Thread | None = None +_writer_thread: threading.Thread | None = None +_sock: socket.socket | None = None +_queue: queue.Queue = queue.Queue(maxsize=MAX_QUEUE) + +# in-memory pending dict: domain → (query_pk, created_at) +_pending: dict = {} +_pending_lock = threading.Lock() + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def start_receiver(port: int): + """ + Attempt to start the UDP receiver on `port`. Safe to call from every + Gunicorn worker — only the first to bind succeeds; others silently skip. + """ + global _receiver_thread, _writer_thread, _sock + + if _receiver_thread and _receiver_thread.is_alive(): + return # already running in this process + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', port)) + sock.settimeout(1.0) + _sock = sock + logger.info('routermon: UDP syslog receiver bound on port %d', port) + except OSError as exc: + if exc.errno == errno.EADDRINUSE: + logger.debug('routermon: port %d already bound (another worker), skipping', port) + else: + logger.error('routermon: failed to bind UDP port %d: %s', port, exc) + return + + _writer_thread = threading.Thread(target=_writer_loop, daemon=True, name='routermon-writer') + _writer_thread.start() + + _receiver_thread = threading.Thread( + target=_receiver_loop, args=(sock,), daemon=True, name='routermon-receiver' + ) + _receiver_thread.start() + + +def stop_receiver(): + global _sock + if _sock: + try: + _sock.close() + except Exception: + pass + _sock = None + + +# ── Socket receiver loop ────────────────────────────────────────────────────── + +def _receiver_loop(sock: socket.socket): + from .parser import parse_line + + logger.info('routermon: receiver loop started') + while True: + try: + data, _ = sock.recvfrom(4096) + except socket.timeout: + continue + except OSError: + # Socket closed (shutdown) + break + try: + line = data.decode('utf-8', errors='replace').rstrip('\n\r') + result = parse_line(line) + if result is not None: + _enqueue(result) + except Exception as exc: + logger.debug('routermon: parse error: %s', exc) + + logger.info('routermon: receiver loop stopped') + + +def _enqueue(result): + try: + _queue.put_nowait(result) + except queue.Full: + # Drop oldest item to make room (keep most recent) + try: + _queue.get_nowait() + _queue.put_nowait(result) + except Exception: + pass + + +# ── Writer loop ─────────────────────────────────────────────────────────────── + +def _writer_loop(): + logger.info('routermon: writer loop started') + batch = [] + last_flush = time.monotonic() + + while True: + # Collect items until flush threshold or timeout + deadline = last_flush + FLUSH_INTERVAL + while time.monotonic() < deadline and len(batch) < FLUSH_SIZE: + try: + item = _queue.get(timeout=max(0.1, deadline - time.monotonic())) + batch.append(item) + except queue.Empty: + break + + if batch: + try: + _flush(batch) + except Exception as exc: + logger.error('routermon: flush error: %s', exc, exc_info=True) + batch = [] + + last_flush = time.monotonic() + + logger.info('routermon: writer loop stopped') + + +def _flush(batch: list): + """Persist a batch of ParseResult objects to the database.""" + from .models import RouterMonSettings, DnsQuery, DhcpLease + + settings = RouterMonSettings.get() + if not settings.enabled: + return + + excluded = _build_excluded_set(settings.excluded_clients) + now = timezone.now() + + # --- DHCP leases (upsert immediately) --- + dhcp_events = [r.dhcp for r in batch if r.dhcp] + for ev in dhcp_events: + DhcpLease.objects.update_or_create( + ip=ev.ip, + defaults={'mac': ev.mac, 'hostname': ev.hostname}, + ) + + # --- DNS queries --- + hostname_cache = { + lease.ip: lease.hostname + for lease in DhcpLease.objects.filter( + ip__in={r.query.client_ip for r in batch if r.query} + ) + } + + query_objs = [] + for r in batch: + if not r.query: + continue + q = r.query + if q.client_ip in excluded: + continue + obj = DnsQuery( + timestamp=now, + client_ip=q.client_ip, + client_name=hostname_cache.get(q.client_ip, ''), + domain=q.domain, + query_type=q.query_type, + ) + query_objs.append(obj) + + if query_objs: + created = DnsQuery.objects.bulk_create(query_objs) + # Register created queries in pending dict for reply correlation + with _pending_lock: + for obj in created: + _pending[obj.domain] = (obj.pk, now) + + # --- DNS replies: correlate with pending queries --- + reply_events = [r.reply for r in batch if r.reply] + if reply_events: + _apply_replies(reply_events, now) + + # Expire old pending entries + _expire_pending(now) + + # Touch last_received_at once per flush (coalesced) + RouterMonSettings.objects.filter(pk=1).update(last_received_at=now) + + # Trigger geo enrichment in background (lazy: only for rows with resolved_ip that have no geo yet) + pks_needing_geo = list( + DnsQuery.objects.filter( + resolved_ip__isnull=False, country='', timestamp__gte=now - timedelta(minutes=5) + ).values_list('pk', flat=True)[:200] + ) + if pks_needing_geo: + threading.Thread( + target=_enrich_geo, args=(pks_needing_geo,), daemon=True + ).start() + + +def _apply_replies(reply_events, now): + from .models import DnsQuery + + with _pending_lock: + for ev in reply_events: + entry = _pending.pop(ev.domain, None) + if entry is None: + continue + pk, _ = entry + updates = {} + if ev.is_nxdomain: + updates['is_nxdomain'] = True + if ev.resolved_ip: + updates['resolved_ip'] = ev.resolved_ip + if updates: + DnsQuery.objects.filter(pk=pk).update(**updates) + + +def _expire_pending(now): + cutoff = now - timedelta(seconds=REPLY_TTL) + with _pending_lock: + expired = [domain for domain, (_, ts) in _pending.items() if ts < cutoff] + for domain in expired: + del _pending[domain] + + +def _enrich_geo(pks: list[int]): + """Geo-enrich DnsQuery rows by resolved_ip using nginxmon's IPGeoCache.""" + try: + from nginxmon.models import IPGeoCache + from routermon.models import DnsQuery + import ipaddress + + rows = list(DnsQuery.objects.filter(pk__in=pks).values('pk', 'resolved_ip')) + ips = list({r['resolved_ip'] for r in rows if r['resolved_ip']}) + if not ips: + return + + # Fetch missing IPs from ip-api.com + cached = {c.ip: c for c in IPGeoCache.objects.filter(ip__in=ips)} + missing = [ip for ip in ips if ip not in cached] + if missing: + from nginxmon.geo import _lookup_batch + api_results = _lookup_batch(missing) + for ip, data in api_results.items(): + obj, _ = IPGeoCache.objects.get_or_create(ip=ip) + obj.country = data.get('country', '') + obj.country_code = data.get('countryCode', '') + obj.region = data.get('regionName', '') + obj.city = data.get('city', '') + obj.lat = data.get('lat') + obj.lon = data.get('lon') + obj.isp = data.get('isp', '') + obj.save() + cached[ip] = obj + + for row in rows: + geo = cached.get(row['resolved_ip']) + if geo and geo.country: + DnsQuery.objects.filter(pk=row['pk'], country='').update( + country=geo.country, + country_code=geo.country_code, + city=geo.city, + ) + except Exception as exc: + logger.warning('routermon: geo enrichment error: %s', exc) + + +def _build_excluded_set(text: str) -> set: + result = set() + for line in (text or '').splitlines(): + entry = line.strip() + if entry and not entry.startswith('#'): + result.add(entry) + return result diff --git a/routermon/tasks.py b/routermon/tasks.py new file mode 100644 index 0000000..06d274a --- /dev/null +++ b/routermon/tasks.py @@ -0,0 +1,23 @@ +"""APScheduler periodic tasks for routermon.""" +import logging + +logger = logging.getLogger(__name__) + + +def cleanup_old_queries(): + """Delete DnsQuery rows older than retention_days. Run periodically.""" + try: + from datetime import timedelta + from django.utils import timezone + from .models import RouterMonSettings, DnsQuery + + settings = RouterMonSettings.get() + if settings.retention_days <= 0: + return + + cutoff = timezone.now() - timedelta(days=settings.retention_days) + deleted, _ = DnsQuery.objects.filter(timestamp__lt=cutoff).delete() + if deleted: + logger.info('routermon: pruned %d old DNS query records (>%d days)', deleted, settings.retention_days) + except Exception as exc: + logger.error('routermon: cleanup_old_queries error: %s', exc, exc_info=True) diff --git a/routermon/templates/routermon/_live_queries.html b/routermon/templates/routermon/_live_queries.html new file mode 100644 index 0000000..9aafa30 --- /dev/null +++ b/routermon/templates/routermon/_live_queries.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + {% for q in queries %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
      TimeClientDomainTypeResolvedGeo
      {{ q.timestamp|date:"H:i:s" }} + + {% if q.client_name %}{{ q.client_name }}{% endif %} + + + {{ q.query_type }} + {% if q.is_nxdomain %} + NXDOMAIN + {% elif q.resolved_ip %} + {{ q.resolved_ip }} + {% else %} + + {% endif %} + {{ q.geo_display }}
      + No DNS queries yet. Make sure your router is forwarding syslog to this server. +
      diff --git a/routermon/templates/routermon/dashboard.html b/routermon/templates/routermon/dashboard.html new file mode 100644 index 0000000..009a000 --- /dev/null +++ b/routermon/templates/routermon/dashboard.html @@ -0,0 +1,413 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} + + + + +
      + + + + + + {% for msg in messages %} +
      + {{ msg }} +
      + {% endfor %} + + +
      + {% with s=stats %} +
      +
      DNS Queries
      +
      {{ s.total }}
      +
      +
      +
      Unique Domains
      +
      {{ s.unique_domains }}
      +
      +
      +
      Devices
      +
      {{ s.unique_clients }}
      +
      +
      +
      NXDOMAIN
      +
      + {{ s.nxdomain_count }} +
      +
      +
      +
      NX Rate
      +
      + {{ s.nxdomain_pct }}% +
      +
      + {% endwith %} +
      + + +
      + +
      +
      {{ range_label }}
      +
      +
      + +
      +
      Top Domains — {{ range_label }}
      +
      +
      +
      + + +
      +
      +
      Resolved IP Geography — {{ range_label }}
      +
      +
      +
      +
      + + +
      + +
      +
      + Top Queried Domains — {{ range_label }} +
      + + + + + + + + + + + {% for d in top_domains %} + + + + + + + {% empty %} + + {% endfor %} + +
      DomainCountryQueriesNX
      + + + {% if d.country %} + {% if d.country_code %}{{ d.country_code }}{% endif %} + {{ d.country }} + {% else %}—{% endif %} + {{ d.count }} + {{ d.nxcount }}
      No data yet
      +
      + + +
      +
      + Top Clients — {{ range_label }} +
      + + + + + + + + + + + {% for c in top_clients %} + + + + + + + {% empty %} + + {% endfor %} + +
      ClientHostnameQueriesNX
      + + {{ c.hostname|default:"—" }}{{ c.count }} + {{ c.nxcount }}
      No data yet
      +
      +
      + + +
      +
      +
      Live DNS Queries
      + +
      + + + + + +
      +
      + +
      +
      Loading…
      +
      +
      + +
      + + +{% endblock %} diff --git a/routermon/templates/routermon/settings.html b/routermon/templates/routermon/settings.html new file mode 100644 index 0000000..0168585 --- /dev/null +++ b/routermon/templates/routermon/settings.html @@ -0,0 +1,183 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} +
      + + +
      +
      +

      + Router Monitor Settings +

      +

      UDP syslog receiver configuration

      +
      + + Dashboard + +
      + + {% for msg in messages %} +
      + {{ msg }} +
      + {% endfor %} + + +
      +
      +

      Receiver Configuration

      +

      + The server listens for UDP syslog datagrams from your ASUS router's dnsmasq daemon. +

      +
      +
      + {% csrf_token %} + {% for field in form %} +
      + + {{ field }} + {% if field.help_text %}

      {{ field.help_text }}

      {% endif %} + {% for error in field.errors %}

      {{ error }}

      {% endfor %} +
      + {% endfor %} +
      + +
      +
      +
      + + +
      +
      + +
      +

      Router Setup — AsusWRT-Merlin 3006.x (SSH method)

      +

      + SSH into your ASUS GT-AX6000 and run the commands below. This works on all Merlin 3006.x builds. +

      +
      +
      +
      + + +
      + Prerequisite: Enable SSH in the Merlin UI first — + Administration → System → SSH Daemon, set to LAN only, then save. + Also enable JFFS custom scripts and configs on the same page if not already enabled. +
      + +
      +

      + 1 + Enable DNS query logging in dnsmasq +

      +

      + SSH into the router and add a dnsmasq option that persists across reboots via JFFS: +

      +
      ssh admin@192.168.1.1
      +
      +# Add log-queries to dnsmasq (persistent via JFFS)
      +echo "log-queries" >> /jffs/configs/dnsmasq.conf.add
      +
      +# Apply immediately (no reboot needed)
      +service restart_dnsmasq
      +
      + +
      +

      + 2 + Configure remote syslog forwarding +

      +

      + Set nvram variables to forward syslog to this server, then restart the syslog daemon: +

      +
      # Still in the SSH session:
      +nvram set log_remote=1
      +nvram set log_ipaddr=192.168.1.2
      +nvram set log_port={{ settings.syslog_port }}
      +nvram commit
      +
      +service restart_syslog
      +

      + DNS queries should appear on the dashboard within a few seconds of the next DNS lookup on your network. +

      +
      + +
      +

      + 3 + Verify syslog is arriving +

      +
      # On the k3s node (192.168.1.2) — listen for UDP packets:
      +nc -ulk {{ settings.syslog_port }}
      +
      +# Or watch with tcpdump:
      +tcpdump -i any -A udp port {{ settings.syslog_port }}
      +
      +# You should see lines like:
      +# dnsmasq[1234]: query[A] google.com from 192.168.1.x
      +# dnsmasq[1234]: reply google.com is 142.250.80.46
      +
      + + +
      +

      + + If port {{ settings.syslog_port }} is not reachable from the router +

      +

      + If the router can only send to the standard syslog port (514), redirect it on the k3s node: +

      +
      # On the k3s node, run as root — then change log_port to 514 in nvram above
      +iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port {{ settings.syslog_port }}
      +
      +# Persist across reboots (Debian/Ubuntu):
      +apt install iptables-persistent && netfilter-persistent save
      +
      + + +
      +

      To disable / revert

      +
      ssh admin@192.168.1.1
      +
      +# Remove dnsmasq log-queries line
      +sed -i '/^log-queries$/d' /jffs/configs/dnsmasq.conf.add
      +
      +# Disable remote syslog
      +nvram set log_remote=0
      +nvram commit
      +
      +service restart_dnsmasq
      +service restart_syslog
      +
      + +
      +
      + + +
      +

      Receiver Status

      +
      +
      Status
      +
      + {% if settings.enabled %}Enabled (listening on UDP :{{ settings.syslog_port }}){% else %}Disabled{% endif %} +
      +
      Last received
      +
      + {% if settings.last_received_at %}{{ settings.last_received_at|date:"Y-m-d H:i:s" }}{% else %}Never{% endif %} +
      +
      Retention
      +
      {{ settings.retention_days }} days
      +
      +
      + +
      +{% endblock %} diff --git a/routermon/urls.py b/routermon/urls.py new file mode 100644 index 0000000..3d2bfe9 --- /dev/null +++ b/routermon/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.DashboardView.as_view(), name='routermon-dashboard'), + path('settings/', views.SettingsView.as_view(), name='routermon-settings'), + # HTMX partials + path('partials/queries/', views.LiveQueriesPartialView.as_view(), name='routermon-queries-partial'), + # JSON APIs + path('api/chart/', views.ChartDataView.as_view(), name='routermon-chart'), + path('api/geo/', views.GeoStatsView.as_view(), name='routermon-geo'), + # Actions + path('toggle/', views.ToggleView.as_view(), name='routermon-toggle'), + path('status/', views.StatusStreamView.as_view(), name='routermon-status'), +] diff --git a/routermon/views.py b/routermon/views.py new file mode 100644 index 0000000..878db7d --- /dev/null +++ b/routermon/views.py @@ -0,0 +1,307 @@ +import json +import logging +import time +from datetime import timedelta + +from django.contrib import messages +from django.db.models import Count, Q +from django.db.models.functions import TruncDay, TruncHour, TruncMinute +from django.http import JsonResponse, StreamingHttpResponse +from django.shortcuts import redirect, render +from django.utils import timezone +from django.views.generic import TemplateView, View + +from .forms import RouterMonSettingsForm +from .models import DhcpLease, DnsQuery, RouterMonSettings + +logger = logging.getLogger(__name__) + +# ── Time range config (mirrors nginxmon) ───────────────────────────────────── + +_RANGES = { + '5m': {'delta': timedelta(minutes=5), 'trunc': TruncMinute, 'group_n': 1, 'label': 'Last 5 min', 'fmt': '%H:%M'}, + '30m': {'delta': timedelta(minutes=30), 'trunc': TruncMinute, 'group_n': 2, 'label': 'Last 30 min', 'fmt': '%H:%M'}, + '6h': {'delta': timedelta(hours=6), 'trunc': TruncMinute, 'group_n': 15, 'label': 'Last 6 hours', 'fmt': '%H:%M'}, + '1d': {'delta': timedelta(hours=24), 'trunc': TruncHour, 'group_n': 1, 'label': 'Last 24 hours', 'fmt': '%H:%M'}, + '7d': {'delta': timedelta(days=7), 'trunc': TruncHour, 'group_n': 4, 'label': 'Last 7 days', 'fmt': '%m/%d %H:%M'}, + '30d': {'delta': timedelta(days=30), 'trunc': TruncDay, 'group_n': 1, 'label': 'Last 30 days', 'fmt': '%m/%d'}, +} +_DEFAULT_RANGE = '30m' + + +def _get_range(request): + key = request.GET.get('range', _DEFAULT_RANGE) + if key not in _RANGES: + key = _DEFAULT_RANGE + cfg = _RANGES[key] + return key, cfg, timezone.now() - cfg['delta'] + + +def _floor_bucket_key(dt, trunc_fn, group_n, fmt): + if trunc_fn is TruncMinute: + m = (dt.minute // group_n) * group_n + aligned = dt.replace(minute=m, second=0, microsecond=0) + elif trunc_fn is TruncHour: + h = (dt.hour // group_n) * group_n + aligned = dt.replace(hour=h, minute=0, second=0, microsecond=0) + else: + from datetime import date + epoch = date(2020, 1, 1) + d = dt.date() if hasattr(dt, 'date') else dt + days = (d - epoch).days + floored = epoch + timedelta(days=(days // group_n) * group_n) + aligned = dt.replace( + year=floored.year, month=floored.month, day=floored.day, + hour=0, minute=0, second=0, microsecond=0, + ) + return aligned.strftime(fmt) + + +# ── Dashboard ───────────────────────────────────────────────────────────────── + +class DashboardView(TemplateView): + template_name = 'routermon/dashboard.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + range_key, cfg, since = _get_range(self.request) + settings = RouterMonSettings.get() + + qs = DnsQuery.objects.filter(timestamp__gte=since) + total = qs.count() + nxdomain_count = qs.filter(is_nxdomain=True).count() + + stats = { + 'total': total, + 'unique_domains': qs.values('domain').distinct().count(), + 'unique_clients': qs.values('client_ip').distinct().count(), + 'nxdomain_count': nxdomain_count, + 'nxdomain_pct': round(nxdomain_count / total * 100, 1) if total else 0, + } + + top_domains = list( + qs.values('domain') + .annotate( + count=Count('id'), + nxcount=Count('id', filter=Q(is_nxdomain=True)), + ) + .order_by('-count')[:20] + ) + + # Attach geo (country) to top domains via resolved_ip + domain_geo = {} + for row in qs.filter(resolved_ip__isnull=False, country__gt='').values('domain', 'country_code', 'country').distinct()[:100]: + if row['domain'] not in domain_geo: + domain_geo[row['domain']] = {'country': row['country'], 'country_code': row['country_code']} + for d in top_domains: + geo = domain_geo.get(d['domain'], {}) + d['country'] = geo.get('country', '') + d['country_code'] = geo.get('country_code', '') + + # Build client hostname map from DhcpLease + client_ips = list(qs.values_list('client_ip', flat=True).distinct()[:50]) + hostname_map = { + lease.ip: lease.hostname + for lease in DhcpLease.objects.filter(ip__in=client_ips) + if lease.hostname + } + + top_clients = list( + qs.values('client_ip', 'client_name') + .annotate(count=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True))) + .order_by('-count')[:15] + ) + for c in top_clients: + c['hostname'] = hostname_map.get(c['client_ip'], c['client_name'] or '') + + ctx.update({ + 'settings': settings, + 'stats': stats, + 'top_domains': top_domains, + 'top_clients': top_clients, + 'current_range': range_key, + 'range_label': cfg['label'], + 'all_ranges': [(k, v['label']) for k, v in _RANGES.items()], + 'top_domains_json': json.dumps(top_domains, default=str), + }) + return ctx + + +# ── Settings ────────────────────────────────────────────────────────────────── + +class SettingsView(View): + template_name = 'routermon/settings.html' + + def get(self, request): + obj = RouterMonSettings.get() + return render(request, self.template_name, { + 'form': RouterMonSettingsForm(instance=obj), + 'settings': obj, + }) + + def post(self, request): + obj = RouterMonSettings.get() + form = RouterMonSettingsForm(request.POST, instance=obj) + if form.is_valid(): + form.save() + messages.success(request, 'Router Monitor settings saved.') + return redirect('routermon-settings') + return render(request, self.template_name, {'form': form, 'settings': obj}) + + +# ── HTMX partials ───────────────────────────────────────────────────────────── + +class LiveQueriesPartialView(View): + _SORT_MAP = { + 'timestamp': 'timestamp', + 'client': 'client_ip', + 'domain': 'domain', + 'type': 'query_type', + } + + def get(self, request): + qs = DnsQuery.objects.all() + + client_f = request.GET.get('client', '').strip() + domain_f = request.GET.get('domain', '').strip() + type_f = request.GET.get('type', '').strip() + nx_f = request.GET.get('nxdomain', '').strip() + sort_col = request.GET.get('sort', 'timestamp') + sort_ord = request.GET.get('order', 'desc') + + if client_f: + qs = qs.filter(Q(client_ip=client_f) | Q(client_name__icontains=client_f)) + if domain_f: + qs = qs.filter(domain__icontains=domain_f) + if type_f: + qs = qs.filter(query_type__iexact=type_f) + if nx_f == '1': + qs = qs.filter(is_nxdomain=True) + + db_col = self._SORT_MAP.get(sort_col, 'timestamp') + qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}') + + return render(request, 'routermon/_live_queries.html', {'queries': qs[:60]}) + + +class GeoStatsView(View): + """Country-level aggregates + bubble data for the geo map.""" + + def get(self, request): + range_key, cfg, since = _get_range(request) + qs = DnsQuery.objects.filter(timestamp__gte=since) + + countries = list( + qs.exclude(country_code='') + .values('country_code', 'country') + .annotate(total=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True))) + .order_by('-total') + ) + + # Bubble data from IPGeoCache for resolved IPs + ip_agg = list( + qs.filter(resolved_ip__isnull=False) + .values('resolved_ip') + .annotate(total=Count('id')) + .order_by('-total')[:300] + ) + ip_set = [r['resolved_ip'] for r in ip_agg] + + try: + from nginxmon.models import IPGeoCache + geo_cache = { + c.ip: (c.lat, c.lon, c.country, c.city) + for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False) + } + except Exception: + geo_cache = {} + + bubbles = [] + for row in ip_agg: + c = geo_cache.get(row['resolved_ip']) + if c and c[0] is not None and c[1] is not None: + bubbles.append({ + 'lat': round(float(c[0]), 2), + 'lon': round(float(c[1]), 2), + 'total': row['total'], + 'label': c[3] or c[2] or row['resolved_ip'], + }) + + return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key}) + + +# ── Chart data ──────────────────────────────────────────────────────────────── + +class ChartDataView(View): + def get(self, request): + range_key, cfg, since = _get_range(request) + trunc_fn = cfg['trunc'] + group_n = cfg['group_n'] + fmt = cfg['fmt'] + + rows = list( + DnsQuery.objects + .filter(timestamp__gte=since) + .annotate(bucket=trunc_fn('timestamp')) + .values('bucket') + .annotate( + total=Count('id'), + nxcount=Count('id', filter=Q(is_nxdomain=True)), + ) + .order_by('bucket') + ) + + buckets: dict = {} + for row in rows: + bkt = row['bucket'] + if bkt is None: + continue + key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt) + if key not in buckets: + buckets[key] = {'total': 0, 'nxcount': 0} + buckets[key]['total'] += row['total'] + buckets[key]['nxcount'] += row['nxcount'] + + return JsonResponse({ + 'labels': list(buckets), + 'total': [v['total'] for v in buckets.values()], + 'nxcount': [v['nxcount'] for v in buckets.values()], + 'range': range_key, + 'label': cfg['label'], + }) + + +# ── Toggle + SSE status stream ───────────────────────────────────────────────── + +class ToggleView(View): + def post(self, request): + settings = RouterMonSettings.get() + settings.enabled = not settings.enabled + settings.save(update_fields=['enabled']) + return JsonResponse({'enabled': settings.enabled}) + + +class StatusStreamView(View): + """SSE — pushes receiver status JSON every 3 seconds.""" + + def get(self, request): + def event_stream(): + while True: + try: + settings = RouterMonSettings.get() + total = DnsQuery.objects.count() + last = settings.last_received_at.isoformat() if settings.last_received_at else None + payload = json.dumps({ + 'enabled': settings.enabled, + 'last_received_at': last, + 'total_queries': total, + }) + yield f'data: {payload}\n\n' + except Exception: + pass + time.sleep(3) + + resp = StreamingHttpResponse(event_stream(), content_type='text/event-stream') + resp['Cache-Control'] = 'no-cache' + resp['X-Accel-Buffering'] = 'no' + return resp diff --git a/templates/base.html b/templates/base.html index f081645..9493983 100644 --- a/templates/base.html +++ b/templates/base.html @@ -151,6 +151,16 @@
      + +
      + + + + {% trans "Router Monitor" %} +
      +
      +
      From 3409fc804508285e7de7329b44186484c177e02b Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:12:21 +1000 Subject: [PATCH 77/95] fix build --- .../migrations/0002_change_enabled_default.py | 18 ++++++++++++++++++ routermon/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 routermon/migrations/0002_change_enabled_default.py diff --git a/routermon/migrations/0002_change_enabled_default.py b/routermon/migrations/0002_change_enabled_default.py new file mode 100644 index 0000000..a6c4779 --- /dev/null +++ b/routermon/migrations/0002_change_enabled_default.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-04-16 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('routermon', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='routermonsettings', + name='enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/routermon/models.py b/routermon/models.py index 64ed561..d4e2232 100644 --- a/routermon/models.py +++ b/routermon/models.py @@ -4,7 +4,7 @@ from django.db import models class RouterMonSettings(models.Model): """Singleton (pk=1) — router syslog receiver configuration.""" - enabled = models.BooleanField(default=True) + enabled = models.BooleanField(default=False) syslog_port = models.IntegerField( default=5514, help_text=( From 7e0db3cdfbbd9bda5d910e1ccd54a10439b8eb1a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:15:15 +1000 Subject: [PATCH 78/95] fix ci --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2dfd93b..54a67a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY core/ ./core/ COPY links/ ./links/ COPY netscan/ ./netscan/ COPY nginxmon/ ./nginxmon/ +COPY routermon/ ./rountermon/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ From 1fb54880d6e07e7cc39c8171c9577243c3c3e085 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:18:46 +1000 Subject: [PATCH 79/95] fix build --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 54a67a2..e482a51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ COPY core/ ./core/ COPY links/ ./links/ COPY netscan/ ./netscan/ COPY nginxmon/ ./nginxmon/ -COPY routermon/ ./rountermon/ +COPY routermon/ ./routermon/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ @@ -143,6 +143,7 @@ COPY --chown=appuser:appuser core/ ./core/ COPY --chown=appuser:appuser links/ ./links/ COPY --chown=appuser:appuser netscan/ ./netscan/ COPY --chown=appuser:appuser nginxmon/ ./nginxmon/ +COPY --chown=appuser:appuser routermon/ ./routermon/ COPY --chown=appuser:appuser new_theme/ ./new_theme/ COPY --chown=appuser:appuser templates/ ./templates/ COPY --chown=appuser:appuser static/ ./static/ From 1574fb53f1568ee4a6bc3064548739515771b8c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 04:22:21 +0000 Subject: [PATCH 80/95] chore: update manifest image to 1.0.288 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 3c1008b..fa3100f 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -103,14 +103,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.283" + image: "ghcr.io/wahyd4/links:1.0.288" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.283" + image: "ghcr.io/wahyd4/links:1.0.288" securityContext: runAsUser: 1000 imagePullPolicy: Always From 3d481385b07d34b0fd78578b6809271ed781a926 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:26:23 +1000 Subject: [PATCH 81/95] Update --- k8s/manifest.template.yaml | 2 ++ k8s/manifest.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/k8s/manifest.template.yaml b/k8s/manifest.template.yaml index 20646c8..652c9d9 100644 --- a/k8s/manifest.template.yaml +++ b/k8s/manifest.template.yaml @@ -81,6 +81,8 @@ spec: matchLabels: app: links replicas: 1 + strategy: + type: Recreate template: metadata: labels: diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index fa3100f..a631815 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -81,6 +81,8 @@ spec: matchLabels: app: links replicas: 1 + strategy: + type: Recreate template: metadata: labels: From 611305b0ab9de9e19dace56da08dc93ff7cdf9b0 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 14:58:39 +1000 Subject: [PATCH 82/95] fix(routermon): fix receiver startup, logging config, and JS template escaping - Remove SO_REUSEADDR from UDP socket so only one gunicorn worker binds port 5514 (second worker intentionally gets EADDRINUSE and skips) - Add print() fallback for bind confirmation/errors so startup is visible even when Django logging config is not fully set up - Fix duplicate LOGGING dict in settings.py: second assignment was overriding the first and removing the root logger handler, silently swallowing all routermon/core startup logs - Add sys.stderr fallback in core/apps.py ready() for startup exceptions - Fix JS SyntaxError: {{ top_domains_json }} needs |safe filter since Django auto-escapes quotes to " inside script tags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/apps.py | 2 ++ core/settings.py | 21 ++++---------------- routermon/receiver.py | 6 +++++- routermon/templates/routermon/dashboard.html | 2 +- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/core/apps.py b/core/apps.py index 68bd57f..6c6b5ca 100644 --- a/core/apps.py +++ b/core/apps.py @@ -89,4 +89,6 @@ class CoreConfig(AppConfig): ) logger.info("routermon: scheduled cleanup job (every 6h)") except Exception as exc: + import sys + print(f'routermon: startup error (non-fatal): {exc}', file=sys.stderr, flush=True) logger.warning("routermon: startup error (non-fatal): %s", exc) diff --git a/core/settings.py b/core/settings.py index fc9ee78..8521143 100644 --- a/core/settings.py +++ b/core/settings.py @@ -232,21 +232,8 @@ CRAWL4AI_ENABLED = os.environ.get('CRAWL4AI_ENABLED', 'True').lower() in ('true' BUILD_TIME = os.environ.get('BUILD_TIME', '') BUILD_VERSION = os.environ.get('BUILD_VERSION', '') -# For debugging -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'level': 'DEBUG', - }, - }, - 'loggers': { - 'links': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': True, - }, - }, +LOGGING['loggers']['links'] = { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, } diff --git a/routermon/receiver.py b/routermon/receiver.py index fd7004c..f2cbdf4 100644 --- a/routermon/receiver.py +++ b/routermon/receiver.py @@ -54,15 +54,19 @@ def start_receiver(port: int): try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # No SO_REUSEADDR: we intentionally want only one worker to bind. + # Other workers receive EADDRINUSE and skip cleanly. sock.bind(('0.0.0.0', port)) sock.settimeout(1.0) _sock = sock + print(f'routermon: UDP syslog receiver bound on port {port}', flush=True) logger.info('routermon: UDP syslog receiver bound on port %d', port) except OSError as exc: if exc.errno == errno.EADDRINUSE: logger.debug('routermon: port %d already bound (another worker), skipping', port) else: + import sys + print(f'routermon: failed to bind UDP port {port}: {exc}', file=sys.stderr, flush=True) logger.error('routermon: failed to bind UDP port %d: %s', port, exc) return diff --git a/routermon/templates/routermon/dashboard.html b/routermon/templates/routermon/dashboard.html index 009a000..e1fbd0a 100644 --- a/routermon/templates/routermon/dashboard.html +++ b/routermon/templates/routermon/dashboard.html @@ -324,7 +324,7 @@ async function loadChart() { }); // Top domains mini bar chart - const topData = {{ top_domains_json }}; + const topData = {{ top_domains_json|safe }}; const top10 = topData.slice(0, 10).reverse(); domainsChartEl.setOption({ tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}}, From 8d4a3d78438f84537ee595a5af3b0254b3ccc90e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 05:02:04 +0000 Subject: [PATCH 83/95] chore: update manifest image to 1.0.289 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index a631815..d7e9bf4 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.288" + image: "ghcr.io/wahyd4/links:1.0.289" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.288" + image: "ghcr.io/wahyd4/links:1.0.289" securityContext: runAsUser: 1000 imagePullPolicy: Always From 430ca18b4144e8c6db597947c2519d594dc223a2 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 15:34:23 +1000 Subject: [PATCH 84/95] routermon: add WAN incoming traffic monitoring - Add WanEvent model (src_ip, protocol, dst_port, src_port, geo fields) with migration 0003_wanevent - Extend parser to handle kernel:/iptables WAN_IN: syslog lines - Generalise _RE_SYSLOG to accept 'kernel' process name (no pid) - Parse KEY=value tokens from iptables log (robust vs monolithic regex) - Reject private source IPs silently - Receiver: bulk-create WanEvent rows in _flush(); replace per-flush geo threads with a single bounded geo-enrichment worker (_geo_queue, max 500) to safely handle high-volume port scans - Tasks: batch-delete WanEvent rows (<=3 day retention cap); batch-delete DnsQuery rows to avoid long SQLite locks - Views: WanLivePartialView (filterable HTMX table), WanChartDataView (timeline JSON); dashboard context adds wan_total_24h, top_attacked_ports_json, top_wan_sources - Templates: - _live_wan.html: live event table with color-coded protocol, clickable IP/port filters, well-known port labels - dashboard.html: WAN section with 24h counter, timeline chart, top attacked ports bar chart, top source IPs table, live event stream - settings.html: Step 5 guide for /jffs/scripts/firewall-start with rate-limited iptables LOG rules (INPUT + FORWARD chains, 60/min limit) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- routermon/migrations/0003_wanevent.py | 32 ++++ routermon/models.py | 32 ++++ routermon/parser.py | 61 +++++++- routermon/receiver.py | 151 +++++++++++++------ routermon/tasks.py | 34 ++++- routermon/templates/routermon/_live_wan.html | 59 ++++++++ routermon/templates/routermon/dashboard.html | 139 +++++++++++++++++ routermon/templates/routermon/settings.html | 46 ++++++ routermon/urls.py | 2 + routermon/views.py | 96 +++++++++++- 10 files changed, 599 insertions(+), 53 deletions(-) create mode 100644 routermon/migrations/0003_wanevent.py create mode 100644 routermon/templates/routermon/_live_wan.html diff --git a/routermon/migrations/0003_wanevent.py b/routermon/migrations/0003_wanevent.py new file mode 100644 index 0000000..22ee597 --- /dev/null +++ b/routermon/migrations/0003_wanevent.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-04-16 05:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('routermon', '0002_change_enabled_default'), + ] + + operations = [ + migrations.CreateModel( + name='WanEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(db_index=True)), + ('src_ip', models.GenericIPAddressField(db_index=True)), + ('dst_port', models.IntegerField(blank=True, db_index=True, null=True)), + ('protocol', models.CharField(max_length=10)), + ('src_port', models.IntegerField(blank=True, null=True)), + ('country', models.CharField(blank=True, max_length=100)), + ('country_code', models.CharField(blank=True, max_length=10)), + ('city', models.CharField(blank=True, max_length=100)), + ('isp', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['src_ip', 'timestamp'], name='routermon_w_src_ip_69d0c6_idx'), models.Index(fields=['dst_port', 'timestamp'], name='routermon_w_dst_por_75939a_idx'), models.Index(fields=['timestamp', 'protocol'], name='routermon_w_timesta_143a69_idx')], + }, + ), + ] diff --git a/routermon/models.py b/routermon/models.py index d4e2232..50bf690 100644 --- a/routermon/models.py +++ b/routermon/models.py @@ -72,6 +72,38 @@ class DnsQuery(models.Model): return ', '.join(parts) if parts else '—' +class WanEvent(models.Model): + """One incoming WAN connection attempt logged by iptables (WAN_IN: prefix).""" + + timestamp = models.DateTimeField(db_index=True) + src_ip = models.GenericIPAddressField(db_index=True) + dst_port = models.IntegerField(null=True, blank=True, db_index=True) + protocol = models.CharField(max_length=10) # TCP / UDP / ICMP + src_port = models.IntegerField(null=True, blank=True) + # Geo for src_ip (populated asynchronously) + country = models.CharField(max_length=100, blank=True) + country_code = models.CharField(max_length=10, blank=True) + city = models.CharField(max_length=100, blank=True) + isp = models.CharField(max_length=200, blank=True) + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['src_ip', 'timestamp']), + models.Index(fields=['dst_port', 'timestamp']), + models.Index(fields=['timestamp', 'protocol']), + ] + + def __str__(self): + port = f':{self.dst_port}' if self.dst_port else '' + return f'{self.src_ip} → {self.protocol}{port} @ {self.timestamp:%Y-%m-%d %H:%M:%S}' + + @property + def geo_display(self): + parts = [p for p in [self.city, self.country] if p] + return ', '.join(parts) if parts else '—' + + class DhcpLease(models.Model): """Latest-known DHCP lease: maps LAN IP to MAC + hostname.""" diff --git a/routermon/parser.py b/routermon/parser.py index f1a5656..09cd4b0 100644 --- a/routermon/parser.py +++ b/routermon/parser.py @@ -1,16 +1,19 @@ """ -Parse syslog datagrams from AsusWRT-Merlin's dnsmasq. +Parse syslog datagrams from AsusWRT-Merlin's dnsmasq and kernel/iptables. Handles RFC 3164 syslog envelope: Mmm DD HH:MM:SS hostname dnsmasq[PID]: message or with PRI prefix: Mmm DD HH:MM:SS hostname dnsmasq[PID]: message -Recognised message formats: +Recognised dnsmasq message formats: query[TYPE] domain from IP — DNS query event reply domain is IP — resolved to an IP reply domain is NXDOMAIN — domain does not exist reply domain is NODATA — exists but no records of requested type DHCPACK(iface) IP MAC [hostname] — DHCP lease acknowledged + +Recognised kernel/iptables message formats: + WAN_IN: IN=eth0 SRC=x.x.x.x DST=y.y.y.y ... PROTO=TCP SPT=N DPT=N ... """ import re import logging @@ -31,7 +34,7 @@ _RE_SYSLOG = re.compile( r'(?:<\d+>)?' # optional PRI r'\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}' # timestamp (Mmm DD HH:MM:SS) r'\s+\S+' # hostname - r'\s+(dnsmasq(?:-dhcp)?)\[\d+\]:' # process (group 1) + r'\s+(dnsmasq(?:-dhcp)?|kernel)(?:\[\d+\])?:' # process (group 1) r'\s*(.*)', # message body (group 2) re.DOTALL, ) @@ -46,6 +49,9 @@ _RE_REPLY = re.compile(r'^reply\s+([\w.\-]+)\s+is\s+(\S+)') # dnsmasq DHCP ack: DHCPACK(iface) IP MAC [hostname] _RE_DHCP = re.compile(r'^DHCPACK\(\S+\)\s+([\d.]+)\s+([\da-fA-F:]+)\s*(\S*)') +# kernel: iptables log prefix marker +_RE_WAN_PREFIX = re.compile(r'WAN_IN:\s+') + # Simple IPv4/IPv6 check (enough to distinguish from NXDOMAIN/NODATA strings) _RE_IP = re.compile(r'^[\d.a-fA-F:]+$') @@ -69,6 +75,11 @@ def _is_public_ip(addr: str) -> bool: return False +def _parse_kv(text: str) -> dict: + """Parse KEY=value pairs from an iptables log line.""" + return dict(re.findall(r'(\w+)=([^\s]+)', text)) + + # ── Result dataclasses ──────────────────────────────────────────────────────── @dataclass @@ -93,11 +104,20 @@ class DhcpEvent: hostname: str +@dataclass +class WanEventParsed: + src_ip: str + protocol: str # TCP / UDP / ICMP + dst_port: Optional[int] + src_port: Optional[int] + + @dataclass class ParseResult: query: Optional[QueryEvent] = None reply: Optional[ReplyEvent] = None dhcp: Optional[DhcpEvent] = None + wan_event: Optional[WanEventParsed] = None # ── Public API ──────────────────────────────────────────────────────────────── @@ -115,10 +135,22 @@ def parse_line(raw: str) -> Optional[ParseResult]: if not m: # Some Merlin builds omit the standard syslog prefix; try matching # dnsmasq message body directly. + process = None body = raw else: + process = m.group(1) body = m.group(2).strip() + # ── Kernel / iptables WAN events ───────────────────────────────────────── + if process == 'kernel' or (process is None and _RE_WAN_PREFIX.search(raw)): + # Strip optional kernel timestamp "[12345.678] " + body_stripped = re.sub(r'^\[\d+\.\d+\]\s*', '', body) + if _RE_WAN_PREFIX.search(body_stripped): + return _parse_wan_event(body_stripped) + return None + + # ── dnsmasq events ─────────────────────────────────────────────────────── + # DNS query mq = _RE_QUERY.match(body) if mq: @@ -154,3 +186,26 @@ def parse_line(raw: str) -> Optional[ParseResult]: )) return None + + +def _parse_wan_event(body: str) -> Optional[ParseResult]: + """Parse an iptables WAN_IN log line body into a WanEventParsed.""" + kv = _parse_kv(body) + src_ip = kv.get('SRC', '') + if not src_ip or not _is_public_ip(src_ip): + return None + protocol = kv.get('PROTO', 'UNKNOWN').upper() + try: + dst_port = int(kv['DPT']) if 'DPT' in kv else None + except (ValueError, KeyError): + dst_port = None + try: + src_port = int(kv['SPT']) if 'SPT' in kv else None + except (ValueError, KeyError): + src_port = None + return ParseResult(wan_event=WanEventParsed( + src_ip=src_ip, + protocol=protocol, + dst_port=dst_port, + src_port=src_port, + )) diff --git a/routermon/receiver.py b/routermon/receiver.py index f2cbdf4..7c7c051 100644 --- a/routermon/receiver.py +++ b/routermon/receiver.py @@ -5,6 +5,8 @@ Architecture: - One background thread owns the UDP socket and enqueues parsed events. - A separate writer thread drains the queue and bulk-saves to the DB every FLUSH_INTERVAL seconds (or when the queue reaches FLUSH_SIZE). + - A single geo-enrichment worker drains a bounded queue so WAN scan volumes + don't create unbounded thread churn. - Only one Gunicorn worker binds the socket (EADDRINUSE is silently ignored in the others — matches the project's existing in-process APScheduler style). @@ -29,11 +31,14 @@ FLUSH_INTERVAL = 2 # seconds between writer flushes FLUSH_SIZE = 100 # flush immediately when queue reaches this size REPLY_TTL = 10 # seconds to keep a query in _pending for reply matching MAX_QUEUE = 10_000 # drop oldest items if queue exceeds this (backpressure) +MAX_GEO_QUEUE = 500 # bounded geo-enrichment queue (drop excess rather than thread-spawn) _receiver_thread: threading.Thread | None = None _writer_thread: threading.Thread | None = None +_geo_thread: threading.Thread | None = None _sock: socket.socket | None = None _queue: queue.Queue = queue.Queue(maxsize=MAX_QUEUE) +_geo_queue: queue.Queue = queue.Queue(maxsize=MAX_GEO_QUEUE) # in-memory pending dict: domain → (query_pk, created_at) _pending: dict = {} @@ -47,7 +52,7 @@ def start_receiver(port: int): Attempt to start the UDP receiver on `port`. Safe to call from every Gunicorn worker — only the first to bind succeeds; others silently skip. """ - global _receiver_thread, _writer_thread, _sock + global _receiver_thread, _writer_thread, _geo_thread, _sock if _receiver_thread and _receiver_thread.is_alive(): return # already running in this process @@ -70,6 +75,9 @@ def start_receiver(port: int): logger.error('routermon: failed to bind UDP port %d: %s', port, exc) return + _geo_thread = threading.Thread(target=_geo_worker_loop, daemon=True, name='routermon-geo') + _geo_thread.start() + _writer_thread = threading.Thread(target=_writer_loop, daemon=True, name='routermon-writer') _writer_thread.start() @@ -157,7 +165,7 @@ def _writer_loop(): def _flush(batch: list): """Persist a batch of ParseResult objects to the database.""" - from .models import RouterMonSettings, DnsQuery, DhcpLease + from .models import RouterMonSettings, DnsQuery, DhcpLease, WanEvent settings = RouterMonSettings.get() if not settings.enabled: @@ -213,19 +221,44 @@ def _flush(batch: list): # Expire old pending entries _expire_pending(now) + # --- WAN events --- + wan_events = [r.wan_event for r in batch if r.wan_event] + if wan_events: + wan_objs = [ + WanEvent( + timestamp=now, + src_ip=ev.src_ip, + protocol=ev.protocol, + dst_port=ev.dst_port, + src_port=ev.src_port, + ) + for ev in wan_events + ] + WanEvent.objects.bulk_create(wan_objs) + # Enqueue unique source IPs for geo enrichment (non-blocking) + seen_ips = set() + for ev in wan_events: + if ev.src_ip not in seen_ips: + seen_ips.add(ev.src_ip) + try: + _geo_queue.put_nowait(('wan', ev.src_ip)) + except queue.Full: + pass # drop silently under high scan volume + # Touch last_received_at once per flush (coalesced) RouterMonSettings.objects.filter(pk=1).update(last_received_at=now) - # Trigger geo enrichment in background (lazy: only for rows with resolved_ip that have no geo yet) + # Enqueue DNS geo enrichment (deduplicated by IP) pks_needing_geo = list( DnsQuery.objects.filter( resolved_ip__isnull=False, country='', timestamp__gte=now - timedelta(minutes=5) ).values_list('pk', flat=True)[:200] ) - if pks_needing_geo: - threading.Thread( - target=_enrich_geo, args=(pks_needing_geo,), daemon=True - ).start() + for pk in pks_needing_geo: + try: + _geo_queue.put_nowait(('dns', pk)) + except queue.Full: + break def _apply_replies(reply_events, now): @@ -254,46 +287,75 @@ def _expire_pending(now): del _pending[domain] -def _enrich_geo(pks: list[int]): - """Geo-enrich DnsQuery rows by resolved_ip using nginxmon's IPGeoCache.""" - try: - from nginxmon.models import IPGeoCache - from routermon.models import DnsQuery - import ipaddress +# ── Geo enrichment worker ───────────────────────────────────────────────────── - rows = list(DnsQuery.objects.filter(pk__in=pks).values('pk', 'resolved_ip')) - ips = list({r['resolved_ip'] for r in rows if r['resolved_ip']}) - if not ips: - return +def _geo_worker_loop(): + """Single background thread draining the geo enrichment queue.""" + logger.info('routermon: geo worker started') + while True: + try: + item = _geo_queue.get(timeout=5) + except queue.Empty: + continue + kind, value = item + try: + if kind == 'dns': + _enrich_dns_geo(value) + elif kind == 'wan': + _enrich_wan_geo(value) + except Exception as exc: + logger.debug('routermon: geo enrichment error (%s %s): %s', kind, value, exc) - # Fetch missing IPs from ip-api.com - cached = {c.ip: c for c in IPGeoCache.objects.filter(ip__in=ips)} - missing = [ip for ip in ips if ip not in cached] - if missing: - from nginxmon.geo import _lookup_batch - api_results = _lookup_batch(missing) - for ip, data in api_results.items(): - obj, _ = IPGeoCache.objects.get_or_create(ip=ip) - obj.country = data.get('country', '') - obj.country_code = data.get('countryCode', '') - obj.region = data.get('regionName', '') - obj.city = data.get('city', '') - obj.lat = data.get('lat') - obj.lon = data.get('lon') - obj.isp = data.get('isp', '') - obj.save() - cached[ip] = obj - for row in rows: - geo = cached.get(row['resolved_ip']) - if geo and geo.country: - DnsQuery.objects.filter(pk=row['pk'], country='').update( - country=geo.country, - country_code=geo.country_code, - city=geo.city, - ) - except Exception as exc: - logger.warning('routermon: geo enrichment error: %s', exc) +def _fetch_geo(ip: str): + """Return IPGeoCache object for ip, fetching from ip-api.com if needed.""" + from nginxmon.models import IPGeoCache + obj = IPGeoCache.objects.filter(ip=ip).first() + if obj and obj.country: + return obj + # Fetch from ip-api.com + from nginxmon.geo import _lookup_batch + results = _lookup_batch([ip]) + data = results.get(ip, {}) + if not data: + return None + obj, _ = IPGeoCache.objects.get_or_create(ip=ip) + obj.country = data.get('country', '') + obj.country_code = data.get('countryCode', '') + obj.region = data.get('regionName', '') + obj.city = data.get('city', '') + obj.lat = data.get('lat') + obj.lon = data.get('lon') + obj.isp = data.get('isp', '') + obj.save() + return obj + + +def _enrich_dns_geo(pk: int): + from routermon.models import DnsQuery + row = DnsQuery.objects.filter(pk=pk, country='').values('pk', 'resolved_ip').first() + if not row or not row['resolved_ip']: + return + geo = _fetch_geo(row['resolved_ip']) + if geo and geo.country: + DnsQuery.objects.filter(pk=pk, country='').update( + country=geo.country, + country_code=geo.country_code, + city=geo.city, + ) + + +def _enrich_wan_geo(src_ip: str): + """Geo-enrich all WanEvent rows for src_ip that have no country yet.""" + from routermon.models import WanEvent + geo = _fetch_geo(src_ip) + if geo and geo.country: + WanEvent.objects.filter(src_ip=src_ip, country='').update( + country=geo.country, + country_code=geo.country_code, + city=geo.city, + isp=geo.isp, + ) def _build_excluded_set(text: str) -> set: @@ -303,3 +365,4 @@ def _build_excluded_set(text: str) -> set: if entry and not entry.startswith('#'): result.add(entry) return result + diff --git a/routermon/tasks.py b/routermon/tasks.py index 06d274a..f126aa1 100644 --- a/routermon/tasks.py +++ b/routermon/tasks.py @@ -3,21 +3,45 @@ import logging logger = logging.getLogger(__name__) +_BATCH = 500 # rows per delete batch to avoid long locks + def cleanup_old_queries(): - """Delete DnsQuery rows older than retention_days. Run periodically.""" + """Delete DnsQuery and WanEvent rows older than retention_days. Run periodically.""" try: from datetime import timedelta from django.utils import timezone - from .models import RouterMonSettings, DnsQuery + from .models import RouterMonSettings, DnsQuery, WanEvent settings = RouterMonSettings.get() if settings.retention_days <= 0: return cutoff = timezone.now() - timedelta(days=settings.retention_days) - deleted, _ = DnsQuery.objects.filter(timestamp__lt=cutoff).delete() - if deleted: - logger.info('routermon: pruned %d old DNS query records (>%d days)', deleted, settings.retention_days) + + # Batch-delete DnsQuery rows + total_dns = 0 + while True: + ids = list(DnsQuery.objects.filter(timestamp__lt=cutoff).values_list('pk', flat=True)[:_BATCH]) + if not ids: + break + deleted, _ = DnsQuery.objects.filter(pk__in=ids).delete() + total_dns += deleted + + # WAN events accumulate fast under port scans — use shorter retention (3 days max) + wan_retention = min(settings.retention_days, 3) + wan_cutoff = timezone.now() - timedelta(days=wan_retention) + total_wan = 0 + while True: + ids = list(WanEvent.objects.filter(timestamp__lt=wan_cutoff).values_list('pk', flat=True)[:_BATCH]) + if not ids: + break + deleted, _ = WanEvent.objects.filter(pk__in=ids).delete() + total_wan += deleted + + if total_dns: + logger.info('routermon: pruned %d old DNS query records (>%d days)', total_dns, settings.retention_days) + if total_wan: + logger.info('routermon: pruned %d old WAN event records (>%d days)', total_wan, wan_retention) except Exception as exc: logger.error('routermon: cleanup_old_queries error: %s', exc, exc_info=True) diff --git a/routermon/templates/routermon/_live_wan.html b/routermon/templates/routermon/_live_wan.html new file mode 100644 index 0000000..9131b61 --- /dev/null +++ b/routermon/templates/routermon/_live_wan.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + {% for ev in wan_events %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
      TimeSource IPProtoDst PortSrc PortGeoISP
      {{ ev.timestamp|date:"H:i:s" }} + + + {{ ev.protocol }} + + {% if ev.dst_port %} + + {% with p=ev.dst_port %} + {% if p == 22 %}SSH + {% elif p == 23 %}Telnet + {% elif p == 80 %}HTTP + {% elif p == 443 %}HTTPS + {% elif p == 3389 %}RDP + {% elif p == 8080 %}HTTP-alt + {% endif %} + {% endwith %} + {% else %} + + {% endif %} + {{ ev.src_port|default:"—" }} + {% if ev.country_code %} + {{ ev.country_code }} {{ ev.geo_display }} + {% else %} + + {% endif %} + {{ ev.isp|default:"—" }}
      + No WAN events yet. Set up iptables logging on your router — see the Settings page. +
      diff --git a/routermon/templates/routermon/dashboard.html b/routermon/templates/routermon/dashboard.html index e1fbd0a..e46a0f0 100644 --- a/routermon/templates/routermon/dashboard.html +++ b/routermon/templates/routermon/dashboard.html @@ -230,6 +230,78 @@
      + +
      +
      +
      +
      WAN Incoming Attempts
      + Last 24h: {{ wan_total_24h }} +
      +
      + + + + + +
      +
      + + +
      +
      +
      + + +
      +
      +
      Top Targeted Ports (24h)
      +
      +
      +
      +
      Top Source IPs (24h)
      + + + + + + + + + + {% for s in top_wan_sources %} + + + + + + {% empty %} + + {% endfor %} + +
      Source IPHitsCountry
      + + {{ s.count }}{{ s.country_code }}{% if s.country %} {{ s.country }}{% endif %}
      No data yet
      +
      +
      + + +
      +
      Loading…
      +
      +
      +
      {% endblock %} diff --git a/routermon/templates/routermon/settings.html b/routermon/templates/routermon/settings.html index 0168585..74b75f5 100644 --- a/routermon/templates/routermon/settings.html +++ b/routermon/templates/routermon/settings.html @@ -143,6 +143,46 @@ iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port {{ settin apt install iptables-persistent && netfilter-persistent save
      + +
      +

      Step 5 — Log WAN incoming connection attempts (optional)

      +

      + Add iptables rules that log every new incoming connection from the WAN interface. + The router syslog will forward these kernel messages alongside DNS logs. + Create or edit /jffs/scripts/firewall-start and add: +

      +
      #!/bin/sh
      +# /jffs/scripts/firewall-start — runs after each firewall restart
      +
      +# Auto-detect WAN interface (eth0, ppp0, vlan2, …)
      +WAN_IF=$(nvram get wan0_ifname 2>/dev/null)
      +[ -z "$WAN_IF" ] && WAN_IF=$(ip route | grep default | awk '{print $5}' | head -1)
      +
      +if [ -z "$WAN_IF" ]; then
      +  logger -t firewall-start "Could not detect WAN interface, skipping WAN logging rules"
      +  exit 0
      +fi
      +
      +logger -t firewall-start "Adding WAN logging rules on $WAN_IF"
      +
      +# Log new connections TO the router (management, VPN, etc.)
      +iptables -I INPUT -i "$WAN_IF" -m state --state NEW \
      +  -m limit --limit 60/min --limit-burst 100 \
      +  -j LOG --log-prefix "WAN_IN: " --log-level 6
      +
      +# Log new connections FORWARDED to LAN devices (port-forwarding rules)
      +iptables -I FORWARD -i "$WAN_IF" -m state --state NEW \
      +  -m limit --limit 60/min --limit-burst 100 \
      +  -j LOG --log-prefix "WAN_IN: " --log-level 6
      +

      Make the script executable and run it once:

      +
      chmod +x /jffs/scripts/firewall-start
      +/jffs/scripts/firewall-start
      +

      + The --limit 60/min rate limit protects against syslog floods during port scans. + Rules are automatically re-applied after each firewall restart. +

      +
      +

      To disable / revert

      @@ -151,6 +191,12 @@ apt install iptables-persistent && netfilter-persistent save # Remove dnsmasq log-queries line sed -i '/^log-queries$/d' /jffs/configs/dnsmasq.conf.add +# Remove WAN logging rules (if added) +rm -f /jffs/scripts/firewall-start +# Flush current rules: +iptables -D INPUT -j LOG --log-prefix "WAN_IN: " 2>/dev/null || true +iptables -D FORWARD -j LOG --log-prefix "WAN_IN: " 2>/dev/null || true + # Disable remote syslog nvram set log_remote=0 nvram commit diff --git a/routermon/urls.py b/routermon/urls.py index 3d2bfe9..28858e6 100644 --- a/routermon/urls.py +++ b/routermon/urls.py @@ -6,9 +6,11 @@ urlpatterns = [ path('settings/', views.SettingsView.as_view(), name='routermon-settings'), # HTMX partials path('partials/queries/', views.LiveQueriesPartialView.as_view(), name='routermon-queries-partial'), + path('partials/wan/', views.WanLivePartialView.as_view(), name='routermon-wan-partial'), # JSON APIs path('api/chart/', views.ChartDataView.as_view(), name='routermon-chart'), path('api/geo/', views.GeoStatsView.as_view(), name='routermon-geo'), + path('api/wan/chart/', views.WanChartDataView.as_view(), name='routermon-wan-chart'), # Actions path('toggle/', views.ToggleView.as_view(), name='routermon-toggle'), path('status/', views.StatusStreamView.as_view(), name='routermon-status'), diff --git a/routermon/views.py b/routermon/views.py index 878db7d..a72e086 100644 --- a/routermon/views.py +++ b/routermon/views.py @@ -12,7 +12,7 @@ from django.utils import timezone from django.views.generic import TemplateView, View from .forms import RouterMonSettingsForm -from .models import DhcpLease, DnsQuery, RouterMonSettings +from .models import DhcpLease, DnsQuery, RouterMonSettings, WanEvent logger = logging.getLogger(__name__) @@ -114,6 +114,21 @@ class DashboardView(TemplateView): for c in top_clients: c['hostname'] = hostname_map.get(c['client_ip'], c['client_name'] or '') + wan_since = timezone.now() - timedelta(hours=24) + wan_qs = WanEvent.objects.filter(timestamp__gte=wan_since) + wan_total = wan_qs.count() + top_attacked_ports = list( + wan_qs.filter(dst_port__isnull=False) + .values('dst_port', 'protocol') + .annotate(count=Count('id')) + .order_by('-count')[:10] + ) + top_wan_sources = list( + wan_qs.values('src_ip', 'country', 'country_code') + .annotate(count=Count('id')) + .order_by('-count')[:10] + ) + ctx.update({ 'settings': settings, 'stats': stats, @@ -123,6 +138,10 @@ class DashboardView(TemplateView): 'range_label': cfg['label'], 'all_ranges': [(k, v['label']) for k, v in _RANGES.items()], 'top_domains_json': json.dumps(top_domains, default=str), + 'wan_total_24h': wan_total, + 'top_attacked_ports': top_attacked_ports, + 'top_attacked_ports_json': json.dumps(top_attacked_ports, default=str), + 'top_wan_sources': top_wan_sources, }) return ctx @@ -305,3 +324,78 @@ class StatusStreamView(View): resp['Cache-Control'] = 'no-cache' resp['X-Accel-Buffering'] = 'no' return resp + + +# ── WAN views ───────────────────────────────────────────────────────────────── + +class WanLivePartialView(View): + """HTMX partial: recent WAN events table.""" + + _SORT_MAP = { + 'timestamp': 'timestamp', + 'src_ip': 'src_ip', + 'dst_port': 'dst_port', + 'protocol': 'protocol', + 'country': 'country', + } + + def get(self, request): + qs = WanEvent.objects.all() + + src_f = request.GET.get('src_ip', '').strip() + port_f = request.GET.get('dst_port', '').strip() + proto_f = request.GET.get('protocol', '').strip() + country_f = request.GET.get('country', '').strip() + sort_col = request.GET.get('sort', 'timestamp') + sort_ord = request.GET.get('order', 'desc') + + if src_f: + qs = qs.filter(src_ip__icontains=src_f) + if port_f: + try: + qs = qs.filter(dst_port=int(port_f)) + except ValueError: + pass + if proto_f: + qs = qs.filter(protocol__iexact=proto_f) + if country_f: + qs = qs.filter(Q(country__icontains=country_f) | Q(country_code__iexact=country_f)) + + db_col = self._SORT_MAP.get(sort_col, 'timestamp') + qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}') + + return render(request, 'routermon/_live_wan.html', {'wan_events': qs[:60]}) + + +class WanChartDataView(View): + """JSON — WAN event counts over time (for chart).""" + + def get(self, request): + range_key, cfg, since = _get_range(request) + trunc_fn = cfg['trunc'] + group_n = cfg['group_n'] + fmt = cfg['fmt'] + + rows = list( + WanEvent.objects + .filter(timestamp__gte=since) + .annotate(bucket=trunc_fn('timestamp')) + .values('bucket') + .annotate(total=Count('id')) + .order_by('bucket') + ) + + buckets: dict = {} + for row in rows: + bkt = row['bucket'] + if bkt is None: + continue + key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt) + buckets[key] = buckets.get(key, 0) + row['total'] + + return JsonResponse({ + 'labels': list(buckets), + 'total': list(buckets.values()), + 'range': range_key, + 'label': cfg['label'], + }) From dcbd1fe67988e1041003c4c6d8093631287e2793 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 05:38:09 +0000 Subject: [PATCH 85/95] chore: update manifest image to 1.0.290 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index d7e9bf4..c655fca 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.289" + image: "ghcr.io/wahyd4/links:1.0.290" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.289" + image: "ghcr.io/wahyd4/links:1.0.290" securityContext: runAsUser: 1000 imagePullPolicy: Always From cfca2c2ed70a36c00dabd833dc66f64919b1342f Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 16:13:13 +1000 Subject: [PATCH 86/95] routermon: add WAN geo map + fix batch geo enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WanGeoStatsView (api/wan/geo/) returning country choropleth + bubble data for WAN source IPs using IPGeoCache lat/lon - Add wanGeoMap (420px) in dashboard between ports chart and live table: red colour scale by country, rose bubbles sized by attempt count, top-5 country legend - Rewrite _geo_worker_loop to drain up to 200 queue items per cycle, bulk DB cache check, batch API calls (100 IPs per request) instead of 1 request per IP — fixes ip-api.com timeout pile-up under scan volumes - Add _geo_skip in-memory set: IPs that return no geo data get an empty IPGeoCache placeholder saved to DB so they are never retried; skip set also prevents re-queuing known-empty IPs during flush - Remove _fetch_geo / _enrich_dns_geo / _enrich_wan_geo helpers (logic consolidated into the new batch worker) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- routermon/receiver.py | 174 ++++++++++++------- routermon/templates/routermon/dashboard.html | 81 +++++++++ routermon/urls.py | 1 + routermon/views.py | 44 +++++ 4 files changed, 239 insertions(+), 61 deletions(-) diff --git a/routermon/receiver.py b/routermon/receiver.py index 7c7c051..f20cb65 100644 --- a/routermon/receiver.py +++ b/routermon/receiver.py @@ -235,10 +235,11 @@ def _flush(batch: list): for ev in wan_events ] WanEvent.objects.bulk_create(wan_objs) - # Enqueue unique source IPs for geo enrichment (non-blocking) + # Enqueue unique source IPs for geo enrichment (non-blocking). + # Skip IPs already known to have no geo data to reduce queue churn. seen_ips = set() for ev in wan_events: - if ev.src_ip not in seen_ips: + if ev.src_ip not in seen_ips and ev.src_ip not in _geo_skip: seen_ips.add(ev.src_ip) try: _geo_queue.put_nowait(('wan', ev.src_ip)) @@ -289,73 +290,124 @@ def _expire_pending(now): # ── Geo enrichment worker ───────────────────────────────────────────────────── +# In-memory set of IPs that returned no geo data — avoids re-queuing them. +# Cleared when it grows too large to prevent unbounded memory use. +_geo_skip: set[str] = set() +_GEO_SKIP_MAX = 10_000 + + def _geo_worker_loop(): - """Single background thread draining the geo enrichment queue.""" + """ + Batch geo enrichment worker. + + Drains up to 200 queue items per cycle, deduplicates IPs, performs a + single bulk DB cache check, then calls the ip-api.com batch endpoint + (100 IPs per request) only for IPs that are truly missing from the cache. + Failed IPs get an empty IPGeoCache placeholder saved to DB so they are + never looked up again. + """ logger.info('routermon: geo worker started') + + from nginxmon.geo import _lookup_batch + from nginxmon.models import IPGeoCache + from routermon.models import DnsQuery, WanEvent + while True: + # Block until at least one item arrives. try: - item = _geo_queue.get(timeout=5) + first = _geo_queue.get(timeout=5) except queue.Empty: continue - kind, value = item + + # Non-blocking drain — collect up to 200 items before processing. + batch = [first] + while len(batch) < 200: + try: + batch.append(_geo_queue.get_nowait()) + except queue.Empty: + break + + dns_pks = [v for k, v in batch if k == 'dns'] + wan_ips = list({v for k, v in batch if k == 'wan'}) # deduplicated + + # Resolve DNS pks → resolved_ip in one query. + dns_pk_ip: dict[int, str] = {} + if dns_pks: + for row in DnsQuery.objects.filter( + pk__in=dns_pks, country='' + ).values('pk', 'resolved_ip'): + if row['resolved_ip']: + dns_pk_ip[row['pk']] = row['resolved_ip'] + + all_ips: set[str] = set(dns_pk_ip.values()) | set(wan_ips) + if not all_ips: + continue + try: - if kind == 'dns': - _enrich_dns_geo(value) - elif kind == 'wan': - _enrich_wan_geo(value) + # Bulk DB cache check — one query for all IPs. + geo_cache: dict[str, IPGeoCache] = { + c.ip: c for c in IPGeoCache.objects.filter(ip__in=all_ips) + } + + # Only fetch IPs not already in the DB (even empty entries count as "done"). + missing = [ + ip for ip in all_ips + if ip not in geo_cache and ip not in _geo_skip + ] + + if missing: + for i in range(0, len(missing), 100): + chunk = missing[i:i + 100] + results = _lookup_batch(chunk) + + # Upsert successful lookups. + for ip, data in results.items(): + obj, _ = IPGeoCache.objects.get_or_create(ip=ip) + obj.country = data.get('country', '') + obj.country_code = data.get('countryCode', '') + obj.region = data.get('regionName', '') + obj.city = data.get('city', '') + obj.lat = data.get('lat') + obj.lon = data.get('lon') + obj.isp = data.get('isp', '') + obj.save() + geo_cache[ip] = obj + + # Save empty placeholder for every IP that failed — prevents + # future DB misses and stops the API from being retried. + failed = set(chunk) - set(results) + if failed: + IPGeoCache.objects.bulk_create( + [IPGeoCache(ip=ip) for ip in failed], + ignore_conflicts=True, + ) + _geo_skip.update(failed) + if len(_geo_skip) > _GEO_SKIP_MAX: + _geo_skip.clear() + + # Apply geo data to DNS records. + for pk, ip in dns_pk_ip.items(): + geo = geo_cache.get(ip) + if geo and geo.country: + DnsQuery.objects.filter(pk=pk, country='').update( + country=geo.country, + country_code=geo.country_code, + city=geo.city, + ) + + # Apply geo data to WAN records (bulk-update per unique IP). + for ip in set(wan_ips): + geo = geo_cache.get(ip) + if geo and geo.country: + WanEvent.objects.filter(src_ip=ip, country='').update( + country=geo.country, + country_code=geo.country_code, + city=geo.city, + isp=getattr(geo, 'isp', ''), + ) + except Exception as exc: - logger.debug('routermon: geo enrichment error (%s %s): %s', kind, value, exc) - - -def _fetch_geo(ip: str): - """Return IPGeoCache object for ip, fetching from ip-api.com if needed.""" - from nginxmon.models import IPGeoCache - obj = IPGeoCache.objects.filter(ip=ip).first() - if obj and obj.country: - return obj - # Fetch from ip-api.com - from nginxmon.geo import _lookup_batch - results = _lookup_batch([ip]) - data = results.get(ip, {}) - if not data: - return None - obj, _ = IPGeoCache.objects.get_or_create(ip=ip) - obj.country = data.get('country', '') - obj.country_code = data.get('countryCode', '') - obj.region = data.get('regionName', '') - obj.city = data.get('city', '') - obj.lat = data.get('lat') - obj.lon = data.get('lon') - obj.isp = data.get('isp', '') - obj.save() - return obj - - -def _enrich_dns_geo(pk: int): - from routermon.models import DnsQuery - row = DnsQuery.objects.filter(pk=pk, country='').values('pk', 'resolved_ip').first() - if not row or not row['resolved_ip']: - return - geo = _fetch_geo(row['resolved_ip']) - if geo and geo.country: - DnsQuery.objects.filter(pk=pk, country='').update( - country=geo.country, - country_code=geo.country_code, - city=geo.city, - ) - - -def _enrich_wan_geo(src_ip: str): - """Geo-enrich all WanEvent rows for src_ip that have no country yet.""" - from routermon.models import WanEvent - geo = _fetch_geo(src_ip) - if geo and geo.country: - WanEvent.objects.filter(src_ip=src_ip, country='').update( - country=geo.country, - country_code=geo.country_code, - city=geo.city, - isp=geo.isp, - ) + logger.warning('routermon: geo batch error: %s', exc) def _build_excluded_set(text: str) -> set: diff --git a/routermon/templates/routermon/dashboard.html b/routermon/templates/routermon/dashboard.html index e46a0f0..7c7ccbb 100644 --- a/routermon/templates/routermon/dashboard.html +++ b/routermon/templates/routermon/dashboard.html @@ -292,6 +292,15 @@
      + +
      +
      +
      Source Geography — {{ range_label }}
      +
      +
      +
      +
      +
      { wanTimelineChart.resize(); wanPortsChart.resize(); }); +// ── WAN geo map ─────────────────────────────────────────────────────────────── +async function loadWanGeoMap() { + const range = '{{ current_range }}'; + const [geoRes, worldRes] = await Promise.all([ + fetch("{% url 'routermon-wan-geo' %}?range=" + range), + fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'), + ]); + const geo = await geoRes.json(); + const world = await worldRes.json(); + + const container = document.getElementById('wanGeoMap'); + const w = container.clientWidth, h = container.clientHeight; + const svg = d3.select('#wanGeoMap').append('svg').attr('width', w).attr('height', h); + const proj = d3.geoNaturalEarth1().scale(w / 6.3).translate([w / 2, h / 2]); + const path = d3.geoPath(proj); + const countries = topojson.feature(world, world.objects.countries); + + const countMap = Object.fromEntries((geo.countries || []).map(c => [c.country_code, c.total])); + const maxCount = Math.max(...Object.values(countMap), 1); + const colour = d3.scaleSequential(d3.interpolateReds).domain([0, maxCount]); + + // Build numeric→alpha2 lookup using the same table from the DNS map + const alpha2Num = {"AF":"004","AX":"008","AL":"008","DZ":"012","AS":"016","AD":"020","AO":"024","AI":"660","AQ":"010","AG":"028","AR":"032","AM":"051","AW":"533","AU":"036","AT":"040","AZ":"031","BS":"044","BH":"048","BD":"050","BB":"052","BY":"112","BE":"056","BZ":"084","BJ":"204","BM":"060","BT":"064","BO":"068","BA":"070","BW":"072","BV":"074","BR":"076","IO":"086","BN":"096","BG":"100","BF":"854","BI":"108","CV":"132","KH":"116","CM":"120","CA":"124","KY":"136","CF":"140","TD":"148","CL":"152","CN":"156","CX":"162","CC":"166","CO":"170","KM":"174","CG":"178","CD":"180","CK":"184","CR":"188","CI":"384","HR":"191","CU":"192","CW":"531","CY":"196","CZ":"203","DK":"208","DJ":"262","DM":"212","DO":"214","EC":"218","EG":"818","SV":"222","GQ":"226","ER":"232","EE":"233","SZ":"748","ET":"231","FK":"238","FO":"234","FJ":"242","FI":"246","FR":"250","GF":"254","PF":"258","TF":"260","GA":"266","GM":"270","GE":"268","DE":"276","GH":"288","GI":"292","GR":"300","GL":"304","GD":"308","GP":"312","GU":"316","GT":"320","GG":"831","GN":"324","GW":"624","GY":"328","HT":"332","HM":"334","VA":"336","HN":"340","HK":"344","HU":"348","IS":"352","IN":"356","ID":"360","IR":"364","IQ":"368","IE":"372","IM":"833","IL":"376","IT":"380","JM":"388","JP":"392","JE":"832","JO":"400","KZ":"398","KE":"404","KI":"296","KP":"408","KR":"410","KW":"414","KG":"417","LA":"418","LV":"428","LB":"422","LS":"426","LR":"430","LY":"434","LI":"438","LT":"440","LU":"442","MO":"446","MG":"450","MW":"454","MY":"458","MV":"462","ML":"466","MT":"470","MH":"584","MQ":"474","MR":"478","MU":"480","YT":"175","MX":"484","FM":"583","MD":"498","MC":"492","MN":"496","ME":"499","MS":"500","MA":"504","MZ":"508","MM":"104","NA":"516","NR":"520","NP":"524","NL":"528","NC":"540","NZ":"554","NI":"558","NE":"562","NG":"566","NU":"570","NF":"574","MK":"807","MP":"580","NO":"578","OM":"512","PK":"586","PW":"585","PS":"275","PA":"591","PG":"598","PY":"600","PE":"604","PH":"608","PN":"612","PL":"616","PT":"620","PR":"630","QA":"634","RE":"638","RO":"642","RU":"643","RW":"646","BL":"652","SH":"654","KN":"659","LC":"662","MF":"663","PM":"666","VC":"670","WS":"882","SM":"674","ST":"678","SA":"682","SN":"686","RS":"688","SC":"690","SL":"694","SG":"702","SX":"534","SK":"703","SI":"705","SB":"090","SO":"706","ZA":"710","GS":"239","SS":"728","ES":"724","LK":"144","SD":"729","SR":"740","SJ":"744","SE":"752","CH":"756","SY":"760","TW":"158","TJ":"762","TZ":"834","TH":"764","TL":"626","TG":"768","TK":"772","TO":"776","TT":"780","TN":"788","TR":"792","TM":"795","TC":"796","TV":"798","UG":"800","UA":"804","AE":"784","GB":"826","US":"840","UM":"581","UY":"858","UZ":"860","VU":"548","VE":"862","VN":"704","VG":"092","VI":"850","WF":"876","EH":"732","YE":"887","ZM":"894","ZW":"716"}; + const numAlpha2 = Object.fromEntries(Object.entries(alpha2Num).map(([a2, num]) => [num, a2])); + + svg.append('g').selectAll('path') + .data(countries.features) + .join('path') + .attr('d', path) + .attr('fill', f => { + const num = String(f.id).padStart(3, '0'); + const a2 = numAlpha2[num]; + const cnt = a2 ? countMap[a2] : 0; + return cnt ? colour(cnt) : '#e5e7eb'; + }) + .attr('stroke', '#d1d5db').attr('stroke-width', 0.4) + .append('title').text(f => { + const num = String(f.id).padStart(3, '0'); + const a2 = numAlpha2[num]; + const cnt = a2 ? countMap[a2] : 0; + return cnt ? `${a2}: ${cnt} attempts` : ''; + }); + + // Bubble circles for source IP locations + const maxBubble = Math.max(...(geo.bubbles || []).map(b => b.total), 1); + const rScale = d3.scaleSqrt().domain([1, maxBubble]).range([3, 24]); + + svg.append('g').selectAll('circle') + .data(geo.bubbles || []) + .join('circle') + .attr('cx', b => proj([b.lon, b.lat])[0]) + .attr('cy', b => proj([b.lon, b.lat])[1]) + .attr('r', b => rScale(b.total)) + .attr('fill', '#f43f5e') + .attr('fill-opacity', 0.55) + .attr('stroke', '#e11d48') + .attr('stroke-width', 0.8) + .append('title') + .text(b => `${b.label}: ${b.total} attempts`); + + // Legend + const legend = document.getElementById('wan-geo-legend'); + (geo.countries || []).slice(0, 5).forEach(c => { + const span = document.createElement('span'); + span.className = 'flex items-center gap-1'; + span.innerHTML = `${c.country || c.country_code} (${c.total})`; + legend.appendChild(span); + }); +} + +loadWanGeoMap(); + function setWanFilter(field, value) { if (field === 'src_ip') document.getElementById('wan-filter-src').value = value; if (field === 'dst_port') document.getElementById('wan-filter-port').value = value; diff --git a/routermon/urls.py b/routermon/urls.py index 28858e6..bde0429 100644 --- a/routermon/urls.py +++ b/routermon/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('api/chart/', views.ChartDataView.as_view(), name='routermon-chart'), path('api/geo/', views.GeoStatsView.as_view(), name='routermon-geo'), path('api/wan/chart/', views.WanChartDataView.as_view(), name='routermon-wan-chart'), + path('api/wan/geo/', views.WanGeoStatsView.as_view(), name='routermon-wan-geo'), # Actions path('toggle/', views.ToggleView.as_view(), name='routermon-toggle'), path('status/', views.StatusStreamView.as_view(), name='routermon-status'), diff --git a/routermon/views.py b/routermon/views.py index a72e086..9daa14f 100644 --- a/routermon/views.py +++ b/routermon/views.py @@ -367,6 +367,50 @@ class WanLivePartialView(View): return render(request, 'routermon/_live_wan.html', {'wan_events': qs[:60]}) +class WanGeoStatsView(View): + """Country-level aggregates + bubble data for WAN incoming events.""" + + def get(self, request): + range_key, cfg, since = _get_range(request) + qs = WanEvent.objects.filter(timestamp__gte=since) + + countries = list( + qs.exclude(country_code='') + .values('country_code', 'country') + .annotate(total=Count('id')) + .order_by('-total') + ) + + ip_agg = list( + qs.values('src_ip') + .annotate(total=Count('id')) + .order_by('-total')[:300] + ) + ip_set = [r['src_ip'] for r in ip_agg] + + try: + from nginxmon.models import IPGeoCache + geo_cache = { + c.ip: (c.lat, c.lon, c.country, c.city) + for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False) + } + except Exception: + geo_cache = {} + + bubbles = [] + for row in ip_agg: + c = geo_cache.get(row['src_ip']) + if c and c[0] is not None and c[1] is not None: + bubbles.append({ + 'lat': round(float(c[0]), 2), + 'lon': round(float(c[1]), 2), + 'total': row['total'], + 'label': c[3] or c[2] or row['src_ip'], + }) + + return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key}) + + class WanChartDataView(View): """JSON — WAN event counts over time (for chart).""" From f8ee2fe2a79fd9b03da5f2bcb31bc6d9da8a2084 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 06:17:09 +0000 Subject: [PATCH 87/95] chore: update manifest image to 1.0.291 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index c655fca..79aec9b 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.290" + image: "ghcr.io/wahyd4/links:1.0.291" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.290" + image: "ghcr.io/wahyd4/links:1.0.291" securityContext: runAsUser: 1000 imagePullPolicy: Always From dde1c6640c204b17a137dd61e0003eb5d7f46cad Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 16:24:20 +1000 Subject: [PATCH 88/95] routermon: add circuit breaker for ip-api.com geo lookups After _GEO_MISS_THRESHOLD (2) consecutive large all-empty API responses, assume ip-api.com is unreachable and enter a 5-minute backoff. During backoff all missing IPs are immediately saved as empty IPGeoCache placeholders so they are not re-queued. Resets automatically when the backoff expires and any result is returned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- routermon/receiver.py | 93 +++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/routermon/receiver.py b/routermon/receiver.py index f20cb65..060791a 100644 --- a/routermon/receiver.py +++ b/routermon/receiver.py @@ -295,6 +295,13 @@ def _expire_pending(now): _geo_skip: set[str] = set() _GEO_SKIP_MAX = 10_000 +# Circuit breaker: if ip-api.com returns empty for N+ consecutive large chunks, +# back off for GEO_BACKOFF_SECS before trying again. +_geo_consecutive_misses: int = 0 +_geo_backoff_until: float = 0.0 +_GEO_MISS_THRESHOLD = 2 # consecutive all-empty chunks before backoff +_GEO_BACKOFF_SECS = 300.0 # 5-minute backoff when unreachable + def _geo_worker_loop(): """ @@ -305,7 +312,13 @@ def _geo_worker_loop(): (100 IPs per request) only for IPs that are truly missing from the cache. Failed IPs get an empty IPGeoCache placeholder saved to DB so they are never looked up again. + + Circuit breaker: after _GEO_MISS_THRESHOLD consecutive all-empty API + responses, all remaining missing IPs are saved as empty placeholders and + API calls are suppressed for _GEO_BACKOFF_SECS (5 min) to avoid a + timeout-storm when ip-api.com is unreachable. """ + global _geo_consecutive_misses, _geo_backoff_until logger.info('routermon: geo worker started') from nginxmon.geo import _lookup_batch @@ -356,34 +369,62 @@ def _geo_worker_loop(): ] if missing: - for i in range(0, len(missing), 100): - chunk = missing[i:i + 100] - results = _lookup_batch(chunk) + # Circuit breaker: if ip-api.com has been unresponsive, skip API + # calls and immediately cache all missing IPs as empty so they + # are not retried until the backoff expires. + in_backoff = time.monotonic() < _geo_backoff_until + if in_backoff: + IPGeoCache.objects.bulk_create( + [IPGeoCache(ip=ip) for ip in missing], + ignore_conflicts=True, + ) + _geo_skip.update(missing) + if len(_geo_skip) > _GEO_SKIP_MAX: + _geo_skip.clear() + else: + for i in range(0, len(missing), 100): + chunk = missing[i:i + 100] + results = _lookup_batch(chunk) - # Upsert successful lookups. - for ip, data in results.items(): - obj, _ = IPGeoCache.objects.get_or_create(ip=ip) - obj.country = data.get('country', '') - obj.country_code = data.get('countryCode', '') - obj.region = data.get('regionName', '') - obj.city = data.get('city', '') - obj.lat = data.get('lat') - obj.lon = data.get('lon') - obj.isp = data.get('isp', '') - obj.save() - geo_cache[ip] = obj + # Upsert successful lookups. + for ip, data in results.items(): + obj, _ = IPGeoCache.objects.get_or_create(ip=ip) + obj.country = data.get('country', '') + obj.country_code = data.get('countryCode', '') + obj.region = data.get('regionName', '') + obj.city = data.get('city', '') + obj.lat = data.get('lat') + obj.lon = data.get('lon') + obj.isp = data.get('isp', '') + obj.save() + geo_cache[ip] = obj - # Save empty placeholder for every IP that failed — prevents - # future DB misses and stops the API from being retried. - failed = set(chunk) - set(results) - if failed: - IPGeoCache.objects.bulk_create( - [IPGeoCache(ip=ip) for ip in failed], - ignore_conflicts=True, - ) - _geo_skip.update(failed) - if len(_geo_skip) > _GEO_SKIP_MAX: - _geo_skip.clear() + # Save empty placeholder for every IP that failed — prevents + # future DB misses and stops the API from being retried. + failed = set(chunk) - set(results) + if failed: + IPGeoCache.objects.bulk_create( + [IPGeoCache(ip=ip) for ip in failed], + ignore_conflicts=True, + ) + _geo_skip.update(failed) + if len(_geo_skip) > _GEO_SKIP_MAX: + _geo_skip.clear() + + # Circuit-breaker tracking: a large chunk with zero results + # almost certainly means the API is unreachable. + if len(chunk) >= 5 and not results: + _geo_consecutive_misses += 1 + if _geo_consecutive_misses >= _GEO_MISS_THRESHOLD: + _geo_backoff_until = time.monotonic() + _GEO_BACKOFF_SECS + logger.warning( + 'routermon: ip-api.com unreachable (%d consecutive misses) — ' + 'backing off for %.0fs', + _geo_consecutive_misses, _GEO_BACKOFF_SECS, + ) + _geo_consecutive_misses = 0 + elif results: + _geo_consecutive_misses = 0 # Apply geo data to DNS records. for pk, ip in dns_pk_ip.items(): From 52c39ebdb159532a65b6e14b1533be626887d37d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 06:30:02 +0000 Subject: [PATCH 89/95] chore: update manifest image to 1.0.292 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 79aec9b..bedbc03 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.291" + image: "ghcr.io/wahyd4/links:1.0.292" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.291" + image: "ghcr.io/wahyd4/links:1.0.292" securityContext: runAsUser: 1000 imagePullPolicy: Always From 60dd5aeffb5f45324ca1ed06be7563f650432ac4 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 16:34:56 +1000 Subject: [PATCH 90/95] routermon: break chunk loop early when circuit breaker fires Stop sending further API requests mid-batch once the backoff is triggered, saving remaining missing IPs as empty placeholders immediately instead of waiting for them to timeout one-by-one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- routermon/receiver.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/routermon/receiver.py b/routermon/receiver.py index 060791a..3ffef31 100644 --- a/routermon/receiver.py +++ b/routermon/receiver.py @@ -423,6 +423,16 @@ def _geo_worker_loop(): _geo_consecutive_misses, _GEO_BACKOFF_SECS, ) _geo_consecutive_misses = 0 + # Save remaining uncached IPs and stop the loop early. + remaining = missing[i + 100:] + if remaining: + IPGeoCache.objects.bulk_create( + [IPGeoCache(ip=ip) for ip in remaining + if ip not in geo_cache], + ignore_conflicts=True, + ) + _geo_skip.update(remaining) + break elif results: _geo_consecutive_misses = 0 From c66295cb8763be7f8098ca072488adc69639bd98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 06:39:42 +0000 Subject: [PATCH 91/95] chore: update manifest image to 1.0.293 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index bedbc03..ae6ff38 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.292" + image: "ghcr.io/wahyd4/links:1.0.293" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.292" + image: "ghcr.io/wahyd4/links:1.0.293" securityContext: runAsUser: 1000 imagePullPolicy: Always From 3955e95b9ef2e702ae9903cb64c8581120b0909c Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 16:42:52 +1000 Subject: [PATCH 92/95] geo: replace ip-api.com with local DB-IP City Lite MMDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite nginxmon/geo.py to use maxminddb + DB-IP City Lite MMDB (https://cdn.jsdelivr.net/npm/dbip-city-lite/dbip-city-lite.mmdb.gz) instead of ip-api.com HTTP API — eliminates all network timeouts and rate-limit issues; lookups are now sub-millisecond in-process - Keep _lookup_batch() signature unchanged so routermon receiver requires no changes - MMDB downloaded on first use (lazy) and refreshed monthly via APScheduler; reader cached module-level (thread-safe for reads) - Add management command: manage.py download_geo_db - Update k8s init container to download MMDB on first deploy if absent - Add maxminddb==3.1.1 dependency CC BY 4.0 — DB-IP (https://db-ip.com) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/apps.py | 13 ++ k8s/manifest.yaml | 2 +- nginxmon/geo.py | 134 +++++++++++++++--- .../management/commands/download_geo_db.py | 11 ++ pyproject.toml | 1 + uv.lock | 58 ++++++++ 6 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 nginxmon/management/commands/download_geo_db.py diff --git a/core/apps.py b/core/apps.py index 6c6b5ca..c6148b5 100644 --- a/core/apps.py +++ b/core/apps.py @@ -71,6 +71,19 @@ class CoreConfig(AppConfig): ) logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") + # ── nginxmon: monthly geo DB refresh ────────────────────────────────── + try: + from nginxmon.geo import download_geo_db as refresh_geo_db + scheduler.add_job( + refresh_geo_db, + trigger=IntervalTrigger(days=30), + id='refresh_geo_db', + replace_existing=True, + ) + logger.info('nginxmon: scheduled geo DB refresh (every 30 days)') + except Exception as exc: + logger.warning('nginxmon: geo DB scheduler setup failed: %s', exc) + # ── routermon ────────────────────────────────────────────────────────── try: from routermon.tasks import cleanup_old_queries diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index ae6ff38..0e27bb2 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -106,7 +106,7 @@ spec: initContainers: - name: links-init image: "ghcr.io/wahyd4/links:1.0.293" - command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] + command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index && (test -f /app/data/dbip-city-lite.mmdb || uv run manage.py download_geo_db)"] volumeMounts: - name: data mountPath: /app/data diff --git a/nginxmon/geo.py b/nginxmon/geo.py index 336de51..7819b91 100644 --- a/nginxmon/geo.py +++ b/nginxmon/geo.py @@ -1,9 +1,19 @@ """ -Geo-IP enrichment using ip-api.com batch endpoint (free, no key required). -Results are cached in IPGeoCache to minimise outbound requests. +Geo-IP enrichment using a local DB-IP City Lite MMDB database. + +The MMDB file is downloaded from jsDelivr on first use and refreshed +monthly via an APScheduler job. All lookups are in-process (no network +round-trips after the initial download), so there are no timeouts or +rate-limit concerns. Results are still cached in IPGeoCache so the +routermon receiver and other callers only do MMDB lookups for IPs that +aren't already in the database. """ +import gzip import ipaddress import logging +import os +import shutil +import threading import requests @@ -11,8 +21,9 @@ from .models import IPGeoCache, NginxAccessLog logger = logging.getLogger(__name__) -_BATCH_URL = 'http://ip-api.com/batch' -_BATCH_FIELDS = 'country,countryCode,regionName,city,lat,lon,isp,status,query' +# DB-IP City Lite MMDB — CC BY 4.0, ~19 MB compressed, ~125 MB on disk. +_DB_URL = 'https://cdn.jsdelivr.net/npm/dbip-city-lite/dbip-city-lite.mmdb.gz' + _PRIVATE_RANGES = ( ipaddress.ip_network('10.0.0.0/8'), ipaddress.ip_network('172.16.0.0/12'), @@ -22,6 +33,72 @@ _PRIVATE_RANGES = ( ipaddress.ip_network('fc00::/7'), ) +# Module-level reader cache — maxminddb readers are thread-safe for reads. +_reader = None +_reader_lock = threading.Lock() + + +def _db_path() -> str: + from django.conf import settings + return os.path.join(str(settings.BASE_DIR), 'data', 'dbip-city-lite.mmdb') + + +def _get_reader(): + """Return the cached MMDB reader, downloading the DB if not yet present.""" + global _reader + if _reader is not None: + return _reader + with _reader_lock: + if _reader is not None: + return _reader + path = _db_path() + if not os.path.exists(path): + logger.info('nginxmon: geo MMDB not found — downloading now') + try: + _download(path) + except Exception as exc: + logger.warning('nginxmon: geo MMDB download failed: %s', exc) + return None + try: + import maxminddb + _reader = maxminddb.open_database(path) + logger.info('nginxmon: geo MMDB loaded (%s)', path) + except Exception as exc: + logger.warning('nginxmon: failed to open geo MMDB: %s', exc) + return _reader + + +def _reload_reader(): + """Invalidate the cached reader so it is reopened on next use.""" + global _reader + with _reader_lock: + if _reader is not None: + try: + _reader.close() + except Exception: + pass + _reader = None + + +def _download(dest: str): + """Download and decompress the MMDB gzip from jsDelivr.""" + os.makedirs(os.path.dirname(dest), exist_ok=True) + tmp = dest + '.tmp' + logger.info('nginxmon: fetching %s', _DB_URL) + with requests.get(_DB_URL, stream=True, timeout=120) as resp: + resp.raise_for_status() + resp.raw.decode_content = False # keep raw compressed bytes + with gzip.GzipFile(fileobj=resp.raw) as gz, open(tmp, 'wb') as out: + shutil.copyfileobj(gz, out) + os.replace(tmp, dest) + logger.info('nginxmon: geo MMDB saved to %s', dest) + + +def download_geo_db(): + """Public entry point: download/refresh the MMDB and reload the reader.""" + _download(_db_path()) + _reload_reader() + def _is_private(ip: str) -> bool: try: @@ -31,21 +108,44 @@ def _is_private(ip: str) -> bool: return False -def _lookup_batch(ips: list[str]) -> dict[str, dict]: - """Query ip-api.com for up to 100 IPs. Returns {ip: geo_dict}.""" - payload = [{'query': ip, 'fields': _BATCH_FIELDS} for ip in ips[:100]] +def _lookup_ip(ip: str) -> dict | None: + """Return a geo dict for a single IP, or None if unavailable.""" + reader = _get_reader() + if reader is None: + return None try: - resp = requests.post(_BATCH_URL, json=payload, timeout=10) - resp.raise_for_status() - results = {} - for item in resp.json(): - q = item.get('query', '') - if item.get('status') == 'success': - results[q] = item - return results + rec = reader.get(ip) + if rec is None: + return None + country = rec.get('country') or {} + city_rec = rec.get('city') or {} + subdivisions = rec.get('subdivisions') or [] + location = rec.get('location') or {} + return { + 'country': (country.get('names') or {}).get('en', ''), + 'countryCode': country.get('iso_code', ''), + 'regionName': ((subdivisions[0].get('names') or {}).get('en', '') + if subdivisions else ''), + 'city': (city_rec.get('names') or {}).get('en', ''), + 'lat': location.get('latitude'), + 'lon': location.get('longitude'), + 'isp': '', + 'status': 'success', + 'query': ip, + } except Exception as exc: - logger.warning('ip-api.com batch lookup failed: %s', exc) - return {} + logger.debug('nginxmon: MMDB lookup error for %s: %s', ip, exc) + return None + + +def _lookup_batch(ips: list[str]) -> dict[str, dict]: + """Look up IPs in the local MMDB. Returns {ip: geo_dict} (same interface as before).""" + results = {} + for ip in ips: + data = _lookup_ip(ip) + if data: + results[ip] = data + return results def enrich_geo_batch(log_objs: list[NginxAccessLog]): diff --git a/nginxmon/management/commands/download_geo_db.py b/nginxmon/management/commands/download_geo_db.py new file mode 100644 index 0000000..cfba7ef --- /dev/null +++ b/nginxmon/management/commands/download_geo_db.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Download or refresh the DB-IP City Lite geo MMDB database' + + def handle(self, *args, **options): + from nginxmon.geo import download_geo_db + self.stdout.write('Downloading geo database...') + download_geo_db() + self.stdout.write(self.style.SUCCESS('Geo database downloaded successfully')) diff --git a/pyproject.toml b/pyproject.toml index 7b3b74c..797e426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "qdrant-client>=1.13.2", "cryptography>=42.0.0", "kubernetes>=29.0.0", + "maxminddb>=3.1.1", ] [build-system] diff --git a/uv.lock b/uv.lock index 8c95cba..d4c63fc 100644 --- a/uv.lock +++ b/uv.lock @@ -638,6 +638,7 @@ dependencies = [ { name = "gunicorn" }, { name = "kubernetes" }, { name = "markdown" }, + { name = "maxminddb" }, { name = "pillow" }, { name = "playwright" }, { name = "python-magic" }, @@ -683,6 +684,7 @@ requires-dist = [ { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "kubernetes", specifier = ">=29.0.0" }, { name = "markdown", specifier = "==3.8.1" }, + { name = "maxminddb", specifier = ">=3.1.1" }, { name = "pillow", specifier = "~=12.2.0" }, { name = "playwright", specifier = ">=1.40.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -711,6 +713,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/34/3d1ff0cb4843a33817d06800e9383a2b2a2df4d508e37f53a40e829905d9/markdown-3.8.1-py3-none-any.whl", hash = "sha256:46cc0c0f1e5211ab2e9d453582f0b28a1bfaf058a9f7d5c50386b99b588d8811", size = 106642, upload-time = "2025-06-18T14:50:48.52Z" }, ] +[[package]] +name = "maxminddb" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/83/bcd7f2e7dfcf601258a4eab92155816218e8f8adf6608d5f7d39da7ba863/maxminddb-3.1.1.tar.gz", hash = "sha256:b19a938c481518f19a2c534ffdcb3bc59582f0fbbdcf9f81ac9adf912a0af686", size = 212410, upload-time = "2026-03-05T18:14:19.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/32/331c6c0ce56aacee7f71b9b7ef2438ae74b2d788cd56d4a58cd3be3e6bf8/maxminddb-3.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ae6489a1b7fa4ab9b6ac5979d1eec1eed7cb7ef2f73777ddbb8fb8b9bec094e3", size = 54257, upload-time = "2026-03-05T18:13:10.656Z" }, + { url = "https://files.pythonhosted.org/packages/69/6d/4fc324d46b764e870847fc50d7e3b0154dbf165d04d121653066069e3d2c/maxminddb-3.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f41a51bce83b5bbe4dc31b080787b7d4d83d8efa98778eb6f81df3ad9e98734", size = 36314, upload-time = "2026-03-05T18:13:11.75Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/436054930ccd384f2f6e17aa7689207e9334abbaed63bb573d76dbe0ee8f/maxminddb-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9cd4c05d08c22796e83aa54c70feb64121b3eae7257af35fbaced9f5d8d2081", size = 36105, upload-time = "2026-03-05T18:13:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/40/a2/bbded436f06c38716163f87d7d92a62f7d305d2f9f7e2e4155f8749a9f1e/maxminddb-3.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c4a402180154393c9c2502c7704b10a32a065661cd84196bbe7ac56869c6a82", size = 103427, upload-time = "2026-03-05T18:13:13.981Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/8464f1d29007736cfe1e0aa2dd1f3a36f5d7020abb9f14269b614a3f3ae1/maxminddb-3.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14d8b40d8e9b288cee18b8d80a7ba2a28211ce07b9c0e6ce721c5e685e3bf23c", size = 101252, upload-time = "2026-03-05T18:13:15.064Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9d/d3c95b64c05e90091ddef22a7a1bcdabe998f36b4a9f4b814382f712d83f/maxminddb-3.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eb2644548114b22d5808972d6b2b77d4c62084966b9a6be3853cd173ff745d5", size = 100587, upload-time = "2026-03-05T18:13:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0a/7e078abe41896d771e2917bc390fcd13186cd9b1b4ef8b451019f1fc342a/maxminddb-3.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a529873e376ada254c68a54d3ad13c8265eeedc5c56bdfdf25f1044b7f4177b", size = 99126, upload-time = "2026-03-05T18:13:18.001Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/f07411f340b70374d1b76b710b059cfc9874e22f596df3f20d26ebbece5b/maxminddb-3.1.1-cp312-cp312-win32.whl", hash = "sha256:9d98641b111eecc047b560d927379dd044bb36c0e399ee794e865b75ee8ef27a", size = 35632, upload-time = "2026-03-05T18:13:20.322Z" }, + { url = "https://files.pythonhosted.org/packages/a3/cc/1e42136d8416bbcaa81cdfdb26ec75263f106c0c34bf357ca98eebc394d0/maxminddb-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1ad6d3790ca4b2936f3e4ea971ef8383480fa069ed8ea4e5e6345f049d0e9f7", size = 37332, upload-time = "2026-03-05T18:13:21.363Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ed/f3a6b030eef252d4eaa811c87ad3a1d44376de581b11be8246fda1fc3716/maxminddb-3.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:c295f90ce99ce434d6a8477bd94ba4869ca04fe617ac99cea7548f0f6a3e4cd8", size = 34231, upload-time = "2026-03-05T18:13:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/a26cfdb9feed1ec0e66d5adeb37ff1f6bbcf3bb6c26870ec6a4554cacb93/maxminddb-3.1.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:90df0298f6713711ecba893f16ca31c486014e6b1c86611660426e5537cbbe14", size = 37596, upload-time = "2026-03-05T18:13:23.611Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/0bf2b7f2443b71a1c90d9d0772cecd43af392b0d285b20ddae9de9bd57da/maxminddb-3.1.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:42c8dc32b09e5f7c8e2e3d354e5bf48d56bf6c12099dd02df1ceca3f13762d97", size = 38079, upload-time = "2026-03-05T18:13:24.542Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cd/a05c211da1c29eaa00f971e30338823872dccf7b6c4935fc71b8d12d6b34/maxminddb-3.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:13a6dba0e696e904773cd9c17a290dcab9ba4a26128811e087c46c2c2f700c13", size = 35280, upload-time = "2026-03-05T18:13:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/0f4b34d9d9c5b79712d9616b00f3a683489a0b80ac2b61cb492d7d432977/maxminddb-3.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4d72b373930d95ac00db20a49b43eaac707cc633247f29a4c9a24000187505e9", size = 35823, upload-time = "2026-03-05T18:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/69/af/509eb9eeb119019f7f86a9e5bfdc1064e9bca590a07808adf9d4152221a6/maxminddb-3.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afe667a377121a5234778ed0affb9b26bf9d23a0c50551541cc9e38e1c1d1400", size = 54236, upload-time = "2026-03-05T18:13:27.748Z" }, + { url = "https://files.pythonhosted.org/packages/91/6e/09949370a413a7b5b791e8784f527bfa216539451c8c9b801ff06e61effe/maxminddb-3.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9a297da0042877a1eef457e238aa4df1707eb7e254aa96ecb1e17e935939a670", size = 36308, upload-time = "2026-03-05T18:13:28.773Z" }, + { url = "https://files.pythonhosted.org/packages/8a/01/0bff084d31b4441ec00e8ec84fa961efe5dffd3359d5318a557db8302a09/maxminddb-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5c31f5a1e388847b642d8e1b375abfb7b327d51cfd85e9c9f938a3258df7369", size = 36051, upload-time = "2026-03-05T18:13:30.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/31/136f4afed0202d4dbc114668068ba6ef99ab4d5cb3860e8bfc49208965a5/maxminddb-3.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7060e43d0788259b3a9bcc3d604360ebd7b17915300c97f8e254faeb27a70c34", size = 103470, upload-time = "2026-03-05T18:13:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/75/1a/2593692d543498959b2836028ff26c36439343a2122f31a71202072f4e62/maxminddb-3.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb195714caeb419b9a33b07b0b721d620c770210dd955018453fc588a4b7e42", size = 101290, upload-time = "2026-03-05T18:13:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/d8/48/883b8961045ea624ac26ed04896bb7e0ccf911488695508bc20bf5cbe1ab/maxminddb-3.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b42f18cf17dfb3ff52ef8c97c41508d45cf23293d04779dce0fe2ca6f146e78", size = 100617, upload-time = "2026-03-05T18:13:33.74Z" }, + { url = "https://files.pythonhosted.org/packages/58/0a/bc52b27699601c235ccb973e5fbb0426520c8780f0404dd98e4d09b1ff88/maxminddb-3.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfcc3b074d4cfef4d15474368da70b47707df273cd89e1d6a92549f644852f5c", size = 99145, upload-time = "2026-03-05T18:13:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/95/2c/01a3c975622add0f6f04336b400fcf4270c7054c0a35d8622b6bbd4e091b/maxminddb-3.1.1-cp313-cp313-win32.whl", hash = "sha256:3c9d50b5964c00e998ba5fdbad95b62abdf0ed9da5a55eab173f89e38a285e47", size = 35626, upload-time = "2026-03-05T18:13:36.429Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/a64da9ae7dea91d24a12a5649bd62a0eda49ad5ecf184075947971e523ad/maxminddb-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:19be24c36219779e65be57897b36fea340223cafdf3b128f3249e8603be7744d", size = 37330, upload-time = "2026-03-05T18:13:37.355Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/0523374a421b9816da20de42256c7c11e43ce0663decb8b675cf3c0f4561/maxminddb-3.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:6d01a791db367768aa2e15b9c2df5bb4a8d8c11713d872dec5f33dd3e818af26", size = 34222, upload-time = "2026-03-05T18:13:38.355Z" }, + { url = "https://files.pythonhosted.org/packages/23/f1/b5069070975602ad14e7898b883dda0b0785dab3c24f5750f5b7fa4e14c3/maxminddb-3.1.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:b2f859ff9ab56b8ce1c2033fdd978273d432ef1b03fbba24dd28d284bc9e2b41", size = 37401, upload-time = "2026-03-05T18:13:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/85/b3/1816167dbf9e1373f32548d26c45a74215750680e6a61943355e0d2d157a/maxminddb-3.1.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:40116947ad235692dff2f1590269a844d8623036caf99310a0ea6833b04673ca", size = 37923, upload-time = "2026-03-05T18:13:41.153Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/6e3a1ea6586fa49f2c438dd48ef9f7e67352729583f9ac6552f0d7d110ad/maxminddb-3.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b83155134842f6dcd06f9ba9b661028a2154debc41bfc6b8d665d67267891153", size = 35248, upload-time = "2026-03-05T18:13:42.122Z" }, + { url = "https://files.pythonhosted.org/packages/75/3c/bab279d11b8225e283175be8f6250366d362f131e282723946052c09cc2a/maxminddb-3.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:c2aa82dbb882714071403fe19b301a87a750eb8b58af60fd794afebe9447c0fb", size = 35798, upload-time = "2026-03-05T18:13:43.482Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e2/95baf6f117f9bc35de7c83b061b3fd27a49d03e0cda6c4dc5432167ac06e/maxminddb-3.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:65cbeaf809aaeb464e6c89086c45e0e4b9f15b73a28825fea0c570cf6fae18b6", size = 54226, upload-time = "2026-03-05T18:13:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c7/bdcf8ca67c7d93007a4ed512cec61a0e13b43cf9abbb9dfdacd458dff049/maxminddb-3.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ebb79b06f94577c37206a2143e157f4f8e6baf4f4473734b16993e04e0b1fdd7", size = 36368, upload-time = "2026-03-05T18:13:45.778Z" }, + { url = "https://files.pythonhosted.org/packages/da/71/a6cba811aa0ce539dff57400435643b2c05e607563f412c9138f50f14d34/maxminddb-3.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fac648527d83c4357f8f060f362a3c24f3aeb94bd458e2221522b7783dac5235", size = 36022, upload-time = "2026-03-05T18:13:47.02Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/c315b8e072663f77f928996fa6b8a084660d4130e00f092943fa9c892016/maxminddb-3.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c57d402cffae339e07224d04315ec1fd923a113dce5a9b2bf5894df8bdcfa", size = 103242, upload-time = "2026-03-05T18:13:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/d3/13/7914f150c633e8acbbcde6e37b58a280f79888d5668ae5a50bd3846fde2b/maxminddb-3.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bf4df891b71bb30ef0583effb0e694f610649ace69212def73dd20cc4ae8038", size = 101080, upload-time = "2026-03-05T18:13:49.127Z" }, + { url = "https://files.pythonhosted.org/packages/92/a5/d856e34333b80594443a82a384eb383a64c06dd7047a81efb42d129a6412/maxminddb-3.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5fa302b97d205d32e950bb38907a253a76d8a0091f2db5921e6561dbfa8febd1", size = 100611, upload-time = "2026-03-05T18:13:50.667Z" }, + { url = "https://files.pythonhosted.org/packages/19/56/bdc5bb7cdac55895aa69b76c4daa39c72c0bb9840439f08ba21e5b9f24c8/maxminddb-3.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3fa33c7e220106a3b9b24e5046c9573079730b62cad61b70897768f319d84323", size = 98959, upload-time = "2026-03-05T18:13:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/26/ff/ef98fea99940ef8fc9e55607fcf1afbf37774a6e01815837f735427b29d7/maxminddb-3.1.1-cp314-cp314-win32.whl", hash = "sha256:16fa02b016f8d12e9d78a610a3abfe98510c7db2592cfebaaeb768067c10448f", size = 36291, upload-time = "2026-03-05T18:13:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/36/f8/2866267b7728d6877930a22de42e771010f94e5832432a71c1bb0ce1c2e3/maxminddb-3.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:0fa73771e95a1fc4a2c5f3530191473da9eb39ec8301999bd18d9ac6a9becb79", size = 38050, upload-time = "2026-03-05T18:13:54.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/82/5b77a81e7ba8c8c83df1daa6cb701490589f5a17238a716d180a105ddf6d/maxminddb-3.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ad144247e94aca2e6f51408ce24ef9184f6bf440affce61090578382cdf69ffc", size = 34785, upload-time = "2026-03-05T18:13:56.232Z" }, + { url = "https://files.pythonhosted.org/packages/03/ef/72d6ef4f7acc428b8a8f3910f1acaacc33fcd55cd339029c33a234b6211f/maxminddb-3.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5334cee936368d43f7d99028bb37c864e8862465c6eac838e145d995e30707ec", size = 58136, upload-time = "2026-03-05T18:13:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a7/9f925bc30b77107b32cad2cab23aa5c459544079de62d6bcd3558dfe9a90/maxminddb-3.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff7d739fbefe2529b76dce0362a165795e369ca2d79e04882b42035ab2c16e44", size = 38443, upload-time = "2026-03-05T18:13:58.229Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/80c53840f598d77c01b94c718cc9b66658d677cf09b41f84d92aa04d0f81/maxminddb-3.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5d0709dae06d6f242acc1dd02495b6e1668ea898e22431865f8afc95aa4e7d0", size = 37937, upload-time = "2026-03-05T18:13:59.495Z" }, + { url = "https://files.pythonhosted.org/packages/4f/25/6a6f9aaf1af7c8ba83ab84792a7999a442b260bdadd84687800fde56fc4a/maxminddb-3.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e61dca3fd6709817762940f25fc86279957883a00c409612893a168974aba9f1", size = 120046, upload-time = "2026-03-05T18:14:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/e9/30/789efe80b43611cafa21bc130ae21c1b09ce7582c52a71d0f393ec69e868/maxminddb-3.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfb73a59ab7a3ccce1b00f048fdab982303253da26324943c831dd5d0f6db99b", size = 116454, upload-time = "2026-03-05T18:14:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/81/e2/da72e8a106539b40777ea0c991a1f5b896fe1374fce5d6a0d16977d49a1a/maxminddb-3.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f7e7a0accc4011fd0528c9755010c9d4be06ee116cc0c2aff0ce0f63f792cb41", size = 116395, upload-time = "2026-03-05T18:14:02.689Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6d/e18a61f2c2ab08d92801525468be807c5e2f1fed5b817cd35d1c535669db/maxminddb-3.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3f5032043db990f159b0e8e2747468f4665a3b5b345e343f620fe71c91fb2631", size = 113548, upload-time = "2026-03-05T18:14:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/04/ba/015d8608e8ab108b6c5e8c252f51155385a70224f439e898ae01cd3aeb67/maxminddb-3.1.1-cp314-cp314t-win32.whl", hash = "sha256:962edb5f23f9e8dfbdfc775d9e452e4017adae2fb12d1b0a955dbddb7747e781", size = 37499, upload-time = "2026-03-05T18:14:04.823Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/8da91c3a89530209057e27f314859e17f8d93244b73945d01036e89cf819/maxminddb-3.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6879a4d822b894d1717aeef20b3558fdf1d1f6cf1ce25a7ad46d0a43ed745f63", size = 39557, upload-time = "2026-03-05T18:14:06.334Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/36c6010a302f822d054fed7ed7009d39dd0ed662c17a01e36dce956787ca/maxminddb-3.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9cc4eb1d2648c0188d7649babe8df1236d22972a753676db3b7487646a65b0c7", size = 35396, upload-time = "2026-03-05T18:14:07.281Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" From f8a341f9322e40f22e59bbc29a6671c0534701da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 06:49:42 +0000 Subject: [PATCH 93/95] chore: update manifest image to 1.0.294 [skip ci] --- k8s/manifest.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 0e27bb2..b187d8b 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.293" - command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index && (test -f /app/data/dbip-city-lite.mmdb || uv run manage.py download_geo_db)"] + image: "ghcr.io/wahyd4/links:1.0.294" + command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.293" + image: "ghcr.io/wahyd4/links:1.0.294" securityContext: runAsUser: 1000 imagePullPolicy: Always From 11fa2ad673b1bbc61ed5b0d474aec5167539d10e Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Apr 2026 17:06:55 +1000 Subject: [PATCH 94/95] routermon: replace ISP column with Target Service badge in WAN table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove ISP column (always empty with local MMDB — no ISP data) - Add Target Service column with color-coded badges (SSH/RDP/VNC/SMB=red, HTTP/HTTPS=blue, MySQL/PostgreSQL/Redis/MongoDB=purple, SMTP/POP3/IMAP=green, DNS=yellow, FTP=orange) - Restore inline service label in Dst Port cell (e.g. '22 SSH') - Expand port mapping: 21 ports covered (FTP, Telnet, SMTP, DNS, POP3, IMAP, SMB, MSSQL, MySQL, RDP, PostgreSQL, VNC, Redis, HTTP-alt, HTTPS-alt, Elasticsearch, MongoDB) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- routermon/templates/routermon/_live_wan.html | 45 ++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/routermon/templates/routermon/_live_wan.html b/routermon/templates/routermon/_live_wan.html index 9131b61..b0fa894 100644 --- a/routermon/templates/routermon/_live_wan.html +++ b/routermon/templates/routermon/_live_wan.html @@ -7,7 +7,7 @@ Dst Port Src Port Geo - ISP + Target Service @@ -26,12 +26,26 @@ {% with p=ev.dst_port %} - {% if p == 22 %}SSH + {% if p == 21 %}FTP + {% elif p == 22 %}SSH {% elif p == 23 %}Telnet + {% elif p == 25 %}SMTP + {% elif p == 53 %}DNS {% elif p == 80 %}HTTP + {% elif p == 110 %}POP3 + {% elif p == 143 %}IMAP {% elif p == 443 %}HTTPS + {% elif p == 445 %}SMB + {% elif p == 1433 %}MSSQL + {% elif p == 3306 %}MySQL {% elif p == 3389 %}RDP + {% elif p == 5432 %}PostgreSQL + {% elif p == 5900 %}VNC + {% elif p == 6379 %}Redis {% elif p == 8080 %}HTTP-alt + {% elif p == 8443 %}HTTPS-alt + {% elif p == 9200 %}Elastic + {% elif p == 27017 %}MongoDB {% endif %} {% endwith %} {% else %} @@ -46,7 +60,32 @@ {% endif %} - {{ ev.isp|default:"—" }} + + {% with p=ev.dst_port %} + {% if p == 21 %}FTP + {% elif p == 22 %}SSH + {% elif p == 23 %}Telnet + {% elif p == 25 %}SMTP + {% elif p == 53 %}DNS + {% elif p == 80 %}HTTP + {% elif p == 110 %}POP3 + {% elif p == 143 %}IMAP + {% elif p == 443 %}HTTPS + {% elif p == 445 %}SMB + {% elif p == 1433 %}MSSQL + {% elif p == 3306 %}MySQL + {% elif p == 3389 %}RDP + {% elif p == 5432 %}PostgreSQL + {% elif p == 5900 %}VNC + {% elif p == 6379 %}Redis + {% elif p == 8080 %}HTTP-alt + {% elif p == 8443 %}HTTPS-alt + {% elif p == 9200 %}Elastic + {% elif p == 27017 %}MongoDB + {% else %} + {% endif %} + {% endwith %} + {% empty %} From 0b9534668e7698ca414a06e52f39d365ff8b33fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 07:10:56 +0000 Subject: [PATCH 95/95] chore: update manifest image to 1.0.296 [skip ci] --- k8s/manifest.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index b187d8b..4000181 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -105,14 +105,14 @@ spec: emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:1.0.294" + image: "ghcr.io/wahyd4/links:1.0.296" command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:1.0.294" + image: "ghcr.io/wahyd4/links:1.0.296" securityContext: runAsUser: 1000 imagePullPolicy: Always