commit ea1e1eead6ec1201e01fbd5fe42a748d654609a5 Author: Hemmat Date: Fri Aug 7 21:17:17 2026 +0330 start project diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..527d08b --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Copy to .env for local dev (python-dotenv). In production, set these via +# cPanel's Python App "Environment Variables" UI — never commit a real .env. + +FLASK_ENV=development +SECRET_KEY=change-me +DATABASE_URL=sqlite:///kavosh.db +DB_POOL_SIZE=3 +DB_POOL_RECYCLE_SECONDS=280 + +CACHE_TYPE=FileSystemCache +CACHE_DIR=/tmp/kavosh-cache +CACHE_DEFAULT_TIMEOUT=60 + +SESSION_COOKIE_SECURE=true +SESSION_LIFETIME_HOURS=12 + +UPLOAD_MAX_SIZE_MB=500 +UPLOAD_DIR=/home/kavosh/uploads +PARSE_BATCH_SIZE=5000 + +ADMIN_EMAIL=admin@example.com + +# Optional (Chapter 12): only used if you want a rotating-file logging +# fallback in addition to stdout — leave unset to log to stdout only. +# LOG_FALLBACK_FILE=/home/kavosh/logs/kavosh.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..591e94c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.env +*.db +instance/ +/app/static/dist/* +!/app/static/dist/.gitkeep +/node_modules/ +.pytest_cache/ +*.egg-info/ +.DS_Store diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..d08cc5b --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,77 @@ +# Kavosh — cPanel Deployment Checklist + +No cron job is required to run Kavosh. Log parsing starts automatically +in the background as soon as a file is uploaded, and any file left +incomplete (e.g. a worker process recycled mid-parse) resumes the next +time the Overview tab is loaded. See "Optional: cron" at the bottom if +you'd still like the extra resilience of a scheduled fallback. + +1. **Setup Python App** (cPanel) — create the app; note the virtualenv + path and set the startup file to `passenger_wsgi.py`. +2. Activate the generated virtualenv and install dependencies: + ``` + pip install -r requirements.txt + ``` + (Dev-only tools — `pytest` etc. — live in `requirements-dev.txt` and + are NOT installed on the host, per Ch02 factor 5's build/release/run + separation.) +3. Set every variable in `.env.example` via the Python App's + **Environment Variables** UI — never a committed `.env` in production. +4. Create the schema: + ``` + flask db upgrade + ``` +5. Bootstrap the single admin account (interactive password prompt keeps + the raw credential out of process env/config): + ``` + flask create-admin --email you@example.com + ``` +6. Build frontend assets **locally or in CI**, not on the host: + ``` + npm install && npm run build + ``` + Deploy only the resulting `app/static/dist/` output alongside the + Python app — Node never runs on the server (Chapter 05). +7. Configure static file mapping (`.htaccess` or the panel's static-file + rule) so `app/static/dist/*` bypasses Python entirely (Chapter 03). +8. Verify `GET /healthz` returns `{"data": {"status": "ok"}, ...}`, then + log in and upload a file — it should start analyzing within a second + or two, with no further setup needed. + +## Optional: cron + +Two commands remain available for anyone who wants the extra resilience +of a scheduled fallback instead of relying solely on automatic/on-visit +processing: + +``` +*/5 * * * * cd /home/YOURUSER/kavosh && /home/YOURUSER/virtualenv/kavosh/3.11/bin/flask process-logs >> /home/YOURUSER/logs/kavosh-process-logs.log 2>&1 +0 3 * * * cd /home/YOURUSER/kavosh && /home/YOURUSER/virtualenv/kavosh/3.11/bin/flask cleanup >> /home/YOURUSER/logs/kavosh-cleanup.log 2>&1 +``` + +`cleanup` enforces the Chapter 06 retention policy (below) — nothing in +the UI depends on it having run; without it, old raw data simply +accumulates instead of being pruned. `process-logs` is a fallback for +the rare case a background thread dies before finishing (see +`app/services/background.py` for why that can happen and how the app +recovers without cron anyway). + +## Local development +``` +cp .env.example .env # fill in real values +pip install -r requirements.txt -r requirements-dev.txt +npm install && npm run build # or `npm run dev` while iterating on frontend +flask db upgrade +flask create-admin --email you@example.com +flask run +pytest # run the test suite +``` + +## Retention policy enforced by `flask cleanup` (Chapter 06/12, optional) +| Table | Retention | +|---|---| +| `log_entries` | ~30 days | +| `request_stats_hourly` | ~90 days (daily rollup already retained indefinitely) | +| `ip_path_stats_daily` / `ip_status_stats_daily` | ~30 days | +| Raw uploaded log files | deleted once `status="done"` | +| Everything else (`request_stats_daily`, `bot_hits`, `suspicious_events`, `referrer_stats_daily`, `browser_stats_daily`, `human_path_stats_daily`, `blocklist_suggestions`) | indefinite — small, bounded-cardinality row counts | diff --git a/README.md b/README.md new file mode 100644 index 0000000..e98cb05 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Kavosh + +Self-hosted dashboard that ingests Apache/LiteSpeed access logs and turns +them into three sections — Overview, SEO & Bot Behavior, and Suspicious +Requests/IP History — engineered to run inside a constrained cPanel +shared-hosting account (4 CPU cores, 60 entry processes, 2GB RAM, 1,024 +IOPS, 16MB/s I/O, 150 processes, 150 DB connections). + +Stack: Flask (application factory + blueprints) + HTMX/Alpine.js + +Tailwind + Chart.js + Grid.js, served via Passenger, SQLite in WAL mode. +No cron job is required — uploads process automatically in the +background, with light/dark mode and live progress bars for both the +upload and analysis phases. + +## Project docs +- `DEPLOYMENT.md` — cPanel deployment checklist + local dev setup (no cron needed) +- `docs/api-contract-final.md` — consolidated route table + envelope audit +- `.env.example` — every environment variable the app reads +- `migrations/versions/0001`–`0006` — schema history, in order + +## Layout +``` +app/ + blueprints/ overview, seo, security, uploads, auth, api (routes) + models/ SQLAlchemy models — one file per table + services/ log parsing, bot/threat classification, aggregation, + background.py (no-cron auto-processing) + static/src Tailwind/JS source (built via Vite -> static/dist) + templates Jinja base + HTMX partials +migrations/ Alembic schema history +tests/ pytest suite (parser + classifier + route smoke tests, + real log-file fixtures under tests/fixtures/) +``` + +## Quick start (local dev) +``` +cp .env.example .env # fill in real values +pip install -r requirements.txt -r requirements-dev.txt +npm install && npm run build # or `npm run dev` while iterating on frontend +flask db upgrade +flask create-admin --email you@example.com +flask run +pytest +``` + +Visit `/` — it redirects to `/overview` if you're logged in, or `/login` +otherwise. Upload a log file and it starts analyzing immediately; no +cron job or manual CLI command required (see `DEPLOYMENT.md` if you want +the optional cron fallback anyway). The Overview page also lists every +uploaded file with select-and-delete — deleting a file removes its raw +data and correctly recomputes any shared date rollups, rather than just +subtracting the file's contribution naively (see `app/services/ +file_deletion.py`). + +## Design system +- Colors/fonts are defined as Tailwind tokens in `tailwind.config.js` + (`paper`/`surface`/`ink`/`muted`/`accent`/`danger`/`warn`/`ok`, each + with a `-dark` counterpart) — not hardcoded `slate-*` classes. +- Three type roles: `font-display` (Space Grotesk, headings), `font-sans` + (Public Sans, UI chrome), `font-data` (JetBrains Mono, every number/IP/ + path/timestamp — the app's one deliberate signature touch, since the + whole product is "raw log lines turned into a readout"). +- Dark mode toggles a `.dark` class on ``, persisted in + `localStorage`, set synchronously in `base.html`'s `` to avoid a + flash of the wrong theme on load. + +## Notes on how this was built +Built chapter-by-chapter against a 12-chapter project spec, then extended +per project-owner follow-up requests (removing the cron requirement, +dark mode, upload/analysis progress bars, root-URL auth redirect). Where +an implementation choice extended or deviated from the original spec, it's +marked inline with a comment — search for "flagged", "ASSUMPTION", or +"TRADEOFF" to find every one of them. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..cfa8561 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,84 @@ +"""Application factory (Chapter 02 / Chapter 04).""" +from __future__ import annotations + +from flask import Flask, jsonify, redirect, request, url_for +from flask_login import current_user +from sqlalchemy import text + +from app.budget import validate_budget_config +from app.config import get_config +from app.extensions import cache, csrf, db, limiter, login_manager, migrate +from app.logging_setup import configure_logging +from app.utils.assets import register_asset_helper + + +def create_app(config_name: str | None = None) -> Flask: + """Build and configure the Flask application instance.""" + app = Flask(__name__) + app.config.from_object(get_config(config_name)) + validate_budget_config(app) # Chapter 03: fail loudly on out-of-budget config + configure_logging(app) # Chapter 02 factor 11 / Chapter 12 + + if not app.config.get("SECRET_KEY") and not app.testing: + raise RuntimeError("SECRET_KEY must be set via environment variable") + + db.init_app(app) + cache.init_app(app) + csrf.init_app(app) + login_manager.init_app(app) + migrate.init_app(app, db) + limiter.init_app(app) # Chapter 12: rate limiting + + register_blueprints(app) + register_cli(app) + register_asset_helper(app) + + @login_manager.unauthorized_handler + def handle_unauthorized(): + """Chapter 11 envelope compliance: JSON 401 for /api/... paths + instead of Flask-Login's default redirect (which would hand back + login HTML to a fetch()/HTMX JSON caller). Full-page routes still + redirect to the login form as normal. + """ + if request.path.startswith("/api/"): + return jsonify(error={"code": "unauthorized", "message": "Authentication required."}), 401 + return redirect(url_for("auth.login", next=request.path)) + + @app.get("/healthz") + def healthz(): + """Liveness/readiness check (Chapter 12) — DB reachable, no long-running work.""" + db.session.execute(text("SELECT 1")) + return {"data": {"status": "ok"}, "meta": {}} + + @app.get("/") + def index(): + """Root URL: authenticated -> Overview, otherwise -> the login page.""" + if current_user.is_authenticated: + return redirect(url_for("overview.overview")) + return redirect(url_for("auth.login")) + + return app + + +def register_blueprints(app: Flask) -> None: + """Register one blueprint per dashboard section + support area (Ch. 04).""" + from app.blueprints.api import bp as api_bp + from app.blueprints.auth import bp as auth_bp + from app.blueprints.overview import bp as overview_bp + from app.blueprints.security import bp as security_bp + from app.blueprints.seo import bp as seo_bp + from app.blueprints.uploads import bp as uploads_bp + + app.register_blueprint(overview_bp) + app.register_blueprint(seo_bp) + app.register_blueprint(security_bp) + app.register_blueprint(uploads_bp) + app.register_blueprint(api_bp) + app.register_blueprint(auth_bp) + + +def register_cli(app: Flask) -> None: + """Attach `flask ` admin processes (factor 12).""" + from app.cli import register_commands + + register_commands(app) diff --git a/app/blueprints/api/__init__.py b/app/blueprints/api/__init__.py new file mode 100644 index 0000000..f1ea918 --- /dev/null +++ b/app/blueprints/api/__init__.py @@ -0,0 +1,7 @@ +"""Deliberately minimal — Chapter 02's tree lists a standalone `api` +blueprint, but Chapter 04/11 route each section's JSON endpoints through +its own blueprint instead. Left unpopulated per the project owner's +instruction to defer this branch.""" +from flask import Blueprint + +bp = Blueprint("api", __name__) diff --git a/app/blueprints/auth/__init__.py b/app/blueprints/auth/__init__.py new file mode 100644 index 0000000..9595998 --- /dev/null +++ b/app/blueprints/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint("auth", __name__, template_folder="templates") + +from app.blueprints.auth import routes # noqa: E402,F401 registers routes diff --git a/app/blueprints/auth/forms.py b/app/blueprints/auth/forms.py new file mode 100644 index 0000000..9bcdc2e --- /dev/null +++ b/app/blueprints/auth/forms.py @@ -0,0 +1,18 @@ +"""Login form (Chapter 12). CSRF handled automatically via form.hidden_tag() +(Flask-WTF, Ch12's CSRF requirement).""" +from __future__ import annotations + +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired + + +class LoginForm(FlaskForm): + # NOTE: no Email() validator — that validator requires the extra + # `email-validator` package. Login checks the value against a stored + # user row anyway, so a malformed email simply fails to match rather + # than needing format validation up front; kept simple to avoid a new + # dependency. + email = StringField("Email", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Log in") diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py new file mode 100644 index 0000000..97e3eb3 --- /dev/null +++ b/app/blueprints/auth/routes.py @@ -0,0 +1,45 @@ +"""Single-admin auth routes (Chapter 12 / Chapter 11's route table).""" +from __future__ import annotations + +from flask import flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from app.blueprints.auth import bp +from app.blueprints.auth.forms import LoginForm +from app.extensions import db, limiter, login_manager +from app.models.user import User + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@bp.route("/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") # Ch12: rate limiting on /login at minimum +def login(): + if current_user.is_authenticated: + return redirect(url_for("overview.overview")) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.strip().lower()).first() + if user is not None and user.check_password(form.password.data): + login_user(user) + next_url = request.args.get("next") or url_for("overview.overview") + return redirect(next_url) + flash("Invalid email or password.") + + return render_template("auth/login.html", form=form) + + +@bp.post("/logout") +@login_required +def logout(): + # NOTE (flagged): Ch11's route table lists "/login, /logout" under a + # shared "GET/POST" column. Logout is POST-only here — a state-changing + # action behind a plain GET is a CSRF-adjacent anti-pattern Ch12's own + # CSRF requirement argues against; login stays GET (show form) + POST + # (submit), matching the table as-is. + logout_user() + return redirect(url_for("auth.login")) diff --git a/app/blueprints/auth/templates/auth/login.html b/app/blueprints/auth/templates/auth/login.html new file mode 100644 index 0000000..b66731a --- /dev/null +++ b/app/blueprints/auth/templates/auth/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Log in — Kavosh{% endblock %} +{% block content %} +
+
+

Kavosh

+

Sign in to view your site's traffic

+
+
+ {% for message in get_flashed_messages() %} +

{{ message }}

+ {% endfor %} +
+ {{ form.hidden_tag() }} +
+ {{ form.email.label(class="block text-sm font-medium text-muted dark:text-muted-dark mb-1") }} + {{ form.email(class="w-full border border-line dark:border-line-dark rounded-md px-3 py-2 bg-paper dark:bg-paper-dark text-ink dark:text-ink-dark focus:outline-none focus:ring-2 focus:ring-accent dark:focus:ring-accent-dark", autofocus=true) }} +
+
+ {{ form.password.label(class="block text-sm font-medium text-muted dark:text-muted-dark mb-1") }} + {{ form.password(class="w-full border border-line dark:border-line-dark rounded-md px-3 py-2 bg-paper dark:bg-paper-dark text-ink dark:text-ink-dark focus:outline-none focus:ring-2 focus:ring-accent dark:focus:ring-accent-dark") }} +
+ {{ form.submit(class="w-full bg-accent dark:bg-accent-dark text-white dark:text-paper-dark font-medium rounded-md px-4 py-2 hover:opacity-90 transition-opacity cursor-pointer") }} +
+
+
+{% endblock %} diff --git a/app/blueprints/overview/__init__.py b/app/blueprints/overview/__init__.py new file mode 100644 index 0000000..d2634b6 --- /dev/null +++ b/app/blueprints/overview/__init__.py @@ -0,0 +1,13 @@ +from flask import Blueprint +from flask_login import login_required + +bp = Blueprint("overview", __name__, template_folder="templates") + + +@bp.before_request +@login_required +def require_login(): + pass + + +from app.blueprints.overview import routes # noqa: E402,F401 registers routes diff --git a/app/blueprints/overview/queries.py b/app/blueprints/overview/queries.py new file mode 100644 index 0000000..5823af7 --- /dev/null +++ b/app/blueprints/overview/queries.py @@ -0,0 +1,167 @@ +"""Context-builder query functions for the Overview tab (Chapter 08). + +Every function here reads request_stats_hourly / request_stats_daily / +referrer_stats_daily / browser_stats_daily — never log_entries — per +Ch03 rule 6 / Ch08's own data-source rule. +""" +from __future__ import annotations + +from datetime import date + +from sqlalchemy import case, func + +from app.extensions import db +from app.models.browser_stats import BrowserStatsDaily +from app.models.referrer_stats import ReferrerStatsDaily +from app.models.request_stats import RequestStatsDaily, RequestStatsHourly +from app.utils.dates import day_bounds + +# ASSUMPTION (flagged in Ch08): Ch08 doesn't give a numeric threshold for +# "hourly vs daily depending on range width" — picked 3 days. +HOURLY_GRANULARITY_THRESHOLD_DAYS = 3 + + +def get_kpis(from_date: date, to_date: date) -> dict: + """All six Ch08 KPI-card fields, plus peak-day (folded in — Ch11 has + no dedicated route for it and Ch08 calls for only a 'simple max-lookup'). + """ + row = ( + db.session.query( + func.coalesce(func.sum(RequestStatsDaily.count), 0).label("total_requests"), + func.coalesce(func.sum(RequestStatsDaily.unique_ips), 0).label("unique_ips_sum"), + func.coalesce(func.sum(RequestStatsDaily.bytes_sum), 0).label("total_bandwidth"), + func.coalesce(func.sum(RequestStatsDaily.error_count), 0).label("total_errors"), + ) + .filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date) + .one() + ) + num_days = (to_date - from_date).days + 1 + avg_response_size = (row.total_bandwidth / row.total_requests) if row.total_requests else 0.0 + error_rate_pct = (row.total_errors / row.total_requests * 100) if row.total_requests else 0.0 + avg_requests_per_day = row.total_requests / num_days if num_days else 0.0 + + peak_row = ( + db.session.query(RequestStatsDaily.date, RequestStatsDaily.count) + .filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date) + .order_by(RequestStatsDaily.count.desc()) + .first() + ) + + return { + "total_requests": row.total_requests, + # APPROXIMATION (flagged in Ch08): sum of daily unique_ips over-counts + # repeat visitors across days. + "unique_ips": row.unique_ips_sum, + "total_bandwidth_bytes": row.total_bandwidth, + "avg_response_size_bytes": round(avg_response_size, 1), + "error_rate_pct": round(error_rate_pct, 2), + "avg_requests_per_day": round(avg_requests_per_day, 1), + "peak_day": {"date": peak_row.date.isoformat(), "count": peak_row.count} if peak_row else None, + } + + +def get_traffic_chart_series(from_date: date, to_date: date) -> dict: + span_days = (to_date - from_date).days + 1 + if span_days <= HOURLY_GRANULARITY_THRESHOLD_DAYS: + start, end = day_bounds(from_date, to_date) + rows = ( + db.session.query(RequestStatsHourly.date_hour, func.sum(RequestStatsHourly.count).label("count")) + .filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end) + .group_by(RequestStatsHourly.date_hour) + .order_by(RequestStatsHourly.date_hour) + .all() + ) + return {"granularity": "hourly", "series": [{"t": r.date_hour.isoformat(), "count": r.count} for r in rows]} + + rows = ( + db.session.query(RequestStatsDaily.date, RequestStatsDaily.count) + .filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date) + .order_by(RequestStatsDaily.date) + .all() + ) + return {"granularity": "daily", "series": [{"t": r.date.isoformat(), "count": r.count} for r in rows]} + + +def get_status_code_breakdown(from_date: date, to_date: date) -> dict: + start, end = day_bounds(from_date, to_date) + bucket = case( + (RequestStatsHourly.status_code < 300, "2xx"), + (RequestStatsHourly.status_code < 400, "3xx"), + (RequestStatsHourly.status_code < 500, "4xx"), + else_="5xx", + ) + rows = ( + db.session.query(bucket.label("bucket"), func.sum(RequestStatsHourly.count).label("count")) + .filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end) + .group_by("bucket") + .all() + ) + breakdown = {"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0} + for r in rows: + breakdown[r.bucket] = r.count + return breakdown + + +def get_top_urls(from_date: date, to_date: date, page: int, per_page: int) -> tuple[list[list], int]: + """Top URLs by hits. NOTE (flagged in Ch08): only available within the + ~90-day hourly retention window (Ch06) — request_stats_daily has no + path column, so a wider range returns nothing here. + """ + start, end = day_bounds(from_date, to_date) + hits = func.sum(RequestStatsHourly.count) + errors = func.sum(case((RequestStatsHourly.status_code >= 400, RequestStatsHourly.count), else_=0)) + bytes_sum = func.sum(RequestStatsHourly.bytes_sent_sum) + + base_query = ( + db.session.query(RequestStatsHourly.path, hits.label("hits"), bytes_sum.label("bytes_sum"), errors.label("errors")) + .filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end) + .group_by(RequestStatsHourly.path) + ) + total = base_query.count() + rows = base_query.order_by(hits.desc()).offset((page - 1) * per_page).limit(per_page).all() + + results = [ + [ + r.path, + r.hits, + round(r.bytes_sum / r.hits, 1) if r.hits else 0.0, + round(r.errors / r.hits * 100, 2) if r.hits else 0.0, + ] + for r in rows + ] + return results, total + + +def get_top_referrers(from_date: date, to_date: date, page: int, per_page: int) -> tuple[list[list], int]: + """Domain-bucketed referrers (Method A, Ch08 follow-up).""" + hits = func.sum(ReferrerStatsDaily.count) + base_query = ( + db.session.query(ReferrerStatsDaily.referrer_domain, hits.label("hits")) + .filter(ReferrerStatsDaily.date >= from_date, ReferrerStatsDaily.date <= to_date) + .group_by(ReferrerStatsDaily.referrer_domain) + ) + total = base_query.count() + rows = base_query.order_by(hits.desc()).offset((page - 1) * per_page).limit(per_page).all() + return [[r.referrer_domain, r.hits] for r in rows], total + + +def get_browser_breakdown(from_date: date, to_date: date) -> dict: + """Human-only (bots excluded at rollup-write time, Ch08).""" + browser_rows = ( + db.session.query(BrowserStatsDaily.browser, func.sum(BrowserStatsDaily.count).label("count")) + .filter(BrowserStatsDaily.date >= from_date, BrowserStatsDaily.date <= to_date) + .group_by(BrowserStatsDaily.browser) + .order_by(func.sum(BrowserStatsDaily.count).desc()) + .all() + ) + os_rows = ( + db.session.query(BrowserStatsDaily.os, func.sum(BrowserStatsDaily.count).label("count")) + .filter(BrowserStatsDaily.date >= from_date, BrowserStatsDaily.date <= to_date) + .group_by(BrowserStatsDaily.os) + .order_by(func.sum(BrowserStatsDaily.count).desc()) + .all() + ) + return { + "by_browser": [{"name": r.browser, "count": r.count} for r in browser_rows], + "by_os": [{"name": r.os, "count": r.count} for r in os_rows], + } diff --git a/app/blueprints/overview/routes.py b/app/blueprints/overview/routes.py new file mode 100644 index 0000000..7f5defb --- /dev/null +++ b/app/blueprints/overview/routes.py @@ -0,0 +1,94 @@ +"""Overview blueprint routes (Chapter 08 / Chapter 11's route table).""" +from __future__ import annotations + +from flask import current_app, jsonify, request + +from app.blueprints.overview import bp +from app.blueprints.overview.queries import ( + get_browser_breakdown, + get_kpis, + get_status_code_breakdown, + get_top_referrers, + get_top_urls, + get_traffic_chart_series, +) +from app.services.background import resume_incomplete_files +from app.utils.dates import parse_date_range +from app.utils.htmx import render_htmx_aware +from app.utils.pagination import parse_pagination + + +@bp.route("/overview") +def overview(): + """Full page on first load, HTMX partial on tab switch / range change (Ch04). + + Also opportunistically resumes any log_files stuck in queued/processing + (Ch12 simplification: the replacement for cron's "there's always a + next tick" guarantee — see app/services/background.py). + """ + resume_incomplete_files(current_app._get_current_object()) + from_date, to_date = parse_date_range(request) + return render_htmx_aware( + request, + full_template="overview/index.html", + partial_template="overview/_content.html", + from_date=from_date, + to_date=to_date, + ) + + +@bp.get("/api/overview/kpis") +def api_kpis(): + from_date, to_date = parse_date_range(request) + return jsonify(data=get_kpis(from_date, to_date), meta={"from": from_date.isoformat(), "to": to_date.isoformat()}) + + +@bp.get("/api/overview/traffic-chart") +def api_traffic_chart(): + from_date, to_date = parse_date_range(request) + return jsonify( + data=get_traffic_chart_series(from_date, to_date), + meta={"from": from_date.isoformat(), "to": to_date.isoformat()}, + ) + + +@bp.get("/api/overview/status-codes") +def api_status_codes(): + from_date, to_date = parse_date_range(request) + return jsonify( + data=get_status_code_breakdown(from_date, to_date), + meta={"from": from_date.isoformat(), "to": to_date.isoformat()}, + ) + + +@bp.get("/api/overview/top-urls") +def api_top_urls(): + from_date, to_date = parse_date_range(request) + page, per_page = parse_pagination(request) + rows, total = get_top_urls(from_date, to_date, page, per_page) + return jsonify( + data={"rows": rows, "total": total}, + meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page}, + ) + + +@bp.get("/api/overview/top-referrers") +def api_top_referrers(): + from_date, to_date = parse_date_range(request) + page, per_page = parse_pagination(request) + rows, total = get_top_referrers(from_date, to_date, page, per_page) + return jsonify( + data={"rows": rows, "total": total}, + meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page}, + ) + + +@bp.get("/api/overview/browser-breakdown") +def api_browser_breakdown(): + """Chapter 11 addition (Method A, Ch08 follow-up) — not in the + original route table; see docs/api-contract-final.md.""" + from_date, to_date = parse_date_range(request) + return jsonify( + data=get_browser_breakdown(from_date, to_date), + meta={"from": from_date.isoformat(), "to": to_date.isoformat()}, + ) diff --git a/app/blueprints/overview/templates/overview/_content.html b/app/blueprints/overview/templates/overview/_content.html new file mode 100644 index 0000000..d3c3990 --- /dev/null +++ b/app/blueprints/overview/templates/overview/_content.html @@ -0,0 +1,286 @@ +
+ +
+
+

