mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Rebase
This commit is contained in:
@@ -3,6 +3,11 @@ name: Build and Deploy to Kubernetes
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- 'k8s/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'k8s/**'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -10,18 +15,43 @@ env:
|
||||
K8S_NAMESPACE: apps
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
|
||||
- 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
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -29,13 +59,17 @@ 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 }}
|
||||
|
||||
- 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@v3
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -45,14 +79,16 @@ 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
|
||||
env:
|
||||
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
|
||||
# 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
|
||||
git commit -m "chore: update manifest image to 1.0.${{ github.run_number }} [skip ci]"
|
||||
git push
|
||||
|
||||
@@ -101,3 +101,6 @@ DerivedData/
|
||||
# Swift Package Manager
|
||||
.swiftpm/
|
||||
.build/
|
||||
data/db.sqlite3
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
+13
@@ -38,6 +38,9 @@ RUN uv sync --no-dev --frozen --no-install-project
|
||||
COPY manage.py ./
|
||||
COPY core/ ./core/
|
||||
COPY links/ ./links/
|
||||
COPY netscan/ ./netscan/
|
||||
COPY nginxmon/ ./nginxmon/
|
||||
COPY routermon/ ./routermon/
|
||||
COPY new_theme/ ./new_theme/
|
||||
COPY templates/ ./templates/
|
||||
COPY locale/ ./locale/
|
||||
@@ -72,6 +75,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
|
||||
@@ -80,6 +87,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
|
||||
@@ -132,8 +141,12 @@ 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 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/
|
||||
COPY --chown=appuser:appuser qdrant_sync.py ./
|
||||
|
||||
# Final cleanup
|
||||
|
||||
+429
@@ -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/<pk>/edit/` | ModelForm |
|
||||
| `ProfileDeleteView` | `/ui/netscan/profile/<pk>/delete/` | Confirm page |
|
||||
| `ScanRunListView` | `/ui/netscan/profile/<pk>/runs/` | Paginated history, status + severity count cols |
|
||||
| `ScanRunDetailView` | `/ui/netscan/run/<pk>/` | Findings grouped by severity, collapsible raw JSON |
|
||||
| `TriggerScanView` | `/ui/netscan/profile/<pk>/trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list |
|
||||
| `TestTelegramView` | `/ui/netscan/profile/<pk>/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/<int:pk>/edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'),
|
||||
path('profile/<int:pk>/delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'),
|
||||
path('profile/<int:pk>/runs/', ScanRunListView.as_view(), name='netscan-run-list'),
|
||||
path('profile/<int:pk>/trigger/', TriggerScanView.as_view(), name='netscan-trigger'),
|
||||
path('profile/<int:pk>/test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'),
|
||||
path('run/<int:pk>/', 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
|
||||
@@ -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/
|
||||
|
||||
@@ -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 <command>
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
+83
-9
@@ -16,18 +16,92 @@ 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,
|
||||
retry_stuck_image_imports, flush_click_buffer,
|
||||
)
|
||||
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=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
|
||||
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)")
|
||||
|
||||
# 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)")
|
||||
|
||||
# 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)")
|
||||
|
||||
# ── nginxmon: monthly geo DB refresh ──────────────────────────────────
|
||||
try:
|
||||
from nginxmon.geo import download_geo_db as refresh_geo_db
|
||||
scheduler.add_job(
|
||||
schedule_pending_pages,
|
||||
trigger=IntervalTrigger(seconds=120),
|
||||
id='schedule_pending_pages',
|
||||
replace_existing=True
|
||||
refresh_geo_db,
|
||||
trigger=IntervalTrigger(days=30),
|
||||
id='refresh_geo_db',
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("Scheduled periodic task: schedule_pending_pages")
|
||||
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
|
||||
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:
|
||||
import sys
|
||||
print(f'routermon: startup error (non-fatal): {exc}', file=sys.stderr, flush=True)
|
||||
logger.warning("routermon: startup error (non-fatal): %s", exc)
|
||||
|
||||
@@ -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', ''),
|
||||
}
|
||||
+41
-39
@@ -21,22 +21,13 @@ INSTALLED_APPS = [
|
||||
'simplemde',
|
||||
'markdown', # 只需要基本的markdown包
|
||||
'invest',
|
||||
'netscan',
|
||||
'nginxmon',
|
||||
'routermon',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'core.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
@@ -48,6 +39,7 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'core.context_processors.build_info',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -57,7 +49,8 @@ TEMPLATES = [
|
||||
|
||||
# 添加以下基本设置(如果尚未存在)
|
||||
SECRET_KEY = 'your-secret-key-here' # 请更改为一个安全的随机值
|
||||
DEBUG = True
|
||||
|
||||
DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# 数据库设置(使用默认的SQLite配置)
|
||||
@@ -114,6 +107,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://192.168.1.2: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...',
|
||||
@@ -129,6 +140,12 @@ 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'))
|
||||
IMAGES_FOLDER = os.environ.get('IMAGES_FOLDER', os.path.expanduser('~/Pictures'))
|
||||
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
@@ -150,15 +167,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',
|
||||
@@ -178,6 +186,7 @@ LOCALE_INDEPENDENT_PATHS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'core.middleware.CustomLocaleMiddleware', # 使用自定义中间件
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -191,6 +200,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,
|
||||
@@ -218,21 +229,12 @@ 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')
|
||||
|
||||
# For debugging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'links': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': True,
|
||||
},
|
||||
},
|
||||
# Build metadata (injected at Docker image build time)
|
||||
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
||||
BUILD_VERSION = os.environ.get('BUILD_VERSION', '')
|
||||
|
||||
LOGGING['loggers']['links'] = {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
}
|
||||
|
||||
+39
-1
@@ -3,15 +3,35 @@ 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 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
|
||||
path('api/invest/', include('invest.urls', namespace='invest-api')),
|
||||
path('api/invest/', include('invest.api_urls')),
|
||||
# Media files
|
||||
path('media/<path:path>', serve, {
|
||||
'document_root': settings.MEDIA_ROOT,
|
||||
@@ -28,6 +48,24 @@ urlpatterns = [
|
||||
path('custom/<slug:alias>/edit/', LinkUpdateView.as_view(), name='custom_link_update'),
|
||||
|
||||
path('invest/', include('invest.urls')),
|
||||
|
||||
# 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/<path:image_url> (also plural alias)
|
||||
path('import/images/<path:image_url>', import_image_view, name='import-image'),
|
||||
path('imports/images/<path:image_url>', import_image_view, name='imports-image'),
|
||||
|
||||
# Public file access — /public/files/{uuid}-{filename}
|
||||
re_path(
|
||||
r'^public/files/(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<filename>.+)$',
|
||||
PublicFileView.as_view(),
|
||||
name='public-file',
|
||||
),
|
||||
|
||||
# Include main app URLs with locale
|
||||
path('', include('links.urls')),
|
||||
]
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-18 04:07
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Portfolio',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('account_id', models.CharField(blank=True, max_length=50)),
|
||||
('base_currency', models.CharField(default='USD', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PriceCache',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticker', models.CharField(max_length=20)),
|
||||
('exchange', models.CharField(blank=True, default='', max_length=10)),
|
||||
('price', models.DecimalField(decimal_places=6, max_digits=20)),
|
||||
('currency', models.CharField(default='USD', max_length=10)),
|
||||
('change_percent', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
|
||||
('prev_close', models.DecimalField(blank=True, decimal_places=6, max_digits=20, null=True)),
|
||||
('fetched_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['ticker', 'exchange', 'fetched_at'], name='invest_pric_ticker_69ac3b_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Report',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('content', models.TextField()),
|
||||
('period_start', models.DateField()),
|
||||
('period_end', models.DateField()),
|
||||
('generated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('report_type', models.CharField(choices=[('WEEKLY', 'Weekly'), ('MANUAL', 'Manual')], default='WEEKLY', max_length=10)),
|
||||
('valuation_snapshot', models.JSONField(default=dict, help_text='Snapshot of prices and holdings at time of report generation')),
|
||||
('portfolio', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='invest.portfolio')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-generated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Stock',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticker', models.CharField(max_length=20)),
|
||||
('exchange', models.CharField(blank=True, default='', help_text='Exchange code, e.g. NASDAQ, HKG. Empty = US market default.', max_length=10)),
|
||||
('company_name', models.CharField(blank=True, max_length=200)),
|
||||
('shares_held', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('avg_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Weighted average cost per share in quote_currency', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('quote_currency', models.CharField(default='USD', max_length=10)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('portfolio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='invest.portfolio')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['ticker'],
|
||||
'unique_together': {('portfolio', 'ticker', 'exchange')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tx_type', models.CharField(choices=[('BUY', 'Buy'), ('SELL', 'Sell')], max_length=4)),
|
||||
('date', models.DateField()),
|
||||
('price_per_share', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('shares', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))])),
|
||||
('fee', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('source', models.CharField(default='manual', help_text="Origin of transaction: 'manual', 'ai', 'import'", max_length=20)),
|
||||
('idempotency_key', models.CharField(blank=True, help_text='Unique key to prevent duplicate AI writes', max_length=100, null=True, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('stock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='invest.stock')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['date', 'created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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 + stern nginx ingestion together
|
||||
dev:
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
source .venv/bin/activate
|
||||
PIDS=()
|
||||
cleanup() {
|
||||
echo "Stopping background processes…"
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
npm run dev &
|
||||
PIDS+=($!)
|
||||
|
||||
uv run manage.py nginxmon_stern &
|
||||
PIDS+=($!)
|
||||
|
||||
uv run manage.py runserver 0.0.0.0:8000
|
||||
|
||||
# Stream nginx logs via stern only (without starting Django)
|
||||
stern:
|
||||
#!/usr/bin/env bash
|
||||
source .venv/bin/activate
|
||||
uv run manage.py nginxmon_stern
|
||||
|
||||
# Run Tailwind CSS in watch mode (standalone)
|
||||
tailwind:
|
||||
npm run dev
|
||||
|
||||
# ── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Copy vendor JS/CSS from node_modules to static/vendor/
|
||||
vendor:
|
||||
node scripts/copy-vendor.js
|
||||
|
||||
# Build Tailwind CSS (minified, for production)
|
||||
build-css:
|
||||
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:
|
||||
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 }}
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
strategy:
|
||||
type: Recreate
|
||||
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
|
||||
- containerPort: 5514
|
||||
name: syslog-udp
|
||||
protocol: UDP
|
||||
hostPort: 5514
|
||||
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
|
||||
+58
-5
@@ -26,6 +26,35 @@ spec:
|
||||
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
|
||||
@@ -52,6 +81,8 @@ spec:
|
||||
matchLabels:
|
||||
app: links
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -59,6 +90,7 @@ spec:
|
||||
tags.datadoghq.com/env: "prod"
|
||||
tags.datadoghq.com/service: "links"
|
||||
spec:
|
||||
serviceAccountName: links
|
||||
volumes:
|
||||
- name: downloads
|
||||
persistentVolumeClaim:
|
||||
@@ -66,18 +98,21 @@ spec:
|
||||
- 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:main"
|
||||
command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py collectstatic --noinput && uv run manage.py rebuild_search_index"]
|
||||
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:main"
|
||||
image: "ghcr.io/wahyd4/links:1.0.296"
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
imagePullPolicy: Always
|
||||
@@ -88,9 +123,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
|
||||
@@ -122,6 +161,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
|
||||
@@ -136,13 +177,17 @@ spec:
|
||||
- containerPort: 8000
|
||||
name: links-port
|
||||
protocol: TCP
|
||||
- containerPort: 5514
|
||||
name: syslog-udp
|
||||
protocol: UDP
|
||||
hostPort: 5514
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 400Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1000Mi
|
||||
cpu: 1200m
|
||||
memory: 2Gi
|
||||
imagePullSecrets:
|
||||
- name: github-image-pull-secret
|
||||
---
|
||||
@@ -271,3 +316,11 @@ spec:
|
||||
name: links
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
# ServiceAccount for the links pod
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: links
|
||||
namespace: apps
|
||||
|
||||
@@ -3,12 +3,15 @@ from rest_framework.routers import DefaultRouter
|
||||
from . import page_views
|
||||
from . import post_views
|
||||
from . import api_views
|
||||
from . import file_views
|
||||
|
||||
# Create a router and register our viewsets with it
|
||||
router = DefaultRouter(trailing_slash=False)
|
||||
router.register('links', api_views.LinkViewSet, basename='api-links')
|
||||
router.register('pages', page_views.PageViewSet, basename='api-pages')
|
||||
router.register('posts', post_views.PostViewSet, basename='api-posts')
|
||||
router.register('music', api_views.MusicViewSet, basename='api-music')
|
||||
router.register('files', file_views.FileUploadViewSet, basename='api-files')
|
||||
|
||||
# The API URLs are determined automatically by the router
|
||||
urlpatterns = [
|
||||
|
||||
+28
-2
@@ -1,8 +1,9 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from .models import ImageCollection, Image
|
||||
from .serializers import ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .models import Link, ImageCollection, Image
|
||||
from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer
|
||||
from .storage import R2Storage
|
||||
import uuid
|
||||
import logging
|
||||
@@ -12,6 +13,31 @@ import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class LinkViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = LinkSerializer
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return Link.objects.prefetch_related('tags').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='most-visited')
|
||||
def most_visited(self, request):
|
||||
"""Return links sorted by click_count descending."""
|
||||
queryset = Link.objects.prefetch_related('tags').filter(click_count__gt=0).order_by('-click_count')
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class ImageCollectionViewSet(viewsets.ModelViewSet):
|
||||
queryset = ImageCollection.objects.all()
|
||||
serializer_class = ImageCollectionSerializer
|
||||
|
||||
+173
@@ -8,3 +8,176 @@ 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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.urls import path, re_path
|
||||
from . import file_views
|
||||
|
||||
urlpatterns = [
|
||||
path('', file_views.FileListView.as_view(), name='file-list'),
|
||||
path('upload/', file_views.FileUploadView.as_view(), name='file-upload'),
|
||||
# /ui/files/{uuid}-{filename} — filename is cosmetic, lookup is by uuid only
|
||||
re_path(
|
||||
r'^(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<filename>.+)$',
|
||||
file_views.FileDownloadView.as_view(),
|
||||
name='file-download',
|
||||
),
|
||||
path('<uuid:pk>/delete/', file_views.FileDeleteView.as_view(), name='file-delete'),
|
||||
path('<uuid:pk>/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'),
|
||||
path('<uuid:pk>/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'),
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
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, JSONParser
|
||||
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, JSONParser]
|
||||
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
|
||||
|
||||
|
||||
def import_image_view(request, image_url):
|
||||
"""Proxy-and-cache an external image via /import/images/<path:image_url>.
|
||||
|
||||
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.
|
||||
|
||||
If the background download fails, the stub record is deleted automatically so no
|
||||
size=0 ghost appears in the file list. The next visit to the same URL will create
|
||||
a fresh stub and retry. A periodic APScheduler task (`retry_stuck_image_imports`)
|
||||
also reschedules any stubs left behind by killed threads.
|
||||
"""
|
||||
import hashlib
|
||||
import posixpath
|
||||
from threading import Thread
|
||||
from .tasks import download_and_save_image
|
||||
|
||||
# Build the canonical source URL, preserving query string.
|
||||
# Django's <path:> 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
|
||||
_, 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)
|
||||
+7
-1
@@ -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}."))
|
||||
|
||||
|
||||
+1
-1
@@ -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'}
|
||||
|
||||
@@ -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 <path d="..."> 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)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-30 09:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0046_kg_progress_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='is_public',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Is Public'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,11 @@
|
||||
from django.views.generic import TemplateView
|
||||
from django.shortcuts import render
|
||||
from django.http import JsonResponse
|
||||
from django.views import View
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MiniAppsListView(TemplateView):
|
||||
@@ -34,6 +40,14 @@ class MiniAppsListView(TemplateView):
|
||||
'icon': 'fas fa-chart-line',
|
||||
'color': '#27ae60'
|
||||
},
|
||||
{
|
||||
'name': 'Nginx IP Ban',
|
||||
'description': 'Manage the nginx ingress block list — view, add, and remove banned IPs and CIDR ranges directly from the Kubernetes ConfigMap.',
|
||||
'url': 'mini-apps-ip-ban',
|
||||
'thumbnail': 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=400&h=300&fit=crop',
|
||||
'icon': 'fas fa-ban',
|
||||
'color': '#e74c3c'
|
||||
},
|
||||
{
|
||||
'name': 'Weather Dashboard',
|
||||
'description': 'Real-time weather information with beautiful visualizations and forecasts.',
|
||||
@@ -41,7 +55,7 @@ class MiniAppsListView(TemplateView):
|
||||
'thumbnail': 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=400&h=300&fit=crop',
|
||||
'icon': 'fas fa-cloud-sun',
|
||||
'color': '#e74c3c'
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
context['mini_apps'] = mini_apps
|
||||
@@ -73,3 +87,84 @@ class FIREPlanningView(TemplateView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['page_title'] = 'FIRE Planning Calculator'
|
||||
return context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nginx IP Ban mini app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONFIGMAP_NAME = 'ingress-nginx-controller'
|
||||
_CONFIGMAP_NS = 'ingress-nginx'
|
||||
_CONFIGMAP_KEY = 'block-cidrs-manual'
|
||||
|
||||
|
||||
def _k8s_v1():
|
||||
"""Return a CoreV1Api client, preferring in-cluster then local kubeconfig."""
|
||||
from kubernetes import client, config
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
except config.ConfigException:
|
||||
config.load_kube_config()
|
||||
return client.CoreV1Api()
|
||||
|
||||
|
||||
def _read_blocked_ips() -> list[str]:
|
||||
v1 = _k8s_v1()
|
||||
cm = v1.read_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS)
|
||||
raw = (cm.data or {}).get(_CONFIGMAP_KEY, '')
|
||||
return [ip.strip() for ip in raw.split(',') if ip.strip()]
|
||||
|
||||
|
||||
def _write_blocked_ips(ips: list[str]) -> None:
|
||||
v1 = _k8s_v1()
|
||||
from kubernetes.client import V1ConfigMap
|
||||
body = V1ConfigMap(data={_CONFIGMAP_KEY: ','.join(ips)})
|
||||
v1.patch_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS, body)
|
||||
|
||||
|
||||
class NginxIPBanView(View):
|
||||
template = 'links/mini_apps/ip_ban.html'
|
||||
|
||||
def get(self, request):
|
||||
error = None
|
||||
blocked = []
|
||||
try:
|
||||
blocked = _read_blocked_ips()
|
||||
except Exception as exc:
|
||||
logger.error('ip_ban: failed to read ConfigMap: %s', exc)
|
||||
error = str(exc)
|
||||
return render(request, self.template, {'blocked': blocked, 'error': error})
|
||||
|
||||
def post(self, request):
|
||||
action = request.POST.get('action')
|
||||
try:
|
||||
blocked = _read_blocked_ips()
|
||||
if action == 'add':
|
||||
raw = request.POST.get('ips', '')
|
||||
# Accept comma- or newline-separated entries
|
||||
new_ips = [
|
||||
ip.strip()
|
||||
for part in raw.replace('\n', ',').split(',')
|
||||
for ip in [part.strip()]
|
||||
if ip
|
||||
]
|
||||
added = 0
|
||||
for ip in new_ips:
|
||||
if ip not in blocked:
|
||||
blocked.append(ip)
|
||||
added += 1
|
||||
_write_blocked_ips(blocked)
|
||||
return JsonResponse({'ok': True, 'blocked': blocked, 'added': added})
|
||||
|
||||
elif action == 'remove':
|
||||
ip = request.POST.get('ip', '').strip()
|
||||
if ip in blocked:
|
||||
blocked.remove(ip)
|
||||
_write_blocked_ips(blocked)
|
||||
return JsonResponse({'ok': True, 'blocked': blocked})
|
||||
|
||||
return JsonResponse({'ok': False, 'error': 'Unknown action'}, status=400)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('ip_ban: action=%s error=%s', action, exc)
|
||||
return JsonResponse({'ok': False, 'error': str(exc)}, status=500)
|
||||
|
||||
+130
@@ -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)
|
||||
|
||||
@@ -271,6 +272,7 @@ class Post(models.Model):
|
||||
title = models.CharField(_('Title'), max_length=200)
|
||||
summary = models.TextField(_('Summary'), blank=True, help_text=_('A brief summary of the post'))
|
||||
content = models.TextField(_('Content'))
|
||||
is_public = models.BooleanField(_('Is Public'), default=False, db_index=True)
|
||||
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
|
||||
tags = models.ManyToManyField('Tag', blank=True, related_name='posts')
|
||||
@@ -338,3 +340,131 @@ 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)
|
||||
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)
|
||||
|
||||
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 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:
|
||||
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"
|
||||
|
||||
|
||||
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.'
|
||||
),
|
||||
)
|
||||
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.'
|
||||
),
|
||||
)
|
||||
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.'),
|
||||
)
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
+16
-4
@@ -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
|
||||
@@ -67,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'
|
||||
@@ -142,6 +148,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 +206,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({
|
||||
|
||||
+62
-8
@@ -1,12 +1,15 @@
|
||||
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
import json
|
||||
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.http import Http404
|
||||
from django.views import View
|
||||
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django.utils import timezone
|
||||
from datetime import datetime
|
||||
from .models import Post
|
||||
from .templatetags.tasklist_markdown import update_task_in_markdown
|
||||
@@ -20,6 +23,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')
|
||||
|
||||
@@ -71,7 +79,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)
|
||||
@@ -98,10 +108,35 @@ class PublicPostView(DetailView):
|
||||
return context
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
try:
|
||||
return super().get_object(queryset)
|
||||
except Http404:
|
||||
obj = super().get_object(queryset)
|
||||
if not obj.is_public:
|
||||
raise Http404("Post not found")
|
||||
return obj
|
||||
|
||||
|
||||
class PostShareView(View):
|
||||
"""Toggle public sharing for a post. POST body: {"enable": true|false}"""
|
||||
|
||||
def post(self, request, pk):
|
||||
try:
|
||||
post = Post.objects.get(pk=pk)
|
||||
except Post.DoesNotExist:
|
||||
return JsonResponse({'error': 'Not found'}, status=404)
|
||||
|
||||
try:
|
||||
body = json.loads(request.body or '{}')
|
||||
enable = bool(body.get('enable', True))
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
enable = True
|
||||
|
||||
post.is_public = enable
|
||||
post.save(update_fields=['is_public'])
|
||||
|
||||
public_url = (
|
||||
request.build_absolute_uri(reverse('public-post', args=[post.pk]))
|
||||
if post.is_public else None
|
||||
)
|
||||
return JsonResponse({'is_public': post.is_public, 'url': public_url})
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
@@ -131,6 +166,25 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def append_content(self, request, pk=None):
|
||||
"""
|
||||
Append markdown text to a post's existing content.
|
||||
|
||||
POST /api/posts/{id}/append_content/
|
||||
Body: {"content": "## New section\n\nSome text"}
|
||||
"""
|
||||
post = self.get_object()
|
||||
extra = request.data.get('content', '').strip()
|
||||
|
||||
if not extra:
|
||||
return Response({'error': 'content is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
post.content = post.content + '\n\n' + extra
|
||||
post.save(update_fields=['content', 'updated_at'])
|
||||
|
||||
return Response({'status': 'ok'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_task(self, request, pk=None):
|
||||
"""
|
||||
|
||||
+14
-2
@@ -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)
|
||||
|
||||
+49
-8
@@ -1,5 +1,18 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Page, Post, ImageCollection, Image, Tag
|
||||
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:
|
||||
@@ -12,24 +25,24 @@ class PageSerializer(serializers.ModelSerializer):
|
||||
class PostSerializer(serializers.ModelSerializer):
|
||||
tags = serializers.ListField(child=serializers.CharField(), required=False, write_only=True, help_text="List of tag slugs")
|
||||
tag_details = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = ['id', 'title', 'summary', 'content', 'tags', 'tag_details', 'created_at', 'updated_at']
|
||||
fields = ['id', 'title', 'summary', 'content', 'is_public', 'tags', 'tag_details', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
extra_kwargs = {
|
||||
'title': {'required': True},
|
||||
'content': {'required': True},
|
||||
'summary': {'required': False}
|
||||
}
|
||||
|
||||
|
||||
def get_tag_details(self, obj):
|
||||
return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()]
|
||||
|
||||
def create(self, validated_data):
|
||||
tag_slugs = validated_data.pop('tags', [])
|
||||
post = Post.objects.create(**validated_data)
|
||||
|
||||
|
||||
for tag_slug in tag_slugs:
|
||||
try:
|
||||
tag = Tag.objects.get(slug=tag_slug)
|
||||
@@ -37,14 +50,14 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
# If tag doesn't exist, create it with the slug as both name and slug
|
||||
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
|
||||
post.tags.add(tag)
|
||||
|
||||
|
||||
return post
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tag_slugs = validated_data.pop('tags', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
|
||||
if tag_slugs is not None:
|
||||
instance.tags.clear()
|
||||
for tag_slug in tag_slugs:
|
||||
@@ -54,7 +67,7 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
# If tag doesn't exist, create it with the slug as both name and slug
|
||||
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
|
||||
instance.tags.add(tag)
|
||||
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
@@ -83,3 +96,31 @@ 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):
|
||||
return obj.public_url
|
||||
|
||||
def get_is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
|
||||
+364
-78
@@ -1,4 +1,5 @@
|
||||
from django.utils import timezone
|
||||
from django.db.models import F
|
||||
from datetime import timedelta
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -54,60 +55,82 @@ 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:
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
job_timeout = SiteSettings.get().screenshot_job_timeout_seconds
|
||||
|
||||
screenshot.status = Screenshot.Status.PROCESSING
|
||||
screenshot.error = None
|
||||
screenshot.save()
|
||||
|
||||
# Create screenshots directory
|
||||
@@ -122,15 +145,22 @@ 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
|
||||
screenshot.status = Screenshot.Status.COMPLETED
|
||||
screenshot.save()
|
||||
|
||||
|
||||
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
|
||||
@@ -145,35 +175,55 @@ 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}")
|
||||
if retry_count < MAX_RETRIES:
|
||||
retry_delay = fibonacci(retry_count)
|
||||
logger.error(f"Failed to capture screenshot for page {page_id}: {exc}", exc_info=True)
|
||||
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.error = f"Error: {str(exc)}. Retrying in {retry_delay} seconds..."
|
||||
screenshot.status = Screenshot.Status.FAILED
|
||||
screenshot.error = f"Failed after {screenshot.retry_count} attempts: {str(exc)}"
|
||||
screenshot.save()
|
||||
except:
|
||||
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
|
||||
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, screenshot.retry_count],
|
||||
id=f'capture_screenshot_{screenshot_id}_retry_{screenshot.retry_count}',
|
||||
replace_existing=True
|
||||
)
|
||||
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}")
|
||||
try:
|
||||
screenshot.status = Screenshot.Status.FAILED
|
||||
screenshot.error = f"Scheduler error, giving up: {str(sched_exc)}"
|
||||
screenshot.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_page(page_id, retry_count=0):
|
||||
"""Task to process a page and extract its metadata"""
|
||||
@@ -232,6 +282,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()
|
||||
|
||||
@@ -242,7 +296,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
|
||||
@@ -277,48 +331,103 @@ 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 — up to
|
||||
the configured concurrency limit to prevent OOM.
|
||||
"""
|
||||
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 (slots available: {available_slots})")
|
||||
|
||||
for screenshot in stuck:
|
||||
logger.info(f"Retrying screenshot {screenshot.id} (page {screenshot.page_id}, attempt {screenshot.retry_count + 1}/{MAX_RETRIES})")
|
||||
screenshot.status = Screenshot.Status.PENDING
|
||||
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()
|
||||
|
||||
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)
|
||||
@@ -336,3 +445,180 @@ 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
|
||||
# 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 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: '<cache-prefix>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.
|
||||
|
||||
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()
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css">
|
||||
<!-- Dropzone (CDN — page-specific, benefits from edge proximity) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dropzone@6.0.0-beta.2/dist/dropzone.css">
|
||||
<style>
|
||||
.dropzone {
|
||||
border: 2px dashed #e5e7eb;
|
||||
@@ -187,7 +188,7 @@
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{% for tag in collection.tags.all %}
|
||||
{% with number=forloop.counter %}
|
||||
<a href="{% url 'tag-detail' tag.slug %}"
|
||||
<a href="{% url 'tag-detail' tag.slug %}"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
@@ -195,7 +196,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
@@ -257,7 +258,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</a>
|
||||
<button class="image-action" onclick="editDescription('{{ image.id }}', '{{ image.description|default:'' }}')" title="{% trans 'Edit Description' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
@@ -333,7 +334,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
|
||||
<!-- Dropzone (CDN — page-specific, benefits from edge proximity) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/dropzone@6.0.0-beta.2/dist/dropzone.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const colors = ['blue', 'green', 'yellow', 'red', 'indigo', 'purple', 'pink'];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
{# Select2 CSS now loaded globally from vendor in base.html #}
|
||||
<style>
|
||||
.select2-container--classic .select2-selection--multiple {
|
||||
border: 1px solid #d1d5db !important;
|
||||
@@ -118,8 +118,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
{# jQuery and Select2 are loaded globally from vendor in base.html #}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2 for tags
|
||||
|
||||
@@ -15,95 +15,10 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Collections Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 md:gap-8">
|
||||
{% for collection in collections %}
|
||||
<div class="group relative w-full">
|
||||
<!-- Fixed size container -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200 h-[280px] sm:h-[320px]">
|
||||
<a href="{% url 'collection-detail' collection.pk %}" class="block h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Image Preview Container - Fixed Height -->
|
||||
<div class="h-[160px] sm:h-[200px] bg-gradient-to-br from-gray-50 to-gray-100 p-2 sm:p-3">
|
||||
<div class="grid grid-cols-2 gap-1.5 sm:gap-2 h-full">
|
||||
{% with images=collection.images.all|slice:":4" %}
|
||||
{% for image in images %}
|
||||
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm
|
||||
{% if forloop.counter > 2 %}hidden sm:block{% endif %}">
|
||||
<img src="{{ image.get_thumbnail_url }}"
|
||||
alt="{{ image.title }}"
|
||||
class="object-cover w-full h-full">
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
|
||||
<svg class="w-8 h-8 sm:w-12 sm:h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-500">{% trans "No images yet" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection Info - Flex Grow to Fill Remaining Space -->
|
||||
<div class="flex-1 p-3 sm:p-4 flex flex-col">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ collection.name }}</h3>
|
||||
{% if collection.description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ collection.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Image Count -->
|
||||
<p class="mt-0.5 text-xs sm:text-sm text-gray-500">
|
||||
{{ collection.images.count }} {% trans "images" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="absolute top-1.5 sm:top-2 right-1.5 sm:right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<a href="{% url 'collection-update' collection.pk %}"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Edit Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Delete Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
|
||||
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
|
||||
<a href="{% url 'collection-create' %}"
|
||||
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
|
||||
{% trans "Create Collection" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div id="paginated-content">
|
||||
{% include "links/includes/collection_list_items.html" %}
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Add getCookie function
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Single Alpine scope wraps the entire page + HUD -->
|
||||
<div x-data="fileManager()"
|
||||
@open-expiry.window="openExpiry($event.detail)"
|
||||
@copy-link.window="copyAndToast($event.detail.url)">
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input type="file" id="globalFileInput" multiple class="hidden">
|
||||
|
||||
<!-- Full-page drag overlay -->
|
||||
<div id="dragOverlay"
|
||||
class="fixed inset-0 z-40 bg-red-50/80 border-4 border-dashed border-red-400 flex items-center justify-center pointer-events-none opacity-0 transition-opacity duration-150">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-red-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<p class="text-xl font-semibold text-red-600">{% trans "Drop files to upload" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{% trans "Files" %}</h1>
|
||||
<button @click="openPicker()"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
{% trans "Upload Files" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files Table -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-gray-700">
|
||||
{% trans "All Files" %} <span class="text-gray-400 font-normal">({{ files.count }})</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if files %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Name" %}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell">{% trans "Type" %}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Size" %}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">{% trans "Uploaded" %}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Public" %}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">{% trans "Downloads" %}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{% for file in files %}
|
||||
<tr class="hover:bg-gray-50"
|
||||
x-data="fileRow('{{ file.pk }}', {{ file.is_public|lower }}, '{{ file.expires_at|date:'c'|default:'' }}', '{{ file.public_url }}')">
|
||||
<!-- Name -->
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ file.download_url }}"
|
||||
class="font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center max-w-xs"
|
||||
@click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{% if file.is_image %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 2v12a2 2 0 002 2z"/>
|
||||
{% else %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
{% endif %}
|
||||
</svg>
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<!-- Type -->
|
||||
<td class="px-4 py-3 text-gray-500 hidden md:table-cell">{{ file.mime_type|truncatechars:30 }}</td>
|
||||
<!-- Size -->
|
||||
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">{{ file.formatted_size }}</td>
|
||||
<!-- Uploaded -->
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap hidden sm:table-cell">{{ file.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<!-- Public -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span x-show="isPublic"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer w-fit"
|
||||
@click="togglePublic()"
|
||||
title="{% trans 'Click to make private' %}">
|
||||
● {% trans "Public" %}
|
||||
</span>
|
||||
<span x-show="!isPublic"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 cursor-pointer w-fit"
|
||||
@click="togglePublic()"
|
||||
title="{% trans 'Click to make public' %}">
|
||||
○ {% trans "Private" %}
|
||||
</span>
|
||||
<span x-show="isPublic && expiresAt"
|
||||
:class="isExpired ? 'text-red-500' : 'text-gray-400'"
|
||||
class="text-xs cursor-pointer"
|
||||
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))"
|
||||
x-text="isExpired ? '⚠ Expired' : '⏱ ' + formatExpiry(expiresAt)">
|
||||
</span>
|
||||
<span x-show="isPublic && !expiresAt"
|
||||
class="text-xs text-gray-400 cursor-pointer"
|
||||
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))">
|
||||
{% trans "No expiry" %}
|
||||
</span>
|
||||
<span x-show="isPublic" class="text-xs">
|
||||
<button @click="copyPublicUrl()"
|
||||
class="text-blue-500 hover:text-blue-700 flex items-center">
|
||||
<svg class="w-3 h-3 mr-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% trans "Copy link" %}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Downloads -->
|
||||
<td class="px-4 py-3 text-gray-600 hidden sm:table-cell">{{ file.download_count }}</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-right relative">
|
||||
<div class="flex items-center justify-end space-x-1">
|
||||
<a href="{{ file.download_url }}"
|
||||
{% if file.is_image %}target="_blank"{% endif %}
|
||||
class="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="{% trans 'Download / View' %}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button x-show="isPublic"
|
||||
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))"
|
||||
class="p-1.5 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded"
|
||||
title="{% trans 'Set expiry' %}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<form method="post" action="{% url 'file-delete' file.pk %}"
|
||||
@submit.prevent="if(confirm('{% trans 'Delete this file?' %}')) $el.submit()">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="{% trans 'Delete' %}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="py-16 text-center text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium">{% trans "No files yet" %}</p>
|
||||
<p class="text-xs mt-1">{% trans "Drag files anywhere on this page, or click Upload Files above." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div><!-- /main content -->
|
||||
|
||||
<!-- ── Toast notification ──────────────────────────────────────── -->
|
||||
<div x-show="toastVisible"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1"
|
||||
style="position:fixed;top:5rem;right:1.5rem;z-index:9999;pointer-events:none;background:#111827;color:#fff;font-size:.875rem;padding:.625rem 1rem;border-radius:.5rem;box-shadow:0 10px 25px rgba(0,0,0,.4);display:flex;align-items:center;gap:.5rem;"
|
||||
<svg style="width:1rem;height:1rem;color:#4ade80;flex-shrink:0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span x-text="toastMsg"></span>
|
||||
</div>
|
||||
|
||||
<!-- ── Global Expiry Modal ─────────────────────────────────────── -->
|
||||
<div x-show="expiryModal.open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
style="position:fixed;inset:0;z-index:9999"
|
||||
class="bg-black/40 flex items-center justify-center p-4"
|
||||
@click.self="expiryModal.open = false">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-sm p-6"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">{% trans "Set Expiry Date" %}</h3>
|
||||
<button @click="expiryModal.open = false"
|
||||
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">{% trans "Leave blank for no expiry. The public link will stop working after this date." %}</p>
|
||||
<input type="datetime-local" x-model="expiryModal.expiryInput"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent">
|
||||
<div class="flex justify-between items-center mt-5">
|
||||
<button @click="clearExpiry()"
|
||||
class="text-sm text-red-500 hover:text-red-700 font-medium">{% trans "Clear expiry" %}</button>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="expiryModal.open = false"
|
||||
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">{% trans "Cancel" %}</button>
|
||||
<button @click="saveExpiry()"
|
||||
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload Progress HUD ────────────────────────────────────────
|
||||
Always rendered. Stuck to bottom-right via inline style so no
|
||||
ancestor transform can interfere with CSS position:fixed.
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div style="position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;width:20rem">
|
||||
<div class="bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden">
|
||||
|
||||
<!-- Header — always visible, click to fold/unfold -->
|
||||
<div @click="hudOpen = !hudOpen"
|
||||
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer select-none">
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- animated pulse dot when uploading -->
|
||||
<span x-show="uploads.some(u => u.status === 'uploading')"
|
||||
class="w-2 h-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0"></span>
|
||||
<span x-show="!uploads.some(u => u.status === 'uploading')"
|
||||
class="w-2 h-2 rounded-full bg-gray-300 flex-shrink-0"></span>
|
||||
<span class="text-sm font-semibold text-gray-700">
|
||||
<span x-show="uploads.some(u => u.status === 'uploading')"
|
||||
x-text="'{% trans "Uploading" %} ' + uploads.filter(u => u.status === 'uploading').length + ' / ' + uploads.length"></span>
|
||||
<span x-show="!uploads.some(u => u.status === 'uploading')">{% trans "Uploads" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button x-show="uploads.some(u => u.status === 'uploading')"
|
||||
@click.stop="cancelAll()"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium px-2 py-0.5 rounded hover:bg-red-50 transition-colors">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<!-- chevron rotates when open -->
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
|
||||
:class="hudOpen ? '' : 'rotate-180'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible body -->
|
||||
<div x-show="hudOpen"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1">
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="uploads.length === 0"
|
||||
class="px-4 py-5 text-center text-xs text-gray-400">
|
||||
{% trans "Drag files anywhere or click Upload Files." %}
|
||||
</div>
|
||||
|
||||
<!-- File rows -->
|
||||
<div x-show="uploads.length > 0" class="max-h-60 overflow-y-auto divide-y divide-gray-100">
|
||||
<template x-for="u in uploads" :key="u.id">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs font-medium text-gray-700 truncate max-w-[180px]" x-text="u.name"></span>
|
||||
<span class="text-xs ml-2 flex-shrink-0 font-medium"
|
||||
:class="{
|
||||
'text-blue-500': u.status === 'uploading',
|
||||
'text-green-600': u.status === 'done',
|
||||
'text-red-500': u.status === 'error',
|
||||
'text-gray-400': u.status === 'cancelled'
|
||||
}"
|
||||
x-text="u.status === 'uploading' ? u.progress + '%' : u.status"></span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div class="h-1.5 rounded-full transition-all duration-200"
|
||||
:class="{
|
||||
'bg-blue-500': u.status === 'uploading',
|
||||
'bg-green-500': u.status === 'done',
|
||||
'bg-red-400': u.status === 'error',
|
||||
'bg-gray-300': u.status === 'cancelled'
|
||||
}"
|
||||
:style="'width:' + u.progress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div><!-- /collapsible body -->
|
||||
</div>
|
||||
</div><!-- /HUD -->
|
||||
|
||||
<!-- ── File Preview Modal ─────────────────────────────────────── -->
|
||||
<div x-show="previewModal.open" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
@click.self="closePreview()"
|
||||
@keydown.escape.window="closePreview()">
|
||||
|
||||
<!-- Modal: auto-sizes to content, never exceeds 92vw × 92vh -->
|
||||
<div class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
||||
style="max-width:min(92vw,1280px); max-height:92vh; width:max-content; min-width:300px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 flex-shrink-0 w-full box-border">
|
||||
<h3 class="text-sm font-semibold text-gray-800 truncate mr-4 min-w-0" x-text="previewModal.name"></h3>
|
||||
<button @click="closePreview()" class="text-gray-400 hover:text-gray-600 flex-shrink-0 p-0.5 rounded hover:bg-gray-100">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: flex-1 + min-h-0 lets it shrink properly; overflow-auto for scroll safety -->
|
||||
<div class="flex-1 min-h-0 overflow-auto flex items-center justify-center bg-gray-50 relative">
|
||||
|
||||
<!-- Prev button -->
|
||||
<button x-show="fileList.length > 1"
|
||||
@click="prevFile()"
|
||||
class="absolute left-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
|
||||
title="Previous (←)">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next button -->
|
||||
<button x-show="fileList.length > 1"
|
||||
@click="nextFile()"
|
||||
class="absolute right-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
|
||||
title="Next (→)">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Image: respects natural dimensions, capped at viewport -->
|
||||
<template x-if="previewModal.open && previewModal.mimeType.startsWith('image/')">
|
||||
<img :src="previewModal.url" :alt="previewModal.name"
|
||||
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto; object-fit:contain;">
|
||||
</template>
|
||||
|
||||
<!-- Video -->
|
||||
<template x-if="previewModal.open && previewModal.mimeType.startsWith('video/')">
|
||||
<video controls
|
||||
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto;"
|
||||
:src="previewModal.url"></video>
|
||||
</template>
|
||||
|
||||
<!-- Audio -->
|
||||
<template x-if="previewModal.open && previewModal.mimeType.startsWith('audio/')">
|
||||
<div class="text-center space-y-6 py-10 px-8" style="width:360px;">
|
||||
<svg class="w-16 h-16 text-gray-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-600 font-medium" x-text="previewModal.name"></p>
|
||||
<audio controls class="w-full" :src="previewModal.url"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unsupported -->
|
||||
<template x-if="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')">
|
||||
<div class="text-center space-y-4 py-12 px-10" style="width:420px;">
|
||||
<svg class="w-16 h-16 text-gray-200 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-base font-semibold text-gray-700">{% trans "Unfortunately, we can't preview this file." %}</p>
|
||||
<p class="text-sm text-gray-400">{% trans "But you can click the link below to download it." %}</p>
|
||||
<a :href="previewModal.url" :download="previewModal.name"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors mt-2">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<span x-text="previewModal.name"></span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-2.5 border-t border-gray-100 flex items-center justify-between flex-shrink-0 bg-white w-full box-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400" x-text="previewModal.mimeType"></span>
|
||||
<span x-show="fileList.length > 1" class="text-xs text-gray-400"
|
||||
x-text="(currentIndex + 1) + ' / ' + fileList.length"></span>
|
||||
</div>
|
||||
<a :href="previewModal.url" :download="previewModal.name"
|
||||
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-800">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{% trans "Download" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /Alpine scope -->
|
||||
|
||||
<script>
|
||||
function fileManager() {
|
||||
return {
|
||||
uploads: [],
|
||||
hudOpen: false,
|
||||
toastMsg: '',
|
||||
toastVisible: false,
|
||||
_toastTimer: null,
|
||||
expiryModal: { open: false, pk: '', expiryInput: '' },
|
||||
previewModal: { open: false, name: '', mimeType: '', url: '' },
|
||||
fileList: [],
|
||||
currentIndex: -1,
|
||||
|
||||
init() {
|
||||
window.addEventListener('open-preview', e => this.openPreview(e.detail));
|
||||
|
||||
// Load file list for keyboard navigation
|
||||
try {
|
||||
this.fileList = JSON.parse(document.getElementById('file-list-data').textContent);
|
||||
} catch(e) { this.fileList = []; }
|
||||
|
||||
// Arrow key navigation
|
||||
window.addEventListener('keydown', e => {
|
||||
if (!this.previewModal.open) return;
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); this.nextFile(); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); this.prevFile(); }
|
||||
});
|
||||
|
||||
const overlay = document.getElementById('dragOverlay');
|
||||
const input = document.getElementById('globalFileInput');
|
||||
let dragCounter = 0;
|
||||
|
||||
document.addEventListener('dragenter', e => {
|
||||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||||
dragCounter++;
|
||||
overlay.style.opacity = '1';
|
||||
});
|
||||
document.addEventListener('dragleave', () => {
|
||||
dragCounter = Math.max(0, dragCounter - 1);
|
||||
if (dragCounter === 0) overlay.style.opacity = '0';
|
||||
});
|
||||
document.addEventListener('dragover', e => e.preventDefault());
|
||||
document.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
overlay.style.opacity = '0';
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length) this.uploadFiles(files);
|
||||
});
|
||||
|
||||
input.addEventListener('change', e => {
|
||||
if (e.target.files.length) this.uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
},
|
||||
|
||||
openPicker() {
|
||||
document.getElementById('globalFileInput').click();
|
||||
},
|
||||
|
||||
// ── Uploads ─────────────────────────────────────────────────
|
||||
uploadFiles(fileList) {
|
||||
this.hudOpen = true;
|
||||
Array.from(fileList).forEach(file => {
|
||||
const id = Date.now() + Math.random();
|
||||
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', xhr: null });
|
||||
this._uploadOne(file, id);
|
||||
});
|
||||
},
|
||||
|
||||
_uploadOne(file, itemId) {
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
formData.append('csrfmiddlewaretoken', getCsrfToken());
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const find = () => this.uploads.find(u => u.id === itemId);
|
||||
const u0 = find(); if (u0) u0.xhr = xhr;
|
||||
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) { const u = find(); if (u) u.progress = Math.round((e.loaded / e.total) * 100); }
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const u = find();
|
||||
if (u) { u.status = xhr.status >= 200 && xhr.status < 300 ? 'done' : 'error'; if (u.status === 'done') u.progress = 100; }
|
||||
if (this.uploads.every(u => u.status !== 'uploading')) setTimeout(() => { window.location.reload(); }, 1500);
|
||||
};
|
||||
xhr.onerror = () => { const u = find(); if (u) u.status = 'error'; };
|
||||
xhr.open('POST', '{% url "file-upload" %}');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.send(formData);
|
||||
},
|
||||
|
||||
cancelAll() {
|
||||
this.uploads.forEach(u => { if (u.xhr && u.status === 'uploading') { u.xhr.abort(); u.status = 'cancelled'; } });
|
||||
setTimeout(() => { this.uploads = []; }, 800);
|
||||
},
|
||||
|
||||
// ── Preview modal ────────────────────────────────────────────
|
||||
openPreview({ name, mimeType, url }) {
|
||||
this.currentIndex = this.fileList.findIndex(f => f.url === url);
|
||||
this.previewModal = { open: true, name, mimeType, url };
|
||||
},
|
||||
prevFile() {
|
||||
if (this.fileList.length < 2) return;
|
||||
this.currentIndex = (this.currentIndex - 1 + this.fileList.length) % this.fileList.length;
|
||||
const f = this.fileList[this.currentIndex];
|
||||
this.previewModal = { open: true, name: f.name, mimeType: f.mimeType, url: f.url };
|
||||
},
|
||||
nextFile() {
|
||||
if (this.fileList.length < 2) return;
|
||||
this.currentIndex = (this.currentIndex + 1) % this.fileList.length;
|
||||
const f = this.fileList[this.currentIndex];
|
||||
this.previewModal = { open: true, name: f.name, mimeType: f.mimeType, url: f.url };
|
||||
},
|
||||
closePreview() {
|
||||
this.previewModal.open = false;
|
||||
document.querySelectorAll('video, audio').forEach(el => el.pause());
|
||||
},
|
||||
|
||||
// ── Expiry modal ─────────────────────────────────────────────
|
||||
openExpiry({ pk, expiresAt }) {
|
||||
this.expiryModal.pk = pk;
|
||||
this.expiryModal.expiryInput = expiresAt ? new Date(expiresAt).toISOString().slice(0, 16) : '';
|
||||
this.expiryModal.open = true;
|
||||
},
|
||||
saveExpiry() {
|
||||
const expires_at = this.expiryModal.expiryInput ? new Date(this.expiryModal.expiryInput).toISOString() : null;
|
||||
fetch(`/ui/files/${this.expiryModal.pk}/set-expiry/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
||||
body: JSON.stringify({ expires_at }),
|
||||
}).then(r => r.json()).then(d => {
|
||||
this.expiryModal.open = false;
|
||||
window.dispatchEvent(new CustomEvent('expiry-updated', { detail: { pk: this.expiryModal.pk, expires_at: d.expires_at } }));
|
||||
});
|
||||
},
|
||||
clearExpiry() { this.expiryModal.expiryInput = ''; this.saveExpiry(); },
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────
|
||||
copyAndToast(url) {
|
||||
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);
|
||||
this.showToast('{% trans "Link copied!" %}');
|
||||
},
|
||||
showToast(msg) {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
clearTimeout(this._toastTimer);
|
||||
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fileRow(pk, isPublic, expiresAt, publicUrl) {
|
||||
return {
|
||||
pk, isPublic, publicUrl,
|
||||
expiresAt: expiresAt || null,
|
||||
get isExpired() { return this.expiresAt && new Date(this.expiresAt) < new Date(); },
|
||||
init() {
|
||||
window.addEventListener('expiry-updated', e => {
|
||||
if (String(e.detail.pk) === String(this.pk)) this.expiresAt = e.detail.expires_at;
|
||||
});
|
||||
},
|
||||
togglePublic() {
|
||||
fetch(`/ui/files/${this.pk}/toggle-public/`, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() },
|
||||
}).then(r => r.json()).then(d => { this.isPublic = d.is_public; });
|
||||
},
|
||||
formatExpiry(dt) { return dt ? new Date(dt).toLocaleDateString() : ''; },
|
||||
copyPublicUrl() {
|
||||
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);
|
||||
// Fire directly on window — $dispatch bubbles through <tr>/<table> unreliably
|
||||
window.dispatchEvent(new CustomEvent('copy-link', { detail: { url } }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1] || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="file-list-data" type="application/json">
|
||||
[{% for file in files %}{"name":"{{ file.name|escapejs }}","mimeType":"{{ file.mime_type|escapejs }}","url":"{{ file.download_url|escapejs }}"}{% if not forloop.last %},{% endif %}{% endfor %}]
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load markdown_extras %}
|
||||
|
||||
{% block extra_css %}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{% load i18n %}
|
||||
|
||||
<!-- Collections Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 md:gap-8">
|
||||
{% for collection in collections %}
|
||||
<div class="group relative w-full">
|
||||
<!-- Fixed size container -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200 h-[280px] sm:h-[320px]">
|
||||
<a href="{% url 'collection-detail' collection.pk %}" class="block h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Image Preview Container - Fixed Height -->
|
||||
<div class="h-[160px] sm:h-[200px] bg-gradient-to-br from-gray-50 to-gray-100 p-2 sm:p-3">
|
||||
<div class="grid grid-cols-2 gap-1.5 sm:gap-2 h-full">
|
||||
{% with images=collection.images.all|slice:":4" %}
|
||||
{% for image in images %}
|
||||
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm
|
||||
{% if forloop.counter > 2 %}hidden sm:block{% endif %}">
|
||||
<img src="{{ image.get_thumbnail_url }}"
|
||||
alt="{{ image.title }}"
|
||||
class="object-cover w-full h-full">
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
|
||||
<svg class="w-8 h-8 sm:w-12 sm:h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-500">{% trans "No images yet" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection Info - Flex Grow to Fill Remaining Space -->
|
||||
<div class="flex-1 p-3 sm:p-4 flex flex-col">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ collection.name }}</h3>
|
||||
{% if collection.description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ collection.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Image Count -->
|
||||
<p class="mt-0.5 text-xs sm:text-sm text-gray-500">
|
||||
{{ collection.images.count }} {% trans "images" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="absolute top-1.5 sm:top-2 right-1.5 sm:right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<a href="{% url 'collection-update' collection.pk %}"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Edit Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Delete Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
|
||||
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
|
||||
<a href="{% url 'collection-create' %}"
|
||||
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
|
||||
{% trans "Create Collection" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for page in pages %}
|
||||
<li class="p-4">
|
||||
<div class="flex space-x-4">
|
||||
<!-- Thumbnail Container -->
|
||||
<div class="hidden sm:block flex-shrink-0 w-48 h-48 rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
{% with latest_screenshot=page.screenshots.first %}
|
||||
{% if latest_screenshot and latest_screenshot.path %}
|
||||
<img src="{{ latest_screenshot.get_url }}"
|
||||
alt="Page thumbnail"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.onerror=null; this.src='{% static 'images/default-screenshot.png' %}'; this.classList.add('object-contain', 'p-4');">
|
||||
{% else %}
|
||||
<img src="{% static 'images/default-screenshot.png' %}"
|
||||
alt="Default thumbnail"
|
||||
class="w-3/4 h-3/4 object-contain">
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<a href="{{ page.get_absolute_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
|
||||
{{ page.title|default:"Untitled" }}
|
||||
</a>
|
||||
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="block mt-1 text-sm text-gray-600 hover:text-gray-900 break-all">
|
||||
{{ page.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col items-end space-y-2">
|
||||
<!-- Status Badge -->
|
||||
{% if page.process_status == 'completed' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{% trans "Completed" %}
|
||||
</span>
|
||||
{% elif page.process_status == 'processing' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{% trans "Processing" %}
|
||||
</span>
|
||||
{% elif page.process_status == 'failed' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
{% trans "Failed" %}
|
||||
{% if page.retry_count > 0 %}
|
||||
({{ page.retry_count }}/3)
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex space-x-2">
|
||||
<a href="{% url 'page-update' page.pk %}"
|
||||
class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'page-delete' page.pk %}"
|
||||
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="mt-2 flex-1">
|
||||
<p class="text-gray-600 line-clamp-3">{{ page.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="px-4 py-2 text-center text-gray-500">
|
||||
{% trans "No pages found." %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="mt-4 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#paginated-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#paginated-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -5,6 +5,10 @@
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#paginated-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
@@ -16,6 +20,10 @@
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#paginated-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{% load i18n %}
|
||||
|
||||
<!-- Post List -->
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for post in posts %}
|
||||
<li class="hover:bg-gray-50 transition duration-150">
|
||||
<div class="p-4">
|
||||
<div class="space-y-3">
|
||||
<!-- Post Title and Summary -->
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
<a href="{% url 'post-detail' post.id %}" class="hover:text-blue-600 hover:underline">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if post.summary %}
|
||||
<p class="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{{ post.summary }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
{% with number=forloop.counter %}
|
||||
<a href="{% url 'tag-detail' tag.slug %}"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Timestamps and Actions -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between border-t border-gray-100 pt-3">
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% trans "Created" %}: {{ post.created_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{% trans "Updated" %}: {{ post.updated_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3 sm:mt-0">
|
||||
<a href="{% url 'post-update' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
<a href="{% url 'post-delete' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100 transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
{% trans "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="px-4 py-8 text-center text-gray-500">
|
||||
{% trans "No post found." %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
@@ -0,0 +1,567 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto"
|
||||
x-data="jobsManager()"
|
||||
x-init="init()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800">{% trans "Jobs" %}</h1>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium
|
||||
{% if scheduler_running %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
|
||||
<span class="w-2 h-2 rounded-full inline-block
|
||||
{% if scheduler_running %}bg-green-500{% else %}bg-red-500{% endif %}"></span>
|
||||
{% if scheduler_running %}{% trans "Scheduler running" %}{% else %}{% trans "Scheduler stopped" %}{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs">{% trans "Max concurrent:" %} <strong>{{ site_settings.max_concurrent_screenshot_jobs }}</strong></span>
|
||||
<a href="{% url 'site-settings' %}" class="text-blue-500 hover:underline text-xs">{% trans "Change" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for message in messages %}
|
||||
<div class="rounded-md px-4 py-3 text-sm
|
||||
{% if message.tags == 'success' %}bg-green-50 text-green-800 border border-green-200
|
||||
{% elif message.tags == 'error' %}bg-red-50 text-red-800 border border-red-200
|
||||
{% else %}bg-blue-50 text-blue-800 border border-blue-200{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{% for stat in job_type_stats %}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-gray-600 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 {{ stat.icon_color }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ stat.icon_path }}"/>
|
||||
</svg>
|
||||
{{ stat.label }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 text-center flex-wrap">
|
||||
<a href="?tab={{ stat.id }}&status=all" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
|
||||
<div class="text-xl font-bold text-gray-700">{{ stat.total }}</div>
|
||||
<div class="text-xs text-gray-400">{% trans "Total" %}</div>
|
||||
</a>
|
||||
{% if stat.pending is not None %}
|
||||
<a href="?tab={{ stat.id }}&status=pending" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
|
||||
<div class="text-xl font-bold text-yellow-500">{{ stat.pending }}</div>
|
||||
<div class="text-xs text-gray-400">{% trans "Pending" %}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if stat.processing is not None %}
|
||||
<a href="?tab={{ stat.id }}&status=processing" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
|
||||
<div class="text-xl font-bold text-blue-500">{{ stat.processing }}</div>
|
||||
<div class="text-xs text-gray-400">{% trans "Running" %}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if stat.completed is not None %}
|
||||
<a href="?tab={{ stat.id }}&status=completed" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
|
||||
<div class="text-xl font-bold text-green-500">{{ stat.completed }}</div>
|
||||
<div class="text-xs text-gray-400">{% trans "Done" %}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if stat.failed is not None %}
|
||||
<a href="?tab={{ stat.id }}&status=failed" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
|
||||
<div class="text-xl font-bold text-red-500">{{ stat.failed }}</div>
|
||||
<div class="text-xs text-gray-400">{% trans "Failed" %}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Tab + Filter Bar -->
|
||||
<div class="bg-white rounded-lg shadow mb-0 rounded-b-none border-b-0">
|
||||
<div class="flex items-center justify-between px-4 pt-3 pb-0">
|
||||
<!-- Tabs (driven by job registry) -->
|
||||
<div class="flex gap-1">
|
||||
{% for tab_info in job_type_tabs %}
|
||||
<a href="?tab={{ tab_info.id }}&status=all"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
|
||||
{% if tab_info.is_active %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
|
||||
{{ tab_info.label }}
|
||||
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full
|
||||
{% if tab_info.is_active %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{{ tab_info.count }}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="?tab=scheduler"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
|
||||
{% if tab == 'scheduler' %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
|
||||
{% trans "Scheduler" %}
|
||||
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full
|
||||
{% if tab == 'scheduler' %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{{ scheduled_jobs|length }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status filter pills (from registry status_choices) -->
|
||||
{% if status_choices %}
|
||||
<div class="flex gap-1.5 pb-1">
|
||||
{% for value, label in status_choices %}
|
||||
<a href="?tab={{ tab }}&status={{ value }}"
|
||||
style="padding:.3rem .8rem;border-radius:9999px;font-size:.8rem;font-weight:500;text-decoration:none;display:inline-block;white-space:nowrap;
|
||||
{% if status_filter == value %}
|
||||
{% if value == 'all' %}background:#1f2937;color:#fff;
|
||||
{% elif value == 'pending' %}background:#d97706;color:#fff;
|
||||
{% elif value == 'processing' %}background:#2563eb;color:#fff;
|
||||
{% elif value == 'completed' %}background:#16a34a;color:#fff;
|
||||
{% elif value == 'failed' %}background:#dc2626;color:#fff;
|
||||
{% else %}background:#374151;color:#fff;{% endif %}
|
||||
{% else %}
|
||||
{% if value == 'all' %}background:#f3f4f6;color:#374151;
|
||||
{% elif value == 'pending' %}background:#fef3c7;color:#92400e;
|
||||
{% elif value == 'processing' %}background:#eff6ff;color:#1e40af;
|
||||
{% elif value == 'completed' %}background:#f0fdf4;color:#166534;
|
||||
{% elif value == 'failed' %}background:#fff1f2;color:#991b1b;
|
||||
{% else %}background:#f3f4f6;color:#374151;{% endif %}
|
||||
{% endif %}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk action toolbar -->
|
||||
<div class="border border-blue-200 border-t-0 px-4 py-2 flex items-center gap-3"
|
||||
style="background:#eff6ff;min-height:42px;">
|
||||
<span x-show="selectedIds.length === 0" style="font-size:.8rem;color:#6b7280;">
|
||||
☑ {% trans "Use the checkbox in the header row to select all, or check individual rows — then use bulk actions here" %}
|
||||
</span>
|
||||
<div x-show="selectedIds.length > 0" class="flex items-center gap-3 w-full">
|
||||
<span style="font-size:.85rem;font-weight:600;color:#1d4ed8;" x-text="`${selectedIds.length} {% trans 'selected' %}`"></span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="bulkAction('retry')"
|
||||
style="background:#1d4ed8;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;">
|
||||
↺ {% trans "Retry" %}
|
||||
</button>
|
||||
<button @click="bulkAction('fail')" x-show="'fail' in bulkActionsMap"
|
||||
style="background:#d97706;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;">
|
||||
✕ {% trans "Mark Failed" %}
|
||||
</button>
|
||||
<button @click="if(confirm('{% trans "Delete selected items?" %}')) bulkAction('delete')"
|
||||
style="background:#dc2626;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;">
|
||||
🗑 {% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="clearSelection()" style="margin-left:auto;font-size:.75rem;color:#3b82f6;background:none;border:none;cursor:pointer;text-decoration:underline;">{% trans "Clear" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden bulk-action form -->
|
||||
<form id="bulk-form" method="post" style="display:none">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" id="bulk-action-input">
|
||||
<input type="hidden" name="ids" id="bulk-ids-input">
|
||||
<input type="hidden" name="tab" value="{{ tab }}">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
</form>
|
||||
|
||||
<!-- List Panel -->
|
||||
<div class="bg-white rounded-lg shadow rounded-t-none border-t-0 overflow-hidden">
|
||||
|
||||
{% if tab == 'scheduler' %}
|
||||
<!-- Scheduler Jobs -->
|
||||
<div class="px-4 pt-3 pb-2 flex items-center gap-2">
|
||||
<label class="text-xs text-gray-500">{% trans "Filter" %}:</label>
|
||||
<select x-model="schedulerFilter"
|
||||
class="text-xs rounded border border-gray-200 px-2 py-1 text-gray-700 bg-white shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-300">
|
||||
<option value="all">{% trans "All functions" %}</option>
|
||||
<template x-for="name in schedulerJobTypes" :key="name">
|
||||
<option :value="name" x-text="name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<span class="text-xs text-gray-400" x-show="schedulerFilter !== 'all'">
|
||||
(<span x-text="filteredScheduledJobs.length"></span> {% trans "job(s)" %})
|
||||
</span>
|
||||
</div>
|
||||
{% if scheduled_jobs %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">{% trans "Job ID" %}</th>
|
||||
<th class="px-4 py-3 text-left">{% trans "Function" %}</th>
|
||||
<th class="px-4 py-3 text-left">{% trans "Trigger" %}</th>
|
||||
<th class="px-4 py-3 text-left">{% trans "Next Run" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="job in filteredScheduledJobs" :key="job.id">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-mono text-gray-500" style="font-size:.8rem;" x-text="job.id"></td>
|
||||
<td class="px-4 py-3 font-medium" style="font-size:.85rem;" x-text="job.name"></td>
|
||||
<td class="px-4 py-3 text-gray-400" style="font-size:.85rem;" x-text="job.trigger"></td>
|
||||
<td class="px-4 py-3 text-gray-400 whitespace-nowrap" style="font-size:.85rem;" x-text="job.next_run || '\u2014'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div x-show="filteredScheduledJobs.length === 0"
|
||||
class="px-6 py-8 text-center text-gray-400 text-sm">
|
||||
{% trans "No jobs match this filter." %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No scheduled jobs." %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Unified job table (all registered types) -->
|
||||
{% if page_rows %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-3 py-3 w-8">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
@change="toggleAll($event.target.checked, allIds)"
|
||||
:checked="allIds.length > 0 && selectedIds.length === allIds.length">
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">ID</th>
|
||||
<th class="px-3 py-3 text-left">{{ current_tab_config.title_label }}</th>
|
||||
{% if 'source_url' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Source URL" %}</th>{% endif %}
|
||||
{% if 'status' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Status" %}</th>{% endif %}
|
||||
{% if 'retry' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Retries" %}</th>{% endif %}
|
||||
{% if 'error' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Error" %}</th>{% endif %}
|
||||
{% if 'size' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Size" %}</th>{% endif %}
|
||||
<th class="px-3 py-3 text-left">{% trans "Updated" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for row in page_rows %}
|
||||
<tr class="hover:bg-gray-50" :class="{'bg-blue-50': selectedIds.includes('{{ row.id }}') }">
|
||||
<td class="px-3 py-2">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
value="{{ row.id }}"
|
||||
@change="toggle('{{ row.id }}')"
|
||||
:checked="selectedIds.includes('{{ row.id }}')">
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-400" style="font-size:.75rem;">{{ row.id|truncatechars:12 }}</td>
|
||||
<td class="px-3 py-2 max-w-xs">
|
||||
{% if row.detail_url and row.detail_url != '#' %}
|
||||
<a href="{{ row.detail_url }}" class="text-blue-600 hover:underline block truncate max-w-xs" style="font-size:.85rem;" title="{{ row.title }}">
|
||||
{{ row.title|truncatechars:55 }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-700 block truncate max-w-xs" style="font-size:.85rem;">{{ row.title|truncatechars:55 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if 'source_url' in current_tab_config.columns %}
|
||||
<td class="px-3 py-2 max-w-xs">
|
||||
<a href="{{ row.extra.source_url }}" target="_blank" rel="noopener"
|
||||
class="text-gray-400 hover:text-blue-500 block truncate max-w-xs" style="font-size:.8rem;" title="{{ row.extra.source_url }}">
|
||||
{{ row.extra.source_url|truncatechars:50 }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if 'status' in current_tab_config.columns %}
|
||||
<td class="px-3 py-2">
|
||||
<span style="display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:9999px;font-size:.75rem;font-weight:500;
|
||||
{% if row.status == 'completed' %}background:#dcfce7;color:#166534;
|
||||
{% elif row.status == 'failed' %}background:#fee2e2;color:#991b1b;
|
||||
{% elif row.status == 'processing' %}background:#dbeafe;color:#1e40af;
|
||||
{% else %}background:#fef9c3;color:#854d0e;{% endif %}">
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if 'retry' in current_tab_config.columns %}
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
|
||||
{% if row.retry is not None %}{{ row.retry }}/{{ row.retry_max }}{% else %}<span class="text-gray-300">—</span>{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if 'error' in current_tab_config.columns %}
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
|
||||
{% if row.error %}
|
||||
<button type="button"
|
||||
@click="showError('{{ row.id }}', `{{ row.error|escapejs }}`)"
|
||||
style="text-align:left;color:#ef4444;text-decoration:underline;text-decoration-style:dotted;cursor:pointer;background:none;border:none;font-size:.85rem;max-width:18rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;"
|
||||
title="{{ row.error }}">
|
||||
{{ row.error|truncatechars:60 }}
|
||||
</button>
|
||||
{% else %}<span class="text-gray-300">—</span>{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if 'size' in current_tab_config.columns %}
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
|
||||
{% if row.extra.formatted_size %}{{ row.extra.formatted_size }}{% else %}<span class="text-gray-300">—</span>{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="px-3 py-2 text-gray-400 whitespace-nowrap" style="font-size:.8rem;">{{ row.updated_at|timesince }} {% trans "ago" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No items match this filter." %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}pages table removed — unified table above handles all types{% endcomment %}
|
||||
{% if False %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-3 py-3 w-8">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
@change="toggleAll($event.target.checked, allIds)"
|
||||
:checked="allIds.length > 0 && selectedIds.length === allIds.length">
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">ID</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Title / URL" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Status" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Retries" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Error" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Updated" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for pg in page_obj.object_list %}
|
||||
<tr class="hover:bg-gray-50" :class="{'bg-blue-50': selectedIds.includes('{{ pg.id }}') }">
|
||||
<td class="px-3 py-2">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
value="{{ pg.id }}"
|
||||
@change="toggle('{{ pg.id }}')"
|
||||
:checked="selectedIds.includes('{{ pg.id }}')">
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-400" style="font-size:.75rem;">{{ pg.id }}</td>
|
||||
<td class="px-3 py-2 max-w-xs">
|
||||
<a href="{% url 'page-detail' pg.pk %}"
|
||||
class="text-blue-600 hover:underline block truncate max-w-xs"
|
||||
style="font-size:.85rem;"
|
||||
title="{{ pg.url }}">
|
||||
{{ pg.title|default:pg.url|truncatechars:55 }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span style="display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:9999px;font-size:.75rem;font-weight:500;
|
||||
{% if pg.process_status == 'completed' %}background:#dcfce7;color:#166534;
|
||||
{% elif pg.process_status == 'failed' %}background:#fee2e2;color:#991b1b;
|
||||
{% elif pg.process_status == 'processing' %}background:#dbeafe;color:#1e40af;
|
||||
{% else %}background:#fef9c3;color:#854d0e;{% endif %}">
|
||||
{{ pg.process_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">{{ pg.retry_count }}/3</td>
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
|
||||
{% if pg.error_message %}
|
||||
<button type="button"
|
||||
@click="showError('{{ pg.id }}', `{{ pg.error_message|escapejs }}`)"
|
||||
style="text-align:left;color:#ef4444;text-decoration:underline;text-decoration-style:dotted;cursor:pointer;background:none;border:none;font-size:.85rem;max-width:18rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;"
|
||||
title="{{ pg.error_message }}">
|
||||
{{ pg.error_message|truncatechars:60 }}
|
||||
</button>
|
||||
{% else %}<span class="text-gray-300">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-400 whitespace-nowrap" style="font-size:.8rem;">{{ pg.updated_at|timesince }} {% trans "ago" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No pages match this filter." %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}image_imports table removed — unified table above handles all types{% endcomment %}
|
||||
{% if False %}<!-- image_imports -->
|
||||
{% if page_obj.object_list %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-3 py-3 w-8">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
@change="toggleAll($event.target.checked, allIds)"
|
||||
:checked="allIds.length > 0 && selectedIds.length === allIds.length">
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">ID</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Filename" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Source URL" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Status" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Size" %}</th>
|
||||
<th class="px-3 py-3 text-left">{% trans "Updated" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for imp in page_obj.object_list %}
|
||||
<tr class="hover:bg-gray-50" :class="{'bg-blue-50': selectedIds.includes('{{ imp.id }}') }">
|
||||
<td class="px-3 py-2">
|
||||
<input type="checkbox" class="rounded text-blue-600"
|
||||
value="{{ imp.id }}"
|
||||
@change="toggle('{{ imp.id }}')"
|
||||
:checked="selectedIds.includes('{{ imp.id }}')">
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-400" style="font-size:.7rem;">{{ imp.id|truncatechars:12 }}</td>
|
||||
<td class="px-3 py-2 max-w-xs">
|
||||
<a href="{{ imp.download_url }}" class="text-blue-600 hover:underline block truncate max-w-xs" style="font-size:.85rem;" title="{{ imp.name }}">
|
||||
{{ imp.name|truncatechars:40 }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2 max-w-xs">
|
||||
<a href="{{ imp.source_url }}" target="_blank" rel="noopener"
|
||||
class="text-gray-400 hover:text-blue-500 block truncate max-w-xs" style="font-size:.8rem;" title="{{ imp.source_url }}">
|
||||
{{ imp.source_url|truncatechars:50 }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
{% if imp.size > 0 %}
|
||||
<span style="display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:9999px;font-size:.75rem;font-weight:500;background:#dcfce7;color:#166534;">
|
||||
{% trans "done" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:9999px;font-size:.75rem;font-weight:500;background:#fef9c3;color:#854d0e;">
|
||||
{% trans "pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
|
||||
{% if imp.size > 0 %}{{ imp.formatted_size }}{% else %}<span class="text-gray-300">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-400 whitespace-nowrap" style="font-size:.8rem;">{{ imp.updated_at|timesince }} {% trans "ago" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No image imports match this filter." %}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="px-4 py-3 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 text-xs">
|
||||
{% trans "Showing" %} {{ page_obj.start_index }}–{{ page_obj.end_index }}
|
||||
{% trans "of" %} {{ page_obj.paginator.count }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?tab={{ tab }}&status={{ status_filter }}&page={{ page_obj.previous_page_number }}"
|
||||
class="px-2.5 py-1 rounded text-xs bg-gray-100 hover:bg-gray-200 text-gray-600">‹ {% trans "Prev" %}</a>
|
||||
{% endif %}
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<span class="px-2.5 py-1 rounded text-xs font-medium" style="background:#1d4ed8;color:#fff">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?tab={{ tab }}&status={{ status_filter }}&page={{ num }}"
|
||||
class="px-2.5 py-1 rounded text-xs bg-gray-100 hover:bg-gray-200 text-gray-600">{{ num }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?tab={{ tab }}&status={{ status_filter }}&page={{ page_obj.next_page_number }}"
|
||||
class="px-2.5 py-1 rounded text-xs bg-gray-100 hover:bg-gray-200 text-gray-600">{% trans "Next" %} ›</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Error Detail Modal -->
|
||||
<div x-show="errorModal.open" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@keydown.escape.window="errorModal.open = false">
|
||||
<div class="absolute inset-0 bg-black/40" @click="errorModal.open = false"></div>
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-800">{% trans "Error Details" %}</h3>
|
||||
<button @click="errorModal.open = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<p class="text-xs text-gray-400 mb-2">{% trans "Job ID:" %} <span class="font-mono" x-text="errorModal.id"></span></p>
|
||||
<pre class="bg-gray-900 text-red-300 text-xs p-4 rounded-lg overflow-auto max-h-80 whitespace-pre-wrap break-all font-mono" x-text="errorModal.error"></pre>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex justify-end">
|
||||
<button @click="errorModal.open = false"
|
||||
style="background:#6b7280;color:#fff;padding:.35rem 1rem;border-radius:.375rem;font-size:.8rem;cursor:pointer;">
|
||||
{% trans "Close" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{ all_ids|json_script:"jobs-all-ids" }}
|
||||
{{ scheduled_jobs|json_script:"scheduled-jobs-data" }}
|
||||
{{ bulk_actions|json_script:"bulk-actions-data" }}
|
||||
|
||||
<script>
|
||||
function jobsManager() {
|
||||
return {
|
||||
selectedIds: [],
|
||||
allIds: JSON.parse(document.getElementById('jobs-all-ids').textContent),
|
||||
errorModal: { open: false, id: '', error: '' },
|
||||
tab: '{{ tab }}',
|
||||
scheduledJobs: JSON.parse(document.getElementById('scheduled-jobs-data')?.textContent || '[]'),
|
||||
schedulerFilter: 'all',
|
||||
bulkActionsMap: JSON.parse(document.getElementById('bulk-actions-data').textContent),
|
||||
get schedulerJobTypes() {
|
||||
return [...new Set(this.scheduledJobs.map(j => j.name))].sort();
|
||||
},
|
||||
get filteredScheduledJobs() {
|
||||
if (this.schedulerFilter === 'all') return this.scheduledJobs;
|
||||
return this.scheduledJobs.filter(j => j.name === this.schedulerFilter);
|
||||
},
|
||||
|
||||
init() {},
|
||||
|
||||
toggle(id) {
|
||||
const idx = this.selectedIds.indexOf(id);
|
||||
if (idx === -1) this.selectedIds.push(id);
|
||||
else this.selectedIds.splice(idx, 1);
|
||||
},
|
||||
|
||||
toggleAll(checked, ids) {
|
||||
this.selectedIds = checked ? [...ids] : [];
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
showError(id, error) {
|
||||
this.errorModal = { open: true, id, error };
|
||||
},
|
||||
|
||||
bulkAction(type) {
|
||||
if (this.selectedIds.length === 0) return;
|
||||
const actionName = this.bulkActionsMap[type];
|
||||
if (!actionName) return;
|
||||
if (type === 'delete' && !confirm(`Delete ${this.selectedIds.length} item(s)?`)) return;
|
||||
document.getElementById('bulk-action-input').value = actionName;
|
||||
document.getElementById('bulk-ids-input').value = this.selectedIds.join(',');
|
||||
document.getElementById('bulk-form').submit();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -223,7 +223,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- Chart.js (CDN — page-specific, benefits from edge proximity) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var ctx = document.getElementById('clickChart').getContext('2d');
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
{# jQuery and Select2 are loaded globally from vendor in base.html #}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2 for tags
|
||||
|
||||
@@ -725,8 +725,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<!-- Chart.js (CDN — page-specific, benefits from edge proximity) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
console.log('🔥 FIRE Planning JavaScript loaded!');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends 'base_blank.html' %}
|
||||
{% load i18n %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Image Gallery" %} - {% trans "GoLinks" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{% static 'vendor/fontawesome/css/all.min.css' %}">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Nginx IP Ban Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{% url 'mini-apps-list' %}" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-ban text-red-500"></i> Nginx IP Ban Manager
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 mt-0.5">
|
||||
Manages <code class="bg-gray-100 px-1 rounded text-xs">block-cidrs-manual</code>
|
||||
in the <code class="bg-gray-100 px-1 rounded text-xs">ingress-nginx-controller</code> ConfigMap
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
{% if error %}
|
||||
<div id="error-banner" class="px-4 py-3 rounded-md text-sm border bg-red-50 text-red-800 border-red-200">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- AJAX feedback -->
|
||||
<div id="feedback" class="hidden px-4 py-2 rounded-md text-sm border"></div>
|
||||
|
||||
<!-- Add IPs -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-plus-circle text-red-500 mr-1"></i> Ban IP(s) / CIDR(s)
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
Enter one or more IPs or CIDRs, comma- or newline-separated.
|
||||
Examples: <code class="bg-gray-100 px-1 rounded">1.2.3.4</code>,
|
||||
<code class="bg-gray-100 px-1 rounded">5.6.7.0/24</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<textarea id="add-ips-input" rows="3"
|
||||
placeholder="1.2.3.4 5.6.7.0/24 8.8.8.8, 9.9.9.9"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-400 focus:border-transparent"></textarea>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<button id="add-btn"
|
||||
class="inline-flex items-center px-5 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 transition-colors">
|
||||
<i class="fas fa-ban mr-2"></i> Ban IPs
|
||||
</button>
|
||||
<span class="text-xs text-gray-400">Duplicates are skipped automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banned IPs list -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-list text-gray-500 mr-1"></i>
|
||||
Currently Banned
|
||||
<span id="count-badge"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
||||
{{ blocked|length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div id="ip-list">
|
||||
{% if blocked %}
|
||||
<ul class="divide-y divide-gray-100">
|
||||
{% for ip in blocked %}
|
||||
<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50" data-ip="{{ ip }}">
|
||||
<span class="font-mono text-sm text-gray-800">{{ ip }}</span>
|
||||
<button class="remove-btn text-xs text-red-500 hover:text-red-700 font-medium transition-colors"
|
||||
data-ip="{{ ip }}">
|
||||
<i class="fas fa-times mr-1"></i>Remove
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p id="empty-msg" class="px-6 py-8 text-sm text-gray-400 text-center">
|
||||
<i class="fas fa-check-circle text-green-400 mr-2"></i>No IPs are currently banned.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
const feedbackEl = document.getElementById('feedback');
|
||||
const listEl = document.getElementById('ip-list');
|
||||
const countBadge = document.getElementById('count-badge');
|
||||
|
||||
function showFeedback(msg, isError) {
|
||||
feedbackEl.textContent = msg;
|
||||
feedbackEl.className = isError
|
||||
? 'px-4 py-2 rounded-md text-sm border bg-red-50 text-red-800 border-red-200'
|
||||
: 'px-4 py-2 rounded-md text-sm border bg-green-50 text-green-800 border-green-200';
|
||||
feedbackEl.classList.remove('hidden');
|
||||
clearTimeout(feedbackEl._t);
|
||||
feedbackEl._t = setTimeout(() => feedbackEl.classList.add('hidden'), 4000);
|
||||
}
|
||||
|
||||
function renderList(blocked) {
|
||||
countBadge.textContent = blocked.length;
|
||||
if (blocked.length === 0) {
|
||||
listEl.innerHTML = '<p id="empty-msg" class="px-6 py-8 text-sm text-gray-400 text-center"><i class="fas fa-check-circle text-green-400 mr-2"></i>No IPs are currently banned.</p>';
|
||||
return;
|
||||
}
|
||||
const rows = blocked.map(ip =>
|
||||
`<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50" data-ip="${escHtml(ip)}">
|
||||
<span class="font-mono text-sm text-gray-800">${escHtml(ip)}</span>
|
||||
<button class="remove-btn text-xs text-red-500 hover:text-red-700 font-medium transition-colors" data-ip="${escHtml(ip)}">
|
||||
<i class="fas fa-times mr-1"></i>Remove
|
||||
</button>
|
||||
</li>`
|
||||
).join('');
|
||||
listEl.innerHTML = `<ul class="divide-y divide-gray-100">${rows}</ul>`;
|
||||
listEl.querySelectorAll('.remove-btn').forEach(btn => btn.addEventListener('click', handleRemove));
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
async function post(data) {
|
||||
const body = new URLSearchParams({csrfmiddlewaretoken: csrfToken, ...data});
|
||||
const resp = await fetch(window.location.pathname, {method: 'POST', body});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// Add
|
||||
document.getElementById('add-btn').addEventListener('click', async () => {
|
||||
const input = document.getElementById('add-ips-input');
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
try {
|
||||
const data = await post({action: 'add', ips: val});
|
||||
if (data.ok) {
|
||||
input.value = '';
|
||||
renderList(data.blocked);
|
||||
showFeedback(`✓ Banned ${data.added} new IP(s). Total: ${data.blocked.length}.`, false);
|
||||
} else {
|
||||
showFeedback('Error: ' + data.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
showFeedback('Network error: ' + e, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove (delegated for dynamically rendered rows)
|
||||
listEl.addEventListener('click', handleRemoveDelegated);
|
||||
|
||||
async function handleRemoveDelegated(e) {
|
||||
const btn = e.target.closest('.remove-btn');
|
||||
if (!btn) return;
|
||||
await handleRemove.call(btn, e);
|
||||
}
|
||||
|
||||
async function handleRemove(e) {
|
||||
const ip = this.dataset.ip;
|
||||
if (!confirm(`Remove "${ip}" from the ban list?`)) return;
|
||||
try {
|
||||
const data = await post({action: 'remove', ip});
|
||||
if (data.ok) {
|
||||
renderList(data.blocked);
|
||||
showFeedback(`✓ Removed ${ip}.`, false);
|
||||
} else {
|
||||
showFeedback('Error: ' + data.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
showFeedback('Network error: ' + e, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up initial remove buttons
|
||||
listEl.querySelectorAll('.remove-btn').forEach(btn => btn.addEventListener('click', handleRemove));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -25,43 +25,35 @@
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300">
|
||||
</div>
|
||||
|
||||
<!-- Overlay Gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
<!-- Gradient: dark at top AND bottom, transparent in middle -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/70 via-black/10 to-black/80"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="absolute inset-0 flex flex-col justify-end p-6 text-white">
|
||||
<!-- Icon and Title -->
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center mr-3"
|
||||
style="background-color: {{ app.color }};">
|
||||
<i class="{{ app.icon }} text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold">{{ app.name }}</h3>
|
||||
<!-- Icon + Title pinned to top -->
|
||||
<div class="absolute top-0 left-0 right-0 flex items-center p-5">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center mr-3 flex-shrink-0"
|
||||
style="background-color: {{ app.color }};">
|
||||
<i class="{{ app.icon }} text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white leading-tight">{{ app.name }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-gray-200 text-sm mb-4 leading-relaxed">
|
||||
{{ app.description }}
|
||||
</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<!-- Description + Button pinned to bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<p class="text-gray-200 text-sm mb-4 leading-relaxed">{{ app.description }}</p>
|
||||
{% if app.url != "#" %}
|
||||
<a href="{% url app.url %}"
|
||||
class="inline-flex items-center justify-center px-6 py-3 rounded-lg font-medium transition-all duration-200 transform hover:scale-105"
|
||||
class="inline-flex items-center justify-center w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 transform hover:scale-105"
|
||||
style="background-color: {{ app.color }};">
|
||||
<i class="fas fa-play mr-2"></i>
|
||||
{% trans "Launch App" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="inline-flex items-center justify-center px-6 py-3 rounded-lg font-medium bg-gray-600 cursor-not-allowed">
|
||||
<div class="inline-flex items-center justify-center w-full px-6 py-3 rounded-lg font-medium bg-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-lock mr-2"></i>
|
||||
{% trans "Coming Soon" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Hover Effect -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-transparent via-transparent to-white/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load static %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@@ -99,7 +98,7 @@
|
||||
<!-- Tags -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-sm font-medium text-gray-500">{% trans "Tags" %}</h2>
|
||||
<h2 class="text-sm font-medium text-gray-500">{% trans "Tags" %}</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in page.tags.all %}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- SimpleMDE CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
|
||||
<!-- Select2 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{% static 'simplemde/simplemde.min.css' %}">
|
||||
{# Select2 CSS is loaded globally from vendor in base.html #}
|
||||
<style>
|
||||
.overlay {
|
||||
display: none;
|
||||
@@ -197,10 +195,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- SimpleMDE JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
||||
<!-- Select2 JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
|
||||
{# jQuery and Select2 are loaded globally from vendor in base.html #}
|
||||
<script>
|
||||
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
|
||||
// This is especially important after task list checkbox toggles
|
||||
|
||||
@@ -11,125 +11,8 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for page in pages %}
|
||||
<li class="p-4">
|
||||
<div class="flex space-x-4">
|
||||
<!-- Thumbnail Container -->
|
||||
<div class="hidden sm:block flex-shrink-0 w-48 h-48 rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
{% with latest_screenshot=page.screenshots.first %}
|
||||
{% if latest_screenshot and latest_screenshot.path %}
|
||||
<img src="{{ latest_screenshot.get_url }}"
|
||||
alt="Page thumbnail"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.onerror=null; this.src='{% static 'images/default-screenshot.png' %}'; this.classList.add('object-contain', 'p-4');">
|
||||
{% else %}
|
||||
<img src="{% static 'images/default-screenshot.png' %}"
|
||||
alt="Default thumbnail"
|
||||
class="w-3/4 h-3/4 object-contain">
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<a href="{{ page.get_absolute_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
|
||||
{{ page.title|default:"Untitled" }}
|
||||
</a>
|
||||
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="block mt-1 text-sm text-gray-600 hover:text-gray-900 break-all">
|
||||
{{ page.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col items-end space-y-2">
|
||||
<!-- Status Badge -->
|
||||
{% if page.process_status == 'completed' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{% trans "Completed" %}
|
||||
</span>
|
||||
{% elif page.process_status == 'processing' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{% trans "Processing" %}
|
||||
</span>
|
||||
{% elif page.process_status == 'failed' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
{% trans "Failed" %}
|
||||
{% if page.retry_count > 0 %}
|
||||
({{ page.retry_count }}/3)
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex space-x-2">
|
||||
<a href="{% url 'page-update' page.pk %}"
|
||||
class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'page-delete' page.pk %}"
|
||||
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="mt-2 flex-1">
|
||||
<p class="text-gray-600 line-clamp-3">{{ page.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="px-4 py-2 text-center text-gray-500">
|
||||
{% trans "No pages found." %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div id="paginated-content">
|
||||
{% include "links/includes/page_list_items.html" %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="mt-4 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -323,6 +323,130 @@
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Table of Contents Layout ===== */
|
||||
#post-toc-layout {
|
||||
max-width: 80rem; /* max-w-6xl equivalent */
|
||||
margin: 0.5rem auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
#toc-sidebar {
|
||||
display: none; /* hidden by default (mobile) */
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
#post-toc-layout {
|
||||
display: flex;
|
||||
align-items: stretch; /* sidebar stretches to match content height — required for sticky */
|
||||
gap: 1.5rem;
|
||||
}
|
||||
#toc-sidebar {
|
||||
display: block;
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#toc-sidebar-inner {
|
||||
position: sticky;
|
||||
top: 5rem; /* sits just below fixed navbar (~64px) */
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
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;
|
||||
}
|
||||
|
||||
#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 TOC 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;
|
||||
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;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
#toc-mobile-btn:hover { background: #1d4ed8; }
|
||||
|
||||
/* Mobile drawer */
|
||||
#toc-mobile-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,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; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -357,8 +481,21 @@
|
||||
|
||||
|
||||
|
||||
<!-- Main Content - Optimized Layout -->
|
||||
<div class="main-content max-w-5xl mx-auto px-2 py-2">
|
||||
<!-- Two-column layout: sticky TOC sidebar + main content -->
|
||||
<div id="post-toc-layout">
|
||||
|
||||
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
|
||||
<aside id="toc-sidebar">
|
||||
<div id="toc-sidebar-inner">
|
||||
<div>
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
|
||||
<nav id="toc-nav"></nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content" id="post-main-content">
|
||||
<!-- Title and Actions - Responsive Layout -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 sm:flex-1">{{ post.title }}</h1>
|
||||
@@ -381,15 +518,37 @@
|
||||
</svg>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
<a href="{% url 'public-post' post.pk %}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 hover:text-green-800 bg-green-50 hover:bg-green-100 rounded-md transition duration-150">
|
||||
<!-- Share widget -->
|
||||
<div x-data="shareWidget()" x-init="init()" class="relative">
|
||||
<!-- Not yet shared -->
|
||||
<button x-show="!isPublic" @click="enableShare()"
|
||||
:disabled="busy"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 hover:text-green-800 bg-green-50 hover:bg-green-100 rounded-md transition duration-150 disabled:opacity-50">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
|
||||
</svg>
|
||||
{% trans "Share" %}
|
||||
</a>
|
||||
</button>
|
||||
<!-- Already shared: copy + revoke -->
|
||||
<div x-show="isPublic" class="flex items-center gap-1">
|
||||
<button @click="copyUrl()"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-md transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
<span x-text="copied ? '{% trans "Copied!" %}' : '{% trans "Shared" %}'"></span>
|
||||
</button>
|
||||
<button @click="revokeShare()" :disabled="busy"
|
||||
title="{% trans 'Revoke public access' %}"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition disabled:opacity-50">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="openDeleteModal()"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-700 hover:text-red-800 bg-red-50 hover:bg-red-100 rounded-md transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -541,11 +700,95 @@
|
||||
<article class="prose max-w-none prose-lg">
|
||||
{% markdown_with_tasks post.content post.task_states %}
|
||||
</article>
|
||||
|
||||
<!-- Append Content Section -->
|
||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{% trans "Append Content" %}</h3>
|
||||
<textarea id="append-content-input"
|
||||
class="w-full min-h-[200px] px-3 py-2 text-sm font-mono border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y"
|
||||
placeholder="{% trans 'Write markdown to append...' %}"></textarea>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span id="append-status" class="text-xs text-gray-500"></span>
|
||||
<button id="append-btn" onclick="appendContent()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{% trans "Append" %}
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- end post-main-content -->
|
||||
</div><!-- end post-toc-layout -->
|
||||
|
||||
<!-- Mobile TOC floating button -->
|
||||
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile TOC backdrop -->
|
||||
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<!-- Mobile TOC drawer -->
|
||||
<div id="toc-mobile-drawer">
|
||||
<div style="padding:1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
|
||||
<p style="font-weight:600;color:#374151;margin:0;">{% trans "Contents" %}</p>
|
||||
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-nav-mobile"></nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function shareWidget() {
|
||||
return {
|
||||
isPublic: {{ post.is_public|yesno:"true,false" }},
|
||||
publicUrl: '{{ request.build_absolute_uri }}' .replace('/ui/posts/{{ post.pk }}/', '/public/posts/{{ post.pk }}/'),
|
||||
busy: false,
|
||||
copied: false,
|
||||
init() {
|
||||
// Compute the actual public URL from the current origin
|
||||
this.publicUrl = window.location.origin + '{% url "public-post" post.pk %}';
|
||||
},
|
||||
async _toggle(enable) {
|
||||
this.busy = true;
|
||||
try {
|
||||
const resp = await fetch('{% url "post-share" post.pk %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '',
|
||||
},
|
||||
body: JSON.stringify({ enable }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
this.isPublic = data.is_public;
|
||||
if (data.url) this.publicUrl = data.url;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
enableShare() { this._toggle(true); },
|
||||
revokeShare() { this._toggle(false); },
|
||||
async copyUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.publicUrl);
|
||||
this.copied = true;
|
||||
setTimeout(() => { this.copied = false; }, 2000);
|
||||
} catch(e) {
|
||||
window.open(this.publicUrl, '_blank');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
</script>
|
||||
<script>
|
||||
function openDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
@@ -868,6 +1111,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');
|
||||
@@ -906,5 +1180,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);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- SimpleMDE CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
|
||||
<!-- Select2 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{% static 'simplemde/simplemde.min.css' %}">
|
||||
{# Select2 CSS now loaded globally from vendor in base.html #}
|
||||
<style>
|
||||
.CodeMirror {
|
||||
height: 400px;
|
||||
@@ -129,12 +127,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- SimpleMDE JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
||||
<!-- jQuery (required for Select2) -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<!-- Select2 JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
|
||||
{# jQuery and Select2 are now loaded globally from vendor in base.html #}
|
||||
<script>
|
||||
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
|
||||
// This is especially important after task list checkbox toggles
|
||||
|
||||
@@ -42,83 +42,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Post List -->
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for post in posts %}
|
||||
<li class="hover:bg-gray-50 transition duration-150">
|
||||
<div class="p-4">
|
||||
<div class="space-y-3">
|
||||
<!-- Post Title and Summary -->
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
<a href="{% url 'post-detail' post.id %}" class="hover:text-blue-600 hover:underline">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if post.summary %}
|
||||
<p class="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{{ post.summary }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
{% with number=forloop.counter %}
|
||||
<a href="{% url 'tag-detail' tag.slug %}"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Timestamps and Actions -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between border-t border-gray-100 pt-3">
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% trans "Created" %}: {{ post.created_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{% trans "Updated" %}: {{ post.updated_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3 sm:mt-0">
|
||||
<a href="{% url 'post-update' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
<a href="{% url 'post-delete' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100 transition duration-150">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
{% trans "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="px-4 py-8 text-center text-gray-500">
|
||||
{% trans "No post found." %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div id="paginated-content">
|
||||
{% include "links/includes/post_list_items.html" %}
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ post.title }}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
|
||||
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/markdown.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
/* Think Panel Styles */
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="max-w-4xl mx-auto mt-10 p-8 bg-white shadow-md rounded-lg">
|
||||
<!-- Post Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500">
|
||||
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
<!-- Two-column layout: sticky TOC sidebar + main content -->
|
||||
<div id="post-toc-layout">
|
||||
|
||||
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
|
||||
<aside id="toc-sidebar">
|
||||
<div id="toc-sidebar-inner">
|
||||
<div>
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
|
||||
<nav id="toc-nav"></nav>
|
||||
</div>
|
||||
</div>
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="prose">
|
||||
{% markdown_with_tasks post.content post.task_states %}
|
||||
<!-- Main Content -->
|
||||
<div id="post-main-content" style="padding:2rem;background:#fff;box-shadow:0 1px 3px 0 rgba(0,0,0,.1);border-radius:0.5rem;">
|
||||
<!-- Post Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500">
|
||||
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="prose">
|
||||
{% markdown_with_tasks post.content post.task_states %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile TOC floating button -->
|
||||
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile TOC backdrop -->
|
||||
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<!-- Mobile TOC drawer -->
|
||||
<div id="toc-mobile-drawer">
|
||||
<div style="padding:1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
|
||||
<p style="font-weight:600;color:#374151;margin:0;">Contents</p>
|
||||
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-nav-mobile"></nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Advanced Search - GoLinks</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'vendor/fontawesome/css/all.min.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -1000px 0; }
|
||||
@@ -28,474 +25,7 @@
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 min-h-screen">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useMemo } = React;
|
||||
|
||||
// Badge Component
|
||||
const Badge = ({ children, variant = "default", className = "" }) => {
|
||||
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 (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Card Component
|
||||
const Card = ({ children, className = "", hover = false }) => (
|
||||
<div className={`bg-white rounded-lg shadow-sm border border-gray-200 ${hover ? 'hover:shadow-md transition-shadow duration-200' : ''} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Input Component
|
||||
const Input = ({ className = "", ...props }) => (
|
||||
<input
|
||||
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select Component
|
||||
const Select = ({ children, className = "", ...props }) => (
|
||||
<select
|
||||
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium ring-offset-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Skeleton Loader
|
||||
const Skeleton = ({ className = "" }) => (
|
||||
<div className={`shimmer rounded ${className}`}></div>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Card hover className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
result.type === 'link' ? 'bg-blue-100 text-blue-600' :
|
||||
result.type === 'page' ? 'bg-green-100 text-green-600' :
|
||||
'bg-purple-100 text-purple-600'
|
||||
}`}>
|
||||
<i className={`fas ${typeIcons[result.type]}`}></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={result.type === 'link' ? result.url : result.detail_url || result.url}
|
||||
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
|
||||
target={result.type === 'link' ? '_blank' : '_self'}
|
||||
rel={result.type === 'link' ? 'noopener noreferrer' : ''}
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
<Badge variant={result.type}>
|
||||
{result.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{result.type === 'link' && result.original_url && (
|
||||
<a
|
||||
href={result.original_url}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fas fa-external-link-alt mr-1"></i>
|
||||
{result.original_url}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(result.description || result.summary) && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
|
||||
{result.description || result.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
<i className="fas fa-calendar mr-1"></i>
|
||||
{formatDate(result.created_at)}
|
||||
</span>
|
||||
{result.click_count !== undefined && (
|
||||
<span>
|
||||
<i className="fas fa-mouse-pointer mr-1"></i>
|
||||
{result.click_count} clicks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.tags && result.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{result.tags.map(tag => (
|
||||
<Badge key={tag.id} variant="default" className="text-xs">
|
||||
<i className="fas fa-tag mr-1"></i>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<a
|
||||
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<i className="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<a href="/" className="inline-block hover:opacity-80 transition-opacity">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-3">
|
||||
<i className="fas fa-search text-blue-600"></i>
|
||||
Advanced Search
|
||||
</h1>
|
||||
</a>
|
||||
<p className="text-gray-600">Search through all Links, Pages, and Posts</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<Card className="p-6 mb-8 glass">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search Query
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Enter keywords to search..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filter by Type
|
||||
</label>
|
||||
<Select value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
<option value="link">Links</option>
|
||||
<option value="page">Pages</option>
|
||||
<option value="post">Posts</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort By
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2"><label className="text-sm font-medium text-gray-700">AI Vector Search</label><input type="checkbox" checked={isVector} onChange={(e) => setIsVector(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /></div>
|
||||
<Select value={sort} onChange={(e) => setSort(e.target.value)} disabled={isVector}>
|
||||
<option value="relevance">Most Relevant</option>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="oldest">Oldest First</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="fas fa-spinner fa-spin mr-2"></i>
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fas fa-search mr-2"></i>
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{query && (
|
||||
<div>
|
||||
{/* Results Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{loading ? (
|
||||
'Searching...'
|
||||
) : error ? (
|
||||
<span className="text-red-600">
|
||||
<i className="fas fa-exclamation-circle mr-2"></i>
|
||||
{error}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
Search results for: <span className="text-blue-600">"{query}"</span>
|
||||
{total > 0 && (
|
||||
<span className="text-gray-500 text-sm ml-2">
|
||||
({total} {total === 1 ? 'result' : 'results'})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Loading Skeletons */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="w-10 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results List */}
|
||||
{!loading && !error && results.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{results.map(result => (
|
||||
<SearchResultItem key={`${result.type}-${result.id}`} result={result} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{!loading && !error && results.length === 0 && query && (
|
||||
<Card className="p-12 text-center">
|
||||
<i className="fas fa-search text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
|
||||
<p className="text-gray-500">Try adjusting your search terms or filters</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<i className="fas fa-chevron-left mr-2"></i>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{[...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 (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={page === pageNum ? "default" : "outline"}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className="w-10 h-10 p-0"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Next
|
||||
<i className="fas fa-chevron-right ml-2"></i>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!query && (
|
||||
<Card className="p-12 text-center glass">
|
||||
<i className="fas fa-search text-6xl text-blue-200 mb-4"></i>
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">Start Searching</h3>
|
||||
<p className="text-gray-600 max-w-md mx-auto">
|
||||
Enter a search query to find links, pages, and posts.
|
||||
You can search by title, content, URL, or tags.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the app
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<SearchApp />);
|
||||
</script>
|
||||
<script src="{% static 'dist/search.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
const { useState, useEffect, useCallback, useMemo } = React;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">{% trans "Settings" %}</h1>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- Public Sharing Domain -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Public Sharing Domain" %}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{% trans "Domain (with protocol) used for public file sharing links. Leave blank to use the same domain as the app." %}
|
||||
<br>
|
||||
<span class="font-mono text-xs text-gray-400">{% trans "Example:" %} https://go.example.com</span>
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
name="public_sharing_domain"
|
||||
id="public_sharing_domain"
|
||||
value="{{ site_settings.public_sharing_domain }}"
|
||||
placeholder="https://go.example.com"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{% if site_settings.public_sharing_domain %}
|
||||
<p class="mt-2 text-xs text-green-600">
|
||||
{% trans "Active. Public links will use:" %}
|
||||
<span class="font-mono">{{ site_settings.public_sharing_domain }}/public/files/…</span>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{% trans "Not set. Public links will use the app's own domain." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Worker Concurrency -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Max Concurrent Screenshot Jobs" %}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{% trans "Maximum number of Chromium browser instances running simultaneously for screenshots. Reduce this if the container runs out of memory (OOM)." %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
name="max_concurrent_screenshot_jobs"
|
||||
id="max_concurrent_screenshot_jobs"
|
||||
value="{{ site_settings.max_concurrent_screenshot_jobs }}"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-32 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<span class="text-sm text-gray-400">{% trans "instances" %}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">{% trans "Recommended: 1–3 for containers with limited RAM." %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Pending Pages Interval -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Page Processing Interval" %}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{% trans "How often (in seconds) the scheduler checks for pending pages and queues them for metadata extraction." %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
name="schedule_pending_pages_interval"
|
||||
id="schedule_pending_pages_interval"
|
||||
value="{{ site_settings.schedule_pending_pages_interval }}"
|
||||
min="10"
|
||||
max="3600"
|
||||
class="w-32 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<span class="text-sm text-gray-400">{% trans "seconds" %}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">{% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Pending Screenshots Interval -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Screenshot Retry Interval" %}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{% trans "How often (in seconds) the scheduler checks for stuck or pending screenshots and retries them." %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
name="schedule_pending_screenshots_interval"
|
||||
id="schedule_pending_screenshots_interval"
|
||||
value="{{ site_settings.schedule_pending_screenshots_interval }}"
|
||||
min="10"
|
||||
max="3600"
|
||||
class="w-32 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<span class="text-sm text-gray-400">{% trans "seconds" %}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">{% trans "Default: 120. Range: 10–3600. Changes take effect immediately." %}</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /grid -->
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
style="background:#1d4ed8;color:#fff;padding:.5rem 1.5rem;border-radius:.375rem;font-size:.875rem;font-weight:500;cursor:pointer;"
|
||||
onmouseover="this.style.background='#1e40af'"
|
||||
onmouseout="this.style.background='#1d4ed8'"
|
||||
>
|
||||
{% trans "Save Settings" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Shared Posts Management -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mt-6"
|
||||
x-data="sharedPostsMgr()"
|
||||
x-init="init()">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-700">{% trans "Shared Posts" %}</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{% trans "Posts currently accessible via public links. Disable to return to 404." %}</p>
|
||||
</div>
|
||||
<button x-show="posts.length > 0" @click="revokeAll()"
|
||||
:disabled="busy"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
{% trans "Revoke All" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="posts.length === 0 && !loading" class="py-6 text-center text-sm text-gray-400">
|
||||
{% trans "No posts are currently shared publicly." %}
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="py-6 text-center text-sm text-gray-400">
|
||||
{% trans "Loading…" %}
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<ul x-show="!loading" class="divide-y divide-gray-100">
|
||||
<template x-for="post in posts" :key="post.id">
|
||||
<li class="flex items-center gap-3 py-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<a :href="'/ui/posts/' + post.id + '/'"
|
||||
class="font-medium text-gray-800 hover:text-blue-600 text-sm truncate block" x-text="post.title"></a>
|
||||
<a :href="post.publicUrl" target="_blank"
|
||||
class="text-xs text-gray-400 hover:text-gray-600 font-mono" x-text="post.publicUrl"></a>
|
||||
</div>
|
||||
<button @click="revoke(post)" :disabled="busy"
|
||||
class="flex-shrink-0 flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 border border-red-200 rounded hover:bg-red-50 disabled:opacity-50 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{% trans "Revoke" %}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function sharedPostsMgr() {
|
||||
return {
|
||||
posts: [],
|
||||
loading: false,
|
||||
busy: false,
|
||||
init() {
|
||||
{% for p in public_posts %}
|
||||
this.posts.push({
|
||||
id: {{ p.pk }},
|
||||
title: '{{ p.title|escapejs }}',
|
||||
publicUrl: window.location.origin + '{% url "public-post" p.pk %}',
|
||||
});
|
||||
{% endfor %}
|
||||
},
|
||||
async _toggle(postId, enable) {
|
||||
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||
await fetch('/ui/posts/' + postId + '/share/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
|
||||
body: JSON.stringify({ enable }),
|
||||
});
|
||||
},
|
||||
async revoke(post) {
|
||||
this.busy = true;
|
||||
try {
|
||||
await this._toggle(post.id, false);
|
||||
this.posts = this.posts.filter(p => p.id !== post.id);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async revokeAll() {
|
||||
if (!confirm('{% trans "Revoke public access for all shared posts?" %}')) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
await Promise.all(this.posts.map(p => this._toggle(p.id, false)));
|
||||
this.posts = [];
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -33,10 +33,10 @@
|
||||
{% trans "Least Used" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Quick Filter Input -->
|
||||
<div class="w-full sm:w-64 relative">
|
||||
<input type="text" id="filter-tags-input" placeholder="{% trans 'Filter tags...' %}"
|
||||
<input type="text" id="filter-tags-input" placeholder="{% trans 'Filter tags...' %}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -101,15 +101,15 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- No results message (hidden by default) -->
|
||||
<div id="no-results-message" class="hidden col-span-full text-center py-8 text-gray-500">
|
||||
{% trans "No matching tags found." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include ECharts and wordcloud extension -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<!-- ECharts (CDN — page-specific, benefits from edge proximity) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
|
||||
|
||||
<script>
|
||||
@@ -209,21 +209,21 @@
|
||||
myChart.resize();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Quick filter functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterInput = document.getElementById('filter-tags-input');
|
||||
const tagItems = document.querySelectorAll('.tag-item');
|
||||
const noResultsMessage = document.getElementById('no-results-message');
|
||||
|
||||
|
||||
if (filterInput && tagItems.length > 0) {
|
||||
filterInput.addEventListener('keyup', function() {
|
||||
const filterTerm = this.value.toLowerCase().trim();
|
||||
let visibleCount = 0;
|
||||
|
||||
|
||||
tagItems.forEach(function(item) {
|
||||
const tagName = item.getAttribute('data-tag-name');
|
||||
|
||||
|
||||
if (tagName && tagName.includes(filterTerm)) {
|
||||
item.style.display = '';
|
||||
visibleCount++;
|
||||
@@ -231,7 +231,7 @@
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Show/hide no results message
|
||||
if (visibleCount === 0 && filterTerm.length > 0) {
|
||||
noResultsMessage.classList.remove('hidden');
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -21,6 +21,8 @@ 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'),
|
||||
path('ui/jobs/', views.JobsView.as_view(), name='jobs'),
|
||||
|
||||
# Pages
|
||||
path('ui/pages/', page_views.PageListView.as_view(), name='page-list'),
|
||||
@@ -41,6 +43,7 @@ urlpatterns = [
|
||||
path('ui/posts/<int:pk>/', post_views.PostDetailView.as_view(), name='post-detail'),
|
||||
path('ui/posts/<int:pk>/edit/', post_views.PostUpdateView.as_view(), name='post-update'),
|
||||
path('ui/posts/<int:pk>/delete/', post_views.PostDeleteView.as_view(), name='post-delete'),
|
||||
path('ui/posts/<int:pk>/share/', post_views.PostShareView.as_view(), name='post-share'),
|
||||
path('ui/posts/<int:post_id>/tts/', views.generate_tts, name='post-tts'),
|
||||
|
||||
# Collection slideshow
|
||||
@@ -53,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'),
|
||||
|
||||
+312
-52
@@ -4,8 +4,9 @@ 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
|
||||
from django.core.cache import cache
|
||||
import json
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib import messages
|
||||
@@ -89,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)}
|
||||
@@ -137,7 +138,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
|
||||
@@ -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}")
|
||||
@@ -218,13 +222,17 @@ 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
|
||||
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
|
||||
@@ -533,6 +561,238 @@ 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()
|
||||
public_posts = Post.objects.filter(is_public=True).order_by('-updated_at')
|
||||
return render(request, self.template_name, {
|
||||
'site_settings': site_settings,
|
||||
'public_posts': public_posts,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from core.scheduler import scheduler
|
||||
|
||||
site_settings = SiteSettings.get()
|
||||
site_settings.public_sharing_domain = request.POST.get('public_sharing_domain', '').strip()
|
||||
try:
|
||||
max_jobs = int(request.POST.get('max_concurrent_screenshot_jobs', 2))
|
||||
site_settings.max_concurrent_screenshot_jobs = max(1, min(max_jobs, 20))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
pages_interval = int(request.POST.get('schedule_pending_pages_interval', 120))
|
||||
site_settings.schedule_pending_pages_interval = max(10, min(pages_interval, 3600))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
screenshots_interval = int(request.POST.get('schedule_pending_screenshots_interval', 120))
|
||||
site_settings.schedule_pending_screenshots_interval = max(10, min(screenshots_interval, 3600))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
site_settings.save()
|
||||
|
||||
# Reschedule periodic jobs with the new intervals
|
||||
try:
|
||||
scheduler.reschedule_job(
|
||||
'schedule_pending_pages',
|
||||
trigger=IntervalTrigger(seconds=site_settings.schedule_pending_pages_interval),
|
||||
)
|
||||
scheduler.reschedule_job(
|
||||
'schedule_pending_screenshots',
|
||||
trigger=IntervalTrigger(seconds=site_settings.schedule_pending_screenshots_interval),
|
||||
)
|
||||
except Exception:
|
||||
pass # Scheduler may not be running in test/CLI context
|
||||
|
||||
messages.success(request, _('Settings saved.'))
|
||||
return redirect('site-settings')
|
||||
|
||||
|
||||
class JobsView(View):
|
||||
template_name = 'links/jobs.html'
|
||||
PAGE_SIZE = 50
|
||||
|
||||
def get(self, request):
|
||||
from . import job_registry
|
||||
from .models import SiteSettings as SS
|
||||
from core.scheduler import scheduler
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
job_types = job_registry.all_types()
|
||||
tab = request.GET.get('tab', job_types[0]['id'] if job_types else 'scheduler')
|
||||
status_filter = request.GET.get('status', 'all')
|
||||
page_num = request.GET.get('page', 1)
|
||||
|
||||
# Build stats for all registered job types
|
||||
job_type_stats = []
|
||||
for jt in job_types:
|
||||
raw = jt['get_stats']()
|
||||
job_type_stats.append({
|
||||
'id': jt['id'],
|
||||
'label': jt['label'],
|
||||
'icon_color': jt['icon_color'],
|
||||
'icon_path': jt['icon_path'],
|
||||
'total': raw.get('total', 0),
|
||||
'pending': raw.get('pending'),
|
||||
'processing': raw.get('processing'),
|
||||
'completed': raw.get('completed'),
|
||||
'failed': raw.get('failed'),
|
||||
})
|
||||
|
||||
# Tab navigation list
|
||||
job_type_tabs = [
|
||||
{
|
||||
'id': jt['id'],
|
||||
'label': jt['label'],
|
||||
'count': next((s['total'] for s in job_type_stats if s['id'] == jt['id']), 0),
|
||||
'is_active': tab == jt['id'],
|
||||
}
|
||||
for jt in job_types
|
||||
]
|
||||
|
||||
# APScheduler jobs
|
||||
scheduled_jobs = []
|
||||
if scheduler.running:
|
||||
for job in scheduler.get_jobs():
|
||||
scheduled_jobs.append({
|
||||
'id': job.id,
|
||||
'name': job.func.__name__ if callable(job.func) else str(job.func),
|
||||
'next_run': str(job.next_run_time) if job.next_run_time else None,
|
||||
'trigger': str(job.trigger),
|
||||
})
|
||||
|
||||
# Handle scheduler tab or unrecognised tab
|
||||
current_jt = job_registry.get_type(tab)
|
||||
if tab == 'scheduler' or current_jt is None:
|
||||
return render(request, self.template_name, {
|
||||
'job_type_tabs': job_type_tabs,
|
||||
'job_type_stats': job_type_stats,
|
||||
'tab': 'scheduler',
|
||||
'status_filter': status_filter,
|
||||
'page_obj': None,
|
||||
'page_rows': [],
|
||||
'all_ids': [],
|
||||
'bulk_actions': {},
|
||||
'status_choices': [],
|
||||
'current_tab_config': None,
|
||||
'scheduled_jobs': scheduled_jobs,
|
||||
'scheduler_running': scheduler.running,
|
||||
'site_settings': SS.get(),
|
||||
})
|
||||
|
||||
# Paginate and serialize rows for the active job type
|
||||
qs = current_jt['get_queryset'](status_filter)
|
||||
paginator = Paginator(qs, self.PAGE_SIZE)
|
||||
page_obj = paginator.get_page(page_num)
|
||||
page_rows = [current_jt['serialize'](obj) for obj in page_obj.object_list]
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'job_type_tabs': job_type_tabs,
|
||||
'job_type_stats': job_type_stats,
|
||||
'tab': tab,
|
||||
'status_filter': status_filter,
|
||||
'page_obj': page_obj,
|
||||
'page_rows': page_rows,
|
||||
'all_ids': [r['id'] for r in page_rows],
|
||||
'bulk_actions': current_jt.get('bulk_actions', {}),
|
||||
'status_choices': current_jt.get('status_choices', []),
|
||||
'current_tab_config': {
|
||||
'id': current_jt['id'],
|
||||
'label': current_jt['label'],
|
||||
'title_label': current_jt.get('title_label', 'Title'),
|
||||
'columns': current_jt.get('columns', ['id', 'title', 'status', 'updated']),
|
||||
},
|
||||
'scheduled_jobs': scheduled_jobs,
|
||||
'scheduler_running': scheduler.running,
|
||||
'site_settings': SS.get(),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
action = request.POST.get('action')
|
||||
ids_raw = request.POST.get('ids', '')
|
||||
from .models import Screenshot, Page
|
||||
|
||||
# Parse selected IDs (comma-separated or multi-value)
|
||||
ids = [i.strip() for i in ids_raw.split(',') if i.strip()]
|
||||
|
||||
# ── Bulk screenshot actions ──────────────────────────────────────
|
||||
if action == 'bulk_fail_screenshots':
|
||||
qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
|
||||
n = qs.update(status=Screenshot.Status.FAILED, error='Marked failed by user')
|
||||
messages.success(request, _(f'Marked {n} screenshot(s) as failed.'))
|
||||
|
||||
elif action == 'bulk_retry_screenshots':
|
||||
qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
|
||||
n = qs.update(status=Screenshot.Status.PENDING, retry_count=0, error=None)
|
||||
messages.success(request, _(f'Reset {n} screenshot(s) to pending.'))
|
||||
|
||||
elif action == 'bulk_delete_screenshots':
|
||||
qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
|
||||
n = qs.delete()[0]
|
||||
messages.success(request, _(f'Deleted {n} screenshot record(s).'))
|
||||
|
||||
# ── Bulk page actions ─────────────────────────────────────────────
|
||||
elif action == 'bulk_fail_pages':
|
||||
qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
|
||||
n = qs.update(process_status=Page.ProcessStatus.FAILED, error_message='Marked failed by user')
|
||||
messages.success(request, _(f'Marked {n} page(s) as failed.'))
|
||||
|
||||
elif action == 'bulk_retry_pages':
|
||||
qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
|
||||
n = qs.update(process_status=Page.ProcessStatus.PENDING, retry_count=0, error_message='')
|
||||
messages.success(request, _(f'Reset {n} page(s) to pending.'))
|
||||
|
||||
elif action == 'bulk_delete_pages':
|
||||
qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
|
||||
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).'))
|
||||
|
||||
elif action == 'bulk_delete_netscan_runs':
|
||||
from netscan.models import ScanRun
|
||||
qs = ScanRun.objects.filter(pk__in=ids) if ids else ScanRun.objects.none()
|
||||
n = qs.delete()[0]
|
||||
messages.success(request, _(f'Deleted {n} netscan run(s).'))
|
||||
|
||||
elif action == 'bulk_delete_knowledge_graph_snapshots':
|
||||
messages.error(request, _('Unknown action.'))
|
||||
|
||||
# Preserve tab/status after POST
|
||||
tab = request.POST.get('tab', 'screenshots')
|
||||
status_filter = request.POST.get('status_filter', 'all')
|
||||
return redirect(f"{request.path}?tab={tab}&status={status_filter}")
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def generate_tts(request, post_id):
|
||||
"""Generate TTS audio for a post"""
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Links Project Memory
|
||||
|
||||
## Project: links (GoLinks + mini-apps)
|
||||
- Django + APScheduler + Tailwind + SQLite (local) / PostgreSQL (prod via DB_HOST env var)
|
||||
- Deployment: Docker → K3s at 192.168.1.2, namespace `home-apps`
|
||||
- Use `uv run manage.py` (not bare `python manage.py`) — greenlet/playwright has macOS code-signing issues locally
|
||||
- Templates extend `base.html` from `templates/base.html` (not app-level)
|
||||
- Scheduler singleton: `core.scheduler.scheduler` (BackgroundScheduler, already running from CoreConfig.ready)
|
||||
- App scheduler wiring pattern: import signals in AppConfig.ready(), wrap ORM calls in try/except
|
||||
|
||||
## NetScan sub-app (implemented 2026-03-21)
|
||||
- Located at `netscan/`
|
||||
- 3 models: ScanProfile, ScanRun, ScanFinding
|
||||
- 6 check modules in `netscan/checks/`: router, dns, ingress, cameras, tls, ports
|
||||
- Scanner orchestrator: `netscan/scanner.run_scan(profile_id, triggered_by)`
|
||||
- Telegram notifications: `netscan/notifications.py` (raw requests, no library)
|
||||
- URL prefix: `/ui/netscan/`
|
||||
- Migration written manually (0001_initial.py) — local playwright/greenlet prevents running makemigrations
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from .models import ScanProfile, ScanRun, ScanFinding
|
||||
|
||||
|
||||
@admin.register(ScanProfile)
|
||||
class ScanProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'enabled', 'schedule_interval', 'gateway_ip', 'public_ip', 'last_run_at', 'created_at']
|
||||
list_filter = ['enabled', 'schedule_interval']
|
||||
|
||||
|
||||
@admin.register(ScanRun)
|
||||
class ScanRunAdmin(admin.ModelAdmin):
|
||||
list_display = ['profile', 'status', 'triggered_by', 'started_at', 'finished_at']
|
||||
list_filter = ['status', 'triggered_by', 'profile']
|
||||
readonly_fields = ['started_at', 'finished_at', 'summary']
|
||||
|
||||
|
||||
@admin.register(ScanFinding)
|
||||
class ScanFindingAdmin(admin.ModelAdmin):
|
||||
list_display = ['run', 'check_name', 'severity', 'title']
|
||||
list_filter = ['severity', 'check_name']
|
||||
@@ -0,0 +1,89 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetscanConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'netscan'
|
||||
|
||||
def ready(self):
|
||||
import netscan.signals # noqa: F401
|
||||
self._register_job_type()
|
||||
try:
|
||||
from netscan.tasks import schedule_profile
|
||||
from netscan.models import ScanProfile
|
||||
for profile in ScanProfile.objects.filter(enabled=True):
|
||||
schedule_profile(profile)
|
||||
logger.info(f'Scheduled netscan profile: {profile.name}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not schedule netscan profiles on startup: {e}')
|
||||
|
||||
def _register_job_type(self):
|
||||
from links import job_registry
|
||||
from netscan.models import ScanRun
|
||||
from django.urls import reverse
|
||||
|
||||
def ns_stats():
|
||||
return {
|
||||
'total': ScanRun.objects.count(),
|
||||
'pending': ScanRun.objects.filter(status='pending').count(),
|
||||
'processing': ScanRun.objects.filter(status='running').count(),
|
||||
'completed': ScanRun.objects.filter(status='success').count(),
|
||||
'failed': ScanRun.objects.filter(status='failed').count(),
|
||||
}
|
||||
|
||||
def ns_queryset(sf):
|
||||
qs = ScanRun.objects.select_related('profile').order_by('-started_at')
|
||||
if sf == 'pending':
|
||||
return qs.filter(status='pending')
|
||||
if sf == 'processing':
|
||||
return qs.filter(status='running')
|
||||
if sf == 'completed':
|
||||
return qs.filter(status='success')
|
||||
if sf == 'failed':
|
||||
return qs.filter(status='failed')
|
||||
return qs
|
||||
|
||||
def ns_serialize(obj):
|
||||
try:
|
||||
detail_url = reverse('netscan-run-detail', args=[obj.pk])
|
||||
except Exception:
|
||||
detail_url = '#'
|
||||
duration = obj.duration_seconds
|
||||
return {
|
||||
'id': str(obj.id),
|
||||
'title': obj.profile.name if obj.profile else f'Run #{obj.pk}',
|
||||
'detail_url': detail_url,
|
||||
'status': obj.status,
|
||||
'retry': None,
|
||||
'retry_max': None,
|
||||
'error': obj.summary.get('error', '') if isinstance(obj.summary, dict) else '',
|
||||
'updated_at': obj.finished_at or obj.started_at,
|
||||
'extra': {
|
||||
'duration': f'{duration}s' if duration is not None else '',
|
||||
},
|
||||
}
|
||||
|
||||
job_registry.register({
|
||||
'id': 'netscan',
|
||||
'label': 'Netscan',
|
||||
'icon_color': 'text-blue-500',
|
||||
'icon_path': (
|
||||
'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v10m0 0h10M9 13H5'
|
||||
'm4 0v6m10-6v6m-5-6v6'
|
||||
),
|
||||
'title_label': 'Profile',
|
||||
'status_choices': [
|
||||
('all', 'All'), ('pending', 'Pending'), ('processing', 'Running'),
|
||||
('completed', 'Success'), ('failed', 'Failed'),
|
||||
],
|
||||
'columns': ['id', 'title', 'status', 'error', 'updated'],
|
||||
'get_stats': ns_stats,
|
||||
'get_queryset': ns_queryset,
|
||||
'serialize': ns_serialize,
|
||||
'bulk_actions': {
|
||||
'delete': 'bulk_delete_netscan_runs',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
check_name: str
|
||||
severity: str # ok | info | warning | critical
|
||||
title: str
|
||||
detail: str
|
||||
raw: dict = field(default_factory=dict)
|
||||
@@ -0,0 +1,117 @@
|
||||
import socket
|
||||
import logging
|
||||
from .base import Finding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_NAME = 'camera_rtsp'
|
||||
TIMEOUT = 5
|
||||
RTSP_PORT = 554
|
||||
|
||||
|
||||
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 _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
|
||||
@@ -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)},
|
||||
)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
NetScan
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Home network security scanner</p>
|
||||
</div>
|
||||
<a href="{% url 'netscan-profile-create' %}"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
New Profile
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not profile_data %}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-20">
|
||||
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-shield-alt text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-2">No scan profiles yet</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">Create your first scan profile to start monitoring your home network.</p>
|
||||
<a href="{% url 'netscan-profile-create' %}"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create your first scan profile
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Profiles table -->
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Profile</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Network</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden md:table-cell">Last Run</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide hidden sm:table-cell">Findings</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for item in profile_data %}
|
||||
{% with p=item.profile run=item.last_run worst=item.worst_severity %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
|
||||
<!-- Profile name + badges -->
|
||||
<td class="px-4 py-4">
|
||||
<div class="font-semibold text-gray-900 flex items-center gap-2">
|
||||
{% if worst %}
|
||||
{% if worst == 'critical' %}🔴{% elif worst == 'warning' %}🟡{% else %}🟢{% endif %}
|
||||
{% endif %}
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if p.enabled %}bg-green-100 text-green-700{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{% if p.enabled %}Enabled{% else %}Disabled{% endif %}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-600">
|
||||
Every {{ p.schedule_interval }} day{{ p.schedule_interval|pluralize }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Network info -->
|
||||
<td class="px-4 py-4 hidden sm:table-cell">
|
||||
<div class="text-gray-700 font-mono text-xs">{{ p.gateway_ip }}</div>
|
||||
<div class="text-gray-400 font-mono text-xs">{{ p.public_ip }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Last run -->
|
||||
<td class="px-4 py-4 hidden md:table-cell text-gray-500 text-xs">
|
||||
{% if p.last_run_at %}{{ p.last_run_at|date:"M d, H:i" }}{% else %}<span class="text-gray-400">Never</span>{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Findings -->
|
||||
<td class="px-4 py-4 hidden sm:table-cell">
|
||||
{% if run %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if run.summary.critical %}<span class="text-red-600 font-medium text-xs">🔴{{ run.summary.critical }}</span>{% endif %}
|
||||
{% if run.summary.warning %}<span class="text-yellow-600 font-medium text-xs">🟡{{ run.summary.warning }}</span>{% endif %}
|
||||
{% if run.summary.ok %}<span class="text-green-600 font-medium text-xs">🟢{{ run.summary.ok }}</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-300 text-xs">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<form method="post" action="{% url 'netscan-trigger' p.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-md hover:bg-red-700 font-medium">
|
||||
<i class="fas fa-play mr-1.5"></i> Run
|
||||
</button>
|
||||
</form>
|
||||
<a href="{% url 'netscan-run-list' p.pk %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-history mr-1.5"></i> History
|
||||
</a>
|
||||
<a href="{% url 'netscan-profile-edit' p.pk %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-edit mr-1.5"></i> Edit
|
||||
</a>
|
||||
<a href="{% url 'netscan-profile-delete' p.pk %}"
|
||||
class="inline-flex items-center px-2 py-1.5 bg-red-50 text-red-500 text-xs rounded-md hover:bg-red-100">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto px-4 py-12">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-trash text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-2">Delete Scan Profile</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete <strong>{{ object.name }}</strong>?
|
||||
All scan runs and findings for this profile will be permanently deleted.
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<a href="{% url 'netscan-dashboard' %}"
|
||||
class="px-6 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 font-medium">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,234 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
{{ form_title }}
|
||||
</h1>
|
||||
<a href="{% url 'netscan-dashboard' %}" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" id="profile-form" class="px-4 py-5 sm:p-6 space-y-8">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- General -->
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">General</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Name (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_name">Name</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div class="flex items-center gap-2">
|
||||
{{ form.enabled }}
|
||||
<label class="text-sm font-medium text-gray-700" for="id_enabled">Enabled</label>
|
||||
</div>
|
||||
|
||||
<!-- Schedule interval -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_schedule_interval">Scan interval</label>
|
||||
{{ form.schedule_interval }}
|
||||
{% for error in form.schedule_interval.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100">
|
||||
|
||||
<!-- Network -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Network</h2>
|
||||
<button type="button" id="auto-detect-btn"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
|
||||
<i class="fas fa-magic mr-2"></i>
|
||||
Auto-detect
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_gateway_ip">Gateway IP</label>
|
||||
{{ form.gateway_ip }}
|
||||
{% for error in form.gateway_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_public_ip">Public IP</label>
|
||||
{{ form.public_ip }}
|
||||
{% for error in form.public_ip.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_network_cidr">Network CIDR</label>
|
||||
{{ form.network_cidr }}
|
||||
{% if form.network_cidr.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.network_cidr.help_text }}</p>{% endif %}
|
||||
{% for error in form.network_cidr.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_auth_provider_host">Auth provider host</label>
|
||||
{{ form.auth_provider_host }}
|
||||
{% if form.auth_provider_host.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.auth_provider_host.help_text }}</p>{% endif %}
|
||||
{% for error in form.auth_provider_host.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_domains_text">Domains</label>
|
||||
{{ form.domains_text }}
|
||||
{% if form.domains_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.domains_text.help_text }}</p>{% endif %}
|
||||
{% for error in form.domains_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_cameras_text">Camera IPs</label>
|
||||
{{ form.cameras_text }}
|
||||
{% if form.cameras_text.help_text %}<p class="text-xs text-gray-400 mt-1">{{ form.cameras_text.help_text }}</p>{% endif %}
|
||||
{% for error in form.cameras_text.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100">
|
||||
|
||||
<!-- Telegram Notifications -->
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Telegram Notifications</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_bot_token">Bot token</label>
|
||||
{{ form.telegram_bot_token }}
|
||||
{% for error in form.telegram_bot_token.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_telegram_chat_id">Chat ID</label>
|
||||
{{ form.telegram_chat_id }}
|
||||
{% for error in form.telegram_chat_id.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" for="id_notify_on_severity">Notify on severity</label>
|
||||
{{ form.notify_on_severity }}
|
||||
{% for error in form.notify_on_severity.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if object.pk %}
|
||||
<div class="mt-4">
|
||||
<button type="button" id="test-telegram-btn"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium border border-blue-200">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Test Telegram
|
||||
</button>
|
||||
<span id="test-telegram-result" class="ml-3 text-sm hidden"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||
<a href="{% url 'netscan-dashboard' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('auto-detect-btn').addEventListener('click', function () {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Detecting...';
|
||||
|
||||
fetch("{% url 'netscan-detect-network' %}")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.gateway_ip) document.getElementById('id_gateway_ip').value = data.gateway_ip;
|
||||
if (data.public_ip) document.getElementById('id_public_ip').value = data.public_ip;
|
||||
if (data.network_cidr) document.getElementById('id_network_cidr').value = data.network_cidr;
|
||||
|
||||
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Detected!';
|
||||
btn.classList.replace('text-blue-700', 'text-green-700');
|
||||
btn.classList.replace('bg-blue-50', 'bg-green-50');
|
||||
btn.classList.replace('border-blue-200', 'border-green-200');
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
|
||||
btn.classList.replace('text-green-700', 'text-blue-700');
|
||||
btn.classList.replace('bg-green-50', 'bg-blue-50');
|
||||
btn.classList.replace('border-green-200', 'border-blue-200');
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
btn.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Failed';
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic mr-2"></i>Auto-detect';
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if object.pk %}
|
||||
<script>
|
||||
document.getElementById('test-telegram-btn').addEventListener('click', function () {
|
||||
const btn = this;
|
||||
const result = document.getElementById('test-telegram-result');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Sending...';
|
||||
result.className = 'ml-3 text-sm hidden';
|
||||
|
||||
fetch("{% url 'netscan-test-telegram' object.pk %}", {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}'},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
result.classList.remove('hidden');
|
||||
if (data.ok) {
|
||||
result.className = 'ml-3 text-sm text-green-600';
|
||||
result.textContent = '✓ Test message sent!';
|
||||
} else {
|
||||
result.className = 'ml-3 text-sm text-red-600';
|
||||
result.textContent = '✗ ' + (data.error || 'Failed');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
result.classList.remove('hidden');
|
||||
result.className = 'ml-3 text-sm text-red-600';
|
||||
result.textContent = '✗ Network error';
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Test Telegram';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400 mb-4">
|
||||
<a href="{% url 'netscan-dashboard' %}" class="hover:text-gray-600">NetScan</a>
|
||||
<span>/</span>
|
||||
<a href="{% url 'netscan-run-list' run.profile.pk %}" class="hover:text-gray-600">{{ run.profile.name }}</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-600">Run #{{ run.pk }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div class="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ run.profile.name }} — Run #{{ run.pk }}</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
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 <strong>{{ run.triggered_by }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if run.status == 'success' %}bg-green-100 text-green-700
|
||||
{% elif run.status == 'failed' %}bg-red-100 text-red-700
|
||||
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
<form method="post" action="{% url 'netscan-trigger' run.profile.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
|
||||
<i class="fas fa-redo mr-1.5"></i> Re-run
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary badges -->
|
||||
<div class="flex flex-wrap gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
{% if run.summary.critical %}
|
||||
<div class="flex items-center gap-1.5 bg-red-50 text-red-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🔴 {{ run.summary.critical }} Critical
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.warning %}
|
||||
<div class="flex items-center gap-1.5 bg-yellow-50 text-yellow-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🟡 {{ run.summary.warning }} Warning
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.ok %}
|
||||
<div class="flex items-center gap-1.5 bg-green-50 text-green-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
🟢 {{ run.summary.ok }} OK
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if run.summary.info %}
|
||||
<div class="flex items-center gap-1.5 bg-blue-50 text-blue-700 px-3 py-1.5 rounded-lg text-sm font-semibold">
|
||||
ℹ️ {{ run.summary.info }} Info
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical -->
|
||||
{% if critical_findings %}
|
||||
<details class="mb-4 open" open>
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-red-50 border border-red-200 rounded-xl px-5 py-3 font-semibold text-red-800 select-none">
|
||||
🔴 Critical Findings ({{ critical_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in critical_findings %}
|
||||
<div class="bg-white border border-red-200 rounded-xl p-4">
|
||||
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Warnings -->
|
||||
{% if warning_findings %}
|
||||
<details class="mb-4">
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-yellow-50 border border-yellow-200 rounded-xl px-5 py-3 font-semibold text-yellow-800 select-none">
|
||||
🟡 Warnings ({{ warning_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in warning_findings %}
|
||||
<div class="bg-white border border-yellow-200 rounded-xl p-4">
|
||||
<div class="font-medium text-gray-900 mb-1">{{ f.title }}</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- OK / Info -->
|
||||
{% if ok_findings %}
|
||||
<details class="mb-4">
|
||||
<summary class="flex items-center gap-2 cursor-pointer bg-green-50 border border-green-200 rounded-xl px-5 py-3 font-semibold text-green-800 select-none">
|
||||
🟢 OK / Info ({{ ok_findings|length }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for f in ok_findings %}
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-2 font-medium text-gray-900 mb-1">
|
||||
{% if f.severity == 'info' %}ℹ️{% else %}🟢{% endif %}
|
||||
{{ f.title }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{{ f.detail }}</p>
|
||||
{% if f.raw %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600">View raw data</summary>
|
||||
<pre class="mt-2 bg-gray-50 rounded-lg p-3 text-xs overflow-auto text-gray-700">{{ f.raw|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if not critical_findings and not warning_findings and not ok_findings %}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-hourglass-half text-3xl mb-2"></i>
|
||||
<p>No findings recorded yet — the scan may still be running.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<a href="{% url 'netscan-dashboard' %}" class="text-gray-400 hover:text-gray-600 text-sm">
|
||||
<i class="fas fa-arrow-left mr-1"></i> NetScan
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mt-1">{{ profile.name }} — Scan History</h1>
|
||||
</div>
|
||||
<form method="post" action="{% url 'netscan-trigger' profile.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium text-sm">
|
||||
<i class="fas fa-play mr-2"></i> Run Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if runs %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Started</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Duration</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Triggered by</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Status</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Findings</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for run in runs %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-gray-800 font-mono text-xs">{{ run.started_at|date:"M d, H:i:s" }}</td>
|
||||
<td class="px-4 py-3 text-gray-500">
|
||||
{% if run.duration_seconds is not None %}{{ run.duration_seconds }}s{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if run.triggered_by == 'manual' %}bg-blue-50 text-blue-700{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.triggered_by }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if run.status == 'success' %}bg-green-100 text-green-700
|
||||
{% elif run.status == 'failed' %}bg-red-100 text-red-700
|
||||
{% elif run.status == 'running' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="flex items-center gap-2">
|
||||
{% if run.summary.critical %}<span class="text-red-600 font-medium">🔴{{ run.summary.critical }}</span>{% endif %}
|
||||
{% if run.summary.warning %}<span class="text-yellow-600 font-medium">🟡{{ run.summary.warning }}</span>{% endif %}
|
||||
{% if run.summary.ok %}<span class="text-green-600 font-medium">🟢{{ run.summary.ok }}</span>{% endif %}
|
||||
{% if run.summary.info %}<span class="text-blue-600 font-medium">ℹ️{{ run.summary.info }}</span>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{% url 'netscan-run-detail' run.pk %}"
|
||||
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View →</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6 gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">← Prev</a>
|
||||
{% endif %}
|
||||
<span class="px-3 py-1.5 text-sm text-gray-600">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm hover:bg-gray-50">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<i class="fas fa-history text-4xl mb-3"></i>
|
||||
<p>No scan runs yet. Click <strong>Run Now</strong> to start.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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/<int:pk>/edit/', views.ProfileUpdateView.as_view(), name='netscan-profile-edit'),
|
||||
path('profile/<int:pk>/delete/', views.ProfileDeleteView.as_view(), name='netscan-profile-delete'),
|
||||
path('profile/<int:pk>/runs/', views.ScanRunListView.as_view(), name='netscan-run-list'),
|
||||
path('profile/<int:pk>/trigger/', views.TriggerScanView.as_view(), name='netscan-trigger'),
|
||||
path('profile/<int:pk>/test-telegram/', views.TestTelegramView.as_view(), name='netscan-test-telegram'),
|
||||
path('detect-network/', views.DetectNetworkView.as_view(), name='netscan-detect-network'),
|
||||
path('run/<int:pk>/', views.ScanRunDetailView.as_view(), name='netscan-run-detail'),
|
||||
]
|
||||
@@ -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)
|
||||
Generated
+251
-716
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user