This commit is contained in:
2026-04-18 14:19:51 +10:00
193 changed files with 18465 additions and 2166 deletions
+51 -15
View File
@@ -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
+3
View File
@@ -101,3 +101,6 @@ DerivedData/
# Swift Package Manager
.swiftpm/
.build/
data/db.sqlite3
.playwright-mcp/
+13
View File
@@ -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
View File
@@ -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.70250 (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
+85 -197
View File
@@ -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/
+42 -19
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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')),
]
BIN
View File
Binary file not shown.
+105
View File
@@ -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'],
},
),
]
+166
View File
@@ -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 }}
+326
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
},
})
+5
View File
@@ -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'
+16
View File
@@ -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'),
]
+275
View File
@@ -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
View File
@@ -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
View File
@@ -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'}
+50
View File
@@ -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)
+35
View File
@@ -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'],
},
),
]
+23
View File
@@ -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'),
),
]
+88
View File
@@ -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.01.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'),
),
]
+18
View File
@@ -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',
),
]
+96 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+7 -5
View File
@@ -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 -3
View File
@@ -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
+2 -87
View File
@@ -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
+622
View File
@@ -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
View File
@@ -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" %}
+567
View File
@@ -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 %}
+2 -1
View File
@@ -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');
+1 -1
View File
@@ -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;
+190
View File
@@ -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&#10;5.6.7.0/24&#10;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 %}
+14 -22
View File
@@ -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>
+1 -2
View File
@@ -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 %}
+4 -8
View File
@@ -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
+2 -119
View File
@@ -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 %}
+422 -8
View File
@@ -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 %}
+4 -10
View File
@@ -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
+2 -77
View File
@@ -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 %}
+256 -23
View File
@@ -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>
+4 -474
View File
@@ -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;
+224
View File
@@ -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: 13 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: 103600. 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: 103600. 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 %}
+10 -10
View File
@@ -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');
+2
View File
@@ -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)
]
+4
View File
@@ -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
View File
@@ -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"""
+18
View File
@@ -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
View File
+21
View File
@@ -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']
+89
View File
@@ -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',
},
})
View File
+10
View File
@@ -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)
+117
View File
@@ -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
+86
View File
@@ -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)},
)]
+93
View File
@@ -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
+58
View File
@@ -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
+117
View File
@@ -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
+131
View File
@@ -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
+39
View File
@@ -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()
+73
View File
@@ -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
+63
View File
@@ -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),
),
]
View File
+84
View File
@@ -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}'
+60
View File
@@ -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)}
+74
View File
@@ -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
+17
View File
@@ -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)
+25
View File
@@ -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}')
+133
View File
@@ -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 %}
+234
View File
@@ -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 %}
+147
View File
@@ -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 %}
+98
View File
@@ -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 %}
+14
View File
@@ -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'),
]
+215
View File
@@ -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)
+251 -716
View File
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