Overview

+

What happened on your site recently

+
+
+ + +
+
+ + +
+

Upload a log file

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

Uploaded files

+ +
+
+
+ + +
+ +
+
+

Traffic over time

+
+
+
+

Status codes

+
+
+
+ +
+
+

Top URLs

+
+
+
+

Top referrers

+
+
+
+ +
+
+

Browsers

+
+
+
+

Operating systems

+
+
+
+ + +
diff --git a/app/blueprints/overview/templates/overview/index.html b/app/blueprints/overview/templates/overview/index.html new file mode 100644 index 0000000..bc2d656 --- /dev/null +++ b/app/blueprints/overview/templates/overview/index.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Overview — Kavosh{% endblock %} +{% block content %} + {% include "overview/_content.html" %} +{% endblock %} diff --git a/app/blueprints/security/__init__.py b/app/blueprints/security/__init__.py new file mode 100644 index 0000000..17a45d7 --- /dev/null +++ b/app/blueprints/security/__init__.py @@ -0,0 +1,13 @@ +from flask import Blueprint +from flask_login import login_required + +bp = Blueprint("security", __name__, template_folder="templates") + + +@bp.before_request +@login_required +def require_login(): + pass + + +from app.blueprints.security import routes # noqa: E402,F401 registers routes diff --git a/app/blueprints/security/queries.py b/app/blueprints/security/queries.py new file mode 100644 index 0000000..9ea3f66 --- /dev/null +++ b/app/blueprints/security/queries.py @@ -0,0 +1,154 @@ +"""Context-builder query functions for the Security tab (Chapter 10), +with the IP investigation panel's traffic breakdown upgraded to full- +traffic data (bounded per-IP rollup, added as an explicit follow-up to +Chapter 10's scope gap). +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date + +from sqlalchemy import func + +from app.extensions import db +from app.models.blocklist_suggestion import BlocklistSuggestion +from app.models.bot_hit import BotHit +from app.models.ip_registry import IPRegistry +from app.models.ip_traffic_stats import IpPathStatsDaily, IpStatusStatsDaily +from app.models.suspicious_event import SuspiciousEvent +from app.services.severity_scoring import SeverityInputs, compute_effective_severity +from app.utils.dates import day_bounds + +IP_HISTORY_EVENT_LIMIT = 50 +IP_HISTORY_PATH_LIMIT = 20 + + +def get_suspicious_events( + from_date: date, to_date: date, severity: str | None, rule_type: str | None, page: int, per_page: int, +) -> tuple[list[list], int]: + """suspicious_events is small/indexed/indefinitely-retained (Ch06) — + same precedent as Ch09's bot_hits queries, so loading + escalating in + Python doesn't violate Ch03 rule 6 (that targets raw per-request rows). + """ + start, end = day_bounds(from_date, to_date) + query = db.session.query(SuspiciousEvent).filter( + SuspiciousEvent.timestamp >= start, SuspiciousEvent.timestamp < end + ) + if rule_type: + query = query.filter(SuspiciousEvent.rule_matched.like(f"{rule_type}:%")) + events = query.order_by(SuspiciousEvent.timestamp.desc()).all() + + by_ip: dict[str, list[SuspiciousEvent]] = defaultdict(list) + for e in events: + by_ip[e.ip].append(e) + + enriched = [] + for e in events: + ip_events = by_ip[e.ip] + timestamps = sorted(ev.timestamp for ev in ip_events) + avg_interval = ( + (timestamps[-1] - timestamps[0]).total_seconds() / (len(timestamps) - 1) + if len(timestamps) > 1 else None + ) + effective = compute_effective_severity( + SeverityInputs(base_severity=e.severity, ip_event_count=len(ip_events), avg_interval_seconds=avg_interval) + ) + if severity and effective != severity: + continue + enriched.append([e.timestamp.isoformat(), e.ip, e.path, e.rule_matched, effective]) + + total = len(enriched) + offset = (page - 1) * per_page + return enriched[offset : offset + per_page], total + + +def get_sensitive_path_summary(from_date: date, to_date: date) -> list[dict]: + """Grouped by request path; filtered to Ch07's sensitive_path rule + category. One ranked list, not sub-grouped into config/admin/VCS — + Ch07's dictionary has no such taxonomy to reuse. + """ + start, end = day_bounds(from_date, to_date) + rows = ( + db.session.query( + SuspiciousEvent.path, + func.count().label("hit_count"), + func.count(func.distinct(SuspiciousEvent.ip)).label("distinct_ip_count"), + ) + .filter( + SuspiciousEvent.timestamp >= start, SuspiciousEvent.timestamp < end, + SuspiciousEvent.rule_matched.like("sensitive_path:%"), + ) + .group_by(SuspiciousEvent.path) + .order_by(func.count().desc()) + .all() + ) + return [{"path": r.path, "hit_count": r.hit_count, "distinct_ip_count": r.distinct_ip_count} for r in rows] + + +def get_ip_history(ip: str) -> dict | None: + """Pulled from ip_registry (identity + true total_requests), plus + bot_hits/suspicious_events (flagged activity), plus the bounded + per-IP traffic rollup (top_paths / status_code_distribution — true + full-traffic breakdown, added as a follow-up to Ch10's original scope + gap). Retention caveat: the per-IP rollup covers roughly the last 30 + days (see aggregator.py / flask cleanup). + """ + registry = db.session.get(IPRegistry, ip) + if registry is None: + return None + + path_rows = ( + db.session.query(IpPathStatsDaily.path, func.sum(IpPathStatsDaily.count).label("count")) + .filter(IpPathStatsDaily.ip == ip) + .group_by(IpPathStatsDaily.path) + .order_by(func.sum(IpPathStatsDaily.count).desc()) + .limit(IP_HISTORY_PATH_LIMIT) + .all() + ) + status_rows = ( + db.session.query(IpStatusStatsDaily.status_bucket, func.sum(IpStatusStatsDaily.count).label("count")) + .filter(IpStatusStatsDaily.ip == ip) + .group_by(IpStatusStatsDaily.status_bucket) + .all() + ) + + bot_rows = ( + db.session.query(BotHit).filter(BotHit.ip == ip) + .order_by(BotHit.timestamp.desc()).limit(IP_HISTORY_EVENT_LIMIT).all() + ) + suspicious_rows = ( + db.session.query(SuspiciousEvent).filter(SuspiciousEvent.ip == ip) + .order_by(SuspiciousEvent.timestamp.desc()).limit(IP_HISTORY_EVENT_LIMIT).all() + ) + spoofed_bot_names = sorted({b.bot_name for b in bot_rows if not b.verified}) + + return { + "ip": ip, + "first_seen": registry.first_seen.isoformat(), + "last_seen": registry.last_seen.isoformat(), + "total_requests": registry.total_requests, + "reputation_score": registry.reputation_score, + "is_flagged": registry.is_flagged, + "spoofed_bot_names": spoofed_bot_names, + "top_paths": [[r.path, r.count] for r in path_rows], + "status_code_distribution": {r.status_bucket: r.count for r in status_rows}, + "traffic_window_note": "Path/status breakdown reflects roughly the last 30 days (bounded retention).", + "recent_suspicious_events": [ + {"timestamp": s.timestamp.isoformat(), "path": s.path, "rule_matched": s.rule_matched, "severity": s.severity} + for s in suspicious_rows + ], + } + + +def format_blocklist(suggestions: list[BlocklistSuggestion], fmt: str) -> str: + """Ch10: '.htaccess Deny/iptables/fail2ban-style'. 'plain' (a bare IP + list) is the most portable interpretation of "fail2ban-style input" + without assuming a specific fail2ban jail configuration Ch10 doesn't + specify. + """ + ips = [s.ip for s in suggestions] + if fmt == "htaccess": + return "".join(f"Deny from {ip}\n" for ip in ips) + if fmt == "iptables": + return "".join(f"iptables -A INPUT -s {ip} -j DROP\n" for ip in ips) + return "".join(f"{ip}\n" for ip in ips) diff --git a/app/blueprints/security/routes.py b/app/blueprints/security/routes.py new file mode 100644 index 0000000..9eaa6d0 --- /dev/null +++ b/app/blueprints/security/routes.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from flask import Response, jsonify, render_template, request + +from app.blueprints.security import bp +from app.blueprints.security.queries import ( + format_blocklist, get_ip_history, get_sensitive_path_summary, get_suspicious_events, +) +from app.extensions import db +from app.models.blocklist_suggestion import BlocklistSuggestion +from app.utils.dates import parse_date_range +from app.utils.htmx import render_htmx_aware +from app.utils.pagination import parse_pagination + + +@bp.route("/security") +def security(): + from_date, to_date = parse_date_range(request) + severity = request.args.get("severity") or "" + rule_type = request.args.get("rule_type") or "" + return render_htmx_aware( + request, full_template="security/index.html", partial_template="security/_content.html", + from_date=from_date, to_date=to_date, severity=severity, rule_type=rule_type, + ) + + +@bp.get("/api/security/events") +def api_security_events(): + from_date, to_date = parse_date_range(request) + page, per_page = parse_pagination(request) + severity = request.args.get("severity") or None + rule_type = request.args.get("rule_type") or None + rows, total = get_suspicious_events(from_date, to_date, severity, rule_type, page, per_page) + return jsonify( + data={"rows": rows, "total": total}, + meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page}, + ) + + +@bp.get("/api/security/sensitive-paths") +def api_sensitive_paths(): + from_date, to_date = parse_date_range(request) + return jsonify(data=get_sensitive_path_summary(from_date, to_date), meta={"from": from_date.isoformat(), "to": to_date.isoformat()}) + + +@bp.get("/api/security/ip/") +def api_ip_history(ip: str): + history = get_ip_history(ip) + if history is None: + return render_template("security/_ip_not_found.html", ip=ip), 404 + return render_template("security/_ip_history.html", ip_data=history) + + +@bp.get("/api/security/export-blocklist") +def api_export_blocklist(): + fmt = request.args.get("format", "plain") + include_all = request.args.get("all", "false").lower() == "true" + query = BlocklistSuggestion.query + if not include_all: + query = query.filter_by(exported=False) + suggestions = query.order_by(BlocklistSuggestion.created_at).all() + + body = format_blocklist(suggestions, fmt) + for s in suggestions: + s.exported = True + db.session.commit() + + return Response( + body, mimetype="text/plain", + headers={"Content-Disposition": "attachment; filename=kavosh-blocklist.txt"}, + ) diff --git a/app/blueprints/security/templates/security/_content.html b/app/blueprints/security/templates/security/_content.html new file mode 100644 index 0000000..57b2368 --- /dev/null +++ b/app/blueprints/security/templates/security/_content.html @@ -0,0 +1,125 @@ +
+ +
+
+

Suspicious Requests & IP History

+

Who's poking at your site, and how hard

+
+
+ + + + +
+
+ +
+

Suspicious events

+
+
+ +
+

Sensitive-path probes

+
+
+ +
+

Export blocklist

+
+ Download new (.txt) + Re-export all + +
+
+ +
+ + +
diff --git a/app/blueprints/security/templates/security/_ip_history.html b/app/blueprints/security/templates/security/_ip_history.html new file mode 100644 index 0000000..f2a1c18 --- /dev/null +++ b/app/blueprints/security/templates/security/_ip_history.html @@ -0,0 +1,26 @@ +
+ +

{{ ip_data.ip }}

+
+
First seen
{{ ip_data.first_seen }}
+
Last seen
{{ ip_data.last_seen }}
+
Total requests
{{ ip_data.total_requests }}
+
Reputation score
{{ ip_data.reputation_score }}
+
Flagged
+
{{ 'Yes' if ip_data.is_flagged else 'No' }}
+ {% if ip_data.spoofed_bot_names %} +
Spoofed bot claims
{{ ip_data.spoofed_bot_names | join(', ') }}
+ {% endif %} +
+

{{ ip_data.traffic_window_note }}

+

Top paths

+
    {% for path, count in ip_data.top_paths %}
  • {{ path }} — {{ count }}
  • {% endfor %}
+

Status codes

+
    {% for code, count in ip_data.status_code_distribution.items() %}
  • {{ code }} — {{ count }}
  • {% endfor %}
+ {% if ip_data.recent_suspicious_events %} +

Recent flagged events

+
    {% for e in ip_data.recent_suspicious_events %}
  • {{ e.timestamp }} — {{ e.path }} ({{ e.rule_matched }}, {{ e.severity }})
  • {% endfor %}
+ {% endif %} +
diff --git a/app/blueprints/security/templates/security/_ip_not_found.html b/app/blueprints/security/templates/security/_ip_not_found.html new file mode 100644 index 0000000..236254c --- /dev/null +++ b/app/blueprints/security/templates/security/_ip_not_found.html @@ -0,0 +1,4 @@ +
+

No history found for {{ ip }} — it hasn't been seen yet.

+ +
diff --git a/app/blueprints/security/templates/security/index.html b/app/blueprints/security/templates/security/index.html new file mode 100644 index 0000000..564f6d1 --- /dev/null +++ b/app/blueprints/security/templates/security/index.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Security — Kavosh{% endblock %} +{% block content %} + {% include "security/_content.html" %} +{% endblock %} diff --git a/app/blueprints/seo/__init__.py b/app/blueprints/seo/__init__.py new file mode 100644 index 0000000..2997dfb --- /dev/null +++ b/app/blueprints/seo/__init__.py @@ -0,0 +1,13 @@ +from flask import Blueprint +from flask_login import login_required + +bp = Blueprint("seo", __name__, template_folder="templates") + + +@bp.before_request +@login_required +def require_login(): + pass + + +from app.blueprints.seo import routes # noqa: E402,F401 registers routes diff --git a/app/blueprints/seo/queries.py b/app/blueprints/seo/queries.py new file mode 100644 index 0000000..4a0ecac --- /dev/null +++ b/app/blueprints/seo/queries.py @@ -0,0 +1,140 @@ +"""Context-builder query functions for the SEO tab (Chapter 09). + +Per Ch09's own Output instruction, bot-related widgets query bot_hits +directly (small, indefinitely-retained, indexed — Ch06) rather than a new +rollup; only the human-side of the crawled-vs-visited comparison needs +the new human_path_stats_daily table. +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date + +from sqlalchemy import case, func + +from app.extensions import db +from app.models.bot_hit import BotHit +from app.models.human_path_stats import HumanPathStatsDaily +from app.utils.dates import day_bounds + +# ASSUMPTION (flagged): Ch09 doesn't define "major crawler" for the +# crawl-frequency chart's series cap. +TOP_N_BOTS_FOR_CHART = 6 + + +def get_bot_summary(from_date: date, to_date: date) -> list[dict]: + start, end = day_bounds(from_date, to_date) + rows = ( + db.session.query( + BotHit.bot_name, + func.count().label("hits"), + func.sum(case((BotHit.verified.is_(True), 1), else_=0)).label("verified_hits"), + func.max(BotHit.timestamp).label("last_seen"), + ) + .filter(BotHit.timestamp >= start, BotHit.timestamp < end) + .group_by(BotHit.bot_name) + .order_by(func.count().desc()) + .all() + ) + return [ + { + "bot_name": r.bot_name, + "hits": r.hits, + "verified_pct": round(r.verified_hits / r.hits * 100, 1) if r.hits else 0.0, + "last_seen": r.last_seen.isoformat() if r.last_seen else None, + } + for r in rows + ] + + +def get_crawl_chart_data(from_date: date, to_date: date, bot_name: str | None) -> dict: + start, end = day_bounds(from_date, to_date) + base_filters = [BotHit.timestamp >= start, BotHit.timestamp < end] + + if bot_name: + allowed_bots = [bot_name] + else: + top_rows = ( + db.session.query(BotHit.bot_name, func.count().label("hits")) + .filter(*base_filters) + .group_by(BotHit.bot_name) + .order_by(func.count().desc()) + .limit(TOP_N_BOTS_FOR_CHART) + .all() + ) + allowed_bots = [r.bot_name for r in top_rows] + + if not allowed_bots: + return {"series": []} + + rows = ( + db.session.query(func.date(BotHit.timestamp).label("day"), BotHit.bot_name, func.count().label("count")) + .filter(*base_filters, BotHit.bot_name.in_(allowed_bots)) + .group_by("day", BotHit.bot_name) + .order_by("day") + .all() + ) + points_by_bot: dict[str, list[dict]] = defaultdict(list) + for r in rows: + points_by_bot[r.bot_name].append({"t": r.day, "count": r.count}) + + return {"series": [{"bot_name": b, "points": points_by_bot.get(b, [])} for b in allowed_bots]} + + +def get_bot_status_codes(from_date: date, to_date: date) -> dict: + start, end = day_bounds(from_date, to_date) + bucket = case( + (BotHit.status_code < 300, "2xx"), + (BotHit.status_code < 400, "3xx"), + (BotHit.status_code < 500, "4xx"), + else_="5xx", + ) + rows = ( + db.session.query(bucket.label("bucket"), func.count().label("count")) + .filter(BotHit.timestamp >= start, BotHit.timestamp < end) + .group_by("bucket") + .all() + ) + breakdown = {"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0} + for r in rows: + breakdown[r.bucket] = r.count + + # Ch09 calls out 404 by name specifically, not just the 4xx bucket. + not_found_404 = ( + db.session.query(func.count()) + .filter(BotHit.timestamp >= start, BotHit.timestamp < end, BotHit.status_code == 404) + .scalar() + ) + return {"breakdown": breakdown, "not_found_404": not_found_404 or 0} + + +def get_crawled_vs_visited(from_date: date, to_date: date, page: int, per_page: int) -> tuple[list[list], int]: + """Diffed table: one row per path, bot hits vs. human hits.""" + start, end = day_bounds(from_date, to_date) + bot_rows = ( + db.session.query(BotHit.path, func.count().label("hits")) + .filter(BotHit.timestamp >= start, BotHit.timestamp < end) + .group_by(BotHit.path) + .all() + ) + human_rows = ( + db.session.query(HumanPathStatsDaily.path, func.sum(HumanPathStatsDaily.count).label("hits")) + .filter(HumanPathStatsDaily.date >= from_date, HumanPathStatsDaily.date <= to_date) + .group_by(HumanPathStatsDaily.path) + .all() + ) + bot_counts = {r.path: r.hits for r in bot_rows} + human_counts = {r.path: r.hits for r in human_rows} + + combined = [] + for path in set(bot_counts) | set(human_counts): + b, h = bot_counts.get(path, 0), human_counts.get(path, 0) + total = b + h + combined.append([path, b, h, round(b / total * 100, 1) if total else 0.0]) + + # ASSUMPTION (flagged): sorted by bot hits desc — Ch09 doesn't specify. + combined.sort(key=lambda row: row[1], reverse=True) + + total_count = len(combined) + offset = (page - 1) * per_page + return combined[offset : offset + per_page], total_count diff --git a/app/blueprints/seo/routes.py b/app/blueprints/seo/routes.py new file mode 100644 index 0000000..e471458 --- /dev/null +++ b/app/blueprints/seo/routes.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from flask import jsonify, request + +from app.blueprints.seo import bp +from app.blueprints.seo.queries import ( + get_bot_status_codes, + get_bot_summary, + get_crawl_chart_data, + get_crawled_vs_visited, +) +from app.utils.dates import parse_date_range +from app.utils.htmx import render_htmx_aware +from app.utils.pagination import parse_pagination + + +@bp.route("/seo") +def seo(): + from_date, to_date = parse_date_range(request) + return render_htmx_aware( + request, full_template="seo/index.html", partial_template="seo/_content.html", + from_date=from_date, to_date=to_date, + ) + + +@bp.get("/api/seo/bot-summary") +def api_bot_summary(): + from_date, to_date = parse_date_range(request) + return jsonify(data=get_bot_summary(from_date, to_date), meta={"from": from_date.isoformat(), "to": to_date.isoformat()}) + + +@bp.get("/api/seo/crawl-chart-data") +def api_crawl_chart_data(): + from_date, to_date = parse_date_range(request) + bot_name = request.args.get("bot") + return jsonify( + data=get_crawl_chart_data(from_date, to_date, bot_name), + meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "bot": bot_name}, + ) + + +@bp.get("/api/seo/bot-status-codes") +def api_bot_status_codes(): + from_date, to_date = parse_date_range(request) + return jsonify(data=get_bot_status_codes(from_date, to_date), meta={"from": from_date.isoformat(), "to": to_date.isoformat()}) + + +@bp.get("/api/seo/crawled-vs-visited") +def api_crawled_vs_visited(): + from_date, to_date = parse_date_range(request) + page, per_page = parse_pagination(request) + rows, total = get_crawled_vs_visited(from_date, to_date, page, per_page) + return jsonify( + data={"rows": rows, "total": total}, + meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page}, + ) diff --git a/app/blueprints/seo/templates/seo/_content.html b/app/blueprints/seo/templates/seo/_content.html new file mode 100644 index 0000000..cbade37 --- /dev/null +++ b/app/blueprints/seo/templates/seo/_content.html @@ -0,0 +1,102 @@ +
+ +
+
+

SEO & Bot Behavior

+

How search engines are crawling your site

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

Crawl frequency

+
+
+
+

Status codes served to bots

+
+

+
+
+ +
+

Most-crawled vs. most-visited URLs

+
+
+ + +
diff --git a/app/blueprints/seo/templates/seo/index.html b/app/blueprints/seo/templates/seo/index.html new file mode 100644 index 0000000..861db93 --- /dev/null +++ b/app/blueprints/seo/templates/seo/index.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}SEO & Bots — Kavosh{% endblock %} +{% block content %} + {% include "seo/_content.html" %} +{% endblock %} diff --git a/app/blueprints/uploads/__init__.py b/app/blueprints/uploads/__init__.py new file mode 100644 index 0000000..d6dbf65 --- /dev/null +++ b/app/blueprints/uploads/__init__.py @@ -0,0 +1,15 @@ +from flask import Blueprint +from flask_login import login_required + +bp = Blueprint("uploads", __name__, template_folder="templates") + + +@bp.before_request +@login_required +def require_login(): + """Ch01: dashboard reachable from one AUTHENTICATED shell; Ch12: + single-admin login. All routes on this blueprint require a session.""" + pass + + +from app.blueprints.uploads import routes # noqa: E402,F401 registers routes diff --git a/app/blueprints/uploads/queries.py b/app/blueprints/uploads/queries.py new file mode 100644 index 0000000..ce7e146 --- /dev/null +++ b/app/blueprints/uploads/queries.py @@ -0,0 +1,51 @@ +"""Query/formatting helpers for the "Uploaded files" list (project-owner +follow-up request). Kept separate from routes.py to match this project's +established per-blueprint queries.py convention (Ch08/09/10). +""" +from __future__ import annotations + +from app.models.log_file import LogFile + + +def get_uploaded_files(page: int, per_page: int) -> tuple[list[list], int]: + """Newest first, independent of the dashboard date-range picker — + this lists uploads by when they arrived, not by which log dates they + contain (a single file can span many dates). + """ + query = LogFile.query.order_by(LogFile.uploaded_at.desc()) + total = query.count() + rows = query.offset((page - 1) * per_page).limit(per_page).all() + + result = [] + for lf in rows: + result.append([ + lf.id, + lf.filename, + lf.server_type, + _status_display(lf), + lf.uploaded_at.strftime("%Y-%m-%d %H:%M"), + _human_size(lf.size_bytes), + lf.status, # raw status (hidden column) — lets the client disable + # the select checkbox for files still "processing" + ]) + return result, total + + +def _status_display(lf: LogFile) -> str: + if lf.status == "processing": + if lf.total_lines: + pct = round(lf.processed_lines / lf.total_lines * 100) + return f"processing ({pct}%)" + return "processing" + if lf.status == "error": + return f"error: {lf.error_message}" if lf.error_message else "error" + return lf.status + + +def _human_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" diff --git a/app/blueprints/uploads/routes.py b/app/blueprints/uploads/routes.py new file mode 100644 index 0000000..2244127 --- /dev/null +++ b/app/blueprints/uploads/routes.py @@ -0,0 +1,188 @@ +"""Upload endpoint (Chapter 04): validated, streamed-to-disk save. + +Parsing happens in a background thread triggered right after this request +completes (app/services/background.py) — never synchronously inside this +request (Chapter 03, rule 5 still holds: the response returns immediately +regardless of file size). +""" +from __future__ import annotations + +import hashlib +import uuid +from pathlib import Path + +from flask import current_app, jsonify, render_template, request +from werkzeug.utils import secure_filename + +from app.blueprints.uploads import bp +from app.blueprints.uploads.queries import get_uploaded_files +from app.extensions import db, limiter +from app.models.log_file import LogFile +from app.services.background import trigger_processing +from app.services.file_deletion import delete_log_files +from app.utils.pagination import parse_pagination +from app.utils.upload_paths import upload_path_for + +_ALLOWED_EXTENSIONS = {".log", ".txt", ".gz"} +_CHUNK_SIZE = 64 * 1024 # 64 KB per read — never buffer the whole upload +_GZIP_MAGIC = b"\x1f\x8b" + + +class UploadRejected(Exception): + """Raised when an upload fails extension/content validation.""" + + +def _validate_extension(filename: str) -> str: + ext = Path(filename).suffix.lower() + if ext not in _ALLOWED_EXTENSIONS: + raise UploadRejected(f"Unsupported extension {ext!r}; allowed: {_ALLOWED_EXTENSIONS}") + return ext + + +def _sniff_content(first_chunk: bytes, ext: str) -> None: + """Light content sniff — don't just trust the client-supplied MIME type.""" + if ext == ".gz": + if not first_chunk.startswith(_GZIP_MAGIC): + raise UploadRejected("File has a .gz extension but isn't gzip-magic-prefixed.") + return + if b"\x00" in first_chunk: + raise UploadRejected("File extension claims text but content looks binary.") + + +def _stream_to_temp(file_storage, tmp_path: Path) -> tuple[int, str, bytes]: + """Stream the upload to disk in bounded chunks; return (size, sha256_hex, first_chunk). + + Never calls file.read() on the whole stream (Chapter 03, rule 1). + """ + sha256 = hashlib.sha256() + size = 0 + first_chunk: bytes | None = None + with tmp_path.open("wb") as out: + while True: + chunk = file_storage.stream.read(_CHUNK_SIZE) + if not chunk: + break + if first_chunk is None: + first_chunk = chunk + sha256.update(chunk) + size += len(chunk) + out.write(chunk) + if first_chunk is None: + raise UploadRejected("Uploaded file is empty.") + return size, sha256.hexdigest(), first_chunk + + +@bp.post("/uploads") +@limiter.limit("20 per minute") # Chapter 12: rate limiting on /uploads at minimum +def upload_log_file(): + """Validate, stream, and register an uploaded access log (Ch04/Ch11).""" + file_storage = request.files.get("logfile") + if file_storage is None or not file_storage.filename: + return render_template("uploads/_error.html", message="No file provided."), 400 + + upload_dir = Path(current_app.config["UPLOAD_DIR"]) + (upload_dir / "tmp").mkdir(parents=True, exist_ok=True) + tmp_path = upload_dir / "tmp" / f"{uuid.uuid4().hex}.part" + + try: + ext = _validate_extension(file_storage.filename) + size_bytes, checksum, first_chunk = _stream_to_temp(file_storage, tmp_path) + _sniff_content(first_chunk, ext) + + max_bytes = current_app.config["UPLOAD_MAX_SIZE_MB"] * 1024 * 1024 + if size_bytes > max_bytes: + raise UploadRejected(f"File exceeds {current_app.config['UPLOAD_MAX_SIZE_MB']}MB limit.") + except UploadRejected as exc: + tmp_path.unlink(missing_ok=True) + return render_template("uploads/_error.html", message=str(exc)), 400 + + existing = LogFile.query.filter_by(checksum=checksum).first() + if existing is not None: + tmp_path.unlink(missing_ok=True) + return render_template("uploads/_duplicate.html", log_file=existing) + + log_file = LogFile( + filename=secure_filename(file_storage.filename), + server_type=request.form.get("server_type", "apache"), + format_string=request.form.get("format_string", ""), + status="queued", + size_bytes=size_bytes, + checksum=checksum, + ) + db.session.add(log_file) + db.session.commit() # need the assigned id before the final rename + + tmp_path.rename(upload_path_for(log_file)) + + # Chapter 12 simplification: no cron required — kick off processing + # immediately in a background thread. The response below returns as + # soon as the file is queued (Ch03 rule 5 still holds: this request + # never blocks on parsing), while the thread runs independently. + trigger_processing(current_app._get_current_object(), log_file.id) + + return render_template("uploads/_queued.html", log_file=log_file) + + +@bp.get("/api/uploads//status") +def upload_status(log_file_id: int): + """Polled every 3s by the browser (hx-trigger) until done/error (Ch04).""" + log_file = db.get_or_404(LogFile, log_file_id) + + if request.headers.get("Accept") == "application/json": + return jsonify( + data={ + "id": log_file.id, + "status": log_file.status, + "processed_lines": log_file.processed_lines, + "total_lines": log_file.total_lines, + }, + meta={}, + ) + + template = { + "done": "uploads/_status_done.html", + "error": "uploads/_status_error.html", + # BUG FIX: this key was missing, so "queued" fell through to the + # "processing" fallback below — a file that hadn't been picked up + # by `flask process-logs` yet displayed as "Processing X: 0 lines" + # instead of "Queued — waiting for the next parse cycle", making a + # cron job that simply hasn't run yet indistinguishable from one + # that's actually hung mid-parse. + "queued": "uploads/_queued.html", + }.get(log_file.status, "uploads/_status_processing.html") + return render_template(template, log_file=log_file) + + +@bp.get("/api/uploads") +def list_uploads(): + """Uploaded-files list (project-owner follow-up request) — Grid.js- + backed, same page/per_page convention as every other table (Ch11). + Sorted by upload recency, independent of the dashboard date-range + picker (a single file can span many log dates). + """ + page, per_page = parse_pagination(request) + rows, total = get_uploaded_files(page, per_page) + return jsonify( + data={"rows": rows, "total": total}, + meta={"page": page, "per_page": per_page}, + ) + + +@bp.delete("/api/uploads") +def bulk_delete_uploads(): + """Delete one or more uploaded files and everything derived from them + (log_entries/bot_hits/suspicious_events, the raw file on disk, and a + correct rollup recompute for the affected dates — see + app/services/file_deletion.py for why a rollup recompute is needed + rather than a simple per-file delete). + + Body: {"ids": [1, 2, 3]}. Files currently "processing" are skipped, + not force-deleted, to avoid racing the background parse thread. + """ + body = request.get_json(silent=True) or {} + ids = body.get("ids") + if not isinstance(ids, list) or not ids or not all(isinstance(i, int) for i in ids): + return jsonify(error={"code": "invalid_request", "message": "Expected {\"ids\": [int, ...]}."}), 400 + + result = delete_log_files(ids) + return jsonify(data={"deleted": result.deleted, "skipped": result.skipped}, meta={}) diff --git a/app/blueprints/uploads/templates/uploads/_duplicate.html b/app/blueprints/uploads/templates/uploads/_duplicate.html new file mode 100644 index 0000000..2a6a5ac --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_duplicate.html @@ -0,0 +1,4 @@ +
+ {{ log_file.filename }} + matches an already-uploaded file (status: {{ log_file.status }}); skipped re-upload. +
diff --git a/app/blueprints/uploads/templates/uploads/_error.html b/app/blueprints/uploads/templates/uploads/_error.html new file mode 100644 index 0000000..9d037bd --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_error.html @@ -0,0 +1,3 @@ +
+ {{ message }} +
diff --git a/app/blueprints/uploads/templates/uploads/_queued.html b/app/blueprints/uploads/templates/uploads/_queued.html new file mode 100644 index 0000000..25971cc --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_queued.html @@ -0,0 +1,10 @@ +
+
+ + {{ log_file.filename }} + — queued, starting shortly… +
+
diff --git a/app/blueprints/uploads/templates/uploads/_status_done.html b/app/blueprints/uploads/templates/uploads/_status_done.html new file mode 100644 index 0000000..5805d30 --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_status_done.html @@ -0,0 +1,7 @@ +
+
+ + {{ log_file.filename }} + — done, {{ "{:,}".format(log_file.processed_lines) }} lines analyzed +
+
diff --git a/app/blueprints/uploads/templates/uploads/_status_error.html b/app/blueprints/uploads/templates/uploads/_status_error.html new file mode 100644 index 0000000..a81ce89 --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_status_error.html @@ -0,0 +1,7 @@ +
+
+ + {{ log_file.filename }} +
+

{{ log_file.error_message }}

+
diff --git a/app/blueprints/uploads/templates/uploads/_status_processing.html b/app/blueprints/uploads/templates/uploads/_status_processing.html new file mode 100644 index 0000000..d3546a2 --- /dev/null +++ b/app/blueprints/uploads/templates/uploads/_status_processing.html @@ -0,0 +1,19 @@ +{% set pct = ((log_file.processed_lines / log_file.total_lines) * 100) if log_file.total_lines else None %} +
+
+ {{ log_file.filename }} + + {% if pct is not none %}{{ pct | round(0) | int }}%{% else %}analyzing…{% endif %} + +
+
+
+
+

+ {{ "{:,}".format(log_file.processed_lines) }}{% if log_file.total_lines %} / {{ "{:,}".format(log_file.total_lines) }}{% endif %} lines +

+
diff --git a/app/budget.py b/app/budget.py new file mode 100644 index 0000000..eefb628 --- /dev/null +++ b/app/budget.py @@ -0,0 +1,82 @@ +"""Concrete, checkable translation of the cPanel account ceiling (Chapter 03). + +Single source of truth for the account's literal resource limits. Other +chapters should import BUDGET rather than re-hardcoding these numbers. +validate_budget_config() is called from create_app() so an out-of-budget +deployment fails loudly at startup instead of silently degrading under load +— this is the concrete mechanism behind Chapter 03's "flag, don't silently +accept" requirement. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from flask import Flask + + +@dataclass(frozen=True) +class AccountBudget: + """The hosting account's literal ceiling (Chapter 03). + + iops/io_throughput are informational only here — Python config can't + enforce them directly; they constrain how Ch06/07 batch reads/writes. + """ + + cpu_cores: int = 4 + max_entry_processes: int = 60 + memory_mb: int = 2048 + iops: int = 1024 + io_throughput_mb_s: int = 16 + max_total_processes: int = 150 + max_db_connections: int = 150 + + # Derived engineering targets from Chapter 03's budget table. + db_pool_size_min: int = 2 + db_pool_size_max: int = 5 + + +BUDGET = AccountBudget() + +_DISALLOWED_CACHE_BACKENDS = {"redis", "rediscache", "memcached", "memcachedcache"} + + +def validate_budget_config(app: Flask) -> None: + """Raise at startup if config violates a Chapter 03 rule. + + Cheap checks only (string/int comparisons) since this runs on every + process boot — Passenger may recycle processes frequently (factor 9). + """ + # Validated as its own config key, not read out of + # SQLALCHEMY_ENGINE_OPTIONS — that dict is empty for SQLite (its pool + # classes reject pool_size/pool_recycle outright; see app/config.py's + # _engine_options_for), so the *intended* setting must be checked + # independently of whether the active engine actually consumes it. + pool_size = app.config.get("DB_POOL_SIZE") + if pool_size is None or not (BUDGET.db_pool_size_min <= pool_size <= BUDGET.db_pool_size_max): + raise RuntimeError( + f"DB_POOL_SIZE={pool_size} is outside the Chapter 03 budget " + f"({BUDGET.db_pool_size_min}-{BUDGET.db_pool_size_max} per process; " + f"{BUDGET.max_db_connections} total connections are shared across " + f"up to {BUDGET.max_entry_processes} entry processes)." + ) + + cache_type = str(app.config.get("CACHE_TYPE", "")).lower() + if any(name in cache_type for name in _DISALLOWED_CACHE_BACKENDS): + raise RuntimeError( + f"CACHE_TYPE={app.config.get('CACHE_TYPE')!r} assumes a backend " + "(Redis/Memcached) Chapter 03 says not to assume is available. " + "Use FileSystemCache or a dashboard_cache DB table (Ch06)." + ) + + batch_size = app.config.get("PARSE_BATCH_SIZE") + if not batch_size or batch_size <= 0: + raise RuntimeError( + "PARSE_BATCH_SIZE must be a positive integer — unbounded/whole-file " + "parsing per invocation violates the Chapter 03 memory budget." + ) + + max_upload_mb = app.config.get("UPLOAD_MAX_SIZE_MB") + if not max_upload_mb or max_upload_mb <= 0: + raise RuntimeError( + "UPLOAD_MAX_SIZE_MB must be a positive integer to bound disk/IOPS per upload." + ) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..a4ea1eb --- /dev/null +++ b/app/cli.py @@ -0,0 +1,163 @@ +"""Flask CLI admin commands (factor 12). + +process-logs is now OPTIONAL (Chapter 12 simplification, per project +owner request): file processing is triggered automatically in-app right +after upload (app/services/background.py), so cron is no longer required. +This command still exists for anyone who'd rather run it manually or via +cron — it shares the exact same processing code (app/services/ +log_processor.py) as the automatic background trigger, so both paths +behave identically. cleanup (Chapter 06/12) enforces the retention +policy. create-admin (Chapter 12) bootstraps the single admin account. +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta + +import click +from flask import Flask, current_app + +from app.extensions import db +from app.models.ip_traffic_stats import IpPathStatsDaily, IpStatusStatsDaily +from app.models.log_entry import LogEntry +from app.models.log_file import LogFile +from app.models.request_stats import RequestStatsHourly +from app.models.user import User +from app.services import aggregator +from app.services.log_processor import process_one_batch +from app.utils.upload_paths import upload_path_for + +# Chapter 06 retention policy — the constants `flask cleanup` enforces. +LOG_ENTRIES_RETENTION_DAYS = 30 +HOURLY_STATS_RETENTION_DAYS = 90 +IP_TRAFFIC_STATS_RETENTION_DAYS = 30 + + +def register_commands(app: Flask) -> None: + app.cli.add_command(process_logs) + app.cli.add_command(rollup) + app.cli.add_command(cleanup) + app.cli.add_command(create_admin) + + +@click.command("process-logs") +@click.option("--batch-size", default=None, type=int, help="Override PARSE_BATCH_SIZE.") +def process_logs(batch_size: int | None) -> None: + """Optional manual/cron fallback — processing now also runs + automatically in-app after upload. Parses queued/processing + log_files in bounded, checkpointed batches; one batch per file per + invocation, same as before. + """ + batch = batch_size or current_app.config["PARSE_BATCH_SIZE"] + pending = LogFile.query.filter(LogFile.status.in_(["queued", "processing"])).all() + for log_file in pending: + process_one_batch(log_file, batch) + + +@click.command("rollup") +@click.option("--from", "from_date", required=True, help="ISO date, e.g. 2026-07-01") +@click.option("--to", "to_date", required=True, help="ISO date, e.g. 2026-07-26") +def rollup(from_date: str, to_date: str) -> None: + """Manual rollup recompute for an explicit range (e.g. after a backfill). + + Requires an explicit range — no "all time" default, mirroring Ch03 rule 7 + even for an admin command, to avoid an unbounded scan on constrained hosting. + """ + start = date.fromisoformat(from_date) + end = date.fromisoformat(to_date) + aggregator.compute_rollups_for_range(start, end) + click.echo(f"Rolled up {start} .. {end}") + + +@click.command("cleanup") +def cleanup() -> None: + """Enforce the Chapter 06 retention policy (Chapter 12). + + Previously a stub through every earlier chapter — implemented here as + Chapter 12's non-functional/deployment concern. Cron-invoked (e.g. + daily, off-peak), never a long-running daemon. + """ + now = datetime.utcnow() + deleted_entries = _delete_old_log_entries(now) + deleted_hourly = _collapse_old_hourly_stats(now) + deleted_ip_stats = _delete_old_ip_traffic_stats(now) + archived_files = _delete_parsed_upload_files() + click.echo( + f"Cleanup complete: {deleted_entries} log_entries, {deleted_hourly} " + f"request_stats_hourly, {deleted_ip_stats} ip traffic-rollup rows " + f"deleted; {archived_files} parsed upload file(s) removed from disk." + ) + + +def _delete_old_log_entries(now: datetime) -> int: + """Ch06: raw log_entries retained ~30 days, then deleted.""" + cutoff = now - timedelta(days=LOG_ENTRIES_RETENTION_DAYS) + count = db.session.query(LogEntry).filter(LogEntry.timestamp < cutoff).delete(synchronize_session=False) + db.session.commit() + return count + + +def _collapse_old_hourly_stats(now: datetime) -> int: + """Ch06: request_stats_hourly retained ~90 days, then collapsed into + request_stats_daily only. request_stats_daily is already computed + independently by the aggregator straight from raw log_entries, so + "collapsing" here just means deleting the now-redundant hourly rows + once the retention window passes — no data is lost, since the daily + rollup for that period was already written when the file was parsed. + """ + cutoff = now - timedelta(days=HOURLY_STATS_RETENTION_DAYS) + count = db.session.query(RequestStatsHourly).filter( + RequestStatsHourly.date_hour < cutoff + ).delete(synchronize_session=False) + db.session.commit() + return count + + +def _delete_old_ip_traffic_stats(now: datetime) -> int: + """The bounded per-IP rollup (added as a Ch10 follow-up) was designed + for ~30-day retention, matching log_entries — see aggregator.py. + """ + cutoff_date = (now - timedelta(days=IP_TRAFFIC_STATS_RETENTION_DAYS)).date() + count = db.session.query(IpPathStatsDaily).filter(IpPathStatsDaily.date < cutoff_date).delete(synchronize_session=False) + count += db.session.query(IpStatusStatsDaily).filter(IpStatusStatsDaily.date < cutoff_date).delete(synchronize_session=False) + db.session.commit() + return count + + +def _delete_parsed_upload_files() -> int: + """Ch06: 'compress or delete after successful parse + rollup, rather + than keeping both the raw file and a full raw-row copy.' Deletes + (rather than compresses) — simpler, and avoids spending extra CPU/IOPS + gzip-ing data that's already been fully parsed into the database. + """ + done_files = LogFile.query.filter_by(status="done").all() + removed = 0 + for log_file in done_files: + path = upload_path_for(log_file) + if path.exists(): + path.unlink() + removed += 1 + return removed + + +@click.command("create-admin") +@click.option("--email", default=None, help="Defaults to the ADMIN_EMAIL env var.") +@click.password_option() +def create_admin(email: str | None, password: str) -> None: + """Bootstrap the single admin account (Chapter 12). + + Interactive password prompt (via --password-option's confirmation + prompt) keeps the raw credential out of process env/config, unlike an + ADMIN_PASSWORD env var would — Chapter 12 doesn't specify a bootstrap + mechanism beyond documenting ADMIN_EMAIL, so this is a flagged addition. + """ + resolved_email = (email or current_app.config.get("ADMIN_EMAIL") or "").strip().lower() + if not resolved_email: + raise click.UsageError("No --email given and ADMIN_EMAIL is not set.") + if User.query.filter_by(email=resolved_email).first(): + raise click.UsageError(f"User {resolved_email} already exists.") + + user = User(email=resolved_email) + user.set_password(password) + db.session.add(user) + db.session.commit() + click.echo(f"Created admin user {resolved_email}") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3fb8551 --- /dev/null +++ b/app/config.py @@ -0,0 +1,109 @@ +"""Environment-sourced configuration classes (12-factor factor 3). + +Every value comes from os.environ. No secret, DB URL, or filesystem path +is ever hardcoded; .env.example documents every variable a deployment +must set. +""" +from __future__ import annotations + +import os +from datetime import timedelta + + +def _bool_env(name: str, default: bool = False) -> bool: + """Parse a boolean-ish environment variable.""" + val = os.environ.get(name) + return default if val is None else val.strip().lower() in {"1", "true", "yes", "on"} + + +def _engine_options_for(database_url: str) -> dict: + """pool_size/pool_recycle are QueuePool-only kwargs. + + BUG FIX (caught by actually booting the app): SQLite's default pool + classes (NullPool for file DBs, StaticPool for :memory:) raise + TypeError if handed pool_size/pool_recycle at all — they're not + silently ignored. Chapter 06 makes SQLite the default engine and + MySQL the opt-in fallback, so these kwargs are only meaningful (and + only passed) when DATABASE_URL actually points at a non-SQLite engine. + """ + if database_url.startswith("sqlite"): + return {} + return { + "pool_size": int(os.environ.get("DB_POOL_SIZE", "3")), + "pool_recycle": int(os.environ.get("DB_POOL_RECYCLE_SECONDS", "280")), + "pool_pre_ping": True, + } + + +class BaseConfig: + """Shared config. Subclasses override only what differs per environment.""" + + SECRET_KEY: str | None = os.environ.get("SECRET_KEY") + + # Chapter 06: SQLite/WAL default; swappable via DATABASE_URL without code changes. + SQLALCHEMY_DATABASE_URI: str = os.environ.get("DATABASE_URL", "sqlite:///kavosh.db") + SQLALCHEMY_ENGINE_OPTIONS: dict = _engine_options_for(SQLALCHEMY_DATABASE_URI) + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Chapter 03: 150 max DB connections shared across up to 60 entry + # processes -> keep each process's pool small. Exposed as its own + # config key (not just buried inside SQLALCHEMY_ENGINE_OPTIONS) so + # budget.py can validate the *intended* setting regardless of whether + # the active engine (SQLite) actually consumes it. + DB_POOL_SIZE: int = int(os.environ.get("DB_POOL_SIZE", "3")) + + # Chapter 03: filesystem cache, not Redis. + CACHE_TYPE = os.environ.get("CACHE_TYPE", "FileSystemCache") + CACHE_DIR = os.environ.get("CACHE_DIR", "/tmp/kavosh-cache") + CACHE_DEFAULT_TIMEOUT = int(os.environ.get("CACHE_DEFAULT_TIMEOUT", "60")) + + # Chapter 12: secure session cookie flags. + SESSION_COOKIE_SECURE = _bool_env("SESSION_COOKIE_SECURE", True) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + PERMANENT_SESSION_LIFETIME = timedelta( + hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "12")) + ) + + WTF_CSRF_ENABLED = True + + # Chapter 04/12: upload constraints. + UPLOAD_MAX_SIZE_MB = int(os.environ.get("UPLOAD_MAX_SIZE_MB", "500")) + MAX_CONTENT_LENGTH = UPLOAD_MAX_SIZE_MB * 1024 * 1024 + UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/kavosh/uploads") + + PARSE_BATCH_SIZE = int(os.environ.get("PARSE_BATCH_SIZE", "5000")) + ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") + + +class DevConfig(BaseConfig): + DEBUG = True + SESSION_COOKIE_SECURE = False # allow plain-http local dev + + +class ProdConfig(BaseConfig): + DEBUG = False + + +class TestConfig(BaseConfig): + TESTING = True + DEBUG = True # lets asset() tolerate a missing Vite manifest during tests + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + SQLALCHEMY_ENGINE_OPTIONS = {} # always in-memory SQLite regardless of DATABASE_URL + WTF_CSRF_ENABLED = False + + +_CONFIGS = {"development": DevConfig, "production": ProdConfig, "testing": TestConfig} + + +def get_config(config_name: str | None = None): + """Resolve a config class from FLASK_ENV or an explicit name. + + Defaults to `production` if unset, so an unconfigured deployment never + silently runs with DEBUG on. + """ + name = config_name or os.environ.get("FLASK_ENV", "production") + try: + return _CONFIGS[name] + except KeyError as exc: + raise ValueError(f"Unknown config_name {name!r}; expected one of {list(_CONFIGS)}") from exc diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..42273eb --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,28 @@ +"""Singleton Flask extension instances — initialized, not configured, here. + +Configuration happens in create_app() via .init_app(), so nothing here +holds app- or request-scoped state that must survive a process restart +(factor 6: stateless processes). +""" +from flask_caching import Cache +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import CSRFProtect + +db = SQLAlchemy() +cache = Cache() +csrf = CSRFProtect() +login_manager = LoginManager() +login_manager.login_view = "auth.login" +migrate = Migrate() + +# In-memory storage (Chapter 12: "in-memory or DB-backed... do not require +# Redis"). CAVEAT (flagged): each of up to 60 entry processes (Ch03) keeps +# its own counters, so the effective ceiling across the whole app is up to +# (per-process limit x concurrent processes hit), not one hard global cap. +# Acceptable for this app's threat model (slowing down /login and /uploads +# brute-forcing), but not a strict global rate guarantee. +limiter = Limiter(key_func=get_remote_address, storage_uri="memory://") diff --git a/app/logging_setup.py b/app/logging_setup.py new file mode 100644 index 0000000..1820492 --- /dev/null +++ b/app/logging_setup.py @@ -0,0 +1,34 @@ +"""Structured stdout/stderr logging (Chapter 02 factor 11 / Chapter 12). + +Writes structured (key=value) lines to stdout so the host's log capture +picks them up, per 12-factor logs. Falls back to a size-capped rotating +file only if LOG_FALLBACK_FILE is explicitly set (Ch12: "if stdout capture +is unavailable on the specific hosting setup"). +""" +from __future__ import annotations + +import logging +import os +import sys +from logging.handlers import RotatingFileHandler + +from flask import Flask + +_FORMAT = "%(asctime)s level=%(levelname)s logger=%(name)s msg=%(message)s" + + +def configure_logging(app: Flask) -> None: + formatter = logging.Formatter(_FORMAT) + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + + app.logger.handlers = [stdout_handler] + app.logger.setLevel(logging.INFO if not app.debug else logging.DEBUG) + app.logger.propagate = False + + fallback_path = os.environ.get("LOG_FALLBACK_FILE") + if fallback_path: + file_handler = RotatingFileHandler(fallback_path, maxBytes=5 * 1024 * 1024, backupCount=3) + file_handler.setFormatter(formatter) + app.logger.addHandler(file_handler) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5a64458 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.blocklist_suggestion import BlocklistSuggestion +from app.models.bot_hit import BotHit +from app.models.browser_stats import BrowserStatsDaily +from app.models.human_path_stats import HumanPathStatsDaily +from app.models.ip_registry import IPRegistry +from app.models.ip_traffic_stats import IpPathStatsDaily, IpStatusStatsDaily +from app.models.log_entry import LogEntry +from app.models.log_file import LogFile +from app.models.referrer_stats import ReferrerStatsDaily +from app.models.request_stats import RequestStatsDaily, RequestStatsHourly +from app.models.suspicious_event import SuspiciousEvent +from app.models.user import User + +__all__ = [ + "LogFile", "LogEntry", "RequestStatsHourly", "RequestStatsDaily", + "BotHit", "IPRegistry", "SuspiciousEvent", "BlocklistSuggestion", + "ReferrerStatsDaily", "BrowserStatsDaily", "HumanPathStatsDaily", + "IpPathStatsDaily", "IpStatusStatsDaily", "User", +] diff --git a/app/models/blocklist_suggestion.py b/app/models/blocklist_suggestion.py new file mode 100644 index 0000000..b43a971 --- /dev/null +++ b/app/models/blocklist_suggestion.py @@ -0,0 +1,18 @@ +"""blocklist_suggestions table (Chapter 06). `id` isn't in Ch06's column +list — added because `ip` alone can't be the key (the same IP may be +re-flagged with a different reason later).""" +from __future__ import annotations + +from datetime import datetime + +from app.extensions import db + + +class BlocklistSuggestion(db.Model): + __tablename__ = "blocklist_suggestions" + + id: int = db.Column(db.Integer, primary_key=True) + ip: str = db.Column(db.String(45), nullable=False, index=True) + reason: str = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + exported: bool = db.Column(db.Boolean, nullable=False, default=False) diff --git a/app/models/bot_hit.py b/app/models/bot_hit.py new file mode 100644 index 0000000..b63298d --- /dev/null +++ b/app/models/bot_hit.py @@ -0,0 +1,22 @@ +"""bot_hits table (Chapter 06) — SEO section (Ch09).""" +from __future__ import annotations + +from app.extensions import db + + +class BotHit(db.Model): + __tablename__ = "bot_hits" + + id: int = db.Column(db.Integer, primary_key=True) + log_file_id: int = db.Column(db.Integer, db.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False) + timestamp = db.Column(db.DateTime, nullable=False) + ip: str = db.Column(db.String(45), nullable=False) + bot_name: str = db.Column(db.String(64), nullable=False) + verified: bool = db.Column(db.Boolean, nullable=False, default=False) + path: str = db.Column(db.Text, nullable=False) + status_code: int = db.Column(db.SmallInteger, nullable=False) + + __table_args__ = ( + db.Index("ix_bot_hits_timestamp", "timestamp"), + db.Index("ix_bot_hits_bot_name", "bot_name"), + ) diff --git a/app/models/browser_stats.py b/app/models/browser_stats.py new file mode 100644 index 0000000..85e8447 --- /dev/null +++ b/app/models/browser_stats.py @@ -0,0 +1,14 @@ +"""NEW table (Method A, Ch08 follow-up) — not in Chapter 06's original +list. Human-only browser/OS breakdown widget (Ch08).""" +from __future__ import annotations + +from app.extensions import db + + +class BrowserStatsDaily(db.Model): + __tablename__ = "browser_stats_daily" + + date = db.Column(db.Date, primary_key=True) + browser: str = db.Column(db.String(64), primary_key=True) + os: str = db.Column(db.String(64), primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) diff --git a/app/models/human_path_stats.py b/app/models/human_path_stats.py new file mode 100644 index 0000000..5123e1b --- /dev/null +++ b/app/models/human_path_stats.py @@ -0,0 +1,15 @@ +"""NEW table (Method A, Ch09 follow-up) — not in Chapter 06's original +list. request_stats_hourly/_daily aggregate ALL traffic with no is_bot +split, so Ch09's "most-visited-by-humans" comparison had no rollup to +read. Bot hits excluded at rollup-write time (Ch07's is_bot flag).""" +from __future__ import annotations + +from app.extensions import db + + +class HumanPathStatsDaily(db.Model): + __tablename__ = "human_path_stats_daily" + + date = db.Column(db.Date, primary_key=True) + path: str = db.Column(db.String(2048), primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) diff --git a/app/models/ip_registry.py b/app/models/ip_registry.py new file mode 100644 index 0000000..fb6ec55 --- /dev/null +++ b/app/models/ip_registry.py @@ -0,0 +1,22 @@ +"""ip_registry table (Chapter 06 + Chapter 07 extension). + +last_verified_bot_result: NOT in Ch06's literal column list — added in +Chapter 07. last_verified_at alone can't tell a cache hit *what* was +verified, only *when*; this stores the outcome. +""" +from __future__ import annotations + +from app.extensions import db + + +class IPRegistry(db.Model): + __tablename__ = "ip_registry" + + ip: str = db.Column(db.String(45), primary_key=True) + first_seen = db.Column(db.DateTime, nullable=False) + last_seen = db.Column(db.DateTime, nullable=False) + total_requests: int = db.Column(db.Integer, nullable=False, default=0) + reputation_score: int = db.Column(db.Integer, nullable=False, default=0) + is_flagged: bool = db.Column(db.Boolean, nullable=False, default=False) + last_verified_at = db.Column(db.DateTime, nullable=True) + last_verified_bot_result: bool | None = db.Column(db.Boolean, nullable=True) diff --git a/app/models/ip_traffic_stats.py b/app/models/ip_traffic_stats.py new file mode 100644 index 0000000..83e56e7 --- /dev/null +++ b/app/models/ip_traffic_stats.py @@ -0,0 +1,35 @@ +"""NEW tables (explicit follow-up to Ch10's flagged scope gap): bounded +per-IP traffic breakdown so the IP investigation panel reflects TRUE +full traffic, not just bot_hits/suspicious_events activity. + +CARDINALITY NOTE: unlike the other Method-A tables, this one's row count +scales with distinct IPs per day, which is unbounded for a probed site. +Two mitigations: ip_path_stats_daily keeps only the top N paths per IP +per day (not every ip x path pair); both tables use log_entries' ~30-day +retention (Ch06), enforced by `flask cleanup` (Chapter 12). +""" +from __future__ import annotations + +from app.extensions import db + + +class IpPathStatsDaily(db.Model): + __tablename__ = "ip_path_stats_daily" + + date = db.Column(db.Date, primary_key=True) + ip: str = db.Column(db.String(45), primary_key=True) + path: str = db.Column(db.String(2048), primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) + + __table_args__ = (db.Index("ix_ip_path_stats_ip", "ip"),) + + +class IpStatusStatsDaily(db.Model): + __tablename__ = "ip_status_stats_daily" + + date = db.Column(db.Date, primary_key=True) + ip: str = db.Column(db.String(45), primary_key=True) + status_bucket: str = db.Column(db.String(8), primary_key=True) # 2xx|3xx|4xx|5xx + count: int = db.Column(db.Integer, nullable=False, default=0) + + __table_args__ = (db.Index("ix_ip_status_stats_ip", "ip"),) diff --git a/app/models/log_entry.py b/app/models/log_entry.py new file mode 100644 index 0000000..2613fce --- /dev/null +++ b/app/models/log_entry.py @@ -0,0 +1,29 @@ +"""Optional, time-boxed raw storage (Chapter 06) — retention (~30 days) +enforced by `flask cleanup` (Chapter 12).""" +from __future__ import annotations + +from app.extensions import db + + +class LogEntry(db.Model): + __tablename__ = "log_entries" + + id: int = db.Column(db.Integer, primary_key=True) + log_file_id: int = db.Column(db.Integer, db.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False) + timestamp = db.Column(db.DateTime, nullable=False) # normalized to UTC (Ch07) + ip: str = db.Column(db.String(45), nullable=False) # IPv4 or IPv6 + method: str = db.Column(db.String(10), nullable=False) + path: str = db.Column(db.Text, nullable=False) + status_code: int = db.Column(db.SmallInteger, nullable=False) + bytes_sent: int = db.Column(db.Integer, nullable=False, default=0) + referrer: str | None = db.Column(db.Text, nullable=True) + user_agent: str | None = db.Column(db.Text, nullable=True) + is_bot: bool = db.Column(db.Boolean, nullable=False, default=False) + flagged: bool = db.Column(db.Boolean, nullable=False, default=False) + + __table_args__ = ( + db.Index("ix_log_entries_file_ts", "log_file_id", "timestamp"), + db.Index("ix_log_entries_ip", "ip"), + db.Index("ix_log_entries_path", "path"), + db.Index("ix_log_entries_timestamp", "timestamp"), + ) diff --git a/app/models/log_file.py b/app/models/log_file.py new file mode 100644 index 0000000..c32692c --- /dev/null +++ b/app/models/log_file.py @@ -0,0 +1,22 @@ +"""LogFile model (Chapter 06).""" +from __future__ import annotations + +from datetime import datetime + +from app.extensions import db + + +class LogFile(db.Model): + __tablename__ = "log_files" + + id: int = db.Column(db.Integer, primary_key=True) + filename: str = db.Column(db.String(255), nullable=False) + server_type: str = db.Column(db.String(16), nullable=False) # apache|litespeed + format_string: str = db.Column(db.Text, nullable=False) + uploaded_at: datetime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + status: str = db.Column(db.String(16), nullable=False, default="queued") + total_lines: int | None = db.Column(db.Integer, nullable=True) + processed_lines: int = db.Column(db.Integer, nullable=False, default=0) + size_bytes: int = db.Column(db.BigInteger, nullable=False) + checksum: str = db.Column(db.String(64), nullable=False, index=True) # sha256 hex + error_message: str | None = db.Column(db.Text, nullable=True) diff --git a/app/models/referrer_stats.py b/app/models/referrer_stats.py new file mode 100644 index 0000000..cf69d02 --- /dev/null +++ b/app/models/referrer_stats.py @@ -0,0 +1,14 @@ +"""NEW table (Method A, Ch08 follow-up) — not in Chapter 06's original +list. Gives Top Referrers a rollup data source instead of scanning +log_entries live. Domain-bucketed to keep cardinality bounded.""" +from __future__ import annotations + +from app.extensions import db + + +class ReferrerStatsDaily(db.Model): + __tablename__ = "referrer_stats_daily" + + date = db.Column(db.Date, primary_key=True) + referrer_domain: str = db.Column(db.String(255), primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) diff --git a/app/models/request_stats.py b/app/models/request_stats.py new file mode 100644 index 0000000..f6310fb --- /dev/null +++ b/app/models/request_stats.py @@ -0,0 +1,26 @@ +"""Rollup tables (Chapter 06) — primary source for Overview widgets (Ch08). +Both are site-wide (no log_file_id), consistent with Ch01's single-site scope. +""" +from __future__ import annotations + +from app.extensions import db + + +class RequestStatsHourly(db.Model): + __tablename__ = "request_stats_hourly" + + date_hour = db.Column(db.DateTime, primary_key=True) + path: str = db.Column(db.String(2048), primary_key=True) + status_code: int = db.Column(db.SmallInteger, primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) + bytes_sent_sum: int = db.Column(db.BigInteger, nullable=False, default=0) + + +class RequestStatsDaily(db.Model): + __tablename__ = "request_stats_daily" + + date = db.Column(db.Date, primary_key=True) + count: int = db.Column(db.Integer, nullable=False, default=0) + unique_ips: int = db.Column(db.Integer, nullable=False, default=0) + bytes_sum: int = db.Column(db.BigInteger, nullable=False, default=0) + error_count: int = db.Column(db.Integer, nullable=False, default=0) diff --git a/app/models/suspicious_event.py b/app/models/suspicious_event.py new file mode 100644 index 0000000..d9bef7b --- /dev/null +++ b/app/models/suspicious_event.py @@ -0,0 +1,22 @@ +"""suspicious_events table (Chapter 06) — Security section (Ch10).""" +from __future__ import annotations + +from app.extensions import db + + +class SuspiciousEvent(db.Model): + __tablename__ = "suspicious_events" + + id: int = db.Column(db.Integer, primary_key=True) + log_file_id: int = db.Column(db.Integer, db.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False) + ip: str = db.Column(db.String(45), nullable=False) + timestamp = db.Column(db.DateTime, nullable=False) + path: str = db.Column(db.Text, nullable=False) + rule_matched: str = db.Column(db.String(128), nullable=False) + severity: str = db.Column(db.String(8), nullable=False) # low|medium|high (provisional; Ch10 escalates) + + __table_args__ = ( + db.Index("ix_suspicious_events_timestamp", "timestamp"), + db.Index("ix_suspicious_events_ip", "ip"), + db.Index("ix_suspicious_events_severity", "severity"), + ) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f775247 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,27 @@ +"""Single-admin user model (Chapter 12). Not in Chapter 06's table list — +that chapter scopes log-analytics tables; auth is a separate concern this +chapter owns. Chapter 01 confirms single-admin, no self-registration. +""" +from __future__ import annotations + +from datetime import datetime + +import bcrypt +from flask_login import UserMixin + +from app.extensions import db + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id: int = db.Column(db.Integer, primary_key=True) + email: str = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash: str = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def set_password(self, raw_password: str) -> None: + self.password_hash = bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + def check_password(self, raw_password: str) -> bool: + return bcrypt.checkpw(raw_password.encode("utf-8"), self.password_hash.encode("utf-8")) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/aggregator.py b/app/services/aggregator.py new file mode 100644 index 0000000..b39757e --- /dev/null +++ b/app/services/aggregator.py @@ -0,0 +1,199 @@ +"""Rollup computation (Chapter 06 + Method A extensions from Ch08/09/10). + +Called once per parsed file (app/cli.py::process_logs) for the dates it +touched, ad hoc via `flask rollup` for a manual recompute, and now also +after a file deletion (app/services/file_deletion.py) for whatever dates +the deleted file touched. Every _upsert_* function scans log_entries +inside this background batch job, never at request time — that's what +makes Ch03 rule 6 compliance possible. + +CORRECTNESS FIX: every rollup writer below now deletes a day's existing +rows before writing whatever the fresh scan finds (including writing +nothing, if a day now has zero data). Three of the five writers +previously only ever upserted-when-present and silently left stale rows +behind when a day's data disappeared — unreachable before file deletion +existed (rollups only ever grew), but a real correctness bug once +deletion makes "this day now has less data than before" possible. Only +the two per-IP writers already had this right (Ch10 follow-up); the +other three are fixed here to match. +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime, time, timedelta + +from sqlalchemy import case, func + +from app.extensions import db +from app.models.browser_stats import BrowserStatsDaily +from app.models.human_path_stats import HumanPathStatsDaily +from app.models.ip_traffic_stats import IpPathStatsDaily, IpStatusStatsDaily +from app.models.log_entry import LogEntry +from app.models.referrer_stats import ReferrerStatsDaily +from app.models.request_stats import RequestStatsDaily, RequestStatsHourly +from app.services.blocklist import refresh_blocklist_suggestions +from app.services.referrer import referrer_domain +from app.services.ua_classifier import classify_browser, classify_os +from app.utils.http_status import status_bucket + +TOP_PATHS_PER_IP_PER_DAY = 15 # bounds ip_path_stats_daily row growth (Ch10 follow-up) + + +def compute_rollups_for_range(start: date, end: date) -> None: + """Recompute every rollup for each day in [start, end]. Site-wide, + not per-file (Ch01: single site) — recomputing from scratch per day + avoids double-counting when two uploads cover the same period, and + correctly shrinks a day's numbers back down when a file covering + that day is deleted. + """ + current = start + while current <= end: + _upsert_hourly(current) + _upsert_daily(current) + _upsert_per_line_derived_stats(current) + refresh_blocklist_suggestions(current) + current += timedelta(days=1) + + +def _day_bounds(day: date) -> tuple[datetime, datetime]: + start = datetime.combine(day, time.min) + return start, start + timedelta(days=1) + + +def _upsert_hourly(day: date) -> None: + start, end = _day_bounds(day) + rows = ( + db.session.query( + func.strftime("%Y-%m-%d %H:00:00", LogEntry.timestamp).label("date_hour"), + LogEntry.path, + LogEntry.status_code, + func.count().label("count"), + func.coalesce(func.sum(LogEntry.bytes_sent), 0).label("bytes_sent_sum"), + ) + .filter(LogEntry.timestamp >= start, LogEntry.timestamp < end) + .group_by("date_hour", LogEntry.path, LogEntry.status_code) + .all() + ) + + # Delete-then-insert: replaces the day's hourly rows entirely, + # including leaving none behind if `rows` is now empty (e.g. the + # only file covering this day was just deleted). + db.session.query(RequestStatsHourly).filter( + RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end + ).delete() + + if rows: + payload = [ + { + "date_hour": datetime.strptime(r.date_hour, "%Y-%m-%d %H:%M:%S"), + "path": r.path, + "status_code": r.status_code, + "count": r.count, + "bytes_sent_sum": r.bytes_sent_sum, + } + for r in rows + ] + db.session.execute(RequestStatsHourly.__table__.insert(), payload) + + db.session.commit() + + +def _upsert_daily(day: date) -> None: + start, end = _day_bounds(day) + result = ( + db.session.query( + func.count().label("count"), + func.count(func.distinct(LogEntry.ip)).label("unique_ips"), + func.coalesce(func.sum(LogEntry.bytes_sent), 0).label("bytes_sum"), + func.coalesce(func.sum(case((LogEntry.status_code >= 400, 1), else_=0)), 0).label("error_count"), + ) + .filter(LogEntry.timestamp >= start, LogEntry.timestamp < end) + .one() + ) + + # Delete-then-insert: if this day now has zero entries (its only + # contributing file was deleted), the stale row is removed rather + # than left behind — no rollup row is better than a wrong one. + db.session.query(RequestStatsDaily).filter(RequestStatsDaily.date == day).delete() + + if result.count > 0: + db.session.execute( + RequestStatsDaily.__table__.insert(), + { + "date": day, + "count": result.count, + "unique_ips": result.unique_ips, + "bytes_sum": result.bytes_sum, + "error_count": result.error_count, + }, + ) + + db.session.commit() + + +def _upsert_per_line_derived_stats(day: date) -> None: + """Referrer domain, browser/OS, human-only path counts (Ch08/09), and + per-IP path/status counts (Ch10 follow-up) — one streamed pass over + log_entries (Ch03 rule 1: bounded per-chunk memory via yield_per, + never the whole day loaded at once). Every table here uses the same + delete-then-insert pattern so a day's rows are fully replaced by + whatever the fresh scan finds, including nothing. + """ + start, end = _day_bounds(day) + referrer_counts: dict[str, int] = defaultdict(int) + browser_counts: dict[tuple[str, str], int] = defaultdict(int) + human_path_counts: dict[str, int] = defaultdict(int) + ip_path_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + ip_status_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + + query = ( + db.session.query( + LogEntry.referrer, LogEntry.user_agent, LogEntry.is_bot, + LogEntry.path, LogEntry.ip, LogEntry.status_code, + ) + .filter(LogEntry.timestamp >= start, LogEntry.timestamp < end) + ) + for referrer, user_agent, is_bot, path, ip, status_code in query.yield_per(1000): + domain = referrer_domain(referrer) + if domain: + referrer_counts[domain] += 1 + if not is_bot: # Ch08: bot traffic excluded from human browser/OS breakdown + browser_counts[(classify_browser(user_agent), classify_os(user_agent))] += 1 + human_path_counts[path] += 1 + ip_path_counts[ip][path] += 1 + ip_status_counts[ip][status_bucket(status_code)] += 1 + + db.session.query(ReferrerStatsDaily).filter(ReferrerStatsDaily.date == day).delete() + if referrer_counts: + payload = [{"date": day, "referrer_domain": d, "count": c} for d, c in referrer_counts.items()] + db.session.execute(ReferrerStatsDaily.__table__.insert(), payload) + + db.session.query(BrowserStatsDaily).filter(BrowserStatsDaily.date == day).delete() + if browser_counts: + payload = [{"date": day, "browser": b, "os": o, "count": c} for (b, o), c in browser_counts.items()] + db.session.execute(BrowserStatsDaily.__table__.insert(), payload) + + db.session.query(HumanPathStatsDaily).filter(HumanPathStatsDaily.date == day).delete() + if human_path_counts: + payload = [{"date": day, "path": p, "count": c} for p, c in human_path_counts.items()] + db.session.execute(HumanPathStatsDaily.__table__.insert(), payload) + + db.session.query(IpPathStatsDaily).filter(IpPathStatsDaily.date == day).delete() + if ip_path_counts: + payload = [] + for ip, paths in ip_path_counts.items(): + top_paths = sorted(paths.items(), key=lambda kv: kv[1], reverse=True)[:TOP_PATHS_PER_IP_PER_DAY] + payload.extend({"date": day, "ip": ip, "path": p, "count": c} for p, c in top_paths) + if payload: + db.session.execute(IpPathStatsDaily.__table__.insert(), payload) + + db.session.query(IpStatusStatsDaily).filter(IpStatusStatsDaily.date == day).delete() + if ip_status_counts: + payload = [ + {"date": day, "ip": ip, "status_bucket": bucket, "count": c} + for ip, buckets in ip_status_counts.items() + for bucket, c in buckets.items() + ] + db.session.execute(IpStatusStatsDaily.__table__.insert(), payload) + + db.session.commit() diff --git a/app/services/background.py b/app/services/background.py new file mode 100644 index 0000000..d902b0a --- /dev/null +++ b/app/services/background.py @@ -0,0 +1,97 @@ +"""No-cron automatic processing (Chapter 12 simplification, per project +owner request): triggers file parsing immediately in a background thread +right after upload, and opportunistically resumes any incomplete files +when the Overview page loads — replacing the cron-triggered model. +`flask process-logs` still exists in app/cli.py for anyone who'd rather +use cron, but nothing requires it anymore. + +TRADEOFF (flagged, deviating from Chapter 02/03's "no persistent +background workers, cron-triggered CLI only" stance): a background thread +lives inside the same worker process that handled the upload request. If +Passenger recycles that process mid-parse, the thread dies with it — +progress up to the last commit is still safely checkpointed (same bounded- +batch model as before), but nothing will automatically resume it without +either cron or a page visit. The "resume on page load" hook below is the +deliberate replacement for that guarantee: visiting the Overview tab +re-triggers processing for anything left incomplete, so in the worst case +a stuck file resumes the next time the admin looks at the dashboard, +rather than never. + +This is not a long-lived daemon: each thread terminates once its file +reaches "done"/"error" (or the process is killed), and no thread survives +a process restart — it just gets re-triggered fresh next time. +""" +from __future__ import annotations + +import threading + +from flask import Flask + +from app.extensions import db +from app.models.log_file import LogFile +from app.services.log_processor import process_one_batch + +# In-process guard against launching two threads for the same file at +# once (e.g. the upload trigger and a page-load resume firing close +# together). Per-worker-process only — a different entry process picking +# up the same file concurrently is a low-probability edge case accepted +# for this simplification; each write is still a small checkpointed +# commit, not a giant one, which limits how bad a collision could be. +_active_file_ids: set[int] = set() +_lock = threading.Lock() + + +def _claim(log_file_id: int) -> bool: + with _lock: + if log_file_id in _active_file_ids: + return False + _active_file_ids.add(log_file_id) + return True + + +def _release(log_file_id: int) -> None: + with _lock: + _active_file_ids.discard(log_file_id) + + +def _run_to_completion(app: Flask, log_file_id: int, batch_size: int) -> None: + with app.app_context(): + try: + log_file = db.session.get(LogFile, log_file_id) + if log_file is None: + return + while log_file.status in ("queued", "processing"): + process_one_batch(log_file, batch_size) + db.session.refresh(log_file) + except Exception: + app.logger.exception("Background processing failed for log_file_id=%s", log_file_id) + log_file = db.session.get(LogFile, log_file_id) + if log_file is not None and log_file.status != "done": + log_file.status = "error" + log_file.error_message = "Processing failed unexpectedly; see server logs." + db.session.commit() + finally: + _release(log_file_id) + + +def trigger_processing(app: Flask, log_file_id: int) -> None: + """Start background processing for one file; no-ops if already running.""" + if not _claim(log_file_id): + return + batch_size = app.config["PARSE_BATCH_SIZE"] + thread = threading.Thread( + target=_run_to_completion, args=(app, log_file_id, batch_size), daemon=True + ) + thread.start() + + +def resume_incomplete_files(app: Flask) -> None: + """Opportunistic resume hook, called from the Overview page load — + the deliberate replacement for cron's "there's always a next tick" + guarantee. Cheap: one indexed status-filtered query. + """ + incomplete_ids = [ + lf.id for lf in LogFile.query.filter(LogFile.status.in_(["queued", "processing"])).all() + ] + for log_file_id in incomplete_ids: + trigger_processing(app, log_file_id) diff --git a/app/services/blocklist.py b/app/services/blocklist.py new file mode 100644 index 0000000..acffbd5 --- /dev/null +++ b/app/services/blocklist.py @@ -0,0 +1,67 @@ +"""Blocklist-suggestion generation (Chapter 10 follow-up): no earlier +chapter assigned ownership of populating blocklist_suggestions or setting +ip_registry.is_flagged. Runs in the background aggregator pass (batch, not +request-time, per Ch02/03), reusing severity_scoring.py so there's exactly +one scoring model between the dashboard display and the flagging decision. +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime, timedelta + +from app.extensions import db +from app.models.blocklist_suggestion import BlocklistSuggestion +from app.models.ip_registry import IPRegistry +from app.models.suspicious_event import SuspiciousEvent +from app.services.severity_scoring import SeverityInputs, compute_effective_severity + +_RANK = {"low": 0, "medium": 1, "high": 2} + + +def refresh_blocklist_suggestions(day: date) -> None: + """Flag an IP (is_flagged + a suggestion row) if its escalated severity + for `day` reaches 'high'. Idempotent — skips IPs already suggested. + """ + start = datetime.combine(day, datetime.min.time()) + end = start + timedelta(days=1) + + events = ( + db.session.query(SuspiciousEvent.ip, SuspiciousEvent.timestamp, SuspiciousEvent.severity) + .filter(SuspiciousEvent.timestamp >= start, SuspiciousEvent.timestamp < end) + .all() + ) + if not events: + return + + by_ip: dict[str, list] = defaultdict(list) + for ip, ts, sev in events: + by_ip[ip].append((ts, sev)) + + already_suggested = {ip for (ip,) in db.session.query(BlocklistSuggestion.ip).distinct().all()} + + for ip, ip_events in by_ip.items(): + if ip in already_suggested: + continue + timestamps = sorted(ts for ts, _ in ip_events) + avg_interval = ( + (timestamps[-1] - timestamps[0]).total_seconds() / (len(timestamps) - 1) + if len(timestamps) > 1 else None + ) + worst_base = max((sev for _, sev in ip_events), key=lambda s: _RANK.get(s, 0)) + effective = compute_effective_severity( + SeverityInputs(base_severity=worst_base, ip_event_count=len(ip_events), avg_interval_seconds=avg_interval) + ) + if effective != "high": + continue + + db.session.add(BlocklistSuggestion( + ip=ip, + reason=f"{len(ip_events)} suspicious event(s) on {day.isoformat()}, escalated to high severity", + created_at=datetime.utcnow(), + exported=False, + )) + ip_row = db.session.get(IPRegistry, ip) + if ip_row is not None: + ip_row.is_flagged = True + + db.session.commit() diff --git a/app/services/bot_identifier.py b/app/services/bot_identifier.py new file mode 100644 index 0000000..2c480d2 --- /dev/null +++ b/app/services/bot_identifier.py @@ -0,0 +1,78 @@ +"""Bot signature matching + reverse-DNS verification (Chapter 07). + +Signatures are data (JSON), not hardcoded logic, so the list grows without +a code change. Verification does real DNS I/O — only ever called from the +background process-logs batch job (app/services/classification.py), never +synchronously inside a dashboard request, per Chapter 07's explicit rule. +""" +from __future__ import annotations + +import json +import socket +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path + +SIGNATURES_PATH = Path(__file__).parent / "data" / "bot_signatures.json" + +# Skip re-verifying the same IP more often than this (Ch07: DNS latency is +# a real cost on constrained hosting). +VERIFICATION_TTL = timedelta(days=7) + + +@dataclass(frozen=True) +class BotSignature: + name: str + ua_substrings: tuple[str, ...] + verify_suffixes: tuple[str, ...] # PTR hostname must end in one of these + + +def _load_signatures() -> list[BotSignature]: + raw = json.loads(SIGNATURES_PATH.read_text()) + return [ + BotSignature(name=e["name"], ua_substrings=tuple(e["ua_substrings"]), verify_suffixes=tuple(e["verify_suffixes"])) + for e in raw + ] + + +_SIGNATURES = _load_signatures() + + +def classify_bot(user_agent: str | None) -> str | None: + """Return the claimed bot name via UA substring match, or None.""" + if not user_agent: + return None + ua_lower = user_agent.lower() + for sig in _SIGNATURES: + if any(sub.lower() in ua_lower for sub in sig.ua_substrings): + return sig.name + return None + + +def _signature_for(bot_name: str) -> BotSignature | None: + return next((s for s in _SIGNATURES if s.name == bot_name), None) + + +def verify_bot_ip(ip: str, bot_name: str) -> bool: + """Reverse-DNS + forward-confirm that `ip` really belongs to `bot_name`.""" + sig = _signature_for(bot_name) + if sig is None: + return False + try: + hostname, _, _ = socket.gethostbyaddr(ip) + except (socket.herror, socket.gaierror, OSError): + return False + if not any(hostname.lower().endswith(suffix) for suffix in sig.verify_suffixes): + return False + try: + forward_ips = socket.gethostbyname_ex(hostname)[2] + except (socket.herror, socket.gaierror, OSError): + return False + return ip in forward_ips + + +def is_verification_stale(last_verified_at: datetime | None) -> bool: + """True if this IP needs a fresh DNS check (Ch07 caching rule).""" + if last_verified_at is None: + return True + return datetime.utcnow() - last_verified_at > VERIFICATION_TTL diff --git a/app/services/classification.py b/app/services/classification.py new file mode 100644 index 0000000..60800f7 --- /dev/null +++ b/app/services/classification.py @@ -0,0 +1,122 @@ +"""Per-batch classification pipeline (Chapter 07): ties bot_identifier and +threat_scanner into the bot_hits/suspicious_events/ip_registry write path. +Called once per bulk-insert chunk from app/cli.py — DNS-based verification +belongs here (background batch job), never in a dashboard request. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy import func, insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from app.extensions import db +from app.models.bot_hit import BotHit +from app.models.ip_registry import IPRegistry +from app.models.suspicious_event import SuspiciousEvent +from app.services import bot_identifier, threat_scanner +from app.services.log_parser import ParsedEntry + + +@dataclass +class EntryClassification: + """Cheap, no-I/O flags for the log_entries row itself.""" + is_bot: bool + flagged: bool + + +def classify_entry(entry: ParsedEntry) -> EntryClassification: + """No DNS I/O here — bot *verification* is batched separately below, + since it's stateful (cached per IP) and only worth doing once per IP + per batch, not once per line. + """ + return EntryClassification( + is_bot=bot_identifier.classify_bot(entry.user_agent) is not None, + flagged=threat_scanner.scan(entry) is not None, + ) + + +def write_batch_side_effects(log_file_id: int, entries: list[ParsedEntry]) -> None: + """Derive and bulk-write bot_hits, suspicious_events, ip_registry upserts.""" + if not entries: + return + + bot_hit_rows: list[dict] = [] + suspicious_rows: list[dict] = [] + ip_agg: dict[str, dict] = {} + verified_this_batch: dict[str, bool] = {} # avoid repeat DNS for the same IP in one batch + + for entry in entries: + agg = ip_agg.setdefault(entry.ip, {"first": entry.timestamp, "last": entry.timestamp, "count": 0}) + agg["count"] += 1 + agg["first"] = min(agg["first"], entry.timestamp) + agg["last"] = max(agg["last"], entry.timestamp) + + bot_name = bot_identifier.classify_bot(entry.user_agent) + if bot_name is not None: + verified = verified_this_batch.get(entry.ip) + if verified is None: + verified = _verify_with_cache(entry.ip, bot_name) + verified_this_batch[entry.ip] = verified + bot_hit_rows.append({ + "log_file_id": log_file_id, "timestamp": entry.timestamp, "ip": entry.ip, + "bot_name": bot_name, "verified": verified, "path": entry.path, + "status_code": entry.status_code, + }) + if not verified: + # Single detection, two dashboard consumers (Ch07): spoofed + # bot also surfaces as a suspicious_events row. + suspicious_rows.append({ + "log_file_id": log_file_id, "ip": entry.ip, "timestamp": entry.timestamp, + "path": entry.path, "rule_matched": f"spoofed_bot:{bot_name}", "severity": "medium", + }) + + threat = threat_scanner.scan(entry) + if threat is not None: + suspicious_rows.append({ + "log_file_id": log_file_id, "ip": entry.ip, "timestamp": entry.timestamp, + "path": entry.path, "rule_matched": threat.rule_matched, "severity": threat.severity, + }) + + if bot_hit_rows: + db.session.execute(insert(BotHit.__table__), bot_hit_rows) + if suspicious_rows: + db.session.execute(insert(SuspiciousEvent.__table__), suspicious_rows) + + _upsert_ip_registry(ip_agg, verified_this_batch) + db.session.commit() + + +def _verify_with_cache(ip: str, bot_name: str) -> bool: + """TTL-gated reverse/forward DNS check, cached via + ip_registry.last_verified_bot_result (Ch07 schema addition). + """ + row = db.session.get(IPRegistry, ip) + if row is not None and not bot_identifier.is_verification_stale(row.last_verified_at): + return bool(row.last_verified_bot_result) + return bot_identifier.verify_bot_ip(ip, bot_name) + + +def _upsert_ip_registry(ip_agg: dict[str, dict], verified_this_batch: dict[str, bool]) -> None: + now = datetime.utcnow() + for ip, agg in ip_agg.items(): + values = { + "ip": ip, "first_seen": agg["first"], "last_seen": agg["last"], + "total_requests": agg["count"], "reputation_score": 0, "is_flagged": False, + } + if ip in verified_this_batch: + values["last_verified_at"] = now + values["last_verified_bot_result"] = verified_this_batch[ip] + + stmt = sqlite_insert(IPRegistry.__table__).values(**values) + update_set = { + "last_seen": func.max(IPRegistry.last_seen, stmt.excluded.last_seen), + "first_seen": func.min(IPRegistry.first_seen, stmt.excluded.first_seen), + "total_requests": IPRegistry.total_requests + stmt.excluded.total_requests, + } + if ip in verified_this_batch: + update_set["last_verified_at"] = stmt.excluded.last_verified_at + update_set["last_verified_bot_result"] = stmt.excluded.last_verified_bot_result + stmt = stmt.on_conflict_do_update(index_elements=["ip"], set_=update_set) + db.session.execute(stmt) diff --git a/app/services/data/bot_signatures.json b/app/services/data/bot_signatures.json new file mode 100644 index 0000000..f5e0f72 --- /dev/null +++ b/app/services/data/bot_signatures.json @@ -0,0 +1,9 @@ +[ + {"name": "Googlebot", "ua_substrings": ["Googlebot"], "verify_suffixes": [".googlebot.com", ".google.com"]}, + {"name": "Bingbot", "ua_substrings": ["bingbot"], "verify_suffixes": [".search.msn.com"]}, + {"name": "Yandex", "ua_substrings": ["YandexBot"], "verify_suffixes": [".yandex.ru", ".yandex.com", ".yandex.net"]}, + {"name": "Baidu", "ua_substrings": ["Baiduspider"], "verify_suffixes": [".baidu.com", ".baidu.jp"]}, + {"name": "DuckDuckBot", "ua_substrings": ["DuckDuckBot"], "verify_suffixes": [".duckduckgo.com"]}, + {"name": "AhrefsBot", "ua_substrings": ["AhrefsBot"], "verify_suffixes": [".ahrefs.com"]}, + {"name": "SemrushBot", "ua_substrings": ["SemrushBot"], "verify_suffixes": [".semrush.com"]} +] diff --git a/app/services/data/browser_signatures.json b/app/services/data/browser_signatures.json new file mode 100644 index 0000000..e399705 --- /dev/null +++ b/app/services/data/browser_signatures.json @@ -0,0 +1,8 @@ +[ + {"name": "Edge", "ua_substrings": ["Edg/", "EdgA/", "EdgiOS/"]}, + {"name": "Opera", "ua_substrings": ["OPR/", "Opera"]}, + {"name": "Chrome", "ua_substrings": ["Chrome/", "CriOS/"]}, + {"name": "Firefox", "ua_substrings": ["Firefox/", "FxiOS/"]}, + {"name": "Safari", "ua_substrings": ["Safari/"]}, + {"name": "Internet Explorer", "ua_substrings": ["MSIE ", "Trident/"]} +] diff --git a/app/services/data/os_signatures.json b/app/services/data/os_signatures.json new file mode 100644 index 0000000..fbc1948 --- /dev/null +++ b/app/services/data/os_signatures.json @@ -0,0 +1,7 @@ +[ + {"name": "Windows", "ua_substrings": ["Windows NT"]}, + {"name": "iOS", "ua_substrings": ["iPhone", "iPad", "iPod"]}, + {"name": "macOS", "ua_substrings": ["Mac OS X", "Macintosh"]}, + {"name": "Android", "ua_substrings": ["Android"]}, + {"name": "Linux", "ua_substrings": ["Linux"]} +] diff --git a/app/services/data/threat_patterns.json b/app/services/data/threat_patterns.json new file mode 100644 index 0000000..0dc2faf --- /dev/null +++ b/app/services/data/threat_patterns.json @@ -0,0 +1,11 @@ +{ + "sensitive_paths": [ + "/.env", "/.git/config", "wp-config.php", "/.htpasswd", "/phpmyadmin", + "/xmlrpc.php", ".sql.gz", ".sql.bak", ".zip", ".bak", ".old" + ], + "injection_markers": [ + "union select", "' or '1'='1", " + + + +
+
+ +
+ + +
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/assets.py b/app/utils/assets.py new file mode 100644 index 0000000..d944e07 --- /dev/null +++ b/app/utils/assets.py @@ -0,0 +1,39 @@ +"""Vite manifest reader — maps a source entry to its hashed dist path (Ch05).""" +from __future__ import annotations + +import json +from pathlib import Path + +from flask import Flask + + +class ManifestNotFound(RuntimeError): + """app/static/dist/manifest.json is missing — `npm run build` hasn't been run.""" + + +def _load_manifest(static_dist_dir: Path) -> dict: + for candidate in (static_dist_dir / ".vite" / "manifest.json", static_dist_dir / "manifest.json"): + if candidate.exists(): + return json.loads(candidate.read_text()) + raise ManifestNotFound(f"No Vite manifest under {static_dist_dir} — run `npm run build`.") + + +def register_asset_helper(app: Flask) -> None: + """Register `asset(name)` as a Jinja global (Chapter 05).""" + static_dist_dir = Path(app.static_folder) / "dist" + + @app.context_processor + def inject_asset_helper(): + def asset(name: str) -> str: + try: + manifest = _load_manifest(static_dist_dir) + except ManifestNotFound: + if app.debug: + return f"/static/dist/{name}" # tolerate an unbuilt dev checkout + raise + entry = manifest.get(name) + if entry is None: + raise KeyError(f"{name!r} not in Vite manifest.") + return f"/static/dist/{entry['file']}" + + return {"asset": asset} diff --git a/app/utils/dates.py b/app/utils/dates.py new file mode 100644 index 0000000..2050e7c --- /dev/null +++ b/app/utils/dates.py @@ -0,0 +1,32 @@ +"""Shared from/to date-range parsing (Chapter 11: consistent across every +chart/KPI endpoint; never defaults to "all time" per Chapter 03 rule 7). +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta + +from flask import Request + +DEFAULT_RANGE_DAYS = 7 # within Ch11's stated "7 or 30 day" default allowance + + +def parse_date_range(request: Request) -> tuple[date, date]: + """Parse `?from=&to=` (ISO 8601 dates), defaulting to the last 7 days.""" + to_raw = request.args.get("to") + from_raw = request.args.get("from") + to_date = date.fromisoformat(to_raw) if to_raw else date.today() + from_date = date.fromisoformat(from_raw) if from_raw else to_date - timedelta(days=DEFAULT_RANGE_DAYS - 1) + if from_date > to_date: + from_date, to_date = to_date, from_date + return from_date, to_date + + +def day_bounds(from_date: date, to_date: date) -> tuple[datetime, datetime]: + """Inclusive [from_date, to_date] -> half-open [start, end) datetime range. + + Shared by overview/queries.py, seo/queries.py, and security/queries.py + (consolidated here rather than each keeping its own local copy). + """ + start = datetime.combine(from_date, datetime.min.time()) + end = datetime.combine(to_date, datetime.min.time()) + timedelta(days=1) + return start, end diff --git a/app/utils/envelope.py b/app/utils/envelope.py new file mode 100644 index 0000000..8fa819a --- /dev/null +++ b/app/utils/envelope.py @@ -0,0 +1,18 @@ +"""Shared JSON envelope helpers (Chapter 11). + +Every existing /api/... endpoint already hand-builds this exact shape via +jsonify(data=..., meta=...) — audited for compliance in docs/api-contract- +final.md. This module exists so NEW endpoints (e.g. this chapter's +unauthorized_handler) have one call site instead of re-typing the shape. +""" +from __future__ import annotations + +from flask import jsonify + + +def api_ok(data, meta: dict | None = None): + return jsonify(data=data, meta=meta or {}) + + +def api_error(code: str, message: str, status: int): + return jsonify(error={"code": code, "message": message}), status diff --git a/app/utils/htmx.py b/app/utils/htmx.py new file mode 100644 index 0000000..f530014 --- /dev/null +++ b/app/utils/htmx.py @@ -0,0 +1,20 @@ +"""Shared HTMX full-page-vs-fragment response helper (Chapter 04). + +Every blueprint serving a dashboard tab (Ch08/09/10) uses this instead of +duplicating the `if request.headers.get("HX-Request")` check, so the +convention is identical everywhere. +""" +from __future__ import annotations + +from flask import Request, render_template + + +def is_htmx(request: Request) -> bool: + """True if this request was triggered by an hx-* attribute.""" + return request.headers.get("HX-Request", "").lower() == "true" + + +def render_htmx_aware(request: Request, *, full_template: str, partial_template: str, **ctx): + """Render `full_template` on first load, `partial_template` on HTMX swaps.""" + template = partial_template if is_htmx(request) else full_template + return render_template(template, **ctx) diff --git a/app/utils/http_status.py b/app/utils/http_status.py new file mode 100644 index 0000000..1f7d3a4 --- /dev/null +++ b/app/utils/http_status.py @@ -0,0 +1,14 @@ +"""Shared HTTP status-code bucketing — used by the aggregator (per-IP +rollup) and available for any endpoint needing the same 2xx/3xx/4xx/5xx +buckets, so there's one bucketing rule, not several copies.""" +from __future__ import annotations + + +def status_bucket(status_code: int) -> str: + if status_code < 300: + return "2xx" + if status_code < 400: + return "3xx" + if status_code < 500: + return "4xx" + return "5xx" diff --git a/app/utils/pagination.py b/app/utils/pagination.py new file mode 100644 index 0000000..b831624 --- /dev/null +++ b/app/utils/pagination.py @@ -0,0 +1,14 @@ +"""Shared page/per_page parsing (Chapter 11: consistent across every +Grid.js-backed endpoint).""" +from __future__ import annotations + +from flask import Request + +DEFAULT_PER_PAGE = 20 +MAX_PER_PAGE = 100 + + +def parse_pagination(request: Request) -> tuple[int, int]: + page = max(1, request.args.get("page", 1, type=int)) + per_page = request.args.get("per_page", DEFAULT_PER_PAGE, type=int) + return page, max(1, min(per_page, MAX_PER_PAGE)) diff --git a/app/utils/upload_paths.py b/app/utils/upload_paths.py new file mode 100644 index 0000000..a8fdd4a --- /dev/null +++ b/app/utils/upload_paths.py @@ -0,0 +1,20 @@ +"""Shared upload-path helper (Chapter 04) — used by the uploads blueprint, +the optional CLI processor, and the automatic background-thread processor, +so there's one definition of where a given LogFile's raw upload lives on +disk. Pulled out of the uploads blueprint so services/ doesn't have to +import from blueprints/ (the wrong direction) to reach it. +""" +from __future__ import annotations + +from pathlib import Path + +from flask import current_app + +from app.models.log_file import LogFile + + +def upload_path_for(log_file: LogFile) -> Path: + """Deterministic on-disk path for a LogFile row — avoids a new DB column.""" + ext = Path(log_file.filename).suffix.lower() + upload_dir = Path(current_app.config["UPLOAD_DIR"]) + return upload_dir / f"{log_file.id}{ext}" diff --git a/docs/api-contract-final.md b/docs/api-contract-final.md new file mode 100644 index 0000000..bd389e7 --- /dev/null +++ b/docs/api-contract-final.md @@ -0,0 +1,105 @@ +# Chapter 11 — Final API Contract (post Ch08–12 additions) + +Consolidated per Chapter 11's own instruction to update/extend the route +table as new routes are introduced elsewhere, flagging additions explicitly. + +## Additions beyond the original Chapter 11 table + +| Method | Path | Blueprint | Purpose | Response | Added in | +|---|---|---|---|---|---| +| GET | `/api/overview/browser-breakdown` | overview | Human-only browser/OS breakdown | JSON | Ch08 (Method A) | +| GET | `/` | (root) | Redirect to `/overview` if authenticated, else `/login` | redirect | Ch12 follow-up | +| GET | `/api/uploads` | uploads | List uploaded files, paginated | JSON (Grid.js) | Ch12 follow-up | +| DELETE | `/api/uploads` | uploads | Bulk-delete uploaded files + derived data | JSON | Ch12 follow-up | + +`/login`, `/logout`, and `/healthz` were already listed in the original +table (Ch02/Ch04) and are now actually implemented (Ch12) — no table +change needed, just noting they're no longer placeholders. One narrowing: +`/logout` is implemented as POST-only, not GET/POST, so a state-changing +action isn't reachable via a plain GET (CSRF-safety; see +`app/blueprints/auth/routes.py`). + +## Architecture note: rollup recompute is now delete-safe (Ch12 follow-up) + +Deleting an uploaded file (`app/services/file_deletion.py`) removes its +`log_entries`/`bot_hits`/`suspicious_events` rows explicitly (this +project's SQLite connections don't have `PRAGMA foreign_keys=ON`, so the +`ondelete="CASCADE"` in the migrations is documentation, not enforced +behavior) and recomputes rollups for whatever dates the file touched. +That recompute exposed a real bug in `app/services/aggregator.py`: three +of its five writers only ever upserted-when-data-present and silently +left stale rows behind when a day's data disappeared entirely — never +reachable before deletion existed (rollups only ever grew). All five +writers now use delete-then-insert consistently. See the module +docstrings in `aggregator.py` and `file_deletion.py` for the full +reasoning, including the deliberately-out-of-scope limitation around +`ip_registry`/`blocklist_suggestions` not being recomputed on delete. + +## Architecture note: cron is now optional (Ch12 follow-up) + +`POST /uploads` now triggers processing automatically in a background +thread (`app/services/background.py`), and `GET /overview` opportunistically +resumes any file left incomplete. `flask process-logs` and `flask cleanup` +(`app/cli.py`) still exist and work identically to before for anyone who +wants a cron-based fallback, but nothing in the app requires it anymore. +See the module docstring in `app/services/background.py` for the explicit +tradeoff this introduces relative to the original Chapter 02/03 "no +persistent background workers, cron-triggered CLI only" stance. + +## Full current route table + +| Method | Path | Blueprint | Purpose | Response | Auth | +|---|---|---|---|---|---| +| GET | `/overview` | overview | Overview tab | full page / HTMX partial | required | +| GET | `/api/overview/kpis` | overview | KPI card values | JSON | required | +| GET | `/api/overview/traffic-chart` | overview | Traffic-over-time series | JSON | required | +| GET | `/api/overview/status-codes` | overview | Status-code breakdown | JSON | required | +| GET | `/api/overview/top-urls` | overview | Top URLs table | JSON (Grid.js) | required | +| GET | `/api/overview/top-referrers` | overview | Top referrers table | JSON (Grid.js) | required | +| GET | `/api/overview/browser-breakdown` | overview | Human browser/OS breakdown | JSON | required | +| GET | `/seo` | seo | SEO tab | full page / HTMX partial | required | +| GET | `/api/seo/bot-summary` | seo | Bot summary cards | JSON | required | +| GET | `/api/seo/crawl-chart-data` | seo | Crawl frequency series | JSON | required | +| GET | `/api/seo/bot-status-codes` | seo | Status codes served to bots | JSON | required | +| GET | `/api/seo/crawled-vs-visited` | seo | Bot vs. human URL comparison | JSON (Grid.js) | required | +| GET | `/security` | security | Security tab | full page / HTMX partial | required | +| GET | `/api/security/events` | security | Suspicious events table | JSON (Grid.js) | required | +| GET | `/api/security/sensitive-paths` | security | Sensitive-path summary | JSON | required | +| GET | `/api/security/ip/` | security | IP history drill-down | HTMX fragment | required | +| GET | `/api/security/export-blocklist` | security | Blocklist export | text/plain download | required | +| POST | `/uploads` | uploads | Upload a log file | HTMX fragment (queued state) | required | +| GET | `/api/uploads//status` | uploads | Poll parse status | JSON or HTMX fragment | required | +| GET | `/api/uploads` | uploads | List uploaded files | JSON (Grid.js) | required | +| DELETE | `/api/uploads` | uploads | Bulk-delete uploaded files | JSON | required | +| GET | `/login` | auth | Show login form | full page | public | +| POST | `/login` | auth | Authenticate | redirect | public | +| POST | `/logout` | auth | End session | redirect | required | +| GET | `/healthz` | (root) | Liveness check | JSON | public | +| GET | `/` | (root) | Auth-based redirect | redirect | public | + +## Envelope compliance audit (Chapter 12) + +Every `/api/...` JSON endpoint above was checked against Chapter 11's +`{"data": ..., "meta": {...}}` / `{"error": {"code", "message"}}` +convention. All conform. `app/utils/envelope.py` was added this chapter +as a shared helper for *new* endpoints going forward (used by the +`unauthorized_handler` in `app/__init__.py`) — existing endpoints already +matched the shape by hand and weren't rewritten, to avoid churn on +working code. + +New this chapter: every `/api/...` path now returns a JSON `401` with +`{"error": {"code": "unauthorized", ...}}` when unauthenticated, instead +of Flask-Login's default redirect — consistent with the envelope even +for auth failures. `/api/security/ip/` and `/api/security/export- +blocklist` are the two exceptions where a *successful* response isn't +JSON (HTMX fragment / text download, per the table above and Chapter 10) +— their auth-failure response is still the JSON envelope for consistency. + +## Auth model (Chapter 12) + +Every blueprint except `auth` and the root `/healthz` route requires a +logged-in session (`@bp.before_request` + `flask_login.login_required` in +each blueprint's `__init__.py`). This closes a gap that existed from +Chapter 04 through Chapter 10: all dashboard and `/api/...` routes were +reachable without authentication until Chapter 12 wired in Flask-Login, +even though Chapter 01 specifies "one authenticated single-page shell." diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..88390a6 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask-Migrate/Alembic. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..7138b5e --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,44 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..abbb8eb --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,78 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace('%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode.""" + + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=get_metadata(), **conf_args) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_initial_schema.py b/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..5f3bf16 --- /dev/null +++ b/migrations/versions/0001_initial_schema.py @@ -0,0 +1,126 @@ +"""Initial schema (Chapter 06): log_files, log_entries, request_stats_hourly/ +daily, bot_hits, ip_registry, suspicious_events, blocklist_suggestions. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0001_initial_schema" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "log_files", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("server_type", sa.String(16), nullable=False), + sa.Column("format_string", sa.Text, nullable=False), + sa.Column("uploaded_at", sa.DateTime, nullable=False), + sa.Column("status", sa.String(16), nullable=False, server_default="queued"), + sa.Column("total_lines", sa.Integer, nullable=True), + sa.Column("processed_lines", sa.Integer, nullable=False, server_default="0"), + sa.Column("size_bytes", sa.BigInteger, nullable=False), + sa.Column("checksum", sa.String(64), nullable=False), + sa.Column("error_message", sa.Text, nullable=True), + ) + op.create_index("ix_log_files_checksum", "log_files", ["checksum"]) + + op.create_table( + "log_entries", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("log_file_id", sa.Integer, sa.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("timestamp", sa.DateTime, nullable=False), + sa.Column("ip", sa.String(45), nullable=False), + sa.Column("method", sa.String(10), nullable=False), + sa.Column("path", sa.Text, nullable=False), + sa.Column("status_code", sa.SmallInteger, nullable=False), + sa.Column("bytes_sent", sa.Integer, nullable=False, server_default="0"), + sa.Column("referrer", sa.Text, nullable=True), + sa.Column("user_agent", sa.Text, nullable=True), + sa.Column("is_bot", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("flagged", sa.Boolean, nullable=False, server_default=sa.false()), + ) + op.create_index("ix_log_entries_file_ts", "log_entries", ["log_file_id", "timestamp"]) + op.create_index("ix_log_entries_ip", "log_entries", ["ip"]) + op.create_index("ix_log_entries_path", "log_entries", ["path"]) + op.create_index("ix_log_entries_timestamp", "log_entries", ["timestamp"]) + + op.create_table( + "request_stats_hourly", + sa.Column("date_hour", sa.DateTime, primary_key=True), + sa.Column("path", sa.String(2048), primary_key=True), + sa.Column("status_code", sa.SmallInteger, primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + sa.Column("bytes_sent_sum", sa.BigInteger, nullable=False, server_default="0"), + ) + + op.create_table( + "request_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + sa.Column("unique_ips", sa.Integer, nullable=False, server_default="0"), + sa.Column("bytes_sum", sa.BigInteger, nullable=False, server_default="0"), + sa.Column("error_count", sa.Integer, nullable=False, server_default="0"), + ) + + op.create_table( + "bot_hits", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("log_file_id", sa.Integer, sa.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("timestamp", sa.DateTime, nullable=False), + sa.Column("ip", sa.String(45), nullable=False), + sa.Column("bot_name", sa.String(64), nullable=False), + sa.Column("verified", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("path", sa.Text, nullable=False), + sa.Column("status_code", sa.SmallInteger, nullable=False), + ) + op.create_index("ix_bot_hits_timestamp", "bot_hits", ["timestamp"]) + op.create_index("ix_bot_hits_bot_name", "bot_hits", ["bot_name"]) + + op.create_table( + "ip_registry", + sa.Column("ip", sa.String(45), primary_key=True), + sa.Column("first_seen", sa.DateTime, nullable=False), + sa.Column("last_seen", sa.DateTime, nullable=False), + sa.Column("total_requests", sa.Integer, nullable=False, server_default="0"), + sa.Column("reputation_score", sa.Integer, nullable=False, server_default="0"), + sa.Column("is_flagged", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("last_verified_at", sa.DateTime, nullable=True), + ) + + op.create_table( + "suspicious_events", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("log_file_id", sa.Integer, sa.ForeignKey("log_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("ip", sa.String(45), nullable=False), + sa.Column("timestamp", sa.DateTime, nullable=False), + sa.Column("path", sa.Text, nullable=False), + sa.Column("rule_matched", sa.String(128), nullable=False), + sa.Column("severity", sa.String(8), nullable=False), + ) + op.create_index("ix_suspicious_events_timestamp", "suspicious_events", ["timestamp"]) + op.create_index("ix_suspicious_events_ip", "suspicious_events", ["ip"]) + op.create_index("ix_suspicious_events_severity", "suspicious_events", ["severity"]) + + op.create_table( + "blocklist_suggestions", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("ip", sa.String(45), nullable=False), + sa.Column("reason", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("exported", sa.Boolean, nullable=False, server_default=sa.false()), + ) + op.create_index("ix_blocklist_suggestions_ip", "blocklist_suggestions", ["ip"]) + + +def downgrade(): + op.drop_table("blocklist_suggestions") + op.drop_table("suspicious_events") + op.drop_table("ip_registry") + op.drop_table("bot_hits") + op.drop_table("request_stats_daily") + op.drop_table("request_stats_hourly") + op.drop_table("log_entries") + op.drop_table("log_files") diff --git a/migrations/versions/0002_ip_registry_verification_result.py b/migrations/versions/0002_ip_registry_verification_result.py new file mode 100644 index 0000000..c8b8722 --- /dev/null +++ b/migrations/versions/0002_ip_registry_verification_result.py @@ -0,0 +1,16 @@ +"""Add ip_registry.last_verified_bot_result — flagged Ch07 schema deviation.""" +from alembic import op +import sqlalchemy as sa + +revision = "0002_ip_registry_verification_result" +down_revision = "0001_initial_schema" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("ip_registry", sa.Column("last_verified_bot_result", sa.Boolean, nullable=True)) + + +def downgrade(): + op.drop_column("ip_registry", "last_verified_bot_result") diff --git a/migrations/versions/0003_referrer_and_browser_stats.py b/migrations/versions/0003_referrer_and_browser_stats.py new file mode 100644 index 0000000..4278609 --- /dev/null +++ b/migrations/versions/0003_referrer_and_browser_stats.py @@ -0,0 +1,29 @@ +"""Add referrer_stats_daily and browser_stats_daily (Method A, Ch08).""" +from alembic import op +import sqlalchemy as sa + +revision = "0003_referrer_and_browser_stats" +down_revision = "0002_ip_registry_verification_result" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "referrer_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("referrer_domain", sa.String(255), primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + ) + op.create_table( + "browser_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("browser", sa.String(64), primary_key=True), + sa.Column("os", sa.String(64), primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_table("browser_stats_daily") + op.drop_table("referrer_stats_daily") diff --git a/migrations/versions/0004_human_path_stats.py b/migrations/versions/0004_human_path_stats.py new file mode 100644 index 0000000..3128b64 --- /dev/null +++ b/migrations/versions/0004_human_path_stats.py @@ -0,0 +1,21 @@ +"""Add human_path_stats_daily (Method A, Ch09's most-crawled-vs-visited).""" +from alembic import op +import sqlalchemy as sa + +revision = "0004_human_path_stats" +down_revision = "0003_referrer_and_browser_stats" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "human_path_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("path", sa.String(2048), primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_table("human_path_stats_daily") diff --git a/migrations/versions/0005_ip_traffic_stats.py b/migrations/versions/0005_ip_traffic_stats.py new file mode 100644 index 0000000..f78a2d8 --- /dev/null +++ b/migrations/versions/0005_ip_traffic_stats.py @@ -0,0 +1,34 @@ +"""Add ip_path_stats_daily and ip_status_stats_daily (bounded per-IP +traffic rollup, explicit follow-up to Chapter 10).""" +from alembic import op +import sqlalchemy as sa + +revision = "0005_ip_traffic_stats" +down_revision = "0004_human_path_stats" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "ip_path_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("ip", sa.String(45), primary_key=True), + sa.Column("path", sa.String(2048), primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + ) + op.create_index("ix_ip_path_stats_ip", "ip_path_stats_daily", ["ip"]) + + op.create_table( + "ip_status_stats_daily", + sa.Column("date", sa.Date, primary_key=True), + sa.Column("ip", sa.String(45), primary_key=True), + sa.Column("status_bucket", sa.String(8), primary_key=True), + sa.Column("count", sa.Integer, nullable=False, server_default="0"), + ) + op.create_index("ix_ip_status_stats_ip", "ip_status_stats_daily", ["ip"]) + + +def downgrade(): + op.drop_table("ip_status_stats_daily") + op.drop_table("ip_path_stats_daily") diff --git a/migrations/versions/0006_users.py b/migrations/versions/0006_users.py new file mode 100644 index 0000000..edac1fd --- /dev/null +++ b/migrations/versions/0006_users.py @@ -0,0 +1,23 @@ +"""Add users table (Chapter 12 single-admin auth).""" +from alembic import op +import sqlalchemy as sa + +revision = "0006_users" +down_revision = "0005_ip_traffic_stats" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("email", sa.String(255), nullable=False), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + +def downgrade(): + op.drop_table("users") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..977ecfc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2235 @@ +{ + "name": "kavosh", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kavosh", + "dependencies": { + "@fontsource/jetbrains-mono": "^5.1.0", + "@fontsource/public-sans": "^5.1.0", + "@fontsource/space-grotesk": "^5.1.0", + "alpinejs": "^3.14.1", + "chart.js": "^4.4.4", + "gridjs": "^6.2.0", + "htmx.org": "^2.0.3" + }, + "devDependencies": { + "autoprefixer": "^10.4.20", + "lucide-static": "^0.446.0", + "postcss": "^8.4.45", + "tailwindcss": "^3.4.11", + "vite": "^5.4.6" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/public-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz", + "integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gridjs": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/gridjs/-/gridjs-6.2.0.tgz", + "integrity": "sha512-EAGGfHjyEXWh12Txs6DjTGnWTo226wbowtMrLI+yNZQaJpvs0m7yDcyM7r+D4RA7rZQVj/cfmwEaRz/rlxg8LA==", + "license": "MIT", + "dependencies": { + "preact": "^10.11.3" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmx.org": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", + "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", + "license": "0BSD" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-static": { + "version": "0.446.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.446.0.tgz", + "integrity": "sha512-u9IkgI8k7qk738A9rt+JlENM5Sz/18EaEfqTfMEhXy2Sjq1uJA2I1WacVn6wJPXNHzrFpI5BCSmi1GtnGhsVwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb4d0e5 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "kavosh", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build:icons": "node scripts/build-icons.mjs", + "build": "npm run build:icons && vite build" + }, + "dependencies": { + "htmx.org": "^2.0.3", + "alpinejs": "^3.14.1", + "chart.js": "^4.4.4", + "gridjs": "^6.2.0", + "@fontsource/space-grotesk": "^5.1.0", + "@fontsource/public-sans": "^5.1.0", + "@fontsource/jetbrains-mono": "^5.1.0" + }, + "devDependencies": { + "vite": "^5.4.6", + "tailwindcss": "^3.4.11", + "postcss": "^8.4.45", + "autoprefixer": "^10.4.20", + "lucide-static": "^0.446.0" + } +} diff --git a/passenger_wsgi.py b/passenger_wsgi.py new file mode 100644 index 0000000..5d4a8b8 --- /dev/null +++ b/passenger_wsgi.py @@ -0,0 +1,10 @@ +"""Passenger entrypoint for cPanel's mod_passenger. + +Passenger looks for a module-level `application` WSGI callable. Kept to a +single import + call — no logic here — per factor 7 (port binding) and +factor 9 (disposability): Passenger may spin this process up or recycle +it at any time. +""" +from app import create_app + +application = create_app() diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e008c9c --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +}; diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c6464aa --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Dev/test-only tools (Chapter 02 factor 5: build/release/run separation — +# these are never installed on the cPanel host). +-r requirements.txt +pytest==8.3.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e563579 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-Caching==2.3.0 +Flask-Limiter==3.8.0 +Flask-Migrate==4.0.7 +python-dotenv==1.0.1 +bcrypt==4.2.0 diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs new file mode 100644 index 0000000..2c1a874 --- /dev/null +++ b/scripts/build-icons.mjs @@ -0,0 +1,36 @@ +/** + * Concatenates selected Lucide icons into one sprite: app/static/dist/icons.svg + * (Chapter 05). Build-time only — never runs on the cPanel host. + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ICONS_DIR = join(__dirname, '../node_modules/lucide-static/icons'); +const OUT_PATH = join(__dirname, '../app/static/dist/icons.svg'); + +// NOTE: verify against node_modules/lucide-static/icons/ when bumping the +// lucide-static version — icon names get renamed upstream (e.g. 'home' -> +// 'house', 'alert-triangle' -> 'triangle-alert' as of 0.446.0; caught by +// actually running this script against a real install). +const ICON_NAMES = [ + 'house', 'search', 'shield-alert', 'upload', 'download', + 'chevron-down', 'chevron-right', 'x', 'triangle-alert', 'bot', + 'sun', 'moon', 'log-out', 'loader-circle', +]; + +function extractInner(svg) { + const match = svg.match(/]*>([\s\S]*)<\/svg>/); + if (!match) throw new Error('Unexpected SVG shape'); + return match[1].trim(); +} + +const symbols = ICON_NAMES.map((name) => { + const raw = readFileSync(join(ICONS_DIR, `${name}.svg`), 'utf8'); + return `${extractInner(raw)}`; +}); + +mkdirSync(dirname(OUT_PATH), { recursive: true }); +writeFileSync(OUT_PATH, `\n${symbols.join('\n')}\n\n`); +console.log(`Wrote ${ICON_NAMES.length} icons to ${OUT_PATH}`); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..0dd68f7 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,38 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + './app/templates/**/*.html', + './app/blueprints/**/templates/**/*.html', + './app/static/src/**/*.js', + ], + theme: { + extend: { + // Design tokens (Kavosh visual identity): + // "instrument panel for one person's website" — the subject is raw + // server log lines turned into a readout, so data (numbers, IPs, + // paths, timestamps) is always set in mono, like it's straight off + // the log line, while UI chrome uses a distinct humanist sans. + // Deliberately NOT the cream+terracotta or near-black+neon-accent + // defaults — cool paper base, teal signal color used sparingly. + colors: { + paper: { DEFAULT: '#F1F3F1', dark: '#14181A' }, + surface: { DEFAULT: '#FFFFFF', dark: '#1D2225' }, + 'surface-raised': { DEFAULT: '#FBFBFA', dark: '#242A2D' }, + ink: { DEFAULT: '#14181B', dark: '#E7EAE6' }, + muted: { DEFAULT: '#5B6663', dark: '#96A19D' }, + line: { DEFAULT: '#E1E5E2', dark: '#2C3336' }, + accent: { DEFAULT: '#0E7C86', dark: '#3FC3CE' }, + danger: { DEFAULT: '#C4372F', dark: '#E2685F' }, + warn: { DEFAULT: '#B8860B', dark: '#E0B23C' }, + ok: { DEFAULT: '#3F7D5C', dark: '#6FBE93' }, + }, + fontFamily: { + display: ['"Space Grotesk"', 'ui-sans-serif', 'sans-serif'], + sans: ['"Public Sans"', 'ui-sans-serif', 'sans-serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'], + }, + }, + }, + plugins: [], +}; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0dc8560 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +"""Shared pytest fixtures (Chapter 12).""" +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("SECRET_KEY", "test-secret-key") +os.environ.setdefault("ADMIN_EMAIL", "admin@example.com") + +from app import create_app +from app.extensions import db as _db + + +@pytest.fixture() +def app(): + application = create_app("testing") + with application.app_context(): + _db.create_all() + yield application + _db.session.remove() + _db.drop_all() + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def db(app): + return _db + + +@pytest.fixture() +def logged_in_client(client, db): + from app.models.user import User + + user = User(email="admin@example.com") + user.set_password("correct-horse-battery-staple") + db.session.add(user) + db.session.commit() + + client.post( + "/login", + data={"email": "admin@example.com", "password": "correct-horse-battery-staple"}, + follow_redirects=True, + ) + return client diff --git a/tests/fixtures/hemmatitasks.ir b/tests/fixtures/hemmatitasks.ir new file mode 100644 index 0000000..667baee --- /dev/null +++ b/tests/fixtures/hemmatitasks.ir @@ -0,0 +1,66 @@ +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/_WCVRZS2KDJ1PI3XRN9ND3KOP4G8Z1XR HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/6Q4INVYP09WEQOGW0E1V_VM2ZLXAQ83Z HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/ZWJNKAJ0PKOXEKXPMYK9EA-M4BJD88PG HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/TWW2IYC5JB2D2VC0ON_MOMMEDL8OK8BO HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/9A7MLQMZ4CQULTW-NCGK5GM84GEWNC5H HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:18:36:35 +0330] "GET /.well-known/acme-challenge/X61XHHEADFYG38YYNUM0RFSL_KN_P9T_ HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +99.82.230.203 - - [03/Aug/2026:20:34:28 +0330] "GET /cdn-cgi/trace HTTP/1.1" 404 34212 "-" "UA" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/5V_X4R9CSIRPFO4-UW8LBT_VUE-VLIZT HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/HSL8HGHR9YVRAKBHB6CK376RV-2FAMRK HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/EY8CWEBBNZ003M2YK87PEBLYQR488DR- HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/IGL0QN08WZV2YIZCXCD73GIRDDETPRV2 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/09XOC8O4HDAZIAA38HZWQMXRQ4IR56-C HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [03/Aug/2026:21:36:37 +0330] "GET /.well-known/acme-challenge/IHP7JZ8RKZC3F2CI-LZQN61IZ4RTLG06 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +91.231.89.102 - - [03/Aug/2026:23:34:47 +0330] "GET / HTTP/1.1" 200 8462 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/32D8S8RZ1X3U6QN0YIFMZUGVMHGQ6SER HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/RZ2IO_ZLUT16-VPDVP0DNM4_9S8QZ5BY HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/CFU9A7L3HVTNBI-5WH4TF60AYT-DXOIH HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/TPIVZFAJM2BYRHJI89G8N_WB4AIGTDSG HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/VJ_0DV2NN1W21SDI3VEEESEXN-_M3O__ HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:00:36:40 +0330] "GET /.well-known/acme-challenge/_E4SYH_L0LLKIKXYJLPF6A_WEJN5QKZL HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +192.71.224.103 - - [04/Aug/2026:03:28:57 +0330] "GET / HTTP/1.1" 200 8462 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.1" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/NS_B9P5A3JQY48JUX7JELLJQQ3WZ_AO7 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/_4ZW6I_JJ1UG180MUTJA3VIR6ABVG4EO HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/86AB958PO2043AXP4YFCVR9RYTSYX3H6 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/6VPAM0LUHDNTWL-5WGUBNE-M9MXH1W29 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/5E70O5EX2EQH0HKY6A5PTRQ9DST7XIM5 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:03:36:39 +0330] "GET /.well-known/acme-challenge/GNQBO35VPSI-0YRSMRPF_9A5TVEFI-ER HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +54.211.2.94 - - [04/Aug/2026:06:11:43 +0330] "GET / HTTP/1.1" 301 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36" +52.3.206.35 - - [04/Aug/2026:06:11:44 +0330] "GET / HTTP/1.1" 200 8462 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/O5PQIUHN36WYMHCCWCQOF0K9BWTZZQYK HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/MXIP10TBOY_XHT_GPJL0X9LWYSSZIIZE HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/CTJSY2E-W2-2C91DLV4WBJXYFK9X56KP HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/PTIB4X92HEBCG77Y82VKMNX9R_5VJ8TM HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/UQNVW7DYDVTX_2M0NXF52Q1SNZ-9M_01 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:06:36:36 +0330] "GET /.well-known/acme-challenge/AO64KNXN2CFPSWWG3FJRWDUQYH29RAF2 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +66.249.77.161 - - [04/Aug/2026:08:49:08 +0330] "GET /robots.txt HTTP/1.1" 200 92 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +149.56.150.11 - - [04/Aug/2026:09:35:07 +0330] "GET / HTTP/1.1" 200 24878 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:15 +0330] "GET /ads.txt HTTP/1.1" 404 34212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:16 +0330] "GET / HTTP/1.1" 200 24878 "-" "Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36" +149.56.150.11 - - [04/Aug/2026:09:35:18 +0330] "GET /.well-known/security.txt HTTP/1.1" 404 34212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:19 +0330] "GET /llms.txt HTTP/1.1" 404 34212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:20 +0330] "GET /humans.txt HTTP/1.1" 404 34212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:38 +0330] "GET / HTTP/1.1" 200 8462 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:39 +0330] "GET /wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=efaa5193bbad9c60ffd1 HTTP/1.1" 200 16573 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:40 +0330] "GET /wp-content/themes/twentytwentyfive/assets/fonts/manrope/Manrope-VariableFont_wght.woff2 HTTP/1.1" 200 53600 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:40 +0330] "GET /wp-includes/js/wp-emoji-release.min.js?ver=7.0.2 HTTP/1.1" 200 6237 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:40 +0330] "GET / HTTP/1.1" 200 14503 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/CQG8EAYF0B7KWLC0X84A7ZRPZKEVRZ1N HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/9IGU40Q_SED9VGS70SMM-PC2K-C5B4RC HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/804GXVXFTXADP-0FF4F09BP8DWSEXK0R HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +149.56.150.11 - - [04/Aug/2026:09:35:05 +0330] "GET / HTTP/1.1" 301 0 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:07 +0330] "GET /robots.txt HTTP/1.1" 200 116 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:16 +0330] "GET /security.txt HTTP/1.1" 404 34212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:37 +0330] "GET / HTTP/1.1" 301 0 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:39 +0330] "GET /wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=96a846e1d7b789c39ab9 HTTP/1.1" 200 1165 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:40 +0330] "POST /wp-content/plugins/litespeed-cache/guest.vary.php HTTP/1.1" 200 36 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/HORP-KPI92FQFJMSNS2DDL_JTQRX2D8- HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/T010K1PEH75EK3L2N4JA0I-0H2TTSYTQ HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:09:36:38 +0330] "GET /.well-known/acme-challenge/_-HHHT9VYVZ7F2K7ABL76X6H-VPVCW14 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +199.244.88.227 - - [04/Aug/2026:11:36:49 +0330] "GET / HTTP/1.1" 200 8462 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" +217.144.107.147 - - [04/Aug/2026:12:36:30 +0330] "GET /.well-known/acme-challenge/OCZKTQ313TK29NZTZBN3DK0EVLF9GY1L HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:12:36:30 +0330] "GET /.well-known/acme-challenge/9_J1E_ZSZT8X13OMB8ZL5ZXU9HI2FHP7 HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:12:36:30 +0330] "GET /.well-known/acme-challenge/91DL1NOFC9CDTK3W4-0C4D6662J7-V__ HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:12:36:31 +0330] "GET /.well-known/acme-challenge/U6CK5JUQFVOIIO1DK402RVVXGSV_SKKL HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:12:36:30 +0330] "GET /.well-known/acme-challenge/JW4KG37CSJZAPP2A906SUXL0009MST__ HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" +217.144.107.147 - - [04/Aug/2026:12:36:30 +0330] "GET /.well-known/acme-challenge/Z2XMQ767KXZQE-W8K8J-J3FBYCC3QMZT HTTP/1.1" 200 64 "-" "Cpanel-HTTP-Client/1.0" diff --git a/tests/fixtures/hemmatitasks.ir-ssl_log b/tests/fixtures/hemmatitasks.ir-ssl_log new file mode 100644 index 0000000..7618e52 --- /dev/null +++ b/tests/fixtures/hemmatitasks.ir-ssl_log @@ -0,0 +1,222 @@ +4.227.216.131 - - [03/Aug/2026:17:17:59 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:47 +0330] "GET / HTTP/1.1" 200 41935 "-" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:48 +0330] "GET /wp-includes/js/jquery/jquery-migrate.min.js?ver=3.4.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:48 +0330] "GET /wp-includes/js/hoverintent-js.min.js?ver=2.2.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:48 +0330] "GET /wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=efaa5193bbad9c60ffd1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:48 +0330] "GET /wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=96a846e1d7b789c39ab9 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:48 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:51 +0330] "GET /wp-admin/ HTTP/1.1" 200 47877 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-admin/js/site-health.min.js?ver=7.0.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-admin/js/edit-comments.min.js?ver=7.0.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/jquery.ui.touch-punch.js?ver=0.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/jquery.color.min.js?ver=3.0.0 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/ui/sortable.min.js?ver=1.13.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-content/plugins/litespeed-cache/assets/js/litespeed-cache-admin.js?ver=7.8.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:41:04 +0330] "GET /wp-content/plugins/litespeed-cache/assets/js/iziModal.min.js?ver=7.8.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/underscore.min.js?ver=1.13.8 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/clipboard.min.js?ver=2.0.11 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/ui/core.min.js?ver=1.13.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/dist/vendor/moment.min.js?ver=2.30.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/ui/mouse.min.js?ver=1.13.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/dist/vendor/react.min.js?ver=18.3.1.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/dist/vendor/react-jsx-runtime.min.js?ver=18.3.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/jquery.query.js?ver=2.2.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/ui/autocomplete.min.js?ver=1.13.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:40:52 +0330] "GET /wp-includes/js/jquery/ui/menu.min.js?ver=1.13.3 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:41:01 +0330] "GET /wp-admin/plugins.php HTTP/1.1" 200 44312 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:00 +0330] "GET /wp-admin/admin.php?page=WordfenceScan HTTP/1.1" 200 73503 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:04 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:05 +0330] "GET /wp-content/plugins/wordfence/css/wf-ionicons.1778685035.css?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:05 +0330] "GET /wp-content/plugins/wordfence/css/wf-roboto-font.1778685035.css?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:05 +0330] "GET /wp-content/plugins/wordfence/css/license/free.1778685035.css?ver=7.0.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:05 +0330] "GET /wp-content/plugins/wordfence/css/main.1778685035.css?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:09 +0330] "GET /wp-content/plugins/wordfence/js/wfdropdown.1778685035.js?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:14 +0330] "GET /wp-content/plugins/wordfence/fonts/roboto-KFOlCnqEu92Fr1MmWUlfBBc-AMP6lQ.woff HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-content/plugins/wordfence/css/wf-roboto-font.1778685035.css?ver=8.2.2" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:19 +0330] "GET /wp-content/plugins/wordfence/js/vue.esm-browser.prod.1778685035.js HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-content/plugins/wordfence/js/wordfence.1778685035.js?ver=8.2.2" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:44:54 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 139402 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:03 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 850 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:05 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 976 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:02 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 99 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:07 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 1114 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:13 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 986 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:15 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 3073 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:09 +0330] "GET /wp-content/plugins/wordfence/js/jquery.qrcode.min.1778685035.js?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:09 +0330] "GET /wp-content/plugins/wordfence/js/admin.1778685035.js?ver=8.2.2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:09 +0330] "GET /wp-content/plugins/wordfence/js/wordfence.1778685035.js?ver=8.2.2 HTTP/1.1" 200 337450 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:42:14 +0330] "GET /wp-content/plugins/wordfence/fonts/roboto-KFOlCnqEu92Fr1MmSU5fBBc-AMP6lQ.woff HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-content/plugins/wordfence/css/wf-roboto-font.1778685035.css?ver=8.2.2" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:44:54 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +217.144.107.147 - - [03/Aug/2026:17:45:04 +0330] "POST /wp-admin/admin-ajax.php?action=wordfence_testAjax HTTP/1.1" 200 16 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +5.122.147.240 - - [03/Aug/2026:17:45:09 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 978 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:16 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 51 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:17 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 791 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:45:21 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 3139 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +217.144.107.147 - - [03/Aug/2026:17:45:29 +0330] "GET /.user.ini HTTP/1.1" 403 787 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +5.122.147.240 - - [03/Aug/2026:17:45:55 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:13 +0330] "GET /?_wfsf=viewActivityLog&nonce=aba6fb1140 HTTP/1.1" 200 9827 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:13 +0330] "GET /wp-content/plugins/wordfence/css/fullLog.1778685035.css?ver=8.2.2 HTTP/1.1" 200 178 "https://hemmatitasks.ir/?_wfsf=viewActivityLog&nonce=aba6fb1140" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:14 +0330] "GET /wp-content/plugins/wordfence/images/wordfence-logo.svg HTTP/1.1" 200 1115 "https://hemmatitasks.ir/wp-content/plugins/wordfence/css/fullLog.1778685035.css?ver=8.2.2" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:31 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:33 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:35 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:41 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:47 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:51 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:54 +0330] "GET /wp-admin/plugins.php HTTP/1.1" 200 44313 "https://hemmatitasks.ir/wp-admin/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:57 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:49:05 +0330] "GET /wp-admin/admin.php?page=litespeed-db_optm HTTP/1.1" 200 39057 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:51:08 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/admin.php?page=litespeed-db_optm" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +217.144.107.147 - - [03/Aug/2026:18:00:07 +0330] "POST /wp-admin/admin-ajax.php?action=wordfence_testAjax HTTP/1.1" 200 16 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +217.144.107.147 - - [03/Aug/2026:18:00:27 +0330] "GET /.user.ini HTTP/1.1" 403 787 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +217.144.107.147 - - [03/Aug/2026:18:00:09 +0330] "GET /wp-admin/admin-ajax.php?action=wordfence_doScan&isFork=0&scanMode=standard&cronKey=cc477cc6b1e8aa124e4e7ce8c0fdefee&signature=9608c4678c00370e7b5718e32cab7dc44186236c995dfa2ef377883cb9a7a55b HTTP/1.1" 200 0 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +5.122.147.240 - - [03/Aug/2026:17:45:19 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 791 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +217.144.107.147 - - [03/Aug/2026:17:45:05 +0330] "GET /wp-admin/admin-ajax.php?action=wordfence_doScan&isFork=0&scanMode=standard&cronKey=31c1741cb90a26d64730351ec51687b8&signature=8a8d5b13b0d1956c53dc6942460b1475ccb851a64485a6299dbfb5ceff605e93 HTTP/1.1" 200 0 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +5.122.147.240 - - [03/Aug/2026:17:48:09 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:09 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 15133 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:11 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:37 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:39 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:43 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:45 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:49 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:53 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 936 "https://hemmatitasks.ir/wp-admin/admin.php?page=WordfenceScan" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:48:59 +0330] "GET /wp-admin/plugin-install.php?tab=plugin-information&plugin=litespeed-cache& HTTP/1.1" 200 56170 "https://hemmatitasks.ir/wp-admin/plugins.php" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.122.147.240 - - [03/Aug/2026:17:50:07 +0330] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 47 "https://hemmatitasks.ir/wp-admin/admin.php?page=litespeed-db_optm" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +4.227.216.131 - - [03/Aug/2026:18:11:26 +0330] "GET / HTTP/1.1" 200 7825 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +104.210.140.140 - - [03/Aug/2026:19:04:01 +0330] "GET /robots.txt HTTP/1.1" 200 92 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot" +5.237.229.213 - - [03/Aug/2026:19:53:44 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +5.237.229.213 - - [03/Aug/2026:19:53:43 +0330] "GET / HTTP/1.1" 200 41936 "-" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +4.227.216.131 - - [03/Aug/2026:21:31:58 +0330] "GET / HTTP/1.1" 301 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" +4.227.216.131 - - [03/Aug/2026:21:32:00 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" +151.235.236.207 - - [03/Aug/2026:21:43:07 +0330] "GET / HTTP/1.1" 200 12446 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:09 +0330] "POST /login/?login_only=1 HTTP/1.1" 200 108 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:10 +0330] "GET /cpsess0582844268/?=undefined&login=1&post_login=51605214489983 HTTP/1.1" 302 182 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cpsess0582844268/frontend/jupiter/css/base_overrides.min.css HTTP/1.1" 200 2090 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623774/frontend/jupiter/core/web-components/dist/jupiter-web-components.cmb.min.js?locale=en&locale_optional=1&locale_revision=1785623858 HTTP/1.1" 200 222403 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785713522/frontend/jupiter/assets/application_icons/sprites/icon_spritemap.png?102678a567236ee908ad3c0ca91143b5988e4a79e19c2fcd7561dced7a2080f2_4.0 HTTP/1.1" 200 17110 "https://cpanel.hemmatitasks.ir/cPanel_magic_revision_1785713522/frontend/jupiter/assets/application_icons/sprites/icon_spritemap.css" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785713522/frontend/jupiter/assets/application_icons/sprites/icon_spritemap.svg?102678a567236ee908ad3c0ca91143b5988e4a79e19c2fcd7561dced7a2080f2_4.0 HTTP/1.1" 200 69634 "https://cpanel.hemmatitasks.ir/cPanel_magic_revision_1785713522/frontend/jupiter/assets/application_icons/sprites/icon_spritemap.css" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "POST /cpsess0582844268/execute/Personalization/get HTTP/1.1" 200 202 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cpsess0582844268/frontend/jupiter/assets/brand/cP_orange.png HTTP/1.1" 200 5972 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/libraries/cjt2-dist/plugins/locale.js HTTP/1.1" 200 1736 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/tools/index.cmb.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 14080 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cpsess0582844268/execute/Notifications/get_notifications_count HTTP/1.1" 200 82 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/libraries/lodash/4.8.2/lodash.min.js HTTP/1.1" 200 22697 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "GET /cpsess0582844268/frontend/jupiter/images/spinner.gif HTTP/1.1" 200 1849 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:10 +0330] "GET /cpsess0582844268/frontend/jupiter/index.html?=undefined&login=1&post_login=51605214489983 HTTP/1.1" 200 48445 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785713522/frontend/jupiter/assets/application_icons/sprites/icon_spritemap.css HTTP/1.1" 200 2212 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623195/frontend/jupiter/core/main_content/main_content_spacing.min.css HTTP/1.1" 200 475 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cpsess0582844268/frontend/jupiter/assets/brand/favicon.ico HTTP/1.1" 200 661 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/tools/index.dist.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 151 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/libraries/cjt2-dist/cjt2.cpanel.cmb.min.js?locale=en&locale_revision=1785623858 HTTP/1.1" 200 91441 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:11 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/libraries/cjt2-dist/frameworks.cmb.js HTTP/1.1" 200 210788 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/_assets/master.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 76 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "POST /cpsess0582844268/execute/Personalization/get HTTP/1.1" 200 162 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1759646866/cjt/cjt-min.js?locale=en&locale_optional=1&locale_revision=1785623858 HTTP/1.1" 200 47210 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624208/3rdparty/cloudlinux/assets/static/common-styles.css HTTP/1.1" 200 64521 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624207/3rdparty/cloudlinux/assets/js/resource_usage_config.js HTTP/1.1" 200 273 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624208/3rdparty/cloudlinux/assets/static/polyfills.bundle.min.js?v=7.11.31-1 HTTP/1.1" 200 51419 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624207/3rdparty/cloudlinux/assets/static/resource_usage.bundle.min.js?v=7.11.31-1 HTTP/1.1" 200 21933 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "POST /cpsess0582844268/execute/Resellers/list_accounts HTTP/1.1" 200 155 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "POST /cpsess0582844268/execute/Themes/list HTTP/1.1" 200 107 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:12 +0330] "POST /cpsess0582844268/execute/ResourceUsage/get_usages HTTP/1.1" 200 702 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:19 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl HTTP/1.1" 200 30313 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624207/3rdparty/cloudlinux/assets/css/lvemanager.css HTTP/1.1" 200 1112 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624207/3rdparty/cloudlinux/assets/js/common.js HTTP/1.1" 200 561 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624208/3rdparty/cloudlinux/assets/static/common.bundle.min.js?v=7.11.31-1 HTTP/1.1" 200 280855 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:21 +0330] "GET /cpsess0582844268/3rdparty/cloudlinux/assets/i18n/en-en.json HTTP/1.1" 200 14792 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:32 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 681 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:32 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 44969 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:36 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 229 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:41 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=knockKnock HTTP/1.1" 200 20 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:43 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=knockKnock HTTP/1.1" 200 20 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:27 +0330] "GET /cpsess0582844268/3rdparty/cloudlinux/assets/i18n/en-en.json HTTP/1.1" 200 14792 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:28 +0330] "GET /cpsess0582844268/execute/Notifications/get_notifications_count HTTP/1.1" 200 82 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:27 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 298 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:29 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 33 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:37 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=knockKnock HTTP/1.1" 200 20 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:20 +0330] "GET /cPanel_magic_revision_1785624207/3rdparty/cloudlinux/assets/static/vendor.bundle.min.js?v=7.11.31-1 HTTP/1.1" 200 729136 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:22 +0330] "GET /cpsess0582844268/execute/Notifications/get_notifications_count HTTP/1.1" 200 82 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:22 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/_assets/master.cmb.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 30940 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:21 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 298 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:24 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 809 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:30 +0330] "GET /cpsess0582844268/frontend/jupiter/terminal/index.html HTTP/1.1" 200 25565 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/terminal/index.dist.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 151 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "GET /cPanel_magic_revision_1785623770/frontend/jupiter/terminal/index.cmb.min.js?locale_optional=1&locale=en&locale_revision=1785623858 HTTP/1.1" 200 394 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "GET /cpsess0582844268/execute/Notifications/get_notifications_count HTTP/1.1" 200 82 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "POST /cpsess0582844268/execute/Personalization/get HTTP/1.1" 200 162 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "GET /cpsess0582844268/websocket/Shell?rows=24&cols=148 HTTP/1.1" 101 0 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:31 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=knockKnock HTTP/1.1" 200 20 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:42 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=knockKnock HTTP/1.1" 200 20 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:43:32 +0330] "POST /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl?cgiaction=sendRequest HTTP/1.1" 200 1694 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [03/Aug/2026:21:45:25 +0330] "GET /cpsess0582844268/frontend/jupiter/resource_usage/resource_usage.live.pl HTTP/1.1" 200 30343 "https://cpanel.hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +193.123.91.227 - - [03/Aug/2026:22:03:10 +0330] "GET / HTTP/1.1" 200 7815 "-" "understory/0.1 (CT research; https://github.com/0x4b) +contact-via-repo" +138.201.115.22 - - [03/Aug/2026:22:46:00 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +166.88.182.92 - - [03/Aug/2026:22:46:00 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +185.186.79.61 - - [03/Aug/2026:22:46:01 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +185.186.77.224 - - [03/Aug/2026:22:46:02 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +168.222.245.105 - - [03/Aug/2026:22:46:02 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +38.180.6.107 - - [03/Aug/2026:22:46:03 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +91.148.134.234 - - [03/Aug/2026:22:46:03 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +146.19.196.8 - - [03/Aug/2026:22:46:05 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +37.139.52.142 - - [03/Aug/2026:22:46:06 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +102.211.56.24 - - [03/Aug/2026:22:46:09 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +166.88.239.157 - - [03/Aug/2026:22:46:14 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +88.210.37.11 - - [03/Aug/2026:22:45:59 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +179.60.148.4 - - [03/Aug/2026:22:46:00 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +188.116.20.74 - - [03/Aug/2026:22:46:00 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +45.154.205.227 - - [03/Aug/2026:22:46:00 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +92.118.124.218 - - [03/Aug/2026:22:46:01 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +193.201.15.104 - - [03/Aug/2026:22:46:01 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +195.133.51.125 - - [03/Aug/2026:22:46:02 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +38.180.22.172 - - [03/Aug/2026:22:46:02 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +37.1.208.70 - - [03/Aug/2026:22:46:04 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +166.88.197.104 - - [03/Aug/2026:22:46:06 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +181.215.15.177 - - [03/Aug/2026:22:46:06 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +194.58.34.114 - - [03/Aug/2026:22:46:10 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +185.126.237.171 - - [03/Aug/2026:22:46:15 +0330] "GET / HTTP/1.1" 200 7815 "https://www.host-tracker.com/InstantCheck/ResultComplete/f3c08065-4692-446f-bac7-b9eab6179e91" "Mozilla/5.0 (compatible; HostTracker/2.0; +https://www.host-tracker.com/)" +66.249.77.160 - - [04/Aug/2026:00:29:19 +0330] "GET /?feed=comments-rss2 HTTP/1.1" 301 0 "-" "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.186 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +66.249.77.168 - - [04/Aug/2026:00:29:25 +0330] "GET /comments/feed/ HTTP/1.1" 200 1624 "-" "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.186 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +91.99.167.254 - - [04/Aug/2026:00:38:44 +0330] "GET / HTTP/1.1" 200 41933 "-" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +91.99.167.254 - - [04/Aug/2026:00:38:46 +0330] "GET /wp-includes/js/jquery/jquery-migrate.min.js?ver=3.4.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +91.99.167.254 - - [04/Aug/2026:00:38:46 +0330] "GET /wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=96a846e1d7b789c39ab9 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +91.99.167.254 - - [04/Aug/2026:00:38:46 +0330] "GET /wp-includes/js/hoverintent-js.min.js?ver=2.2.1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +91.99.167.254 - - [04/Aug/2026:00:38:46 +0330] "GET /wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=efaa5193bbad9c60ffd1 HTTP/1.1" 304 0 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +91.99.167.254 - - [04/Aug/2026:00:38:46 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "https://hemmatitasks.ir/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +135.136.51.71 - - [04/Aug/2026:00:48:40 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +135.136.51.71 - - [04/Aug/2026:00:48:41 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +66.249.77.168 - - [04/Aug/2026:01:31:23 +0330] "GET /robots.txt HTTP/1.1" 200 92 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +66.249.77.169 - - [04/Aug/2026:01:31:23 +0330] "GET /home/ HTTP/1.1" 404 9949 "-" "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.186 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +163.7.12.80 - - [04/Aug/2026:03:02:47 +0330] "GET /wp-json/batch/v1 HTTP/1.1" 404 114 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +163.7.12.80 - - [04/Aug/2026:03:02:48 +0330] "GET /?rest_route=/batch/v1 HTTP/1.1" 404 114 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +163.7.12.80 - - [04/Aug/2026:03:02:46 +0330] "GET / HTTP/1.1" 200 24883 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +163.7.12.80 - - [04/Aug/2026:03:02:50 +0330] "POST /?rest_route=/batch/v1 HTTP/1.1" 207 414 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +40.87.20.23 - - [04/Aug/2026:03:27:49 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36" +192.71.142.232 - - [04/Aug/2026:03:28:58 +0330] "GET /category/uncategorized/ HTTP/1.1" 200 8531 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.1" +192.71.126.245 - - [04/Aug/2026:03:28:57 +0330] "GET /hello-world/ HTTP/1.1" 200 10319 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.1" +192.71.126.53 - - [04/Aug/2026:03:28:58 +0330] "GET /hello-world/?replytocom=1 HTTP/1.1" 200 10325 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.1" +217.144.107.147 - - [04/Aug/2026:06:17:57 +0330] "GET /wp-content/plugins/siteleads/build/frontend/widgets/style-index.css HTTP/1.1" 404 9950 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +217.144.107.147 - - [04/Aug/2026:06:17:54 +0330] "POST / HTTP/1.1" 200 12177 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +217.144.107.147 - - [04/Aug/2026:07:00:08 +0330] "GET /wp-admin/admin-ajax.php?action=wordfence_doScan&isFork=0&scanMode=quick&cronKey=6a6ef38228775d4cde2fa1962702e9d8&signature=1db3bce97ce318a67d0a9802ffdfa624ab17b77398a45d1473b96eb7acdef98b HTTP/1.1" 200 0 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +217.144.107.147 - - [04/Aug/2026:07:00:07 +0330] "POST /wp-admin/admin-ajax.php?action=wordfence_testAjax HTTP/1.1" 200 16 "-" "WordPress/7.0.2; https://hemmatitasks.ir" +149.56.150.11 - - [04/Aug/2026:09:35:09 +0330] "GET / HTTP/1.1" 200 24883 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:10 +0330] "GET /hello-world/ HTTP/1.1" 200 31692 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:13 +0330] "GET /category/uncategorized/ HTTP/1.1" 200 25212 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:42 +0330] "GET / HTTP/1.1" 301 0 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:44 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:45 +0330] "GET /robots.txt HTTP/1.1" 301 0 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:46 +0330] "GET /robots.txt HTTP/1.1" 200 92 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:08 +0330] "GET /wp-sitemap.xml HTTP/1.1" 200 461 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:09 +0330] "GET / HTTP/1.1" 200 24883 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:11 +0330] "GET /author/admin/ HTTP/1.1" 200 25157 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.11 - - [04/Aug/2026:09:35:14 +0330] "GET /hello-world/?replytocom=1 HTTP/1.1" 200 31737 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:39 +0330] "GET /wp-content/litespeed/css/852b580ad43ac33c803b301d1773b7cb.css?ver=042a5 HTTP/1.1" 200 7168 "http://hemmatitasks.ir/" "Mozilla/5.0 (compatible; Dataprovider.com)" +149.56.150.129 - - [04/Aug/2026:09:35:47 +0330] "OPTIONS / HTTP/1.1" 200 0 "-" "Mozilla/5.0 (compatible; Dataprovider.com)" +66.249.77.169 - - [04/Aug/2026:10:54:38 +0330] "GET /cart/ HTTP/1.1" 404 9949 "-" "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.186 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +151.235.236.207 - - [04/Aug/2026:11:26:07 +0330] "POST /wp-content/plugins/litespeed-cache/guest.vary.php HTTP/1.1" 200 20 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:07 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:08 +0330] "GET / HTTP/1.1" 200 14502 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:08 +0330] "GET /wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=96a846e1d7b789c39ab9 HTTP/1.1" 200 999 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:08 +0330] "GET /favicon.ico HTTP/1.1" 404 796 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:07 +0330] "GET / HTTP/1.1" 200 7815 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:07 +0330] "GET /wp-content/themes/twentytwentyfive/assets/fonts/manrope/Manrope-VariableFont_wght.woff2 HTTP/1.1" 304 0 "https://hemmatitasks.ir/wp-content/litespeed/css/a6926f33abbb423cd982469391e2a8d4.css?ver=042a5" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:08 +0330] "GET /wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=efaa5193bbad9c60ffd1 HTTP/1.1" 200 14887 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +151.235.236.207 - - [04/Aug/2026:11:26:08 +0330] "GET /wp-content/themes/twentytwentyfive/assets/fonts/manrope/Manrope-VariableFont_wght.woff2 HTTP/1.1" 200 53600 "https://hemmatitasks.ir/" "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" diff --git a/tests/test_background_processing.py b/tests/test_background_processing.py new file mode 100644 index 0000000..8214bbc --- /dev/null +++ b/tests/test_background_processing.py @@ -0,0 +1,52 @@ +"""Regression coverage for the no-cron simplification (Chapter 12): a +real upload through the HTTP endpoint should reach status "done" on its +own, via the automatic background thread, without any `flask +process-logs` invocation — cron/CLI is optional, not required. +""" +from __future__ import annotations + +import io +import time + +from app.models.bot_hit import BotHit +from app.models.log_entry import LogEntry +from app.models.log_file import LogFile + +SAMPLE_LOG = ( + b'203.0.113.10 - - [15/Jul/2026:10:00:00 -0700] "GET /index.html HTTP/1.1" 200 512 ' + b'"https://example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' + b'203.0.113.11 - - [15/Jul/2026:10:00:05 -0700] "GET /about HTTP/1.1" 200 300 ' + b'"-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"\n' +) + + +def test_upload_processes_automatically_without_cron(logged_in_client): + resp = logged_in_client.post( + "/uploads", + data={ + "logfile": (io.BytesIO(SAMPLE_LOG), "sample.log"), + "server_type": "apache", + "format_string": "", + }, + content_type="multipart/form-data", + ) + assert resp.status_code == 200 + + log_file = LogFile.query.order_by(LogFile.id.desc()).first() + assert log_file is not None + + # No `flask process-logs` call anywhere in this test — only the + # background thread triggered by the upload itself should advance it. + deadline = time.time() + 5 + while time.time() < deadline: + from app.extensions import db + db.session.refresh(log_file) + if log_file.status in ("done", "error"): + break + time.sleep(0.1) + + assert log_file.status == "done", f"expected done, got {log_file.status}: {log_file.error_message}" + assert log_file.total_lines == 2 + assert log_file.processed_lines == 2 + assert LogEntry.query.filter_by(log_file_id=log_file.id).count() == 2 + assert BotHit.query.filter_by(log_file_id=log_file.id).count() == 1 diff --git a/tests/test_bot_identifier.py b/tests/test_bot_identifier.py new file mode 100644 index 0000000..59b2e62 --- /dev/null +++ b/tests/test_bot_identifier.py @@ -0,0 +1,32 @@ +"""Bot identification tests (Chapter 07/12).""" +from __future__ import annotations + +from app.services import bot_identifier + + +def test_classify_bot_detects_googlebot(): + ua = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + assert bot_identifier.classify_bot(ua) == "Googlebot" + + +def test_classify_bot_returns_none_for_ordinary_browser(): + ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0" + assert bot_identifier.classify_bot(ua) is None + + +def test_classify_bot_returns_none_for_empty_ua(): + assert bot_identifier.classify_bot(None) is None + + +def test_verify_bot_ip_rejects_when_ptr_lookup_fails(monkeypatch): + def fake_gethostbyaddr(ip): + raise OSError("no PTR record") + + monkeypatch.setattr("socket.gethostbyaddr", fake_gethostbyaddr) + assert bot_identifier.verify_bot_ip("203.0.113.5", "Googlebot") is False + + +def test_verify_bot_ip_accepts_matching_forward_and_reverse(monkeypatch): + monkeypatch.setattr("socket.gethostbyaddr", lambda ip: ("crawl-1-2-3-4.googlebot.com", [], [])) + monkeypatch.setattr("socket.gethostbyname_ex", lambda host: (host, [], ["1.2.3.4"])) + assert bot_identifier.verify_bot_ip("1.2.3.4", "Googlebot") is True diff --git a/tests/test_file_deletion.py b/tests/test_file_deletion.py new file mode 100644 index 0000000..21bbd7f --- /dev/null +++ b/tests/test_file_deletion.py @@ -0,0 +1,151 @@ +"""Regression coverage for uploaded-file deletion (project-owner follow-up +request), with particular focus on the rollup-recompute correctness fix +that shipped alongside it: request_stats_daily/hourly and the derived +per-day tables must correctly shrink (or disappear entirely) when the +file(s) that contributed to a date are deleted — not just grow, which is +all the aggregator was ever previously asked to do. +""" +from __future__ import annotations + +import io +import time +from datetime import date, datetime + +from app.extensions import db +from app.models.bot_hit import BotHit +from app.models.log_entry import LogEntry +from app.models.log_file import LogFile +from app.models.request_stats import RequestStatsDaily, RequestStatsHourly +from app.models.suspicious_event import SuspiciousEvent + +LOG_DATE = date(2026, 7, 15) + +LINE_A = ( + b'203.0.113.10 - - [15/Jul/2026:10:00:00 -0700] "GET /a.html HTTP/1.1" 200 500 ' + b'"-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' +) +LINE_B = ( + b'203.0.113.11 - - [15/Jul/2026:11:00:00 -0700] "GET /b.html HTTP/1.1" 200 700 ' + b'"-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' +) +SCANNER_LINE = ( + b'203.0.113.12 - - [15/Jul/2026:12:00:00 -0700] "GET /.env HTTP/1.1" 404 0 ' + b'"-" "sqlmap/1.7"\n' +) + + +def _upload(client, content: bytes, filename: str) -> LogFile: + resp = client.post( + "/uploads", + data={"logfile": (io.BytesIO(content), filename), "server_type": "apache", "format_string": ""}, + content_type="multipart/form-data", + ) + assert resp.status_code == 200 + log_file = LogFile.query.filter_by(filename=filename).first() + assert log_file is not None + _wait_until_done(log_file) + return log_file + + +def _wait_until_done(log_file: LogFile) -> None: + deadline = time.time() + 5 + while time.time() < deadline: + db.session.refresh(log_file) + if log_file.status in ("done", "error"): + return + time.sleep(0.05) + raise AssertionError(f"file {log_file.id} never finished processing (status={log_file.status})") + + +def _hourly_rows_for_log_date(): + start = datetime.combine(LOG_DATE, datetime.min.time()) + end = start.replace(hour=23, minute=59) + return RequestStatsHourly.query.filter(RequestStatsHourly.date_hour.between(start, end)) + + +def test_deleting_a_file_recomputes_shared_date_rollup_correctly(logged_in_client): + """Two files both touch 2026-07-15. Deleting one must leave the + day's rollup reflecting only the file that remains — not zero, + and not double-counted. + """ + file_a = _upload(logged_in_client, LINE_A, "file-a.log") + file_b = _upload(logged_in_client, LINE_B, "file-b.log") + + daily = db.session.get(RequestStatsDaily, LOG_DATE) + assert daily is not None + assert daily.count == 2 # both lines contributed + + resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) + assert resp.status_code == 200 + body = resp.get_json()["data"] + assert body["deleted"] == [file_a.id] + assert body["skipped"] == [] + + # file_a's own rows are gone... + assert db.session.get(LogFile, file_a.id) is None + assert LogEntry.query.filter_by(log_file_id=file_a.id).count() == 0 + + # ...but file_b's contribution to the same date survives, and the + # rollup now reflects ONLY file_b — this is exactly the bug that + # would have shipped without the aggregator fix (either stuck at 2, + # or wiped to nothing even though file_b's data is still there). + daily = db.session.get(RequestStatsDaily, LOG_DATE) + assert daily is not None + assert daily.count == 1 + assert LogEntry.query.filter_by(log_file_id=file_b.id).count() == 1 + + +def test_deleting_the_only_file_for_a_date_clears_the_rollup_entirely(logged_in_client): + """When NO file covers a date anymore, the stale rollup row must be + removed, not left behind with yesterday's numbers. + """ + file_a = _upload(logged_in_client, LINE_A, "solo-file.log") + assert db.session.get(RequestStatsDaily, LOG_DATE) is not None + assert _hourly_rows_for_log_date().count() > 0 + + resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) + assert resp.status_code == 200 + assert resp.get_json()["data"]["deleted"] == [file_a.id] + + assert db.session.get(RequestStatsDaily, LOG_DATE) is None + assert _hourly_rows_for_log_date().count() == 0 + + +def test_deleting_a_file_removes_bot_hits_and_suspicious_events(logged_in_client): + file_a = _upload(logged_in_client, LINE_B + SCANNER_LINE, "mixed.log") + assert BotHit.query.filter_by(log_file_id=file_a.id).count() == 0 # LINE_B has no bot UA + assert SuspiciousEvent.query.filter_by(log_file_id=file_a.id).count() == 1 # sqlmap + + resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) + assert resp.status_code == 200 + assert SuspiciousEvent.query.filter_by(log_file_id=file_a.id).count() == 0 + + +def test_delete_skips_unknown_id(logged_in_client): + resp = logged_in_client.delete("/api/uploads", json={"ids": [999999]}) + assert resp.status_code == 200 + body = resp.get_json()["data"] + assert body["deleted"] == [] + assert body["skipped"] == [{"id": 999999, "reason": "not found"}] + + +def test_delete_rejects_malformed_body(logged_in_client): + resp = logged_in_client.delete("/api/uploads", json={"ids": "not-a-list"}) + assert resp.status_code == 400 + + +def test_list_uploads_returns_paginated_rows(logged_in_client): + _upload(logged_in_client, LINE_A, "list-test.log") + resp = logged_in_client.get("/api/uploads?page=1&per_page=20") + assert resp.status_code == 200 + data = resp.get_json()["data"] + assert data["total"] >= 1 + row = next(r for r in data["rows"] if r[1] == "list-test.log") + assert row[3] == "done" # status column + assert row[6] == "done" # raw status (hidden column) + + +def test_delete_endpoint_requires_authentication(client): + resp = client.delete("/api/uploads", json={"ids": [1]}) + assert resp.status_code == 401 + assert resp.get_json()["error"]["code"] == "unauthorized" diff --git a/tests/test_log_parser.py b/tests/test_log_parser.py new file mode 100644 index 0000000..a94e30b --- /dev/null +++ b/tests/test_log_parser.py @@ -0,0 +1,50 @@ +"""Log parser tests (Chapter 12: highest testing priority per Ch12).""" +from __future__ import annotations + +from app.services.log_parser.apache import combined_parser, common_parser +from app.services.log_parser.format_compiler import compile_format, parse_apache_timestamp + +COMMON_LINE = '127.0.0.1 - - [10/Oct/2026:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 1234\n' +COMBINED_LINE = ( + '127.0.0.1 - - [10/Oct/2026:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 1234 ' + '"https://example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' +) +MALFORMED_LINE = "this is not a log line\n" + + +def test_common_parser_parses_valid_line(): + entry = common_parser().parse_line(COMMON_LINE) + assert entry is not None + assert entry.ip == "127.0.0.1" + assert entry.method == "GET" + assert entry.path == "/index.html" + assert entry.status_code == 200 + assert entry.bytes_sent == 1234 + assert entry.referrer is None + assert entry.user_agent is None + + +def test_combined_parser_parses_referrer_and_user_agent(): + entry = combined_parser().parse_line(COMBINED_LINE) + assert entry is not None + assert entry.referrer == "https://example.com/" + assert "Chrome" in entry.user_agent + + +def test_malformed_line_returns_none_not_raises(): + assert common_parser().parse_line(MALFORMED_LINE) is None + + +def test_custom_format_string_compiles_and_parses(): + custom_format = '%h %t "%r" %>s' + compiled = compile_format(custom_format) + match = compiled.pattern.match('127.0.0.1 [10/Oct/2026:13:55:36 -0700] "GET /x HTTP/1.1" 404') + assert match is not None + assert match.group("ip") == "127.0.0.1" + assert match.group("status") == "404" + + +def test_parse_apache_timestamp_normalizes_to_utc(): + dt = parse_apache_timestamp("10/Oct/2026:13:55:36 -0700") + assert dt.hour == 20 # -0700 -> UTC + assert dt.tzinfo is None diff --git a/tests/test_real_log_fixtures.py b/tests/test_real_log_fixtures.py new file mode 100644 index 0000000..4bb044e --- /dev/null +++ b/tests/test_real_log_fixtures.py @@ -0,0 +1,37 @@ +"""Regression test against real production log samples (Chapter 12): both +files were provided during development after a real cPanel deployment and +confirmed to parse cleanly with zero failures against the default +Combined-format parser. Locked in here so a future change can't silently +break compatibility with this real-world data. +""" +from __future__ import annotations + +from pathlib import Path + +from app.services.log_parser.apache import combined_parser + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def _count_parse_failures(path: Path) -> tuple[int, int]: + parser = combined_parser() + total = 0 + failed = 0 + with path.open(encoding="utf-8", errors="replace") as f: + for line in f: + total += 1 + if parser.parse_line(line) is None: + failed += 1 + return total, failed + + +def test_ssl_log_fixture_parses_with_zero_failures(): + total, failed = _count_parse_failures(FIXTURES_DIR / "hemmatitasks.ir-ssl_log") + assert total == 222 + assert failed == 0 + + +def test_plain_log_fixture_parses_with_zero_failures(): + total, failed = _count_parse_failures(FIXTURES_DIR / "hemmatitasks.ir") + assert total == 66 + assert failed == 0 diff --git a/tests/test_routes_smoke.py b/tests/test_routes_smoke.py new file mode 100644 index 0000000..44e72ab --- /dev/null +++ b/tests/test_routes_smoke.py @@ -0,0 +1,52 @@ +"""Integration smoke tests, one per blueprint's main route (Chapter 12).""" +from __future__ import annotations + + +def test_root_redirects_to_login_when_not_authenticated(client): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/login" + + +def test_root_redirects_to_overview_when_authenticated(logged_in_client): + resp = logged_in_client.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/overview" + + +def test_healthz_is_public_and_ok(client): + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.get_json()["data"]["status"] == "ok" + + +def test_overview_redirects_when_not_logged_in(client): + resp = client.get("/overview") + assert resp.status_code in (302, 401) + + +def test_overview_reachable_when_logged_in(logged_in_client): + resp = logged_in_client.get("/overview") + assert resp.status_code == 200 + + +def test_seo_reachable_when_logged_in(logged_in_client): + resp = logged_in_client.get("/seo") + assert resp.status_code == 200 + + +def test_security_reachable_when_logged_in(logged_in_client): + resp = logged_in_client.get("/security") + assert resp.status_code == 200 + + +def test_api_endpoint_returns_401_json_when_not_logged_in(client): + resp = client.get("/api/overview/kpis") + assert resp.status_code == 401 + assert resp.get_json()["error"]["code"] == "unauthorized" + + +def test_login_with_bad_credentials_shows_flash(client): + resp = client.post("/login", data={"email": "nope@example.com", "password": "wrong"}, follow_redirects=True) + assert resp.status_code == 200 + assert b"Invalid email or password" in resp.data diff --git a/tests/test_threat_scanner.py b/tests/test_threat_scanner.py new file mode 100644 index 0000000..984ce9b --- /dev/null +++ b/tests/test_threat_scanner.py @@ -0,0 +1,36 @@ +"""Threat/suspicious-pattern scanner tests (Chapter 07/12).""" +from __future__ import annotations + +from datetime import datetime + +from app.services import threat_scanner +from app.services.log_parser import ParsedEntry + + +def _entry(path="/", user_agent="Mozilla/5.0"): + return ParsedEntry( + timestamp=datetime(2026, 7, 1), ip="203.0.113.1", method="GET", path=path, + status_code=200, bytes_sent=100, referrer=None, user_agent=user_agent, + ) + + +def test_scan_detects_sensitive_path(): + match = threat_scanner.scan(_entry(path="/.env")) + assert match is not None + assert match.rule_matched.startswith("sensitive_path:") + + +def test_scan_detects_injection_marker(): + match = threat_scanner.scan(_entry(path="/search?q=' OR '1'='1")) + assert match is not None + assert match.rule_matched.startswith("injection:") + + +def test_scan_detects_scanner_user_agent(): + match = threat_scanner.scan(_entry(path="/", user_agent="sqlmap/1.7")) + assert match is not None + assert match.rule_matched.startswith("scanner_ua:") + + +def test_scan_returns_none_for_benign_request(): + assert threat_scanner.scan(_entry(path="/about")) is None diff --git a/tests/test_uploads.py b/tests/test_uploads.py new file mode 100644 index 0000000..eb3e4b1 --- /dev/null +++ b/tests/test_uploads.py @@ -0,0 +1,86 @@ +"""Upload status-polling tests. + +Covers two things: +1. Regression coverage for the queued-vs-processing template bug reported + after a real deployment: a file sitting in 'queued' status (before the + background trigger/cron picks it up) was rendering the same text as a + genuinely in-progress parse, making the two states indistinguishable. +2. The real percentage progress bar (Ch12: total_lines is now counted on + first pickup, so processed/total renders as an actual percentage + instead of an indeterminate spinner). +""" +from __future__ import annotations + +from app.models.log_file import LogFile + + +def _make_log_file(db, status: str, processed_lines: int = 0, total_lines: int | None = None) -> LogFile: + log_file = LogFile( + filename="sample.log.gz", + server_type="apache", + format_string="", + status=status, + processed_lines=processed_lines, + total_lines=total_lines, + size_bytes=123, + checksum=f"checksum-for-{status}-{processed_lines}-{total_lines}", + ) + db.session.add(log_file) + db.session.commit() + return log_file + + +def test_queued_status_renders_queued_template_not_processing(logged_in_client, db): + log_file = _make_log_file(db, status="queued") + resp = logged_in_client.get(f"/api/uploads/{log_file.id}/status") + assert resp.status_code == 200 + assert b"sample.log.gz" in resp.data + assert b"queued" in resp.data.lower() + assert b"analyzing" not in resp.data.lower() + + +def test_processing_status_without_total_shows_indeterminate(logged_in_client, db): + log_file = _make_log_file(db, status="processing", processed_lines=42) + resp = logged_in_client.get(f"/api/uploads/{log_file.id}/status") + assert resp.status_code == 200 + assert b"sample.log.gz" in resp.data + assert b"42" in resp.data + assert b"analyzing" in resp.data.lower() + + +def test_processing_status_with_total_shows_real_percentage(logged_in_client, db): + log_file = _make_log_file(db, status="processing", processed_lines=25, total_lines=100) + resp = logged_in_client.get(f"/api/uploads/{log_file.id}/status") + assert resp.status_code == 200 + assert b"25%" in resp.data + assert b"25" in resp.data and b"100" in resp.data + + +def test_done_status_renders_done_template(logged_in_client, db): + log_file = _make_log_file(db, status="done", processed_lines=100, total_lines=100) + resp = logged_in_client.get(f"/api/uploads/{log_file.id}/status") + assert resp.status_code == 200 + assert b"sample.log.gz" in resp.data + assert b"done" in resp.data.lower() + assert b"100" in resp.data + + +def test_error_status_renders_error_template(logged_in_client, db): + log_file = _make_log_file(db, status="error") + log_file.error_message = "boom" + db.session.commit() + resp = logged_in_client.get(f"/api/uploads/{log_file.id}/status") + assert resp.status_code == 200 + assert b"sample.log.gz" in resp.data + assert b"boom" in resp.data + + +def test_json_status_includes_total_lines(logged_in_client, db): + log_file = _make_log_file(db, status="processing", processed_lines=10, total_lines=50) + resp = logged_in_client.get( + f"/api/uploads/{log_file.id}/status", headers={"Accept": "application/json"} + ) + assert resp.status_code == 200 + data = resp.get_json()["data"] + assert data["processed_lines"] == 10 + assert data["total_lines"] == 50 diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..b45fbd5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +/** + * Build-time only (Chapter 05) — this file never runs on the cPanel host. + * Two entries: the JS bundle (HTMX+Alpine+Chart.js/Grid.js glue) and the + * Tailwind CSS entry, each emitting a hashed file + manifest.json so + * Flask's asset() helper can resolve cache-busted filenames. + */ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + build: { + manifest: true, + outDir: 'app/static/dist', + emptyOutDir: false, + rollupOptions: { + input: { + main: `${root}app/static/src/js/main.js`, + css: `${root}app/static/src/css/main.css`, + }, + }, + }, +});