diff --git a/.dockerignore b/.dockerignore index 2e0a5b85..339b9bb3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,7 @@ sites/*/scripts_dev/ sites/*/tests/ sites/*/verify/tests/ agent_demo/runs/ +review-reports/ .venv/ # Don't ship — bytecode / venvs. diff --git a/.gitignore b/.gitignore index 4f14a819..f1f69cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,6 @@ agent_demo/runs/ # walmart_careers verifier validation runs (run signatures + DB snapshots): never committed. sites/*/scripts_dev/runs/ sites/*/scripts_dev/**/*.db + +# 4shared verifier validation runs (run signatures + DB snapshots): never committed. +sites/4shared/verify/tests/runs/ diff --git a/AGENTS.md b/AGENTS.md index dd8e527e..9e38a975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40027:40000-40027 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40027:40000-40027 \ +docker run -d -p 8101:8101 -p 40000-40028:40000-40028 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40027` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40028` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41027:40000-40027 webharbor:dev + -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41027); do +for p in $(seq 41000 41028); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 94d397d1..5e84cb62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40027`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41027`). +If a container is already running on `:8101` / `:40000-40028`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41028`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3871b5e8..22e4c1c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40027:40000-40027 webharbor:dev + -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index d35d70bc..c5a2cbab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 28 Flask mirror sites + control plane on :8101. +# 29 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -92,6 +92,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40027 +EXPOSE 8101 40000-40028 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index 4189c209..a5fac996 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 28 sites today, scaling to 100+ together +- **Community-driven** — 29 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40027:40000-40027 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40027` to explore 28 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, and Kaggle`. +Then point your agent at `http://localhost:40000` through `http://localhost:40027` to explore 29 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, and 4shared`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: diff --git a/agent_demo/README.md b/agent_demo/README.md index ee26ed3a..0570c745 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40027:40000-40027 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 939613ec..2e4d41af 100644 --- a/control_server.py +++ b/control_server.py @@ -30,6 +30,7 @@ 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'fedex', 'webmd_doctor', 'healthline', 'kaggle', + '4shared', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/review-reports/PR-90-FINAL-AUDIT.md b/review-reports/PR-90-FINAL-AUDIT.md new file mode 100644 index 00000000..a6786f8e --- /dev/null +++ b/review-reports/PR-90-FINAL-AUDIT.md @@ -0,0 +1,89 @@ +# PR #90 final audit — 4shared + +## Result + +**PASS — 20/20 tasks, 252 visible-browser steps, 0 unresolved findings.** + +The review was rerun against the packaged `webharbor:dev` image after all +remediation. Every task started from the configured homepage, used Playwright +visible-element locators, and finished with a persistence check. The site was +reset before and after every task; every reset restored a byte-identical +runtime/seed pair with MD5 `b577adc216900a6f0e3974a80e51c04c`. + +Complete action traces and task screenshots are retained outside the +agent-visible repository to avoid creating answer-bearing benchmark artifacts. +The table below records sanitized endpoints and evidence classes. + +## Per-task review + +| Task | Steps | Screenshot | URL | Issue | Evidence | Impact | Severity | Reproduction | +| --- | ---: | --- | --- | --- | --- | --- | --- | --- | +| 4shared--0 | 15 | `4shared--0-15-reload-persistence-check.png` | `/file/` | None — PASS | Search, category filter, candidate inspection, metadata comparison, reload | Requested identification remained visible | None | Reset → homepage → follow task | +| 4shared--1 | 24 | `4shared--1-24-reload-persistence-check.png` | `/file/` | None — PASS | Images browse, multiple detail inspections, metadata comparison, reload | Requested image evidence remained visible | None | Reset → homepage → follow task | +| 4shared--2 | 7 | `4shared--2-07-reload-persistence-check.png` | `/file/` | None — PASS | Search, category filter, detail inspection, reload | Requested book comparison completed | None | Reset → homepage → follow task | +| 4shared--3 | 15 | `4shared--3-15-reload-persistence-check.png` | `/file/` | None — PASS | Broad search, candidate inspection, detail verification, reload | Multi-clue identification completed | None | Reset → homepage → follow task | +| 4shared--4 | 7 | `4shared--4-07-reload-persistence-check.png` | `/file/` | None — PASS | Broad search, candidate inspection, license/detail verification | Multi-clue identification completed | None | Reset → homepage → follow task | +| 4shared--5 | 6 | `4shared--5-06-reload-persistence-check.png` | `/file/` | None — PASS | Category browse, both detail pages opened, runtimes compared | Cross-item comparison completed | None | Reset → homepage → follow task | +| 4shared--6 | 6 | `4shared--6-06-reload-persistence-check.png` | `/download/` | None — PASS | Six-result search, target at position 6, detail check, download confirmation | Download state changed exactly as requested | None | Reset → homepage → follow task | +| 4shared--7 | 12 | `4shared--7-12-reload-persistence-check.png` | `/favorites` | None — PASS | Login, eight-result search, target at position 6, favorite, reload | Favorite persisted for the requested account | None | Reset → homepage → follow task | +| 4shared--8 | 12 | `4shared--8-12-reload-persistence-check.png` | `/saved` | None — PASS | Login, search, save, Saved files navigation, reload | Saved-file state persisted | None | Reset → homepage → follow task | +| 4shared--9 | 11 | `4shared--9-11-reload-persistence-check.png` | `/account/edit` | None — PASS | Login, profile fields edited, saved, reopened, reloaded | Both account fields persisted | None | Reset → homepage → follow task | +| 4shared--10 | 9 | `4shared--10-09-reload-persistence-check.png` | `/my-files` | None — PASS | Login, root folder creation, reload | Folder persisted at root | None | Reset → homepage → follow task | +| 4shared--11 | 12 | `4shared--11-12-reload-persistence-check.png` | `/my-files?folder=` | None — PASS | Login, upload form, folder/size/description, Documents classification, reload | Private PDF metadata persisted consistently | None | Reset → homepage → follow task | +| 4shared--12 | 12 | `4shared--12-12-reload-persistence-check.png` | `/my-files?folder=` | None — PASS | Login, source folder, rename, move, destination verification, reload | Name and folder changed together | None | Reset → homepage → follow task | +| 4shared--13 | 9 | `4shared--13-09-reload-persistence-check.png` | `/my-files` | None — PASS | Login, Recycle Bin, restore, root verification, reload | Restored file persisted outside Trash | None | Reset → homepage → follow task | +| 4shared--14 | 13 | `4shared--14-13-reload-persistence-check.png` | `/file//share` | None — PASS | Login, private file navigation, label/permission submission, reload | Share-link state persisted | None | Reset → homepage → follow task | +| 4shared--15 | 11 | `4shared--15-11-reload-persistence-check.png` | `/file/` | None — PASS | Login, public search, detail, comment submission, reload | Exact comment persisted | None | Reset → homepage → follow task | +| 4shared--16 | 12 | `4shared--16-12-reload-persistence-check.png` | `/account` | None — PASS | Login, annual 100GB selection, demo checkout, account reload | Plan and storage allowance persisted | None | Reset → homepage → follow task | +| 4shared--17 | 22 | `4shared--17-22-reload-persistence-check.png` | `/file//share` | None — PASS | Folder create, auto-classified PDF upload, rename, preview-only share, reload | All dependent state changes persisted | None | Reset → homepage → follow task | +| 4shared--18 | 21 | `4shared--18-21-reload-persistence-check.png` | `/saved` | None — PASS | Three detail pages compared, login, selected book saved, reload | Comparison and saved state completed | None | Reset → homepage → follow task | +| 4shared--19 | 16 | `4shared--19-16-reload-persistence-check.png` | `/favorites` | None — PASS | Login, broad search, candidate inspection, favorite, download, reload | Both requested mutations persisted | None | Reset → homepage → follow task | + +## Hardening audit + +- **De-leak:** search results expose titles and summary metadata, not decisive + detail facts. Full task trajectories are not committed. Exact-name action + tasks 6 and 7 now have 6 and 8 results respectively, with each target at + position 6. +- **Distractors:** broad searches used by the tasks return 6–40 plausible + candidates. Near matches deliberately differ in detail metadata or package + purpose. +- **Catalog breadth:** 122 public records cover Music, Video, Apps, Images, + Books, Documents, and Archives. All 16 image records use real, locally served + photographs. +- **Cross-field consistency:** filenames, extensions, categories, MIME-facing + behavior, plan names, plan prices, storage allowances, saved-state labels, + and upload classification were checked across list, detail, confirmation, + and account pages. +- **Known leak archetypes:** no prompt-embedded answer, target-count badge, + decisive result-card fact, pre-sorted unique target, first-item target, + insufficient candidate set, direct-route dependency, self-reported-only + completion, visit-only completion, broad mutation, cross-user mutation, + reset drift, or answer-bearing repository artifact remains. + +## Visual and functional validation + +- 51 responsive page checks: 17 representative pages at 1440×900, 390×844, + and 320×720. +- Zero document overflow, broken images, stretched images, out-of-bounds + controls, or unresolved title truncation. +- Seven supplementary flows pass: signed-out upload entry, registration, + re-login, 500GB checkout selection, 1TB checkout selection, three distinct + footer destinations, and explicit public-search scope while authenticated. +- Homepage uses the captured 4shared upload illustration, real mobile-app QR + code/frame, and source store logos. Asset provenance is recorded in + `sites/4shared/ASSET_SOURCES.md`. +- Fresh deterministic seed: 146 files total (122 public), 4 users, 16 folders, + 16 favorites, 12 saved files, 8 downloads, 12 comments, 4 share links, and + 1 plan order. Calling both seed functions twice leaves counts unchanged. + +## PR-safe screenshots + +Only non-answer-bearing homepage screenshots are committed for PR display: + +- `review-reports/assets/pr-90-4shared-homepage-1440.png` +- `review-reports/assets/pr-90-4shared-homepage-390.png` + +The Hugging Face asset PR must merge before `.assets-revision` can be pinned to +its immutable commit. No GitHub or Hugging Face merge is performed by this +review. diff --git a/review-reports/assets/pr-90-4shared-homepage-1440.png b/review-reports/assets/pr-90-4shared-homepage-1440.png new file mode 100644 index 00000000..e6678556 Binary files /dev/null and b/review-reports/assets/pr-90-4shared-homepage-1440.png differ diff --git a/review-reports/assets/pr-90-4shared-homepage-390.png b/review-reports/assets/pr-90-4shared-homepage-390.png new file mode 100644 index 00000000..a43673dd Binary files /dev/null and b/review-reports/assets/pr-90-4shared-homepage-390.png differ diff --git a/sites/4shared/.requires-images b/sites/4shared/.requires-images new file mode 100644 index 00000000..e69de29b diff --git a/sites/4shared/ASSET_SOURCES.md b/sites/4shared/ASSET_SOURCES.md new file mode 100644 index 00000000..13035123 --- /dev/null +++ b/sites/4shared/ASSET_SOURCES.md @@ -0,0 +1,54 @@ +# 4shared asset provenance + +All catalog thumbnails are real photographic assets. No generated image, generic +placeholder, or network-loaded runtime image is used. The files live in the +pinned Hugging Face asset bundle because `static/images/` is intentionally +ignored by Git. + +## Existing WebHarbor photographs + +| 4shared path | Existing WebHarbor source path | SHA-256 | +| --- | --- | --- | +| `static/images/london.jpg` | `sites/google_search/static/images/google_real/london.jpg` | `dd11fcb9d34fff87ce03e9008a68adadd0e182c7bfcb7c657fbd608d7b8ef65c` | +| `static/images/new-york.jpg` | `sites/google_search/static/images/google_real/new_york_city.jpg` | `2bb9a4689eb0e3ed5b5c0d654a4db3eb47304ebca2a82f65bd87e8d2898de24b` | +| `static/images/denali.jpg` | `sites/google_search/static/images/google_real/mount_denali_mckinley_elevation.jpg` | `6857da22b8620bd28791d05340eebb5236497b4a1b0bb65452d2a2754215ff5e` | + +## Wikimedia Commons photographs + +The remaining photographs were downloaded as 900-pixel thumbnails from their +Commons file pages, visually checked against the corresponding catalog record, +and converted to optimized JPEGs. + +| Local file | Commons source | Creator | License | +| --- | --- | --- | --- | +| `library-reading-room.jpg` | [Library of Congress main reading room](https://commons.wikimedia.org/wiki/File:INTERIOR,_MAIN_READING_ROOM,_LOOKING_NORTHEAST_-_Library_of_Congress,_Northeast_corner_of_First_Street_and_Independence_Avenue_Southeast,_Washington,_District_of_Columbia,_DC_HABS_DC,WASH,461A-12.tif) | Library of Congress HABS | Public domain | +| `atlantic-boardwalk.jpg` | [Dunes and Boardwalk at Bethany Beach](https://commons.wikimedia.org/wiki/File:Dunes_and_Boardwalk_at_Bethany_Beach,_Delaware.jpg) | PointsofNoReturn | CC BY-SA 4.0 | +| `garden-pollinators.jpg` | [Bee on a blue flower](https://commons.wikimedia.org/wiki/File:Bee-Mating-Blue-Flower-large_ForestWander.jpg) | ForestWander | CC BY-SA 3.0 US | +| `alpine-lake-mist.jpg` | [Sunrise over Shadow Mountain Lake](https://commons.wikimedia.org/wiki/File:Sunrise_over_Shadow_Mountain_Lake,_CO_9-12_(19957710950).jpg) | Don Graham | CC BY-SA 2.0 | +| `ceramic-workbench.jpg` | [Ceramics workshop in Fes](https://commons.wikimedia.org/wiki/File:Inside_of_ceramics_workshop_Fes_Morrocco.jpg) | cliffwilliams | CC BY-SA 2.0 | +| `red-bicycle-brick-wall.jpg` | [Bicycles at a brick wall](https://commons.wikimedia.org/wiki/File:0020-fahrradsammlung-RalfR.jpg) | Ralf Roletschek | Free Art License | +| `winter-pines-snow.jpg` | [Heavy snow on pine branches](https://commons.wikimedia.org/wiki/File:Heavy_snow_on_pine_branches_in_Tuntorp_8.jpg) | W.carter | CC BY-SA 4.0 | +| `notebook-fountain-pen.jpg` | [Pen and notebook](https://commons.wikimedia.org/wiki/File:Pen_and_notebook_-_Narei.jpg) | Kaori Kita | CC BY-SA 3.0 | +| `harbor-boats-fog.jpg` | [Boats in San Francisco fog](https://commons.wikimedia.org/wiki/File:At_San_Francisco_2015_057.jpg) | Mike Peel | CC BY-SA 4.0 | +| `wildflower-trail.jpg` | [Wildflower-lined trail](https://commons.wikimedia.org/wiki/File:Wildflower_lined_trail_(52013518556).jpg) | Joshua Tree National Park | Public domain | +| `classic-camera.jpg` | [Vintage Canon A-1 camera](https://commons.wikimedia.org/wiki/File:Vintage_Canon_35mm_SLR_Camera,_Model_A-1,_All-Digital_Control,_Made_In_Japan,_Circa_1978_(13366931504).jpg) | Joe Haupt | CC BY-SA 2.0 | +| `rainy-window-lights.jpg` | [Rain Drops](https://commons.wikimedia.org/wiki/File:Rain_Drops_-_panoramio.jpg) | M. PINARCI | CC BY-SA 3.0 | +| `map-compass.jpg` | [Suunto compass and map](https://commons.wikimedia.org/wiki/File:Suunto_compass_%26_map_(48995280172).jpg) | Olgierd | CC BY 2.0 | + +## Captured 4shared interface assets + +These public interface assets were harvested from the contributor's sanitized +September 2026 Playwright capture. Only the named public files were copied; no +browser profile, cookies, account state, or private capture material is shipped. + +| Local file | Live source URL | +| --- | --- | +| `static/images/ui/upload-image-initial.svg` | `https://static.4shared.com/images/upload-image-initial.svg` | +| `static/images/ui/qr-code-frame.svg` | `https://static.4shared.com/images/QR-code-frame.svg` | +| `static/images/ui/mob-app-qr-code.svg` | `https://static.4shared.com/images/mob-app-deeplink-QR-code.svg` | +| `static/images/ui/logo-google.svg` | `https://static.4shared.com/images/d1new/Google.svg` | +| `static/images/ui/logo-apple.svg` | `https://static.4shared.com/images/logo-apple-color.svg` | +| `static/images/ui/logo-huawei.svg` | `https://static.4shared.com/images/logo-huawei-color.svg` | + +The repository-native logo mark remains in `static/icons/mark.svg` and is +tracked with the application code. diff --git a/sites/4shared/_health.py b/sites/4shared/_health.py new file mode 100644 index 00000000..f33f8ddc --- /dev/null +++ b/sites/4shared/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "4shared"} diff --git a/sites/4shared/app.py b/sites/4shared/app.py new file mode 100644 index 00000000..45ea2847 --- /dev/null +++ b/sites/4shared/app.py @@ -0,0 +1,792 @@ +"""4shared mirror for the WebHarbor offline benchmark.""" + +from __future__ import annotations + +import hashlib +import os +import re +import secrets +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from flask import Flask, abort, flash, redirect, render_template, request, url_for +from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user +from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFProtect +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError + + +SITE_SLUG = "4shared" +SITE_NAME = "4shared" +SITE_PORT = 40028 +BENCHMARK_PASSWORD = "TestPass123!" +BASE_DIR = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +SEED_DIR = BASE_DIR / "instance_seed" +RUNTIME_DB_PATH = INSTANCE_DIR / "4shared.db" +SEED_DB_PATH = SEED_DIR / "4shared.db" +PASSWORD_NAMESPACE = "webharbor-4shared-v1" + +PREMIUM_PLANS = { + "100": {"label": "Premium 100 GB", "account_plan": "Premium", "storage_mb": 102400, "annual": 77.88}, + "500": {"label": "Premium 500 GB", "account_plan": "Premium 500 GB", "storage_mb": 512000, "annual": 119.88}, + "1000": {"label": "Premium 1 TB", "account_plan": "Premium 1 TB", "storage_mb": 1048576, "annual": 155.88}, +} + +UPLOAD_CATEGORY_BY_EXTENSION = { + "aac": "Music", "flac": "Music", "m4a": "Music", "mp3": "Music", "ogg": "Music", "wav": "Music", + "avi": "Video", "mkv": "Video", "mov": "Video", "mp4": "Video", "webm": "Video", + "gif": "Images", "jpeg": "Images", "jpg": "Images", "png": "Images", "webp": "Images", + "epub": "Books", "mobi": "Books", + "7z": "Archives", "rar": "Archives", "tar": "Archives", "zip": "Archives", + "apk": "Apps", "dmg": "Apps", "exe": "Apps", "msi": "Apps", +} + +INSTANCE_DIR.mkdir(parents=True, exist_ok=True) +SEED_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config.update( + SECRET_KEY="webharbor-4shared-deterministic-development-key", + SQLALCHEMY_DATABASE_URI=f"sqlite:///{RUNTIME_DB_PATH}", + SQLALCHEMY_TRACK_MODIFICATIONS=False, + MAX_CONTENT_LENGTH=4 * 1024 * 1024, +) +db = SQLAlchemy(app) +csrf = CSRFProtect(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Log in to manage files and folders." + + +@event.listens_for(Engine, "connect") +def enable_sqlite_foreign_keys(dbapi_connection, _connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +def stable_password_hash(password: str) -> str: + return hashlib.sha256(f"{PASSWORD_NAMESPACE}:{password}".encode()).hexdigest() + + +def slugify(value: str) -> str: + value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return value or "file" + + +def safe_next(target: str | None, fallback: str) -> str: + if not target or "\\" in target: + return fallback + parsed = urlparse(target) + if parsed.scheme or parsed.netloc or not target.startswith("/") or target.startswith("//"): + return fallback + return target + + +def human_size(value: int) -> str: + size = float(value) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B" + size /= 1024 + return f"{size:.1f} GB" + + +app.jinja_env.filters["filesize"] = human_size + + +class User(db.Model, UserMixin): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(160), unique=True, nullable=False, index=True) + display_name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(64), nullable=False) + location = db.Column(db.String(120), default="") + bio = db.Column(db.Text, default="") + plan = db.Column(db.String(32), default="Free") + storage_limit_mb = db.Column(db.Integer, default=15360) + joined_at = db.Column(db.DateTime, nullable=False) + + def set_password(self, password: str) -> None: + self.password_hash = stable_password_hash(password) + + def check_password(self, password: str) -> bool: + return secrets.compare_digest(self.password_hash, stable_password_hash(password)) + + @property + def storage_used(self) -> int: + return sum(item.size_bytes for item in self.files if not item.deleted) + + +class Folder(db.Model): + __tablename__ = "folders" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey("folders.id", ondelete="CASCADE")) + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref=db.backref("folders", cascade="all, delete-orphan"), foreign_keys=[user_id]) + parent = db.relationship("Folder", remote_side=[id], backref="children") + __table_args__ = (db.UniqueConstraint("user_id", "parent_id", "name", name="uq_folder_parent_name"),) + + +class FileItem(db.Model): + __tablename__ = "files" + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), index=True) + folder_id = db.Column(db.Integer, db.ForeignKey("folders.id", ondelete="SET NULL"), index=True) + filename = db.Column(db.String(220), nullable=False) + slug = db.Column(db.String(260), unique=True, nullable=False, index=True) + category = db.Column(db.String(32), nullable=False, index=True) + extension = db.Column(db.String(12), nullable=False) + mime_type = db.Column(db.String(100), nullable=False) + size_bytes = db.Column(db.Integer, nullable=False) + description = db.Column(db.Text, default="") + tags = db.Column(db.String(400), default="") + license_name = db.Column(db.String(100), default="") + uploader_name = db.Column(db.String(120), nullable=False) + public = db.Column(db.Boolean, default=True, nullable=False, index=True) + featured = db.Column(db.Boolean, default=False, nullable=False) + deleted = db.Column(db.Boolean, default=False, nullable=False) + thumbnail = db.Column(db.String(240), default="") + preview_text = db.Column(db.Text, default="") + uploaded_at = db.Column(db.DateTime, nullable=False, index=True) + modified_at = db.Column(db.DateTime, nullable=False) + download_count = db.Column(db.Integer, default=0, nullable=False) + rating = db.Column(db.Float, default=4.5, nullable=False) + + owner = db.relationship("User", backref=db.backref("files", cascade="all, delete-orphan")) + folder = db.relationship("Folder", backref="files") + + @property + def stem(self) -> str: + return self.filename.rsplit(".", 1)[0] + + +class Favorite(db.Model): + __tablename__ = "favorites" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + file_id = db.Column(db.Integer, db.ForeignKey("files.id", ondelete="CASCADE"), nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref=db.backref("favorites", cascade="all, delete-orphan")) + file = db.relationship("FileItem", backref=db.backref("favorite_rows", cascade="all, delete-orphan")) + __table_args__ = (db.UniqueConstraint("user_id", "file_id", name="uq_favorite_user_file"),) + + +class SavedFile(db.Model): + __tablename__ = "saved_files" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + file_id = db.Column(db.Integer, db.ForeignKey("files.id", ondelete="CASCADE"), nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref=db.backref("saved_files", cascade="all, delete-orphan")) + file = db.relationship("FileItem") + __table_args__ = (db.UniqueConstraint("user_id", "file_id", name="uq_saved_user_file"),) + + +class DownloadLog(db.Model): + __tablename__ = "downloads" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + file_id = db.Column(db.Integer, db.ForeignKey("files.id", ondelete="CASCADE"), nullable=False) + downloaded_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref="downloads") + file = db.relationship("FileItem", backref="download_rows") + + +class SharedLink(db.Model): + __tablename__ = "shared_links" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + file_id = db.Column(db.Integer, db.ForeignKey("files.id", ondelete="CASCADE"), nullable=False) + token = db.Column(db.String(48), unique=True, nullable=False, index=True) + permission = db.Column(db.String(24), default="view") + label = db.Column(db.String(120), default="") + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref=db.backref("shared_links", cascade="all, delete-orphan")) + file = db.relationship("FileItem", backref="share_links") + + +class Comment(db.Model): + __tablename__ = "comments" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + file_id = db.Column(db.Integer, db.ForeignKey("files.id", ondelete="CASCADE"), nullable=False) + body = db.Column(db.String(600), nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref="comments") + file = db.relationship("FileItem", backref=db.backref("comments", order_by="Comment.created_at.desc()")) + + +class PlanOrder(db.Model): + __tablename__ = "plan_orders" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + plan_name = db.Column(db.String(40), nullable=False) + billing_period = db.Column(db.String(24), nullable=False) + amount = db.Column(db.Float, nullable=False) + card_last4 = db.Column(db.String(4), nullable=False) + status = db.Column(db.String(24), default="Active", nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + user = db.relationship("User", backref="plan_orders") + + +@login_manager.user_loader +def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + +def public_files_query(): + return FileItem.query.filter_by(public=True, deleted=False) + + +STOP_WORDS = {"the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "or", "is", "it", "by", "with"} + + +def scored_search(query: str, items: list[FileItem]) -> list[FileItem]: + tokens = [token for token in re.split(r"\W+", query.lower()) if len(token) > 1 and token not in STOP_WORDS] + if not tokens: + return items + ranked = [] + for item in items: + title = item.filename.lower() + blob = " ".join((item.filename, item.description, item.tags, item.category, item.extension, item.uploader_name)).lower() + score = sum(4 if token in title else 1 for token in tokens if token in blob) + if score: + ranked.append((item, score)) + ranked.sort(key=lambda row: (-row[1], -row[0].download_count, row[0].filename.lower())) + return [item for item, _score in ranked] + + +def owned_file_or_404(file_id: int) -> FileItem: + item = db.session.get(FileItem, file_id) + if not item or item.owner_id != current_user.id: + abort(404) + return item + + +def viewable_file_or_404(file_id: int) -> FileItem: + """A file the current user is allowed to act on: public, or their own private one. + + Never a trashed file. Used by every route that writes a row referencing a file + the user does not necessarily own (favorite / comment / share), so that another + account's private filename can never surface through those surfaces. + """ + item = db.get_or_404(FileItem, file_id) + owner_is_current = current_user.is_authenticated and item.owner_id == current_user.id + if item.deleted or (not item.public and not owner_is_current): + abort(404) + return item + + +def bounded_int(raw, low: int, high: int) -> int | None: + """A plain decimal integer inside [low, high], else None. SQLite enforces neither.""" + value = (raw or "").strip() + if not value.isdigit(): + return None + number = int(value) + return number if low <= number <= high else None + + +def owned_folder_id_or_abort(raw) -> int | None: + """'' means the account root; anything else must be one of this user's folders. + + A non-numeric value is a client error (400), not a silent fall back to the root. + """ + value = (raw or "").strip() + if not value: + return None + if not value.isdigit(): + abort(400) + folder_id = int(value) + if not Folder.query.filter_by(id=folder_id, user_id=current_user.id).first(): + abort(404) + return folder_id + + +def allocate_file_slug(filename: str, user_id: int) -> str: + """`-`, then the lowest free `-N` suffix. + + Derived from the rows that exist rather than from a wall-clock counter, so a + second upload of the same name (in the same second, or after the first was + trashed) gets a fresh slug instead of violating files.slug UNIQUE. + """ + base = f"{slugify(filename)}-{user_id}" + taken = {row[0] for row in db.session.query(FileItem.slug).filter(FileItem.slug.like(f"{base}%")).all()} + if base not in taken: + return base + suffix = 2 + while f"{base}-{suffix}" in taken: + suffix += 1 + return f"{base}-{suffix}" + + +@app.context_processor +def common_context(): + categories = ["Music", "Video", "Apps", "Images", "Books", "Documents", "Archives"] + favorite_ids = set() + if current_user.is_authenticated: + favorite_ids = {row.file_id for row in current_user.favorites} + return {"nav_categories": categories, "favorite_ids": favorite_ids, "site_port": SITE_PORT} + + +@app.route("/") +def index(): + # index.html renders the hero, the drop zone and the app band only; it lists no + # files, so the homepage runs no catalog queries. + return render_template("index.html") + + +@app.route("/search") +def search(): + query = request.args.get("q", "").strip()[:120] + category_name = request.args.get("category", "All Files").strip()[:32] + sort = request.args.get("sort", "relevance") + items = public_files_query().all() + if category_name and category_name != "All Files": + items = [item for item in items if item.category.lower() == category_name.lower()] + items = scored_search(query, items) + if sort == "downloads": + items.sort(key=lambda item: (-item.download_count, item.filename.lower())) + elif sort == "newest": + items.sort(key=lambda item: (-item.uploaded_at.timestamp(), item.filename.lower())) + elif sort == "size": + items.sort(key=lambda item: (-item.size_bytes, item.filename.lower())) + return render_template("search.html", files=items, query=query, category=category_name, sort=sort) + + +@app.route("/category/") +def category(category: str): + display = category.replace("-", " ").title() + files = public_files_query().filter(db.func.lower(FileItem.category) == display.lower()).order_by(FileItem.download_count.desc(), FileItem.id.asc()).all() + if not files: + abort(404) + return render_template("category.html", files=files, category=display) + + +@app.route("/file/") +def file_detail(slug: str): + item = FileItem.query.filter_by(slug=slug, deleted=False).first_or_404() + if not item.public and (not current_user.is_authenticated or item.owner_id != current_user.id): + abort(404) + related = public_files_query().filter(FileItem.category == item.category, FileItem.id != item.id).order_by(FileItem.download_count.desc(), FileItem.id.asc()).limit(6).all() + return render_template("file_detail.html", file=item, related=related) + + +@app.route("/preview/") +def preview(file_id: int): + item = db.get_or_404(FileItem, file_id) + if item.deleted or (not item.public and (not current_user.is_authenticated or item.owner_id != current_user.id)): + abort(404) + return render_template("preview.html", file=item) + + +@app.post("/download/") +def download(file_id: int): + item = db.get_or_404(FileItem, file_id) + if item.deleted or (not item.public and (not current_user.is_authenticated or item.owner_id != current_user.id)): + abort(404) + item.download_count += 1 + db.session.add(DownloadLog(user_id=current_user.id if current_user.is_authenticated else None, file_id=item.id, downloaded_at=datetime.utcnow())) + db.session.commit() + return render_template("download_ready.html", file=item) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower()[:160] + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + return redirect(safe_next(request.args.get("next"), url_for("account"))) + flash("The email or password is incorrect.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + display_name = request.form.get("display_name", "").strip()[:120] + email = request.form.get("email", "").strip().lower()[:160] + password = request.form.get("password", "") + if len(display_name) < 2 or "@" not in email or len(password) < 8: + flash("Enter a name, valid email, and password of at least 8 characters.", "error") + elif User.query.filter_by(email=email).first(): + flash("An account with that email already exists.", "error") + else: + user = User(email=email, display_name=display_name, joined_at=datetime.utcnow()) + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Welcome to 4shared. Your account is ready.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.post("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) + + +@app.get("/account") +@login_required +def account(): + recent_files = FileItem.query.filter_by(owner_id=current_user.id, deleted=False).order_by(FileItem.modified_at.desc()).limit(6).all() + recent_downloads = DownloadLog.query.filter_by(user_id=current_user.id).order_by(DownloadLog.downloaded_at.desc()).limit(6).all() + return render_template("account.html", recent_files=recent_files, recent_downloads=recent_downloads) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + name = request.form.get("display_name", "").strip()[:120] + if len(name) < 2: + flash("Display name must have at least two characters.", "error") + else: + current_user.display_name = name + current_user.location = request.form.get("location", "").strip()[:120] + current_user.bio = request.form.get("bio", "").strip()[:500] + db.session.commit() + flash("Profile updated.", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.get("/my-files") +@login_required +def my_files(): + folder_id = request.args.get("folder", type=int) + active_folder = None + if folder_id: + active_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404() + folders = Folder.query.filter_by(user_id=current_user.id, parent_id=folder_id).order_by(Folder.name).all() + files = FileItem.query.filter_by(owner_id=current_user.id, folder_id=folder_id, deleted=False).order_by(FileItem.filename).all() + all_folders = Folder.query.filter_by(user_id=current_user.id).order_by(Folder.name).all() + return render_template("my_files.html", folders=folders, files=files, active_folder=active_folder, all_folders=all_folders) + + +@app.post("/folder/new") +@login_required +def folder_new(): + name = request.form.get("name", "").strip()[:120] + parent_id = owned_folder_id_or_abort(request.form.get("parent_id")) + if not name: + flash("Folder name is required.", "error") + elif Folder.query.filter_by(user_id=current_user.id, parent_id=parent_id, name=name).first(): + flash("A folder with that name already exists here.", "error") + else: + db.session.add(Folder(user_id=current_user.id, parent_id=parent_id, name=name, created_at=datetime.utcnow())) + db.session.commit() + flash(f"Folder ‘{name}’ created.", "success") + return redirect(url_for("my_files", folder=parent_id) if parent_id else url_for("my_files")) + + +@app.route("/upload", methods=["GET", "POST"]) +@login_required +def upload(): + folders = Folder.query.filter_by(user_id=current_user.id).order_by(Folder.name).all() + if request.method == "POST": + filename = request.form.get("filename", "").strip()[:220] + category_name = request.form.get("category", "auto")[:32] + description = request.form.get("description", "").strip()[:1000] + folder_id = owned_folder_id_or_abort(request.form.get("folder_id")) + size_kb = bounded_int(request.form.get("size_kb"), 1, 4096) + visibility = (request.form.get("visibility") or "").strip() + if not filename or "." not in filename: + flash("Enter a filename with an extension, such as notes.pdf.", "error") + return render_template("upload.html", folders=folders), 400 + if size_kb is None: + flash("Enter a size in KB between 1 and 4096.", "error") + return render_template("upload.html", folders=folders), 400 + if visibility not in {"private", "public"}: + flash("Choose whether this file is private or public.", "error") + return render_template("upload.html", folders=folders), 400 + extension = filename.rsplit(".", 1)[1].lower()[:12] + if category_name == "auto": + category_name = UPLOAD_CATEGORY_BY_EXTENSION.get(extension, "Documents") + elif category_name not in {"Music", "Video", "Apps", "Images", "Books", "Documents", "Archives"}: + category_name = "Documents" + item = FileItem( + owner_id=current_user.id, folder_id=folder_id, filename=filename, + slug=allocate_file_slug(filename, current_user.id), + category=category_name, extension=extension, mime_type="application/octet-stream", + size_bytes=size_kb * 1024, + description=description, tags="personal upload", license_name="Private", + uploader_name=current_user.display_name, public=visibility == "public", + uploaded_at=datetime.utcnow(), modified_at=datetime.utcnow(), preview_text=description, + ) + try: + db.session.add(item) + db.session.commit() + except IntegrityError: + db.session.rollback() + flash("That upload collided with an existing record. Try again.", "error") + return render_template("upload.html", folders=folders), 409 + flash(f"{filename} uploaded.", "success") + return redirect(url_for("my_files", folder=folder_id) if folder_id else url_for("my_files")) + return render_template("upload.html", folders=folders) + + +@app.post("/file//rename") +@login_required +def rename_file(file_id: int): + item = owned_file_or_404(file_id) + filename = request.form.get("filename", "").strip()[:220] + if not filename or "." not in filename: + flash("Enter a complete filename.", "error") + else: + item.filename = filename + item.extension = filename.rsplit(".", 1)[1].lower()[:12] + item.modified_at = datetime.utcnow() + db.session.commit() + flash("File renamed.", "success") + return redirect(url_for("my_files", folder=item.folder_id) if item.folder_id else url_for("my_files")) + + +@app.post("/file//move") +@login_required +def move_file(file_id: int): + item = owned_file_or_404(file_id) + folder_id = owned_folder_id_or_abort(request.form.get("folder_id")) + item.folder_id = folder_id + item.modified_at = datetime.utcnow() + db.session.commit() + flash("File moved.", "success") + return redirect(url_for("my_files", folder=folder_id) if folder_id else url_for("my_files")) + + +@app.post("/file//delete") +@login_required +def delete_file(file_id: int): + item = owned_file_or_404(file_id) + item.deleted = True + item.modified_at = datetime.utcnow() + db.session.commit() + flash("File moved to Trash.", "success") + return redirect(url_for("my_files")) + + +@app.get("/trash") +@login_required +def trash(): + files = FileItem.query.filter_by(owner_id=current_user.id, deleted=True).order_by(FileItem.modified_at.desc()).all() + return render_template("trash.html", files=files) + + +@app.post("/file//restore") +@login_required +def restore_file(file_id: int): + item = owned_file_or_404(file_id) + item.deleted = False + item.modified_at = datetime.utcnow() + db.session.commit() + flash("File restored.", "success") + return redirect(url_for("trash")) + + +@app.post("/file//favorite") +@login_required +def toggle_favorite(file_id: int): + item = viewable_file_or_404(file_id) + row = Favorite.query.filter_by(user_id=current_user.id, file_id=item.id).first() + if row: + db.session.delete(row) + flash("Removed from favorites.", "success") + else: + db.session.add(Favorite(user_id=current_user.id, file_id=item.id, created_at=datetime.utcnow())) + flash("Added to favorites.", "success") + db.session.commit() + return redirect(safe_next(request.form.get("next"), url_for("file_detail", slug=item.slug))) + + +@app.get("/favorites") +@login_required +def favorites(): + rows = Favorite.query.filter_by(user_id=current_user.id).order_by(Favorite.created_at.desc()).all() + return render_template("favorites.html", files=[row.file for row in rows if not row.file.deleted]) + + +@app.post("/file//save") +@login_required +def save_file(file_id: int): + item = db.get_or_404(FileItem, file_id) + if not item.public or item.deleted: + abort(404) + if not SavedFile.query.filter_by(user_id=current_user.id, file_id=item.id).first(): + db.session.add(SavedFile(user_id=current_user.id, file_id=item.id, created_at=datetime.utcnow())) + db.session.commit() + flash("Saved to My 4shared.", "success") + return redirect(url_for("file_detail", slug=item.slug)) + + +@app.get("/saved") +@login_required +def saved(): + rows = SavedFile.query.filter_by(user_id=current_user.id).order_by(SavedFile.created_at.desc()).all() + return render_template("favorites.html", files=[row.file for row in rows], title="Saved files") + + +@app.route("/file//share", methods=["GET", "POST"]) +@login_required +def share_file(file_id: int): + item = viewable_file_or_404(file_id) + links = SharedLink.query.filter_by(user_id=current_user.id, file_id=item.id).order_by(SharedLink.created_at.desc()).all() + if request.method == "POST": + permission = (request.form.get("permission") or "").strip() + if permission not in {"view", "download"}: + flash("Choose a link permission.", "error") + return render_template("share.html", file=item, links=links), 400 + token = secrets.token_urlsafe(12) + link = SharedLink(user_id=current_user.id, file_id=item.id, token=token, permission=permission, + label=request.form.get("label", "").strip()[:120], created_at=datetime.utcnow()) + db.session.add(link) + db.session.commit() + flash("Share link created.", "success") + return redirect(url_for("share_file", file_id=item.id)) + return render_template("share.html", file=item, links=links) + + +@app.get("/shared/") +def shared(token: str): + link = SharedLink.query.filter_by(token=token).first_or_404() + if link.file.deleted: + abort(404) + return render_template("shared.html", link=link, file=link.file) + + +@app.post("/file//comment") +@login_required +def add_comment(file_id: int): + item = viewable_file_or_404(file_id) + body = request.form.get("body", "").strip()[:600] + if len(body) < 2: + flash("Comment cannot be empty.", "error") + else: + db.session.add(Comment(user_id=current_user.id, file_id=item.id, body=body, created_at=datetime.utcnow())) + db.session.commit() + flash("Comment posted.", "success") + return redirect(url_for("file_detail", slug=item.slug)) + + +@app.get("/activity") +@login_required +def activity(): + downloads = DownloadLog.query.filter_by(user_id=current_user.id).order_by(DownloadLog.downloaded_at.desc()).all() + shares = SharedLink.query.filter_by(user_id=current_user.id).order_by(SharedLink.created_at.desc()).all() + return render_template("activity.html", downloads=downloads, shares=shares) + + +@app.get("/premium") +def premium(): + return render_template("premium.html") + + +@app.route("/premium/checkout", methods=["GET", "POST"]) +@login_required +def premium_checkout(): + # No silent fallback: a missing or unknown plan is a 404, never the first plan. + plan_key = (request.values.get("plan") or "").strip() + if plan_key not in PREMIUM_PLANS: + abort(404) + plan = PREMIUM_PLANS[plan_key] + period = "annual" + amount = plan[period] + if request.method == "POST": + card = re.sub(r"\D", "", request.form.get("card_number", "")) + holder = request.form.get("cardholder", "").strip() + if len(card) != 16 or len(holder) < 2: + flash("Enter the demo 16-digit card number and cardholder name.", "error") + else: + order = PlanOrder(user_id=current_user.id, plan_name=plan["label"], billing_period=period, + amount=amount, card_last4=card[-4:], created_at=datetime.utcnow()) + current_user.plan = plan["account_plan"] + current_user.storage_limit_mb = plan["storage_mb"] + db.session.add(order) + db.session.commit() + return render_template("premium_confirmed.html", order=order, plan=plan) + return render_template("premium_checkout.html", period=period, amount=amount, plan=plan, plan_key=plan_key) + + +@app.get("/help") +def help_center(): + return render_template("help.html") + + +@app.get("/about") +def about(): + return render_template("about.html") + + +@app.get("/press-room") +def press_room(): + return render_template("press_room.html") + + +@app.get("/blog") +def blog(): + return render_template("blog.html") + + +@app.route("/convert/-to-pdf", methods=["GET", "POST"]) +def convert_to_pdf(source_format: str): + allowed_formats = {"doc", "pptx", "docx", "xls", "ppt", "xlsx", "cbr", "txt", "pps", "rtf", "cbz", "fb2", "epub", "djvu"} + if source_format not in allowed_formats: + abort(404) + converted_name = None + if request.method == "POST": + filename = request.form.get("filename", "").strip()[:220] + if not filename.lower().endswith(f".{source_format}"): + flash(f"Choose a .{source_format} file record to convert.", "error") + else: + converted_name = f"{filename.rsplit('.', 1)[0]}.pdf" + return render_template("converter.html", source_format=source_format, converted_name=converted_name) + + +@app.get("/_health") +def health(): + return {"ok": True, "site": SITE_SLUG, "files": public_files_query().count()} + + +@app.errorhandler(400) +def bad_request(_error): + return render_template("400.html"), 400 + + +@app.errorhandler(404) +def not_found(_error): + return render_template("404.html"), 404 + + +@app.errorhandler(500) +def server_error(_error): + db.session.rollback() + return render_template("500.html"), 500 + + +def initialize_database() -> None: + with app.app_context(): + db.create_all() + from seed_data import seed_benchmark_users, seed_database + seed_database() + seed_benchmark_users() + + +initialize_database() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", SITE_PORT)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/4shared/requirements.txt b/sites/4shared/requirements.txt new file mode 100644 index 00000000..cde776e2 --- /dev/null +++ b/sites/4shared/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 diff --git a/sites/4shared/seed_data.py b/sites/4shared/seed_data.py new file mode 100644 index 00000000..acbdb8ab --- /dev/null +++ b/sites/4shared/seed_data.py @@ -0,0 +1,381 @@ +"""Deterministic seed data for the 4shared WebHarbor mirror.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta + + +def _app_module(): + module = sys.modules.get("app") + if module is not None: + return module + main = sys.modules.get("__main__") + if main is not None and hasattr(main, "db") and hasattr(main, "FileItem"): + return main + import app as module + return module + + +_app = _app_module() +BENCHMARK_PASSWORD = _app.BENCHMARK_PASSWORD +Comment = _app.Comment +DownloadLog = _app.DownloadLog +Favorite = _app.Favorite +FileItem = _app.FileItem +Folder = _app.Folder +PlanOrder = _app.PlanOrder +SavedFile = _app.SavedFile +SharedLink = _app.SharedLink +User = _app.User +db = _app.db +slugify = _app.slugify + +SEED_TIME = datetime(2026, 8, 20, 10, 0, 0) + +UPLOADERS = [ + "Open Culture Shelf", "Atlas Media Lab", "Commons Studio", "Learning Exchange", + "Field Notes Collective", "Open Source Desk", "Archive Lantern", "Community Library", +] + +CATALOG = { + "Music": { + "ext": "mp3", "mime": "audio/mpeg", "license": "Public domain recording", + "items": [ + ("Moonlight Sonata First Movement", "A measured solo-piano performance recorded in a quiet recital hall.", "piano beethoven classical nocturne", "Duration 5:42 · 320 kbps · recorded on a Steinway Model B."), + ("Clair de Lune Studio Performance", "An intimate interpretation of Debussy's atmospheric piano work.", "piano debussy impressionist classical", "Duration 4:51 · 256 kbps · remastered from a 2018 session."), + ("Morning Meadow Field Recording", "Dawn birds, light wind, and a distant creek captured in early spring.", "nature birds ambience field recording", "Duration 12:08 · stereo · recorded at 48 kHz."), + ("Nocturne in E Flat Practice Take", "A complete practice-room reading with natural room ambience.", "chopin piano nocturne practice", "Duration 4:33 · 192 kbps · take number 7."), + ("Cello Suite Prelude Live", "A warm live performance of the familiar unaccompanied prelude.", "bach cello suite live classical", "Duration 3:09 · audience recording · restored in 2024."), + ("Rain on Library Windows", "A seamless ambience track of gentle rain against tall windows.", "rain ambience sleep study nature", "Duration 18:40 · stereo · no music or voice."), + ("Blue Hour Jazz Trio", "Original piano, upright bass, and brushed drums in a relaxed medium tempo.", "jazz trio original instrumental", "Duration 6:17 · 24-bit source · key of F minor."), + ("Acoustic Guitar Warmup Etude", "A fingerstyle study designed for intermediate practice sessions.", "guitar acoustic etude practice", "Duration 2:58 · 120 BPM · standard tuning."), + ("Ocean Pier Evening Ambience", "Waves, gulls, and wooden pier creaks recorded just after sunset.", "ocean waves ambience coast", "Duration 14:22 · binaural recording · light wind."), + ("Brass Quintet Festival Fanfare", "An original ceremonial fanfare for two trumpets, horn, trombone, and tuba.", "brass quintet fanfare original", "Duration 2:14 · score revision 3 · concert pitch."), + ("Violin Partita Courante", "A clear rehearsal recording focused on articulation and dance rhythm.", "violin bach partita baroque", "Duration 3:37 · mono room mic · no edits."), + ("Quiet Cafe Background Loop", "Low conversation and table sounds for creative-work ambience.", "cafe ambience background focus", "Duration 9:30 · seamless loop · no identifiable speech."), + ("Mountain Stream in Late Summer", "Close-miked flowing water from a shaded alpine stream.", "water stream nature field recording", "Duration 10:45 · 48 kHz WAV source · normalized to -16 LUFS."), + ("String Quartet Rehearsal Sketch", "An original two-theme chamber sketch from an open rehearsal.", "strings quartet rehearsal original", "Duration 7:06 · rehearsal letter C begins at 3:12."), + ("Vintage Metronome at 72 BPM", "A wooden mechanical metronome captured for music practice.", "metronome rhythm practice 72 bpm", "Duration 5:00 · 72 beats per minute · dry studio sound."), + ("Evening Crickets Field Session", "A summer-night chorus recorded near a woodland edge.", "crickets night nature ambience", "Duration 11:11 · stereo pair · recorded in August."), + ], + }, + "Video": { + "ext": "mp4", "mime": "video/mp4", "license": "Creative Commons Attribution", + "items": [ + ("Introduction to Urban Sketching", "A practical lesson on line, shape, and quick watercolor washes.", "art drawing watercolor tutorial", "Runtime 18:24 · 1080p · includes three street-scene demonstrations."), + ("Night Walk Across Tower Bridge", "A stabilized dusk-to-night walk with ambient city sound.", "london travel bridge city walk", "Runtime 12:36 · 4K master · filmed from south to north."), + ("Denali Landscape Study", "A slow visual study of the mountain, tundra, and reflected light.", "denali alaska mountain nature landscape", "Runtime 8:15 · 2160p · filmed over two clear mornings."), + ("Build a Simple Weather Station", "A classroom demonstration using open hardware sensors.", "science weather tutorial sensors education", "Runtime 22:41 · 1080p · bill of materials appears at 04:18."), + ("Five-Minute Desk Mobility Routine", "A low-impact guided routine for shoulders, hips, and wrists.", "fitness mobility desk stretch", "Runtime 5:38 · captions included · no equipment required."), + ("Open Data Mapping Basics", "A beginner overview of layers, coordinates, and map styling.", "maps gis open data tutorial", "Runtime 27:03 · 1080p · sample project uses GeoJSON."), + ("Coastal Birds Field Guide", "Identification notes and footage for eight common shoreline birds.", "birds coast nature guide", "Runtime 14:19 · captions included · eight species chapters."), + ("Bread Dough Fermentation Timelapse", "A controlled side-by-side rise at three room temperatures.", "bread baking science timelapse", "Runtime 6:52 · labels show 18°C, 22°C, and 27°C."), + ("Community Garden Summer Tour", "A volunteer-led walk through pollinator beds and raised plots.", "garden plants community tour", "Runtime 16:08 · 1080p · filmed in July."), + ("Beginner Astronomy Moon Phases", "A model-based explanation of the lunar cycle and viewing geometry.", "astronomy moon phases education", "Runtime 11:44 · includes a 29.5-day cycle diagram."), + ("Restoring a Wooden Chair", "A careful repair demonstration from disassembly through finish.", "woodworking restoration chair tutorial", "Runtime 31:20 · chapter markers · hand tools only."), + ("Museum Archive Handling Basics", "Gloves, supports, labeling, and safe movement of paper objects.", "museum archive preservation training", "Runtime 13:57 · accessibility captions · revised 2025."), + ("City Cycling Route Planning", "How to evaluate gradients, protected lanes, and intersection risk.", "cycling city maps route planning", "Runtime 19:05 · sample route length 8.4 km."), + ("Watercolor Clouds Three Techniques", "Wet-on-wet, lifting, and dry-brush cloud studies.", "painting watercolor clouds art", "Runtime 15:31 · 1080p · materials list in opening minute."), + ("Library Digitization Workflow", "A demonstration of capture, naming, metadata, and quality control.", "library scanning metadata workflow", "Runtime 24:12 · TIFF master workflow · PDF access copies."), + ("Seed Saving for Beginners", "A seasonal guide to collecting, drying, labeling, and storage.", "garden seeds sustainability guide", "Runtime 17:46 · covers tomatoes, beans, and lettuce."), + ], + }, + "Apps": { + "ext": "zip", "mime": "application/zip", "license": "Open-source package", + "items": [ + ("OpenMap Desktop Portable", "Portable offline map viewer package with sample public data.", "maps desktop offline open source", "Version 3.4.2 · Linux and Windows launchers · SHA-256 listed in README."), + ("NoteStack Markdown Editor", "A lightweight local-first editor for Markdown notes.", "notes markdown editor productivity", "Version 2.8.0 · spellcheck included · export to HTML and PDF."), + ("PhotoBatch Community Edition", "Resize, rotate, and rename image collections without cloud upload.", "photos images batch resize open source", "Version 1.9.5 · supports JPEG, PNG, and WebP."), + ("AudioTag Library Tool", "Edit common audio metadata fields and organize albums.", "music audio tags organizer", "Version 4.1.1 · reads ID3 and Vorbis comments."), + ("StudyTimer Focus Utility", "A simple configurable focus and break timer.", "timer study focus productivity", "Version 1.6.3 · three color themes · CSV session export."), + ("ArchivePeek File Inspector", "Browse archive contents and checksums before extraction.", "archive zip checksum utility", "Version 2.2.4 · ZIP, TAR, and 7z read support."), + ("ArchivePeek File Inspector Legacy Build", "An archived compatibility build of the file inspector for older systems.", "archive file inspector legacy compatibility", "Version 1.7.9 · ZIP-only inspection · no checksum comparison."), + ("ArchivePeek File Inspector Checksums Add-on", "Optional checksum definitions for ArchivePeek deployments.", "archive file inspector checksum addon", "Version 2.1.0 · definitions package only · requires the main application."), + ("ArchivePeek File Inspector Portable Notes", "Release notes and deployment examples for portable ArchivePeek installations.", "archive file inspector portable documentation", "Version 2.2 notes · documentation package · contains no executable."), + ("ArchivePeek File Inspector Recovery Plug-in", "A recovery plug-in for damaged archive headers.", "archive file inspector recovery plugin", "Version 0.6.3 · experimental plug-in · TAR recovery only."), + ("ArchivePeek File Inspector Test Fixtures", "Sample archives for validating file-inspection workflows.", "archive file inspector test fixtures", "Version 2026.4 · 42 synthetic fixtures · not an application installer."), + ("ColorScope Palette Assistant", "Inspect colors and create accessible palette combinations.", "design color accessibility palette", "Version 5.0.0 · WCAG contrast preview · GPL-3.0."), + ("PocketWeather Sample Client", "Demonstration client for an open weather-data endpoint.", "weather sample api client", "Version 0.9.8 · demo data works offline · MIT license."), + ("BookShelf EPUB Catalog", "Catalog local EPUB metadata and reading status.", "books epub catalog library", "Version 3.0.1 · OPF metadata import · local database only."), + ("SubtitleShift Timing Utility", "Adjust subtitle timing by a fixed offset or scale.", "video subtitles timing utility", "Version 1.4.6 · SRT and WebVTT support."), + ("GeoJournal Field Notes", "Create location-aware field notes with offline maps.", "journal maps offline field notes", "Version 2.5.7 · GPX import · coordinates optional."), + ("DiagramLite Flow Editor", "Small vector diagram editor with SVG export.", "diagram svg flowchart editor", "Version 0.8.9 · 24 bundled shapes · autosave enabled."), + ("Checksum Desk", "Generate and compare common file checksums locally.", "checksum sha256 files security", "Version 1.2.0 · SHA-256, SHA-512, and BLAKE2."), + ("CaptionCraft Transcriber", "Manual caption authoring workspace with keyboard controls.", "captions accessibility video editor", "Version 2.0.3 · WebVTT export · waveform preview."), + ("GardenPlot Planner", "Lay out beds and track crop rotations by season.", "garden planner crops open source", "Version 4.3.0 · metric and imperial grids."), + ("FontLedger Collection Viewer", "Preview locally installed font families and metadata.", "fonts typography viewer design", "Version 1.1.8 · specimen PDF export."), + ], + }, + "Images": { + "ext": "jpg", "mime": "image/jpeg", "license": "Creative Commons image", + "items": [ + ("London Skyline at Blue Hour", "A wide cityscape over the Thames as evening lights appear.", "london skyline city travel bridge", "Resolution 3840 × 2160 · captured at 20:14 · lens 24 mm."), + ("New York Skyline at Sunset", "Manhattan towers against a clear pastel sunset.", "new york skyline city sunset", "Resolution 3840 × 2160 · ISO 200 · exposure 1/80 s."), + ("Denali Reflection Panorama", "Snow-covered Denali reflected in still tundra water.", "denali mountain alaska panorama nature", "Resolution 2824 × 2176 · morning light · elevation viewpoint 640 m."), + ("Library Reading Room Windows", "Tall windows and long study tables in a historic reading room.", "library architecture reading room", "Resolution 2400 × 1600 · natural light · no people."), + ("Atlantic Coast Boardwalk", "Weathered boards leading through dunes toward the sea.", "ocean coast boardwalk landscape", "Resolution 3000 × 2000 · late afternoon · focal length 35 mm."), + ("Community Garden Pollinators", "Bees visiting purple flowers in a neighborhood garden.", "garden flowers bees nature", "Resolution 2200 × 1467 · macro crop · photographed in July."), + ("Grand Lake Sunrise", "Warm sunrise light over a mountain lake and surrounding ridges.", "mountain lake sunrise colorado", "Resolution 3200 × 2133 · tripod capture · 06:21 local time."), + ("Ceramic Studio Workbench", "Tools, clay, and unfinished vessels on a working studio table.", "ceramics art studio craft", "Resolution 2600 × 1733 · window light · documentary series."), + ("Red Bicycle by Brick Wall", "A city bicycle parked beside a warm red-brick facade.", "bicycle city street red", "Resolution 2400 × 1600 · 50 mm lens · overcast light."), + ("Winter Pines After Snow", "Fresh snow resting on dense evergreen branches.", "winter snow trees forest", "Resolution 3000 × 2000 · temperature -6°C · polarizing filter."), + ("Music Notebook and Fountain Pen", "A patterned music notebook and fountain pen arranged on a wooden desk.", "notebook fountain pen desk music", "Resolution 2400 × 1600 · overhead composition · daylight."), + ("Harbor Boats in Morning Fog", "Small sailboats emerging through pale harbor fog.", "harbor boats fog water", "Resolution 2800 × 1867 · 85 mm lens · photographed at 07:03."), + ("Wildflower Trail in Spring", "A narrow hillside trail lined with yellow and blue flowers.", "wildflowers trail spring hiking", "Resolution 3200 × 2133 · elevation 1,120 m · April capture."), + ("Classic Camera Detail", "Close view of the controls on a restored mechanical camera.", "camera vintage photography detail", "Resolution 2500 × 1667 · focus-stacked from six frames."), + ("Rainy City Street at Night", "Wet pavement and storefront lights on a quiet city street after dark.", "rain city street night lights", "Resolution 2400 × 1600 · available-light photograph · monochrome."), + ("Map and Compass Flat Lay", "A paper trail map, field compass, and pencil arranged for a hike.", "map compass hiking navigation", "Resolution 3000 × 2000 · overhead studio light · north arrow visible."), + ], + }, + "Books": { + "ext": "epub", "mime": "application/epub+zip", "license": "Public domain text", + "items": [ + ("The Secret Garden Illustrated Edition", "A carefully proofread edition of the classic garden story.", "classic fiction garden children", "338 pages · EPUB 3 · 12 original illustrations."), + ("A Study in Scarlet", "The first Sherlock Holmes novel in a clean reflowable edition.", "classic mystery sherlock holmes", "164 pages · chapter navigation · British spelling retained."), + ("The Time Machine", "H. G. Wells's compact science-fiction novel with editorial notes.", "science fiction classic time travel", "128 pages · 12 chapters · notes begin after page 116."), + ("Anne of Green Gables", "A reflowable edition with a Prince Edward Island map.", "classic fiction anne canada", "412 pages · 38 chapters · includes one regional map."), + ("The Adventures of Tom Sawyer", "A proofread edition with a historical-context introduction.", "classic fiction mark twain adventure", "296 pages · 35 chapters · introduction by Open Shelf editors."), + ("The Wonderful Wizard of Oz", "A color-illustrated EPUB edition of the original 1900 story.", "classic fantasy oz illustrated", "214 pages · 24 chapters · 20 color plates."), + ("Walden", "Thoreau's reflections on simple living with linked endnotes.", "essays nature philosophy thoreau", "384 pages · linked endnotes · 18 chapter essays."), + ("Pride and Prejudice", "A typographically polished edition with character index.", "classic romance austen fiction", "432 pages · 61 chapters · character index included."), + ("Frankenstein 1818 Text", "The original 1818 edition with a concise textual history.", "gothic science fiction shelley", "280 pages · 1818 text · three-volume structure retained."), + ("The Jungle Book", "Stories and poems in a navigable illustrated edition.", "classic stories kipling jungle", "246 pages · 14 illustrations · poems indexed separately."), + ("Meditations Public Domain Translation", "A clear English translation arranged by book and section.", "philosophy stoicism marcus aurelius", "192 pages · 12 books · searchable section numbers."), + ("The Federalist Papers", "All 85 essays with author and topic index.", "history politics essays constitution", "672 pages · 85 essays · searchable topic index."), + ("The Anti-Federalist Papers Selection", "A selected set of arguments opposing ratification, with editorial context.", "history politics essays constitution federalist papers", "244 pages · 24 selected essays · chronological reading list."), + ("Federalist Papers Study Questions", "Classroom prompts organized around major constitutional themes.", "history politics education federalist papers", "118 pages · 60 study questions · instructor notes appendix."), + ("Federalist Papers Author Concordance", "A reference concordance comparing commonly attributed authorship.", "history politics reference federalist papers", "206 pages · author tables · no full essay text."), + ("Federalist Papers Historical Reader", "Speeches, letters, and newspaper extracts from the ratification debate.", "history politics primary sources federalist papers", "356 pages · 41 source extracts · timeline included."), + ("Federalist Papers Constitutional Index", "A subject index linking constitutional clauses to related debates.", "history politics constitution federalist papers", "174 pages · clause index · cross-references only."), + ("Grimms Household Tales Selection", "Thirty selected tales in a reflowable reading edition.", "fairy tales folklore grimm", "318 pages · 30 tales · content notes included."), + ("The Souls of Black Folk", "Du Bois's landmark essays with preserved musical epigraphs.", "history essays sociology du bois", "286 pages · 14 essays · musical bars encoded as images."), + ("Leaves of Grass 1892 Edition", "The deathbed edition with section-level navigation.", "poetry whitman american", "476 pages · 17 sections · line breaks preserved."), + ("Twenty Thousand Leagues Under the Seas", "An illustrated translation of Verne's undersea adventure.", "adventure science fiction ocean verne", "512 pages · 47 chapters · 32 illustrations."), + ], + }, + "Documents": { + "ext": "pdf", "mime": "application/pdf", "license": "Open educational resource", + "items": [ + ("Urban Tree Inventory Field Guide", "A practical guide to measuring, identifying, and recording street trees.", "trees city field guide environment", "64 pages · revision 2.1 · diameter worksheet on page 41."), + ("Community Workshop Facilitation Notes", "Reusable agendas and exercises for small public workshops.", "community workshop facilitation guide", "38 pages · six agenda templates · accessibility checklist on page 32."), + ("Beginner Map Reading Workbook", "Exercises covering scale, symbols, contour lines, and coordinates.", "maps navigation workbook education", "72 pages · 24 exercises · answer key begins on page 66."), + ("Open Photography Metadata Handbook", "A reference to common EXIF, IPTC, and rights fields.", "photography metadata exif handbook", "54 pages · field matrix on page 17 · version 1.4."), + ("Home Energy Audit Checklist", "Room-by-room observations for a non-invasive home energy review.", "energy home checklist sustainability", "22 pages · 87 checklist items · climate notes appendix."), + ("Small Archive Digitization Plan", "A staged plan for naming, scanning, metadata, storage, and QA.", "archive scanning digitization workflow", "46 pages · 12-week sample schedule · risk register on page 39."), + ("Rain Garden Planting Guide", "Site selection and plant lists for compact residential rain gardens.", "garden rain water plants guide", "58 pages · three planting zones · maintenance calendar on page 49."), + ("Accessible Event Planning Workbook", "Prompts for venue, communication, sensory, and mobility access.", "accessibility events workbook planning", "44 pages · 63 prompts · vendor questions on page 29."), + ("Volunteer Trail Survey Form", "Printable forms for recording trail surface and drainage conditions.", "hiking trail survey form", "18 pages · four field forms · condition codes on page 5."), + ("Local History Interview Toolkit", "Consent, recording, description, and preservation guidance.", "history interview oral archive", "66 pages · sample release on page 55 · metadata sheet on page 59."), + ("Public Data Cleaning Recipes", "Spreadsheet-first techniques for dates, categories, and missing values.", "data spreadsheet cleaning tutorial", "82 pages · 19 recipes · validation checklist on page 78."), + ("Neighborhood Bird Count Protocol", "A repeatable ten-minute observation protocol for volunteers.", "birds citizen science protocol", "26 pages · ten-minute count · weather codes on page 12."), + ("Creative Commons Licensing Primer", "A plain-language guide to the six standard CC licenses.", "copyright creative commons licensing", "34 pages · license comparison chart on page 16."), + ("Remote Study Group Playbook", "Roles, meeting formats, and reflection prompts for peer study.", "study remote learning playbook", "40 pages · four meeting formats · facilitator cards appendix."), + ("Museum Label Writing Guide", "Techniques for concise, accessible object labels and panels.", "museum writing accessibility guide", "52 pages · 75-word label exercise on page 21."), + ("Community Garden Crop Calendar", "A temperate-climate planting and harvest planning calendar.", "garden crops calendar planning", "30 pages · zone 6 reference · succession table on page 24."), + ], + }, + "Archives": { + "ext": "zip", "mime": "application/zip", "license": "Creative Commons collection", + "items": [ + ("Urban Sketching Practice Sheets", "Printable perspective, texture, and value exercises.", "art drawing worksheets archive", "28 files · 44.6 MB unpacked · includes PDF and PNG formats."), + ("Open Map Symbol Collection", "A compact set of SVG symbols for community mapping.", "maps icons svg open data", "146 files · SVG format · symbol index included."), + ("Birdsong Identification Samples", "Short labeled clips for common woodland and garden birds.", "birds audio samples nature", "32 files · 96 MB unpacked · WAV and metadata CSV."), + ("Public Domain Botanical Plates", "Scanned botanical plates cleaned for classroom use.", "plants botanical images education", "48 files · 212 MB unpacked · 300 dpi JPEG."), + ("Accessible Presentation Templates", "High-contrast slide layouts with reading-order notes.", "accessibility slides templates", "14 files · PPTX and ODP · font list included."), + ("Community Survey Starter Pack", "Editable questionnaires, consent language, and coding sheets.", "survey community research templates", "21 files · DOCX, ODT, and XLSX formats."), + ("Field Recording Metadata Forms", "Sheets for location, equipment, rights, and technical notes.", "audio field recording metadata", "17 files · printable and spreadsheet versions."), + ("Historic Map Georeference Samples", "Practice maps with control points and completed examples.", "maps history gis tutorial", "26 files · GeoTIFF and CSV · five completed examples."), + ("Beginner Python Data Exercises", "Small CSV datasets and notebooks for introductory analysis.", "python data education notebooks", "39 files · 18 exercises · solutions in separate folder."), + ("Neighborhood Photo Walk Prompts", "Prompt cards and release forms for a group photo walk.", "photography community prompts", "24 files · 18 prompt cards · bilingual release form."), + ("Garden Planning Grid Pack", "Printable bed grids in metric and imperial dimensions.", "garden planning printable grids", "36 files · PDF and SVG · six page sizes."), + ("Oral History Audio Test Files", "Synthetic calibration clips for a digitization workflow.", "audio archive calibration testing", "12 files · WAV format · tones and spoken test counts."), + ("Open Icon Accessibility Set", "Simple interface icons with names and usage notes.", "icons accessibility interface svg", "180 files · SVG and PNG · 24 px and 48 px sizes."), + ("Classroom Weather Data Pack", "One year of fictional station readings for data lessons.", "weather data classroom csv", "13 files · 365 daily rows · data dictionary included."), + ("Local Newsletter Layout Kit", "Editable two- and four-page community newsletter layouts.", "newsletter design templates community", "18 files · Scribus and PDF · three color variants."), + ("Trail Sign Vector Collection", "Editable wayfinding and safety sign illustrations.", "trail hiking signs vector", "64 files · SVG and PDF · monochrome and color variants."), + ], + }, +} + +THUMBNAILS = [ + "images/london.jpg", + "images/new-york.jpg", + "images/denali.jpg", + "images/library-reading-room.jpg", + "images/atlantic-boardwalk.jpg", + "images/garden-pollinators.jpg", + "images/alpine-lake-mist.jpg", + "images/ceramic-workbench.jpg", + "images/red-bicycle-brick-wall.jpg", + "images/winter-pines-snow.jpg", + "images/notebook-fountain-pen.jpg", + "images/harbor-boats-fog.jpg", + "images/wildflower-trail.jpg", + "images/classic-camera.jpg", + "images/rainy-window-lights.jpg", + "images/map-compass.jpg", +] + + +def seed_database(): + """Seed the public catalog once; an existing catalog is a complete no-op.""" + if FileItem.query.filter_by(public=True).count() > 0: + return + file_id = 1 + for category, spec in CATALOG.items(): + for index, (title, description, tags, detail) in enumerate(spec["items"]): + filename = f"{title}.{spec['ext']}" + thumbnail = "" + if category == "Images": + thumbnail = THUMBNAILS[index] + item = FileItem( + id=file_id, + filename=filename, + slug=f"{slugify(filename)}-{file_id}", + category=category, + extension=spec["ext"], + mime_type=spec["mime"], + size_bytes=(index + 3) * (1_380_000 if category in {"Music", "Video"} else 438_000), + description=description, + tags=tags, + license_name=spec["license"], + uploader_name=UPLOADERS[(file_id + index) % len(UPLOADERS)], + public=True, + featured=index in {1, 6}, + thumbnail=thumbnail, + preview_text=detail, + uploaded_at=SEED_TIME - timedelta(days=(index * 5 + file_id % 9)), + modified_at=SEED_TIME - timedelta(days=(index * 3 + file_id % 7)), + download_count=670 + ((file_id * 733) % 48_000), + rating=round(3.8 + ((file_id * 7) % 13) / 10, 1), + ) + db.session.add(item) + file_id += 1 + db.session.commit() + + +BENCHMARK_USERS = [ + ("alice.j@test.com", "Alice Johnson", "Seattle, Washington", "Photographer and community archive volunteer."), + ("bob.c@test.com", "Bob Chen", "Austin, Texas", "Maps, field recordings, and open-data projects."), + ("carol.d@test.com", "Carol Davis", "Chicago, Illinois", "Teacher building accessible classroom resources."), + ("david.k@test.com", "David Kim", "Atlanta, Georgia", "Designer and neighborhood garden coordinator."), +] + +PRIVATE_FILES = [ + ("Quarterly retreat budget.xlsx", "Documents", 184_320, "Working budget with venue and travel estimates."), + ("Seattle photo selects.zip", "Archives", 8_340_000, "Shortlisted city images for the fall exhibit."), + ("Field recording notes.docx", "Documents", 94_208, "Location and microphone notes from the coast session."), + ("Reading list autumn.txt", "Documents", 12_288, "Personal reading list and library holds."), + ("Community map draft.pdf", "Documents", 2_430_000, "Draft map for the public workshop."), + ("Old outline.txt", "Documents", 7_168, "Superseded outline retained in Trash."), +] + + +def seed_benchmark_users(): + """Seed four benchmark accounts and their state once; reruns are no-ops.""" + if User.query.filter_by(email="alice.j@test.com").first(): + return + public_files = FileItem.query.filter_by(public=True).order_by(FileItem.id).all() + private_id = (public_files[-1].id if public_files else 0) + 1 + for user_index, (email, name, location, bio) in enumerate(BENCHMARK_USERS): + user = User( + id=user_index + 1, + email=email, + display_name=name, + location=location, + bio=bio, + plan="Premium" if user_index == 3 else "Free", + storage_limit_mb=102400 if user_index == 3 else 15360, + joined_at=SEED_TIME - timedelta(days=800 - user_index * 73), + ) + user.set_password(BENCHMARK_PASSWORD) + db.session.add(user) + db.session.flush() + folders = [] + for folder_index, folder_name in enumerate(("Work", "Photos", "Shared Projects", "Music")): + folder = Folder( + user_id=user.id, + name=folder_name, + created_at=SEED_TIME - timedelta(days=150 - folder_index * 9 - user_index), + ) + db.session.add(folder) + db.session.flush() + folders.append(folder) + owned = [] + for item_index, (base_name, category, size, description) in enumerate(PRIVATE_FILES): + prefix = ("Alice", "Bob", "Carol", "David")[user_index] + filename = f"{prefix} {base_name}" + extension = filename.rsplit(".", 1)[1].lower() + item = FileItem( + id=private_id, + owner_id=user.id, + folder_id=folders[item_index % len(folders)].id if item_index < 5 else None, + filename=filename, + slug=f"{slugify(filename)}-{private_id}", + category=category, + extension=extension, + mime_type="application/octet-stream", + size_bytes=size + user_index * 4096, + description=description, + tags="private personal benchmark", + license_name="Private", + uploader_name=name, + public=False, + deleted=item_index == 5, + preview_text=f"Private file owned by {name}. {description}", + uploaded_at=SEED_TIME - timedelta(days=40 - item_index * 3 + user_index), + modified_at=SEED_TIME - timedelta(days=10 - item_index + user_index), + download_count=0, + rating=0, + ) + db.session.add(item) + owned.append(item) + private_id += 1 + for offset in range(4): + public = public_files[(user_index * 19 + offset * 9) % len(public_files)] + db.session.add(Favorite(user_id=user.id, file_id=public.id, created_at=SEED_TIME - timedelta(days=12 + offset))) + for offset in range(3): + public = public_files[(user_index * 23 + offset * 11 + 5) % len(public_files)] + db.session.add(SavedFile(user_id=user.id, file_id=public.id, created_at=SEED_TIME - timedelta(days=20 + offset))) + for offset in range(2): + public = public_files[(user_index * 13 + offset * 17 + 2) % len(public_files)] + db.session.add(DownloadLog(user_id=user.id, file_id=public.id, downloaded_at=SEED_TIME - timedelta(days=offset + 2))) + db.session.add(SharedLink( + user_id=user.id, + file_id=owned[0].id, + token=f"demo-{user_index + 1}-retreat-budget", + permission="download" if user_index % 2 else "view", + label="Planning group", + created_at=SEED_TIME - timedelta(days=5 + user_index), + )) + if user_index == 3: + db.session.add(PlanOrder( + user_id=user.id, + plan_name="Premium", + billing_period="annual", + amount=77.88, + card_last4="4242", + status="Active", + created_at=SEED_TIME - timedelta(days=44), + )) + for index in range(12): + db.session.add(Comment( + user_id=(index % 4) + 1, + file_id=public_files[(index * 7 + 3) % len(public_files)].id, + body=( + "The detail notes and file metadata were especially useful for our workshop." + if index % 2 == 0 else + "Preview opened correctly, and the format information matched the download." + ), + created_at=SEED_TIME - timedelta(days=30 - index), + )) + db.session.commit() + + +if __name__ == "__main__": + with _app.app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + print(f"seeded {FileItem.query.count()} files and {User.query.count()} users") diff --git a/sites/4shared/static/css/.gitkeep b/sites/4shared/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/4shared/static/css/main.css b/sites/4shared/static/css/main.css new file mode 100644 index 00000000..fa0e4d22 --- /dev/null +++ b/sites/4shared/static/css/main.css @@ -0,0 +1,368 @@ +:root { + --blue: #0797f6; + --blue-dark: #087fce; + /* Action / focus colour. The brand blue reaches only 3.10:1 against white, so + interactive fills and link text use --action; --blue stays decorative. */ + --action: #0578c4; + --action-dark: #046ab0; + --sky: #7fc8f6; + --ink: #3d4652; + --muted: #6f757b; + --line: #e1e4e7; + --wash: #f4f5f6; + --footer: #30383f; + --shadow: 0 4px 14px rgba(38, 53, 66, .14); +} + +* { box-sizing: border-box; } +html { font-family: Arial, Helvetica, sans-serif; color: var(--ink); background: #fff; } +body { margin: 0; line-height: 1.42; font-size: 14px; } +a { color: var(--action); text-decoration: none; } +a:hover { text-decoration: underline; } +button, input, select, textarea { font: inherit; } +.container { width: min(1170px, calc(100% - 40px)); margin-inline: auto; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +.topbar { height: 82px; background: #fff; display: flex; align-items: center; position: relative; z-index: 20; } +.topbar-inner { display: flex; align-items: center; gap: 28px; } +.home-topbar .topbar-inner { justify-content: center; } +.home-topbar .header-actions { position: absolute; right: max(20px, calc((100vw - 1170px) / 2)); } +.brand { display: inline-flex; align-items: baseline; color: #058ff0; font-size: 28px; font-weight: 700; letter-spacing: -1.7px; line-height: 1; } +.brand:hover { text-decoration: none; } +.brand-four { font-size: 36px; letter-spacing: -3px; } +.header-search { flex: 1; display: flex; max-width: 790px; border: 1px solid #d9dde0; border-radius: 28px; overflow: hidden; box-shadow: 0 3px 8px rgba(0,0,0,.10); } +.header-search input { flex: 1; min-width: 0; border: 0; outline: 0; padding: 14px 20px; color: #4d555d; } +.header-search button { border: 0; background: #fff; color: #555d64; font-size: 25px; padding: 4px 18px 8px; cursor: pointer; } +.header-actions { margin-left: auto; display: flex; align-items: center; gap: 22px; white-space: nowrap; } +.signin-pill { border: 2px solid #9ed6fb; color: var(--action); border-radius: 24px; padding: 8px 17px; font-weight: 700; } +.signin-pill:hover { text-decoration: none; background: #f3faff; } +.user-link { color: #474d52; font-weight: 700; } +.user-dot { color: #707478; font-size: 24px; vertical-align: middle; margin-right: 7px; } +.bell-link { color: #48515a; font-size: 20px; } + +.flash { padding: 12px 16px; border-radius: 5px; margin: 10px 0; } +.flash.success { background: #e8f8f1; color: #146e49; } +.flash.error { background: #fff0f0; color: #a63d3d; } +.flash.message { background: #edf7ff; color: #24638e; } + +.hero { position: relative; overflow: hidden; text-align: center; } +.real-home { background: linear-gradient(#fff 3%, #edf9ff 82%, #e3f4fd); min-height: 790px; padding: 40px 0 70px; } +.hero-inner { position: relative; z-index: 2; } +.hero h1 { color: #050505; font-size: 34px; line-height: 1.15; margin: 0 0 14px; letter-spacing: -1px; } +.hero p { color: var(--muted); font-size: 19px; font-weight: 700; margin: 0 0 40px; } +.cloud { position: absolute; z-index: 1; width: 520px; height: 170px; border-radius: 100px; background: rgba(255,255,255,.88); filter: drop-shadow(0 -10px 0 rgba(255,255,255,.25)); bottom: 160px; } +.cloud::before, .cloud::after { content: ""; position: absolute; border-radius: 50%; background: inherit; } +.cloud::before { width: 230px; height: 230px; left: 60px; top: -95px; } +.cloud::after { width: 300px; height: 300px; right: 15px; top: -125px; } +.cloud-a { left: -150px; } +.cloud-b { right: -150px; transform: scale(.9); } +.hero-search { display: flex; width: min(750px, 100%); margin: 0 auto; border: 2px solid #a4d8f8; border-radius: 31px; background: white; overflow: hidden; box-shadow: 0 2px 5px rgba(0,0,0,.14); } +.hero-search input { flex: 1; min-width: 0; border: 0; outline: 0; padding: 17px 24px; font-size: 16px; } +.hero-search button { width: 58px; height: 54px; margin: 3px; border: 0; border-radius: 50%; background: var(--action); color: #fff; font-size: 27px; cursor: pointer; } +.or-separator { margin: 15px 0; color: #333; font-weight: 700; } +.drop-zone { width: min(750px, 100%); min-height: 225px; margin: 0 auto; border: 2px dashed var(--blue); border-radius: 24px; display: flex; align-items: center; justify-content: center; flex-direction: column; color: var(--action); background: rgba(237,249,255,.55); } +.drop-zone:hover { text-decoration: none; background: rgba(255,255,255,.75); } +.upload-artwork { width: 170px; height: 70px; object-fit: contain; margin-bottom: 10px; } +.upload-button { display: inline-flex; justify-content: center; min-width: 235px; padding: 13px 25px; margin-top: 18px; border-radius: 26px; background: var(--action); color: #fff; font-weight: 700; } +.hero-categories { display: grid; grid-template-columns: repeat(6, 1fr); gap: 35px; margin: 75px auto 0; max-width: 1120px; } +.hero-categories a { height: 152px; border: 1px solid #d9dde0; border-radius: 22px; background: rgba(255,255,255,.94); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; color: #3f464c; box-shadow: 0 1px 3px rgba(0,0,0,.06); } +.hero-categories a:hover { text-decoration: none; border-color: #9ed6fb; } +.hero-categories b { font-size: 42px; line-height: 1; color: #ff902c; font-weight: 400; } +.hero-categories a:nth-child(2) b { color: #f43f57; } +.hero-categories a:nth-child(3) b { color: #54c735; } +.hero-categories a:nth-child(4) b { color: #775cf5; } +.hero-categories a:nth-child(5) b { color: #728b9d; } +.hero-categories a:nth-child(6) b { color: #aab1b7; } + +.app-band { text-align: center; padding: 65px 0 80px; background: #fff; } +.app-band h2 { margin: 0; font-size: 31px; color: #090909; } +.app-band p { color: var(--muted); font-size: 17px; font-weight: 700; } +.qr-motif { position: relative; width: 160px; height: 160px; margin: 20px auto 0; } +.qr-motif img { position: absolute; display: block; } +.qr-frame { inset: 0; width: 160px; height: 160px; } +.qr-code { inset: 16px; width: 128px; height: 128px; } +.app-badges { display: flex; justify-content: center; gap: 16px; margin-top: 22px; } +.store-badge { min-width: 215px; background: #22282d; color: white; border-radius: 28px; padding: 10px 22px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; line-height: 1.1; text-align: left; } +.store-badge > img { width: 25px; height: 25px; object-fit: contain; } +.store-badge > span { display: flex; flex-direction: column; } +.store-badge small { font-size: 9px; } +.store-badge strong { font-size: 15px; } + +.search-page { padding: 0 0 70px; min-height: 65vh; } +.search-tabs { display: flex; justify-content: center; gap: 14px; flex-wrap: wrap; padding: 0 0 30px; } +.search-tabs a { border: 1px solid #d8dce0; border-radius: 22px; color: #454d55; padding: 9px 22px; font-weight: 700; } +.search-tabs a:hover { text-decoration: none; border-color: #a5abb0; } +.search-tabs a.active { color: #fff; background: #747474; border-color: #747474; } +.result-toolbar { display: flex; justify-content: space-between; align-items: center; color: var(--muted); margin: 0 14px 20px; } +.filters { display: flex; align-items: center; gap: 8px; } +.filters select { border: 0; background: #f4f5f6; color: #5f666c; padding: 7px 10px; border-radius: 15px; } +.filters button { border: 0; background: none; font-size: 21px; color: var(--muted); cursor: pointer; } +.result-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 28px 30px; } +.result-row { min-height: 154px; border: 1px solid #e0e2e4; border-radius: 24px; overflow: hidden; display: grid; grid-template-columns: 158px 1fr 48px; background: #fff; position: relative; } +.result-visual { display: flex; align-items: center; justify-content: center; background: #1e7ca4; min-width: 0; overflow: hidden; } +.result-visual img { width: 100%; height: 100%; object-fit: cover; } +.media-glyph { color: #eaf7ff; font-size: 67px; line-height: 1; text-shadow: 0 8px 0 rgba(0,0,0,.04); } +.type-documents, .type-archives { background: #f6f5ef; } +.type-documents .media-glyph, .type-archives .media-glyph { color: #5d6f7c; font-size: 28px; text-shadow: none; } +.result-info { padding: 20px 10px 18px 20px; min-width: 0; } +.result-info h3 { margin: 0 0 3px; font-size: 18px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.result-info h3 a { color: #414a53; } +.result-info p { margin: 0; color: #686f75; } +.result-owner { margin-top: 58px; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.result-add { display: grid; place-items: start center; padding-top: 12px; background: #f3f4f5; color: #46505a; border-radius: 0 0 0 28px; font-size: 28px; } +.result-add:hover { text-decoration: none; background: #ebedef; } + +.page, .file-page { padding: 25px 0 70px; min-height: 62vh; } +.page-title { margin: 0 0 5px; font-size: 28px; color: #222; } +.page-lede { color: var(--muted); margin: 0 0 25px; } +.section { padding: 45px 0; } +.section.alt { background: #f7f8f9; } +.section-head { display: flex; align-items: end; justify-content: space-between; margin-bottom: 20px; } +.section-head h2 { margin: 0; color: #272d32; } +.section-head p { margin: 5px 0 0; color: var(--muted); } +.file-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; } +.file-card { border: 1px solid #e0e3e5; border-radius: 20px; background: #fff; overflow: hidden; min-width: 0; position: relative; } +.file-thumb { height: 126px; display: flex; align-items: center; justify-content: center; background: #1e7ca4; overflow: hidden; } +.file-thumb img { width: 100%; height: 100%; object-fit: cover; } +/* Default to the action colour: on a white .panel (/preview/, /shared/) + the old white-on-white rendered the extension label completely invisible. */ +.file-icon { display: grid; place-items: center; width: 58px; height: 70px; border: 2px solid currentColor; border-radius: 8px; color: var(--action); text-transform: uppercase; font-size: 11px; font-weight: 700; } +.media-preview .file-icon, .file-thumb .file-icon, .result-visual .file-icon { border-color: rgba(255,255,255,.85); color: #fff; } +.file-card-body { min-width: 0; padding: 14px; } +.file-title { display: block; color: #434b53; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-meta { display: flex; gap: 7px; color: var(--muted); font-size: 11px; margin-top: 7px; } +.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 11px; } +.chip, .tag-line span { border: 1px dotted #8d949a; border-radius: 15px; padding: 3px 8px; color: var(--muted); font-size: 10px; } +.rating { color: #f3a91b; } +.icon-button { border: 0; background: none; color: #6d757f; font-size: 19px; cursor: pointer; } +.icon-button.active { color: #ca4462; } + +.media-preview { min-height: 390px; border-radius: 24px; background: #1e7ca4; display: flex; align-items: center; justify-content: center; overflow: hidden; } +.media-preview img { width: 100%; max-height: 610px; object-fit: contain; background: #edf1f4; } +.media-preview > .file-icon { transform: scale(1.6); } +.waveform { width: 94%; height: 260px; display: flex; align-items: center; justify-content: center; gap: 8px; position: relative; } +.waveform i { width: 5px; height: 80px; background: #046da0; opacity: .9; } +.waveform i:nth-child(3n) { height: 150px; } +.waveform i:nth-child(4n) { height: 115px; } +.waveform i:nth-child(5n) { height: 185px; } +.waveform span { position: absolute; bottom: 8px; color: #fff; font-size: 46px; } +.file-heading { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 26px 0 12px; } +.file-heading small { color: #41484e; font-weight: 700; } +.detail-title { color: #313940; font-size: 27px; margin: 1px 0 6px; } +.detail-meta { color: var(--muted); margin: 0; } +.tag-line { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.heart-button { width: 54px; height: 54px; border: 0; border-radius: 50%; background: var(--action); color: #fff; font-size: 31px; cursor: pointer; } +.detail-actions { display: flex; align-items: center; gap: 10px; padding: 18px 0 28px; border-bottom: 1px solid #e2e4e6; } +.detail-actions form { margin: 0; } +.primary-button, .secondary-button, .danger-button { display: inline-flex; align-items: center; justify-content: center; border-radius: 23px; padding: 10px 18px; font-weight: 700; cursor: pointer; } +.primary-button { background: var(--action); color: #fff; border: 1px solid var(--action); } +.primary-button:hover { background: var(--action-dark); text-decoration: none; } +.secondary-button { background: #fff; color: #4a535b; border: 1px solid #d6dadd; } +.secondary-button:hover { border-color: var(--blue); color: var(--blue); text-decoration: none; } +.danger-button { color: #bd3f3f; background: #fff; border: 1px solid #e4bbbb; } +.wide { width: 100%; } +.detail-copy { padding: 5px 0 20px; } +.detail-copy h2, .comments h2 { font-size: 18px; color: #313940; } +.preview-note { background: #f5f7f8; padding: 14px 18px; border-radius: 10px; color: #56616a; } +.comments { padding: 0 0 28px; border-bottom: 1px solid #e1e3e5; } +.comments h2 small { color: var(--muted); } +.comment-form { display: flex; gap: 12px; align-items: center; } +.comment-form textarea { flex: 1; min-height: 44px; height: 44px; border: 0; border-radius: 24px; background: #f0f1f2; padding: 12px 18px; resize: vertical; } +.comment-avatar { color: #707477; font-size: 28px; } +.comment { padding: 14px 0; border-top: 1px solid #eee; } +.comment strong { display: block; } +.comment small { color: var(--muted); } +.related-head { margin-top: 42px; } +.related-grid { grid-template-columns: repeat(2, 1fr); } +.related-grid .file-card { display: grid; grid-template-columns: 155px 1fr; min-height: 145px; } +.related-grid .file-thumb { height: 100%; } + +.panel { border: 1px solid #e0e3e5; border-radius: 18px; background: #fff; padding: 24px; } +.auth-shell { max-width: 500px; margin: 45px auto; } +.auth-shell .panel { border: 0; box-shadow: 0 12px 35px rgba(0,0,0,.20); border-radius: 22px; } +.auth-shell h1 { text-align: center; color: #222; } +.center { text-align: center; } +.form-group { margin-bottom: 16px; } +.form-group label { display: block; margin: 0 0 6px; font-weight: 700; color: #41484f; } +.form-group input, .form-group select, .form-group textarea { width: 100%; border: 1px solid #ccd1d5; border-radius: 4px; padding: 10px 11px; background: #fff; } +.form-group textarea { min-height: 100px; resize: vertical; } +.form-hint, .auth-foot { color: var(--muted); font-size: 12px; } +.auth-foot { text-align: center; margin-top: 18px; } + +.account-layout { display: grid; grid-template-columns: 250px 1fr; gap: 24px; } +.account-nav { padding: 8px 0; min-height: 590px; border-right: 1px solid #e4e6e8; } +.account-nav a { display: block; color: #555e65; padding: 12px 20px; margin: 3px 0; border-radius: 24px 0 0 24px; font-weight: 700; } +.account-nav-logout { margin: 3px 0; } +.account-nav-logout button { display: block; width: 100%; text-align: left; border: 0; background: none; font: inherit; color: #555e65; padding: 12px 20px; border-radius: 24px 0 0 24px; font-weight: 700; cursor: pointer; } +.account-nav-logout button:hover { background: #e1f2fd; color: #066eb3; } +.account-nav a:hover, .account-nav a.current { background: #e1f2fd; color: #066eb3; text-decoration: none; } +.account-nav .account-add { display: inline-block; margin: 0 0 14px; padding: 10px 23px; border-radius: 24px; background: var(--action); color: #fff; box-shadow: 0 4px 10px rgba(7,151,246,.28); } +.account-nav .account-add:hover, .account-nav .account-add.current { background: var(--action-dark); color: #fff; } +.account-nav-separator { display: block; height: 1px; margin: 14px 20px; background: #e5e7e9; } +.account-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.stat-card { border: 1px solid #e0e3e5; border-radius: 18px; padding: 20px; } +.stat-card strong { display: block; font-size: 24px; } +.stat-card span { color: var(--muted); } +.storage-bar { height: 7px; border-radius: 5px; background: #e6e9eb; overflow: hidden; } +.storage-bar i { display: block; height: 100%; background: var(--blue); } +.file-manager-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 20px; } +.inline-form { display: flex; gap: 8px; } +.inline-form input, .inline-form select { border: 1px solid #ccd2d7; border-radius: 20px; padding: 9px 13px; } +.folder-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; } +.folder-card { padding: 16px; border-radius: 18px; background: #e2f2fc; color: #1370ac; } +.folder-card:hover { text-decoration: none; background: #d5edfc; } +.manager-table { width: 100%; border-collapse: collapse; } +.manager-table th { text-align: left; color: var(--muted); padding: 11px; border-bottom: 1px solid #e4e6e8; } +.manager-table td { padding: 13px 11px; border-bottom: 1px solid #e9ebed; vertical-align: middle; } +.manager-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.manager-actions form { display: inline-flex; gap: 4px; } +.manager-actions button, .manager-actions a { border: 1px solid #d5dade; background: #fff; border-radius: 16px; color: #59636b; padding: 5px 9px; font-size: 11px; } +.manager-actions input, .manager-actions select { border: 1px solid #d5dade; border-radius: 14px; padding: 4px 7px; } +.breadcrumb { color: var(--muted); margin-bottom: 17px; font-size: 18px; } +.empty { padding: 46px; text-align: center; color: var(--muted); border: 1px dashed #b9bfc3; border-radius: 14px; } +.notice { background: #fff8df; border: 1px solid #efdda3; padding: 13px; border-radius: 7px; color: #776320; } + +.plan-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 420px)); justify-content: center; gap: 22px; } +.plan { border: 1px solid #dfe3e6; border-radius: 18px; padding: 28px; position: relative; background: #fff; } +.plan.recommended { border: 2px solid var(--blue); box-shadow: var(--shadow); } +.plan-badge { position: absolute; top: -11px; right: 20px; color: #fff; background: #b05f21; border-radius: 13px; padding: 3px 10px; font-size: 10px; } +.plan-price { margin: 14px 0; font-size: 34px; } +.plan-price small { font-size: 12px; color: var(--muted); } +.plan ul { min-height: 150px; padding-left: 20px; color: #59636c; } +.premium-hero { padding: 55px 0 75px; text-align: center; background: radial-gradient(circle at 18% 14%, #eef9ff 0 10%, transparent 28%), radial-gradient(circle at 82% 12%, #eef9ff 0 10%, transparent 28%), #fff; } +.premium-hero > .container > h1 { color: #111; font-size: 32px; margin: 0 0 30px; } +.billing-toggle { display: inline-block; margin-bottom: 32px; } +.billing-toggle span { display: block; color: #b05f21; font-weight: 700; font-size: 11px; } +.billing-toggle p { margin: 7px 0 0; color: var(--muted); } +.billing-toggle b { color: var(--blue); font-size: 24px; } +.four-plans { grid-template-columns: repeat(4, 1fr); max-width: 1100px; margin: 0 auto; gap: 14px; text-align: left; } +.four-plans .plan { padding: 24px; border-radius: 17px; min-width: 0; } +.four-plans .plan h2 { color: var(--action); font-size: 17px; } +.four-plans .plan h4 { font-size: 10px; margin-top: 28px; } +.four-plans .plan ul { min-height: 160px; font-size: 12px; line-height: 1.8; } +.plan-current { display: block; background: #f0f1f2; color: #686c6f; border-radius: 22px; padding: 10px; text-align: center; font-weight: 700; } +.secure-copy { color: var(--muted); font-size: 11px; } +.feature-compare { max-width: 900px; margin: 80px auto 0; } +.feature-compare h2, .premium-faq h2 { color: #111; font-size: 26px; } +.compare-grid { display: grid; grid-template-columns: 1.5fr 1fr 1fr; border: 1px solid #e4e7e9; border-radius: 10px; overflow: hidden; text-align: left; } +.compare-grid > * { padding: 15px 20px; border-bottom: 1px solid #e7e9eb; } +.compare-grid > *:nth-child(3n) { background: #edf8ff; text-align: center; } +.compare-grid > *:nth-child(3n + 2) { text-align: center; } +.premium-faq { max-width: 650px; margin: 70px auto 0; text-align: left; } +.premium-faq h2 { text-align: center; } +.premium-faq details { border-bottom: 1px solid #e6e8ea; padding: 16px; } +.premium-faq summary { font-weight: 700; cursor: pointer; } + +.content-page { max-width: 1020px; } +.eyebrow, .story-tag { color: var(--action); font-size: 11px; font-weight: 800; letter-spacing: .12em; } +.story-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 30px; } +.story-grid .panel h2 { font-size: 20px; line-height: 1.3; } +.story-grid time, .story-grid .story-tag { color: var(--muted); font-size: 11px; } +.converter-result { display: flex; justify-content: space-between; gap: 16px; margin: 20px 0; padding: 14px 16px; border: 1px solid #9bd5b9; border-radius: 8px; background: #ebfaf2; color: #176342; } +.converter-result span { font-size: 12px; } + +.site-footer { background: var(--footer); color: #fff; padding: 48px 0 28px; } +.footer-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 52px; } +.footer-grid h4 { margin: 0 0 13px; font-size: 13px; } +.footer-grid a { display: block; color: #a4abb0; margin: 8px 0; font-size: 12px; } +.footer-bottom { display: flex; align-items: end; justify-content: space-between; gap: 25px; margin-top: 75px; } +.footer-brand { color: #fff; } +.footer-brand .brand-four { color: var(--blue); } +.footer-pills { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } +.footer-pills span { border: 1px solid #4e5860; border-radius: 22px; padding: 8px 15px; font-size: 11px; display: inline-flex; align-items: center; gap: 6px; } +.footer-pills img { width: 14px; height: 14px; object-fit: contain; } + +@media (max-width: 900px) { + .header-search { max-width: none; } + .user-link { max-width: 150px; overflow: hidden; text-overflow: ellipsis; } + .hero-categories { grid-template-columns: repeat(3, 1fr); } + .result-list { grid-template-columns: 1fr; } + .file-grid { grid-template-columns: repeat(2, 1fr); } + .account-layout { grid-template-columns: 190px 1fr; } + .account-card-grid { grid-template-columns: 1fr; } + .story-grid { grid-template-columns: 1fr; } + .folder-grid { grid-template-columns: repeat(2, 1fr); } + .footer-grid { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 600px) { + .container { width: min(100% - 24px, 1170px); } + .topbar { height: 62px; } + .brand { font-size: 21px; } + .brand-four { font-size: 28px; } + .home-topbar .header-actions { right: 12px; } + .signin-pill { padding: 5px 10px; font-size: 11px; } + .header-search { order: 3; flex-basis: 100%; box-shadow: none; } + .topbar:not(.home-topbar) { height: auto; padding: 10px 0; } + .topbar:not(.home-topbar) .topbar-inner { flex-wrap: wrap; gap: 8px; } + .header-search input { padding: 10px 14px; } + .header-search button { font-size: 20px; padding: 2px 13px 5px; } + .user-link { max-width: 115px; font-size: 11px; } + .bell-link { display: none; } + .real-home { min-height: 680px; padding-top: 30px; } + .hero h1 { font-size: 28px; } + .hero p { font-size: 15px; margin-bottom: 25px; } + .hero-search input { padding: 12px 16px; } + .hero-search button { width: 44px; height: 42px; font-size: 20px; } + .drop-zone { min-height: 165px; border-radius: 17px; } + .upload-artwork { width: 130px; height: 54px; } + .upload-button { min-width: 170px; padding: 10px 18px; } + .hero-categories { gap: 10px; margin-top: 42px; } + .hero-categories a { height: 92px; border-radius: 14px; gap: 8px; font-size: 11px; } + .hero-categories b { font-size: 26px; } + .cloud { display: none; } + .app-band { padding: 45px 0; } + .app-band h2 { font-size: 25px; } + .app-band p { font-size: 13px; } + .qr-motif { width: 136px; height: 136px; } + .qr-frame { width: 136px; height: 136px; } + .qr-code { inset: 14px; width: 108px; height: 108px; } + .app-badges { flex-direction: column; align-items: center; } + .store-badge { min-width: 205px; } + .search-tabs { gap: 7px; padding-bottom: 18px; } + .search-tabs a { padding: 6px 11px; font-size: 11px; } + .result-toolbar { align-items: flex-start; gap: 10px; } + .filters select { max-width: 104px; } + .result-row { min-height: 112px; grid-template-columns: 105px 1fr 36px; border-radius: 16px; } + .result-info { padding: 13px 7px 10px 12px; } + .result-info h3 { display: -webkit-box; font-size: 14px; line-height: 1.25; white-space: normal; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + .result-owner { margin-top: 12px; font-size: 10px; } + .result-add { font-size: 21px; } + .media-glyph { font-size: 45px; } + .media-preview { min-height: 215px; border-radius: 15px; } + .waveform { height: 165px; gap: 3px; } + .waveform i { width: 3px; } + .waveform span { font-size: 30px; } + .file-heading { align-items: flex-start; } + .detail-title { font-size: 22px; } + .heart-button { width: 44px; height: 44px; } + .detail-actions { flex-wrap: wrap; } + .detail-actions .primary-button, .detail-actions .secondary-button { padding: 8px 12px; font-size: 11px; } + .comment-form { flex-wrap: wrap; } + .comment-form textarea { flex-basis: calc(100% - 50px); } + .file-grid, .related-grid { grid-template-columns: 1fr; } + .related-grid .file-card { grid-template-columns: 110px minmax(0, 1fr); } + .related-grid .file-title { display: -webkit-box; line-height: 1.3; white-space: normal; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } + .account-layout { grid-template-columns: 1fr; } + .account-nav { display: flex; flex-wrap: wrap; overflow-x: visible; min-height: 0; border: 0; gap: 5px; padding: 0 0 12px; } + .account-nav a, .account-nav-logout button { flex: 0 0 auto; width: auto; border-radius: 18px; background: #f3f4f5; padding: 8px 12px; font-size: 11px; } + .account-nav-separator { display: none; } + .panel { padding: 16px; border-radius: 14px; } + .file-manager-toolbar { display: block; } + .inline-form { margin-top: 10px; flex-wrap: wrap; } + .inline-form input, .inline-form select { min-width: 0; flex: 1 1 130px; } + .folder-grid { grid-template-columns: 1fr 1fr; } + /* Grid and flex children default to min-width:auto, so one wide row (a long + folder name, the new-folder input) pushed /my-files past the viewport at 320px. */ + .account-layout > *, .folder-card, .panel, .manager-table td { min-width: 0; } + .folder-card, .manager-table td, .breadcrumb { overflow-wrap: anywhere; } + .manager-table thead { display: none; } + .manager-table tr { display: block; border: 1px solid #e1e4e6; border-radius: 12px; padding: 8px; margin-bottom: 10px; } + .manager-table td { display: block; border: 0; padding: 5px; } + .manager-actions { display: grid; } + .plan-grid { grid-template-columns: 1fr; } + .four-plans { grid-template-columns: 1fr; } + .footer-grid { grid-template-columns: repeat(2, 1fr); gap: 25px; } + .footer-bottom { display: block; margin-top: 45px; } + .footer-pills { justify-content: flex-start; margin-top: 25px; } +} diff --git a/sites/4shared/static/icons/.gitkeep b/sites/4shared/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/4shared/static/icons/mark.svg b/sites/4shared/static/icons/mark.svg new file mode 100644 index 00000000..cf18fbd7 --- /dev/null +++ b/sites/4shared/static/icons/mark.svg @@ -0,0 +1 @@ + diff --git a/sites/4shared/static/js/.gitkeep b/sites/4shared/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/4shared/static/js/main.js b/sites/4shared/static/js/main.js new file mode 100644 index 00000000..46895be6 --- /dev/null +++ b/sites/4shared/static/js/main.js @@ -0,0 +1 @@ +document.addEventListener("DOMContentLoaded",()=>{const button=document.querySelector(".menu-button");const nav=document.querySelector(".category-nav");if(button&&nav){button.addEventListener("click",()=>{nav.hidden=!nav.hidden;button.setAttribute("aria-expanded",String(!nav.hidden));});}document.querySelectorAll("[data-confirm]").forEach(form=>form.addEventListener("submit",event=>{if(!window.confirm(form.dataset.confirm)){event.preventDefault();}}));}); diff --git a/sites/4shared/tasks.jsonl b/sites/4shared/tasks.jsonl new file mode 100644 index 00000000..a73295ef --- /dev/null +++ b/sites/4shared/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "4shared", "id": "4shared--0", "ques": "Search the Music catalog for nature ambience. Find the track whose detail page says it lasts 10:45, comes from a 48 kHz WAV source, and is normalized to -16 LUFS. Report the exact filename and uploader.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST search or browse the Music catalog on the mirror (a /search visit for its query, or /category/music). (2) The agent MUST open the detail page (/file/) of the track it reports; that page's notes MUST show a 10:45 duration, a 48 kHz WAV source and normalization to -16 LUFS. (3) The final answer MUST give the exact filename including its extension AND the uploader name exactly as shown on that detail page. (4) Final answer non-empty. FAIL if: the reported track's detail page was never opened; the filename or uploader is missing or differs from the page; the answer names a track whose notes do not carry all three facts; answer empty."} +{"web_name": "4shared", "id": "4shared--1", "ques": "Browse Images and find the city skyline photo whose detail page lists ISO 200 and an exposure of 1/80 second. Report its exact filename, uploader, and resolution.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST browse Images on the mirror (/category/images, or a search filtered to Images). (2) The agent MUST open the detail page of the photo it reports; that page's notes MUST show ISO 200 and an exposure of 1/80 s. (3) The final answer MUST give the exact filename with extension, the uploader, and the resolution (width x height) shown on that page. (4) Final answer non-empty. FAIL if: no image detail page was opened; a skyline photo without both ISO 200 and 1/80 s is reported; any of filename / uploader / resolution is missing or differs from the page."} +{"web_name": "4shared", "id": "4shared--2", "ques": "Search Books for classic fiction and inspect the candidates. Which exact filename has 12 chapters and editorial notes that begin after page 116? Also report its displayed file size.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST search Books for classic fiction on the mirror (a /search visit restricted to Books, or /category/books). (2) The agent MUST open the detail page of the book it reports; that page's notes MUST show 12 chapters and editorial notes that begin after page 116. (3) The final answer MUST give the exact filename with extension and the file size exactly as displayed on that page. (4) Final answer non-empty. FAIL if: the detail page was not opened; the answer is a first-result guess whose notes do not carry both facts; the size is missing or is not the displayed value."} +{"web_name": "4shared", "id": "4shared--3", "ques": "Search broadly for garden planning resources. Inspect the plausible results until you find the PDF whose detail page says it has three planting zones and a maintenance calendar on page 49. Report the filename, page count, and uploader.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST run an on-site search related to garden planning. (2) The agent MUST open the detail page of the PDF it reports; that page's notes MUST show three planting zones and a maintenance calendar on page 49. (3) The final answer MUST give the exact filename, the page count shown on that page, and the uploader. (4) Final answer non-empty. FAIL if: no detail page opened; a garden document without both facts is reported; filename, page count or uploader missing or wrong."} +{"web_name": "4shared", "id": "4shared--4", "ques": "Search broadly for accessibility and design resources. Inspect the plausible results until you find the app package whose detail page mentions a WCAG contrast preview. Report the exact filename, version, and license shown on the page.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST run an on-site search for accessibility / design resources. (2) The agent MUST open the detail page of the app package it reports; that page's notes MUST mention a WCAG contrast preview. (3) The final answer MUST give the exact filename, the version string shown, and a license as shown on that page (either the catalog license tag or the license named in the notes is acceptable). (4) Final answer non-empty. FAIL if: detail page not opened; a package without the WCAG note is reported; version or license missing or wrong."} +{"web_name": "4shared", "id": "4shared--5", "ques": "In Videos, compare Open Data Mapping Basics with City Cycling Route Planning by opening both detail pages. Which has the longer runtime, and what are both runtimes?", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open BOTH detail pages, Open Data Mapping Basics and City Cycling Route Planning (two distinct /file/ visits). (2) The final answer MUST state both runtimes exactly as shown on the two pages AND name which video is longer. (3) Final answer non-empty. FAIL if: only one detail page was opened; a runtime is missing or wrong; the shorter video is named as the longer one; answer empty."} +{"web_name": "4shared", "id": "4shared--6", "ques": "Find The Federalist Papers in Books, verify from its detail page that it contains all 85 essays and a searchable topic index, then use the Download button.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST search or browse Books on the mirror and open The Federalist Papers detail page; that page MUST show all 85 essays and a searchable topic index. (2) The agent MUST press the Download button on that page: the trajectory reaches /download/ for that file AFTER the detail page. (3) The after-state database MUST contain exactly one new downloads row for that file and its download counter incremented by one; every other table unchanged. FAIL if: the download page was never reached; the recorded download is for a different file (e.g. a Federalist Papers study/reference title); extra state changes."} +{"web_name": "4shared", "id": "4shared--7", "ques": "Log in with alice.j@test.com and password TestPass123!. Find ArchivePeek File Inspector, add it to Favorites, then open Favorites to confirm it is present.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST open the detail page of ArchivePeek File Inspector itself (the main application, not the Legacy Build / Checksums Add-on / Portable Notes / Recovery Plug-in / Test Fixtures variants) and add it to Favorites from there. (3) The agent MUST open /favorites afterwards. (4) The after-state database MUST show exactly one new favorites row, for alice on that file; every other table unchanged. FAIL if: wrong account; a variant file was favorited; /favorites not opened after favoriting; the favorite is missing from the database."} +{"web_name": "4shared", "id": "4shared--8", "ques": "Log in with alice.j@test.com and password TestPass123!. Find Rain Garden Planting Guide and save it to My 4shared, then open Saved files to confirm it is present.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST open the Rain Garden Planting Guide detail page and use its save-to-library action. (3) The agent MUST open Saved files (/saved) afterwards. (4) The after-state database MUST show exactly one new saved_files row, for alice on that file; every other table unchanged (a Favorite is NOT a save). FAIL if: wrong account; the file was favorited instead of saved; /saved not opened after saving; the saved row is missing."} +{"web_name": "4shared", "id": "4shared--9", "ques": "Log in with alice.j@test.com and password TestPass123!. Update the account location to Portland, Oregon and the bio to ‘Community archive volunteer and urban sketcher.’", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST open Account settings (/account/edit) and submit the form. (3) The after-state database MUST show alice's location equal to 'Portland, Oregon' and her bio equal to the quoted sentence exactly (same words and punctuation), with her display name unchanged; no other user or table changed. FAIL if: another account was edited; location or bio differ from the requested text; the display name was altered."} +{"web_name": "4shared", "id": "4shared--10", "ques": "Log in with bob.c@test.com and password TestPass123!. In My files, create a new root-level folder named Survey Exports.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as bob.c@test.com. (2) The agent MUST open My files (/my-files) and create the folder from there. (3) The after-state database MUST show exactly one new folder owned by bob at the root level (no parent folder) named exactly 'Survey Exports'; every other table unchanged. FAIL if: the folder is nested inside another folder; the name differs in wording, case or spacing; more than one folder was created; wrong account."} +{"web_name": "4shared", "id": "4shared--11", "ques": "Log in with carol.d@test.com and password TestPass123!. Upload a private file record named accessibility-session-notes.pdf, size 640 KB, with description ‘Notes and action items from the accessibility session.’ into the Work folder.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as carol.d@test.com. (2) The agent MUST open the upload form (/upload) and submit it. (3) The after-state database MUST show exactly one new file owned by carol, placed in carol's Work folder, named exactly accessibility-session-notes.pdf, 640 KB (655360 bytes), carrying the exact requested description, private (not public) and classified as Documents; every other table unchanged. FAIL if: the file was made public; wrong folder or size; description differs; more than one upload; wrong account."} +{"web_name": "4shared", "id": "4shared--12", "ques": "Log in with alice.j@test.com and password TestPass123!. Rename Alice Quarterly retreat budget.xlsx to 2027 Retreat Budget.xlsx and move it into the Shared Projects folder.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST open My files (/my-files), locate 'Alice Quarterly retreat budget.xlsx' in its folder, rename it and move it. (3) The after-state database MUST show that same file row now named exactly '2027 Retreat Budget.xlsx' and located in alice's 'Shared Projects' folder; no other file changed and no new file was created. FAIL if: only renamed or only moved; a different file touched; the file was re-uploaded instead of renamed; wrong account."} +{"web_name": "4shared", "id": "4shared--13", "ques": "Log in with david.k@test.com and password TestPass123!. Open Trash and restore David Old outline.txt.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as david.k@test.com. (2) The agent MUST open Trash / Recycle Bin (/trash) and press Restore on 'David Old outline.txt'. (3) The after-state database MUST show that file no longer marked deleted; every other row and table unchanged. FAIL if: a different account's outline was restored; the file remains in Trash; other rows changed."} +{"web_name": "4shared", "id": "4shared--14", "ques": "Log in with alice.j@test.com and password TestPass123!. Open Alice Field recording notes.docx and create a share link labeled Audio volunteers with Preview and download permission.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST open the share page (/file//share) of 'Alice Field recording notes.docx'. (3) The after-state database MUST show exactly one new share link owned by alice on that file with the label exactly 'Audio volunteers' and the 'Preview and download' (download) permission; every other table unchanged. FAIL if: the link is preview-only; it points at a different file; the label differs; wrong account."} +{"web_name": "4shared", "id": "4shared--15", "ques": "Log in with bob.c@test.com and password TestPass123!. Find Beginner Map Reading Workbook and post the comment ‘The coordinate exercises are ideal for our Saturday workshop.’", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as bob.c@test.com. (2) The agent MUST open the Beginner Map Reading Workbook detail page and post the comment from its comment form. (3) The after-state database MUST show exactly one new comment by bob on that file whose body is exactly the quoted sentence; every other table unchanged. FAIL if: the comment text differs; it was posted on another file; it was posted as another account."} +{"web_name": "4shared", "id": "4shared--16", "ques": "Log in with bob.c@test.com and password TestPass123!. Upgrade to the annual Premium 100 GB plan using cardholder Bob Chen and demo card 4242 4242 4242 4242. Confirm the resulting plan name and storage allowance shown on My 4shared.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as bob.c@test.com. (2) The agent MUST open the Premium page, choose the annual Premium 100 GB plan, complete the checkout form (/premium/checkout with cardholder Bob Chen and the demo card) and then open My 4shared (/account) AFTER the checkout. (3) The after-state database MUST show exactly one new plan order for bob (Premium 100 GB, annual, $77.88, card ending 4242) and bob's account plan set to Premium with a 100 GB allowance; every other table and user unchanged. (4) The final answer MUST report the plan name and the 100 GB storage allowance as shown on the account page. FAIL if: the 500 GB or 1 TB plan was bought; the account page was not opened after checkout; the answer omits the plan or the allowance."} +{"web_name": "4shared", "id": "4shared--17", "ques": "Log in with carol.d@test.com and password TestPass123!. Create a root folder named Workshop Handouts, upload a private 384 KB file named spring-workshop-outline.pdf into it with description ‘Draft outline for the spring neighborhood workshop.’, rename it final-spring-workshop-outline.pdf, then create a preview-only share link labeled Planning committee.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as carol.d@test.com. (2) The agent MUST perform all four actions in order: create the root folder 'Workshop Handouts' from My files (/my-files); upload the private 384 KB file spring-workshop-outline.pdf into that folder with the exact description (/upload); rename that file to final-spring-workshop-outline.pdf; then create a preview-only share link labeled 'Planning committee' on it (/file//share). (3) The after-state database MUST show exactly one new folder, exactly one new file (final name, in that folder, 393216 bytes, private, Documents, exact description) and exactly one new share link (preview-only permission, that label, on that file); every other table and pre-existing row unchanged. FAIL if: any step is missing; the file is public or in the wrong folder; the link allows download; the label differs; wrong account."} +{"web_name": "4shared", "id": "4shared--18", "ques": "Compare Pride and Prejudice, Anne of Green Gables, and Twenty Thousand Leagues Under the Seas by opening each book’s detail page. Report which has the most pages, its exact page count, and how many chapters it contains; then log in with david.k@test.com and password TestPass123! and save that book to My 4shared.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open all THREE book detail pages: Pride and Prejudice, Anne of Green Gables and Twenty Thousand Leagues Under the Seas (three distinct /file/ visits). (2) The final answer MUST name the book with the most pages, its exact page count and its chapter count as shown on that book's page. (3) The agent MUST log in through /login as david.k@test.com and save that same book to My 4shared. (4) The after-state database MUST show exactly one new saved_files row, for david on that book; every other table unchanged. FAIL if: fewer than three detail pages opened; a different book is named or saved; page or chapter count missing or wrong; saved under another account; answer empty."} +{"web_name": "4shared", "id": "4shared--19", "ques": "Log in with alice.j@test.com and password TestPass123!. Search broadly for archive metadata resources and inspect the plausible results until you find the document with a 12-week sample schedule and a risk register on page 39. Add it to Favorites, download it, and report its exact filename and total page count.", "web": "http://localhost:40028/", "upstream_url": "https://www.4shared.com/", "verifier_path": "sites/4shared/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST log in through /login as alice.j@test.com. (2) The agent MUST run an on-site search for archive metadata resources and open the detail page of the document it reports; that page's notes MUST show a 12-week sample schedule and a risk register on page 39. (3) The agent MUST add that document to Favorites AND press Download on it (the trajectory reaches /download/ for that file). (4) The after-state database MUST show exactly one new favorites row for alice on that file and exactly one new downloads row by alice for that file with its download counter incremented by one; every other table unchanged. (5) The final answer MUST give the exact filename and the total page count shown on the page. FAIL if: either the favorite or the download is missing; a different document was used; page count or filename missing or wrong; wrong account."} diff --git a/sites/4shared/templates/.gitkeep b/sites/4shared/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/4shared/templates/400.html b/sites/4shared/templates/400.html new file mode 100644 index 00000000..760c43f3 --- /dev/null +++ b/sites/4shared/templates/400.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}That request wasn't valid — 4shared{% endblock %}{% block content %}

That request wasn't valid

One of the submitted values could not be read. Go back, check the form and try again.

Back to 4shared
{% endblock %} diff --git a/sites/4shared/templates/404.html b/sites/4shared/templates/404.html new file mode 100644 index 00000000..db35406f --- /dev/null +++ b/sites/4shared/templates/404.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}File not found — 4shared{% endblock %}{% block content %}

We couldn't find that file

It may have moved, become private, or returned to its owner’s Trash.

Search all files
{% endblock %} diff --git a/sites/4shared/templates/500.html b/sites/4shared/templates/500.html new file mode 100644 index 00000000..16594c56 --- /dev/null +++ b/sites/4shared/templates/500.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Something went wrong — 4shared{% endblock %}{% block content %}

Something went wrong on our side

The request could not be completed. Nothing was saved — please try again.

Back to 4shared
{% endblock %} diff --git a/sites/4shared/templates/_account_nav.html b/sites/4shared/templates/_account_nav.html new file mode 100644 index 00000000..eb6faa55 --- /dev/null +++ b/sites/4shared/templates/_account_nav.html @@ -0,0 +1,12 @@ + diff --git a/sites/4shared/templates/_file_card.html b/sites/4shared/templates/_file_card.html new file mode 100644 index 00000000..4bf4d535 --- /dev/null +++ b/sites/4shared/templates/_file_card.html @@ -0,0 +1,10 @@ + diff --git a/sites/4shared/templates/about.html b/sites/4shared/templates/about.html new file mode 100644 index 00000000..70aa8603 --- /dev/null +++ b/sites/4shared/templates/about.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}About — 4shared{% endblock %}{% block content %}

About 4shared

A search-first file storage and sharing experience.

Search, store and share

4shared brings public-file discovery together with personal cloud storage. People can browse by file category, preview metadata, keep favorites, organize their own folders, and create share links.

About this mirror

This WebHarbor contribution is a deterministic, offline recreation for browser-agent evaluation. Its database and state reset to an identical seed, and its public catalog is limited to benign educational, public-domain, and open-source metadata.

{% endblock %} diff --git a/sites/4shared/templates/account.html b/sites/4shared/templates/account.html new file mode 100644 index 00000000..256266ff --- /dev/null +++ b/sites/4shared/templates/account.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}My 4shared{% endblock %}{% block content %}

Welcome back, {{ current_user.display_name.split()[0] }}

Manage your cloud files and recent activity.

{% endblock %} diff --git a/sites/4shared/templates/account_edit.html b/sites/4shared/templates/account_edit.html new file mode 100644 index 00000000..95eb8e95 --- /dev/null +++ b/sites/4shared/templates/account_edit.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Account settings — 4shared{% endblock %}{% block content %}

Account settings

Keep your public profile information up to date.

{% endblock %} diff --git a/sites/4shared/templates/activity.html b/sites/4shared/templates/activity.html new file mode 100644 index 00000000..6a0c33e4 --- /dev/null +++ b/sites/4shared/templates/activity.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Activity — 4shared{% endblock %}{% block content %}

Activity

Your recent downloads and active share links.

{% endblock %} diff --git a/sites/4shared/templates/base.html b/sites/4shared/templates/base.html new file mode 100644 index 00000000..8301a58f --- /dev/null +++ b/sites/4shared/templates/base.html @@ -0,0 +1,53 @@ + + + + + + {% block title %}4shared — free file sharing and storage{% endblock %} + + + + +
+
+ 4shared + {% if request.endpoint != 'index' or current_user.is_authenticated %} + + {% endif %} + +
+
+
+
+ {% for category, message in get_flashed_messages(with_categories=true) %}
{{ message }}
{% endfor %} +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/sites/4shared/templates/blog.html b/sites/4shared/templates/blog.html new file mode 100644 index 00000000..f23eb186 --- /dev/null +++ b/sites/4shared/templates/blog.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}4shared Blog{% endblock %} +{% block content %} +
+

4SHARED BLOG

+

Ideas for organizing and sharing files

+
+

Build a folder structure that stays useful

Start with a few durable project folders, then use clear filenames and dates where they add context.

Read the organizing guide
+

Choose preview or download permissions

Use preview access when collaborators need to inspect a file, and download access when they need a working copy.

Learn about sharing
+

Find files with focused keywords

Combine topic, format, and distinctive metadata to narrow a large public catalog.

Try public search
+
+
+{% endblock %} diff --git a/sites/4shared/templates/category.html b/sites/4shared/templates/category.html new file mode 100644 index 00000000..1d2e11b0 --- /dev/null +++ b/sites/4shared/templates/category.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ category }} files — 4shared{% endblock %}{% block content %}

{{ category }}

Browse safe public {{ category|lower }} files, ordered by community activity.

{% for file in files %}{% include '_file_card.html' %}{% endfor %}
{% endblock %} diff --git a/sites/4shared/templates/converter.html b/sites/4shared/templates/converter.html new file mode 100644 index 00000000..8ff0dd8c --- /dev/null +++ b/sites/4shared/templates/converter.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}{{ source_format|upper }} to PDF converter — 4shared{% endblock %} +{% block content %} +
+

FILE CONVERTER

+

{{ source_format|upper }} to PDF

+

Create a simulated PDF result without uploading file bytes or contacting an external service.

+ {% if converted_name %}
{{ converted_name }}Conversion ready
{% endif %} +
+
+ +
+
+{% endblock %} diff --git a/sites/4shared/templates/download_ready.html b/sites/4shared/templates/download_ready.html new file mode 100644 index 00000000..13774125 --- /dev/null +++ b/sites/4shared/templates/download_ready.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Download ready — 4shared{% endblock %}{% block content %}

Your download is ready

{{ file.filename }}

This offline mirror records the download but does not transfer executable or user-hosted content.

Return to file
{% endblock %} diff --git a/sites/4shared/templates/favorites.html b/sites/4shared/templates/favorites.html new file mode 100644 index 00000000..7e028336 --- /dev/null +++ b/sites/4shared/templates/favorites.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% set heading=title|default('Favorites') %}{% block title %}{{ heading }} — 4shared{% endblock %}{% block content %}

{{ heading }}

Files you kept for quick access.

{% endblock %} diff --git a/sites/4shared/templates/file_detail.html b/sites/4shared/templates/file_detail.html new file mode 100644 index 00000000..db97e780 --- /dev/null +++ b/sites/4shared/templates/file_detail.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% block title %}{{ file.filename }} — 4shared{% endblock %} +{% block content %} +
+
+ {% if file.thumbnail %}{{ file.stem }}{% elif file.category in ['Music','Video'] %}
{% else %}{{ file.extension }}{% endif %} +
+
+
{{ file.uploader_name }}

{{ file.filename }}

● {{ file.uploader_name }}  in  ▰ My 4shared   {{ file.uploaded_at.strftime('%b %d, %Y') }}

{{ file.extension|upper }}{{ file.size_bytes|filesize }}{{ file.category }}{{ file.license_name }}
+ {% if current_user.is_authenticated %}
{% endif %} +
+
+ ↗  Open in… + {% if current_user.is_authenticated %}
{% else %}+  To library{% endif %} +
+ {% if current_user.is_authenticated %}⌯  Share{% else %}⌯  Share{% endif %} +
+

About this file

{{ file.description }}

{{ file.preview_text }}
+

Comments {{ file.comments|length }}

{% if current_user.is_authenticated %}
{% else %}

Log in to comment.

{% endif %}{% for comment in file.comments %}
{{ comment.user.display_name }}{{ comment.created_at.strftime('%b %d, %Y') }}

{{ comment.body }}

{% endfor %}
+ + +
+{% endblock %} diff --git a/sites/4shared/templates/help.html b/sites/4shared/templates/help.html new file mode 100644 index 00000000..3722c4cf --- /dev/null +++ b/sites/4shared/templates/help.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Help and FAQ — 4shared{% endblock %}{% block content %}

How can we help?

Quick answers for searching, uploading, organizing, and sharing.

Search public files

Use one or several keywords, a topic, file extension, or category. Shorter queries often produce broader results.

Upload and organize

Sign in, choose Upload, add file details, and select a destination folder. You can rename or move it later.

Share a file

Open a file, choose Share file, then create a preview-only or download-enabled link.

Restore from Trash

Deleted files remain in Trash. Open Trash from My 4shared and choose Restore beside the file.

Preview formats

Images display directly. Music, video, books, documents, apps, and archives show descriptive preview metadata.

Free storage

Free accounts include 15 GB. Premium accounts in this mirror include 100 GB and faster-access features.

Convert documents

The source site links to PDF conversion tools for DOC, DOCX, PPT, PPTX, XLS, and XLSX formats. Conversion is informational in this offline mirror.

Safe benchmark content

All catalog records are benign educational or open-cultural metadata. No executable or user-hosted bytes are served.

{% endblock %} diff --git a/sites/4shared/templates/index.html b/sites/4shared/templates/index.html new file mode 100644 index 00000000..2a425c5b --- /dev/null +++ b/sites/4shared/templates/index.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+

Search, store and share easily

+

All your files: music, videos, apps and more

+ +
or
+ + + Drop files here or + Upload files + +
+ {% set icons = {'Music':'♫','Video':'▷','Apps':'♙','Images':'▧','Books':'▥'} %} + {% for item in ['Music','Video','Apps','Images','Books'] %}{{ icons[item] }}{{ item }}{% endfor %} + All Files +
+
+
+

Get 4shared App

Offline access  •  Faster downloads  •  Real-time updates

QR code to download the 4shared app
GET IT ONGoogle PlayDownload on theApp StoreExplore it onAppGallery
+{% endblock %} diff --git a/sites/4shared/templates/login.html b/sites/4shared/templates/login.html new file mode 100644 index 00000000..c5d52479 --- /dev/null +++ b/sites/4shared/templates/login.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Log in — 4shared{% endblock %}{% block content %}

Log in to 4shared

Access your files, folders, favorites, and share links.

New to 4shared? Sign up for free

{% endblock %} diff --git a/sites/4shared/templates/my_files.html b/sites/4shared/templates/my_files.html new file mode 100644 index 00000000..33c9d79c --- /dev/null +++ b/sites/4shared/templates/my_files.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}My files — 4shared{% endblock %}{% block content %}

My files

Organize your uploads into folders and create share links.

{% endblock %} diff --git a/sites/4shared/templates/premium.html b/sites/4shared/templates/premium.html new file mode 100644 index 00000000..1d9b3d2e --- /dev/null +++ b/sites/4shared/templates/premium.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% block title %}4shared Premium - Choose Your Plan{% endblock %} +{% block content %} +
+

Expand your storage with 4shared Premium

+
GET 15% OFF WITH YEARLY

Monthly     Yearly

+
+

Free 15 GB

$0
Current plan

INCLUDES

  • 15 GB of free storage
  • Priority download
  • No credit card required
+

Premium 100 GB

$6.49 / month, billed yearly
Choose annual

EVERYTHING IN FREE +

  • 100 GB of storage
  • Ads-free browsing
  • Direct downloads
  • File and account statistics
+ +

Premium 1 TB

$12.99 / month, billed yearly
Select 1 TB

EVERYTHING IN 500 GB +

  • 1 TB of storage
  • File backup and restore
  • Premium support
+
+

Cancel anytime. Secure payment through the benchmark checkout.

+

Compare features and plans

AccountFreePremiumStorage space15 GB100 GB, 500 GB, 1 TBBandwidth limitation×100 GB monthlyAds-free experience×Maximum upload size2 GB100 GBSupportRegular updatesPremium support×
+

Frequently asked questions

{% for q in ['How do you process payment?','How can I cancel my subscription?','How does renewal work?','What happens to my files if I downgrade or cancel?','What is your refund policy?'] %}
{{ q }}

This offline mirror simulates the workflow without processing a real payment.

{% endfor %}
+
+{% endblock %} diff --git a/sites/4shared/templates/premium_checkout.html b/sites/4shared/templates/premium_checkout.html new file mode 100644 index 00000000..0272eca4 --- /dev/null +++ b/sites/4shared/templates/premium_checkout.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ plan.label }} checkout — 4shared{% endblock %}{% block content %}

Upgrade to {{ plan.label }}

Annual plan · ${{ '%.2f'|format(amount) }} per year · {{ plan.storage_mb // 1024 if plan.storage_mb < 1048576 else 1 }} {{ 'GB storage' if plan.storage_mb < 1048576 else 'TB storage' }}

Benchmark checkout only. Use demo card 4242 4242 4242 4242; no payment is processed.
{% endblock %} diff --git a/sites/4shared/templates/premium_confirmed.html b/sites/4shared/templates/premium_confirmed.html new file mode 100644 index 00000000..6a27b184 --- /dev/null +++ b/sites/4shared/templates/premium_confirmed.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Premium activated — 4shared{% endblock %}{% block content %}

{{ order.plan_name }} activated

Your {{ order.billing_period }} plan is active. Your account now includes {{ plan.storage_mb // 1024 if plan.storage_mb < 1048576 else 1 }} {{ 'GB' if plan.storage_mb < 1048576 else 'TB' }} of storage.

Order {{ order.id }} · card ending {{ order.card_last4 }}

Go to My 4shared
{% endblock %} diff --git a/sites/4shared/templates/press_room.html b/sites/4shared/templates/press_room.html new file mode 100644 index 00000000..86465014 --- /dev/null +++ b/sites/4shared/templates/press_room.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}Press Room — 4shared{% endblock %} +{% block content %} +
+

PRESS ROOM

+

4shared news and media resources

+

Product background, benchmark-safe announcements, and media contact information.

+
+

File discovery gets clearer category filters

Search results now make format, size, and popularity comparisons easier to scan.

+

Mobile access remains central to 4shared

The mobile experience keeps upload, search, and saved files close at hand.

+

About this offline mirror

This benchmark recreation preserves representative public workflows without contacting production services.

+
+
+{% endblock %} diff --git a/sites/4shared/templates/preview.html b/sites/4shared/templates/preview.html new file mode 100644 index 00000000..5c06dc23 --- /dev/null +++ b/sites/4shared/templates/preview.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Preview {{ file.filename }} — 4shared{% endblock %}{% block content %}

Preview: {{ file.filename }}

{% if file.thumbnail %}{{ file.stem }}{% else %}
{{ file.extension }}

{{ file.preview_text }}

{% endif %}
{% endblock %} diff --git a/sites/4shared/templates/register.html b/sites/4shared/templates/register.html new file mode 100644 index 00000000..a7b1bb8b --- /dev/null +++ b/sites/4shared/templates/register.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Sign up — 4shared{% endblock %}{% block content %}

Get 15 GB free

Create an account to upload, organize, and share files.

At least 8 characters.

Already registered? Log in

{% endblock %} diff --git a/sites/4shared/templates/search.html b/sites/4shared/templates/search.html new file mode 100644 index 00000000..34981215 --- /dev/null +++ b/sites/4shared/templates/search.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% block title %}{% if query %}{{ query }} - {% endif %}4shared - free file sharing and storage{% endblock %} +{% block content %} +
+
+ All + {% for item in nav_categories %}{{ item }}{% endfor %} +
+
+ {{ files|length }} files +
+ + + + +
+
+
+ {% for file in files %} + + {% else %}
No files matched. Try fewer words or another category.
{% endfor %} +
+
+{% endblock %} diff --git a/sites/4shared/templates/share.html b/sites/4shared/templates/share.html new file mode 100644 index 00000000..440f26d2 --- /dev/null +++ b/sites/4shared/templates/share.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Share {{ file.filename }} — 4shared{% endblock %}{% block content %}

Share file

Create a link to {{ file.filename }}.

{% endblock %} diff --git a/sites/4shared/templates/shared.html b/sites/4shared/templates/shared.html new file mode 100644 index 00000000..b5054084 --- /dev/null +++ b/sites/4shared/templates/shared.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ file.filename }} shared on 4shared{% endblock %}{% block content %}
{{ file.extension }}

{{ file.filename }}

{{ file.description }}

Preview{% if link.permission == 'download' %}
{% endif %}
{% endblock %} diff --git a/sites/4shared/templates/trash.html b/sites/4shared/templates/trash.html new file mode 100644 index 00000000..89da4452 --- /dev/null +++ b/sites/4shared/templates/trash.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Trash — 4shared{% endblock %}{% block content %}

Trash

Restore files you moved out of My files.

{% endblock %} diff --git a/sites/4shared/templates/upload.html b/sites/4shared/templates/upload.html new file mode 100644 index 00000000..f71e6550 --- /dev/null +++ b/sites/4shared/templates/upload.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Upload files — 4shared{% endblock %}{% block content %}

Upload files

Create a safe file record in this offline benchmark environment.

{% endblock %} diff --git a/sites/4shared/verify/README.md b/sites/4shared/verify/README.md new file mode 100644 index 00000000..7f8e796d --- /dev/null +++ b/sites/4shared/verify/README.md @@ -0,0 +1,81 @@ +# 4shared deterministic grading contract + +Every row in `sites/4shared/tasks.jsonl` points to `verify_0.py` … `verify_19.py` +(`verifier_path`) and carries an English `judge_rubric` of fact checkpoints for the +LLM judge. Ground truth (file slugs, ids, filenames, uploaders, counts, exact row +values) lives **only** inside the `verify_N.py` files. No verifier calls an LLM; +the `llm_*` helpers in `verify_lib.py` exist for API parity with +`sites/merriam_webster/verify/verify_lib.py` and nothing depends on them. + +## Inputs / outputs + +```bash +uv run python sites/4shared/verify/verify_7.py --run_dir /abs/path/to/run \ + [--initial_db /abs/initial.db] [--after_db /abs/after.db] [--no_llm True] +``` + +* `run_dir` holds `trajectory.json` (the `agent_demo/agent.py` shape) and + `screenshots/step_NNN.png`. +* Snapshots: explicit `--initial_db/--after_db`, else `/initial.db` and + `/after.db`, else `docker cp` from `$WH_CONTAINER` (default `wh-review`) + at `/opt/WebSyn/4shared/{instance_seed,instance}/4shared.db`. Missing or + out-of-contract snapshots fail closed (`infra_error: true`). +* Output: JSON `{task_id, pass, reason, evidence[]}` on stdout; exit 0 PASS / 1 FAIL. + `agent_demo/eval_judge.py --run_dir … --verifier True` invokes it this way. + +## What every verifier enforces + +1. **Run-package identity** — exact `task_id`; non-empty `final_answer`; + `terminated: true` with `termination_reason: agent_done`; ≥ 1 step; every recorded + URL (`start_url`, step `url`, `navigate` targets, `final_url`) on the same loopback + host **and port** as `start_url`; every referenced screenshot present and + PNG-framed. +2. **Navigation gates** (anti knowledge-shortcut) — exact mirror paths + (`/file/`, `/login`, `/favorites`, `/saved`, `/my-files`, `/upload`, + `/trash`, `/file//share`, `/premium/checkout`, `/account`, `/download/`), + an on-site `/search` (query tokens) or `/category/` visit where the task says + "search/browse", and required ordering where the task implies it (login before + the action; detail page before download; checkout before the account check). +3. **Answer** — exact filename incl. extension (`contains_filename`), uploader + (`contains_all`), standalone numbers (`contains_number`: not part of a time, + ratio, version or longer digit run), runtimes (`contains_runtime`), displayed + sizes (`contains_size`), resolutions (`contains_resolution`), and comparison + claims (`claims_winner`: the item credited by "longer/most…" must be the winner). + Negated mentions ("not X") do not count. +4. **SQLite after-state** — the snapshot contract (9 tables, seed counts, schema + equality, benchmark users) is validated first. Read-only tasks require **all** + nine tables row-identical. Stateful tasks require the **exact** row delta and + nothing else: + +| task | persisted change that must be exactly present | +|---|---| +| 6 | +1 `downloads` row for The Federalist Papers, its `download_count` +1 | +| 7 | +1 `favorites` (alice, ArchivePeek File Inspector) | +| 8 | +1 `saved_files` (alice, Rain Garden Planting Guide) | +| 9 | alice `users` row: `location`/`bio` only | +| 10 | +1 `folders` (bob, root, "Survey Exports") | +| 11 | +1 `files` (carol, Work, exact name/size/description, private, Documents) | +| 12 | file 123: new filename + `folder_id` (Shared Projects) only | +| 13 | file 146: `deleted` 1 → 0 only | +| 14 | +1 `shared_links` (alice, file 125, download, "Audio volunteers") | +| 15 | +1 `comments` (bob, file 93, exact body) | +| 16 | +1 `plan_orders` (bob, Premium 100 GB, annual, 77.88, 4242); bob `plan`/`storage_limit_mb` | +| 17 | +1 folder, +1 file (final name, in it, 384 KB, private), +1 view-only link; timestamps ordered | +| 18 | +1 `saved_files` (david, Twenty Thousand Leagues Under the Seas) | +| 19 | +1 `favorites` + 1 `downloads` (alice, file 96), `download_count` +1 | + +## Validation harness (`verify/tests/`, excluded from the image) + +```bash +# 1. genuine PASS runs: boots the site from instance_seed/ per task and drives it with Chromium +uv run python sites/4shared/verify/tests/drive_tasks.py --python /bin/python --port 45004 +# 2. matrix: noop / pass / shortcut / wrong / state-mismatch for every task +uv run python sites/4shared/verify/tests/run_matrix.py +``` + +`drive_tasks.py` writes `tests/runs//pass/` (trajectory + screenshots + +`initial.db` + `after.db`); `run_matrix.py` derives the negative variants +(homepage-only empty answer; correct answer with every URL rewritten to the +homepage; a wrong answer or a wrong persisted row; the seed as `after.db`), runs +every verifier with `--no_llm True`, and exits non-zero if any cell disagrees with +its expectation. Run dirs are git-ignored. diff --git a/sites/4shared/verify/tests/drive_tasks.py b/sites/4shared/verify/tests/drive_tasks.py new file mode 100644 index 00000000..dfecc3ec --- /dev/null +++ b/sites/4shared/verify/tests/drive_tasks.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Drive every 4shared task with a real Chromium (Playwright) and write agent.py-shaped +run dirs for the verifier validation matrix (CONTRIBUTING "C. Verify the grading"). + +For each task the harness boots the site standalone from a fresh copy of +instance_seed/ (exactly what websyn_start.sh + site_runner.py do), performs the task +through the UI, records `trajectory.json` (same shape as agent_demo/agent.py: +steps[].url/action/params/screenshot_before/screenshot_after, final_answer, +terminated, termination_reason, verifier_path) plus screenshots/step_NNN.png, stops +the site and stores the seed as `initial.db` and the live instance DB as `after.db`. + +Usage (from the agent_demo uv env, which has Playwright + Chromium): + uv run python sites/4shared/verify/tests/drive_tasks.py \ + --python /path/to/venv/bin/python --port 45004 --out sites/4shared/verify/tests/runs [--only 3,7] + +`--python` is an interpreter that can import the site's requirements.txt (Flask, +Flask-SQLAlchemy, Flask-Login, Flask-WTF). No LLM, no docker. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +from playwright.sync_api import sync_playwright + +HERE = Path(__file__).resolve().parent +SITE_DIR = HERE.parents[1] +TASKS_FILE = SITE_DIR / "tasks.jsonl" +PASSWORD = "TestPass123!" +SERVER_MARKER = "wh4shared-drive-tasks" + + +class Site: + def __init__(self, python: str, port: int, log: Path): + self.python, self.port, self.log = python, port, log + self.proc: subprocess.Popen | None = None + + @property + def base(self) -> str: + return f"http://localhost:{self.port}" + + def start(self) -> None: + inst, seed = SITE_DIR / "instance", SITE_DIR / "instance_seed" + shutil.rmtree(inst, ignore_errors=True) + shutil.copytree(seed, inst) + code = (f"from app import app # {SERVER_MARKER}\n" + f"app.run(host='127.0.0.1', port={self.port}, debug=False, use_reloader=False)\n") + self.proc = subprocess.Popen([self.python, "-c", code], cwd=SITE_DIR, stdout=open(self.log, "ab"), + stderr=subprocess.STDOUT, start_new_session=True) + for _ in range(60): + try: + if urllib.request.urlopen(self.base + "/_health", timeout=2).status == 200: + return + except Exception: + time.sleep(0.5) + raise RuntimeError(f"site did not come up on {self.base}; see {self.log}") + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + os.killpg(self.proc.pid, signal.SIGKILL) + self.proc.wait(timeout=10) + self.proc = None + + +class Recorder: + """Records Playwright actions in the agent_demo/agent.py trajectory format.""" + + def __init__(self, page, run_dir: Path, task: dict, base: str, n: int): + self.page, self.run_dir, self.task, self.base = page, run_dir, task, base + self.shots = run_dir / "screenshots" + shutil.rmtree(run_dir, ignore_errors=True) + self.shots.mkdir(parents=True) + self.steps: list[dict] = [] + self.idx = 0 + self.n = n + page.goto(base + "/") + page.wait_for_load_state("networkidle") + self._shot("step_000.png") + + def _shot(self, name: str) -> None: + self.page.screenshot(path=str(self.shots / name), full_page=False) + + def act(self, action: str, params: dict, fn=None, thought: str = "") -> None: + step = {"step": self.idx, "url": self.page.url, "title": self.page.title(), "thought": thought, + "action": action, "params": params, + "screenshot_before": f"step_{self.idx:03d}.png", "screenshot_after": f"step_{self.idx + 1:03d}.png"} + if fn is not None: + fn() + self.page.wait_for_load_state("networkidle") + step["action_result"] = {"is_done": False, "success": True, "error": None, "extracted_content": ""} + self.idx += 1 + self._shot(step["screenshot_after"]) + self.steps.append(step) + + def click(self, selector, thought: str = "") -> None: + loc = self.page.locator(selector) if isinstance(selector, str) else selector + self.act("click", {"index": 0, "selector": str(selector)}, lambda: loc.first.click(), thought) + + def fill(self, selector: str, text: str, thought: str = "") -> None: + self.act("input", {"index": 0, "text": text, "selector": selector}, lambda: self.page.fill(selector, text), thought) + + def select(self, selector: str, label: str, thought: str = "") -> None: + self.act("select", {"index": 0, "text": label, "selector": selector}, lambda: self.page.select_option(selector, label=label), thought) + + def navigate(self, path: str, thought: str = "") -> None: + url = self.base + path + self.act("navigate", {"url": url}, lambda: self.page.goto(url), thought) + + def done(self, text: str) -> None: + self.act("done", {"text": text, "success": True}) + traj = {"task": self.task["ques"], "task_id": self.task["id"], "start_url": self.base + "/", + "model": "playwright-reviewer-harness", "max_steps": 40, "steps": self.steps, + "terminated": True, "termination_reason": "agent_done", "final_answer": text, + "success_self_report": True, "judge_rubric": self.task.get("judge_rubric", ""), + "verifier_path": self.task.get("verifier_path") or f"sites/4shared/verify/verify_{self.n}.py"} + (self.run_dir / "trajectory.json").write_text(json.dumps(traj, indent=2), encoding="utf-8") + + # --- composite UI moves ------------------------------------------------- + def login(self, email: str) -> None: + self.click(".signin-pill", "open the login page") + self.fill("#email", email) + self.fill("#password", PASSWORD) + self.click("button:has-text('Log in')", "submit credentials") + + def header_search(self, query: str) -> None: + self.fill(".header-search input[name='q']", query) + self.click(".header-search button", "submit the search") + + def hero_search(self, query: str) -> None: + self.fill("form.hero-search input[name='q']", query) + self.click("form.hero-search button", "submit the search") + + def result(self, filename: str): + return self.page.locator(".result-row h3 a", has_text=re.compile("^" + re.escape(filename) + "$")) + + def card(self, filename: str): + return self.page.locator(".file-card .file-title", has_text=re.compile("^" + re.escape(filename) + "$")) + + def row(self, filename: str): + return self.page.locator("tr", has_text=filename) + + def account_nav(self, label: str) -> None: + self.click(f".account-nav a:has-text('{label}')", f"open {label}") + + +# --- one driver per task --------------------------------------------------------- +def t0(r: Recorder): + r.hero_search("nature ambience") + r.click(".search-tabs a:has-text('Music')", "restrict to the Music catalog") + r.click(r.result("Rain on Library Windows.mp3"), "inspect a candidate") + r.navigate("/search?q=nature+ambience&category=Music") + r.click(r.result("Mountain Stream in Late Summer.mp3"), "inspect the next candidate") + r.done("Mountain Stream in Late Summer.mp3, uploaded by Atlas Media Lab (10:45, 48 kHz WAV source, normalized to -16 LUFS).") + + +def t1(r: Recorder): + r.click(".hero-categories a:has-text('Images')", "browse Images") + r.click(r.card("London Skyline at Blue Hour.jpg"), "inspect a skyline candidate") + r.navigate("/category/images") + r.click(r.card("New York Skyline at Sunset.jpg"), "inspect the other skyline") + r.done("New York Skyline at Sunset.jpg, uploader Open Culture Shelf, resolution 3840 × 2160 (ISO 200, 1/80 s).") + + +def t2(r: Recorder): + r.hero_search("classic fiction") + r.click(".search-tabs a:has-text('Books')", "restrict to Books") + r.click(r.result("Pride and Prejudice.epub"), "inspect a candidate") + r.navigate("/search?q=classic+fiction&category=Books") + r.click(r.result("The Time Machine.epub"), "inspect the next candidate") + r.done("The Time Machine.epub — 12 chapters, notes begin after page 116; displayed file size 2.1 MB.") + + +def t3(r: Recorder): + r.hero_search("garden planning") + r.click(r.result("Community Garden Crop Calendar.pdf"), "inspect a candidate") + r.navigate("/search?q=garden+planning") + r.click(r.result("Rain Garden Planting Guide.pdf"), "inspect the next candidate") + r.done("Rain Garden Planting Guide.pdf, 58 pages, uploader Community Library.") + + +def t4(r: Recorder): + r.hero_search("accessibility design") + r.click(r.result("Open Icon Accessibility Set.zip"), "inspect a candidate") + r.navigate("/search?q=accessibility+design") + r.click(r.result("ColorScope Palette Assistant.zip"), "inspect the next candidate") + r.done("ColorScope Palette Assistant.zip, version 5.0.0, license GPL-3.0 (catalog tag: Open-source package).") + + +def t5(r: Recorder): + r.click(".hero-categories a:has-text('Video')", "browse Videos") + r.click(r.card("Open Data Mapping Basics.mp4"), "open the first detail page") + r.navigate("/category/video") + r.click(r.card("City Cycling Route Planning.mp4"), "open the second detail page") + r.done("Open Data Mapping Basics is longer: 27:03 versus 19:05 for City Cycling Route Planning.") + + +def t6(r: Recorder): + r.hero_search("The Federalist Papers") + r.click(".search-tabs a:has-text('Books')", "restrict to Books") + r.click(r.result("The Federalist Papers.epub"), "open the detail page") + r.click("form[action='/download/81'] button", "press Download") + r.done("Verified The Federalist Papers.epub lists 672 pages, 85 essays and a searchable topic index; download completed.") + + +def t7(r: Recorder): + r.login("alice.j@test.com") + r.header_search("ArchivePeek File Inspector") + r.click(r.result("ArchivePeek File Inspector.zip"), "open the main application") + r.click(".heart-button", "add to favorites") + r.click(".user-link", "open My 4shared") + r.account_nav("Favorites") + r.done("Added ArchivePeek File Inspector.zip to Favorites; it is listed on the Favorites page.") + + +def t8(r: Recorder): + r.login("alice.j@test.com") + r.header_search("Rain Garden Planting Guide") + r.click(r.result("Rain Garden Planting Guide.pdf"), "open the detail page") + r.click("button[aria-label='Save to My 4shared']", "save to library") + r.click(".user-link", "open My 4shared") + r.account_nav("Saved files") + r.done("Saved Rain Garden Planting Guide.pdf to My 4shared; it appears under Saved files.") + + +def t9(r: Recorder): + r.login("alice.j@test.com") + r.account_nav("Settings") + r.fill("#location", "Portland, Oregon") + r.fill("#bio", "Community archive volunteer and urban sketcher.") + r.click("button:has-text('Save changes')", "save the profile") + r.done("Updated the account: location Portland, Oregon; bio 'Community archive volunteer and urban sketcher.'") + + +def t10(r: Recorder): + r.login("bob.c@test.com") + r.account_nav("My 4shared") + r.fill("form[action='/folder/new'] input[name='name']", "Survey Exports") + r.click("form[action='/folder/new'] button", "create the folder") + r.done("Created the root-level folder Survey Exports in My files.") + + +def t11(r: Recorder): + r.login("carol.d@test.com") + r.account_nav("Add") + r.fill("#filename", "accessibility-session-notes.pdf") + r.select("#folder_id", "Work") + r.fill("#size_kb", "640") + r.fill("#description", "Notes and action items from the accessibility session.") + r.select("#visibility", "Private") + r.click("button:has-text('Upload file')", "submit the upload") + r.done("Uploaded accessibility-session-notes.pdf (640 KB, private) into the Work folder with the requested description.") + + +def t12(r: Recorder): + r.login("alice.j@test.com") + r.account_nav("My 4shared") + r.click(".folder-card:has-text('Work')", "open the Work folder") + row = r.row("Alice Quarterly retreat budget.xlsx") + r.act("input", {"index": 0, "text": "2027 Retreat Budget.xlsx"}, lambda: row.locator("form[action$='/rename'] input[name='filename']").fill("2027 Retreat Budget.xlsx")) + r.click(row.locator("form[action$='/rename'] button"), "rename the file") + row2 = r.row("2027 Retreat Budget.xlsx") + r.act("select", {"index": 0, "text": "Shared Projects"}, lambda: row2.locator("form[action$='/move'] select").select_option(label="Shared Projects")) + r.click(row2.locator("form[action$='/move'] button"), "move the file") + r.done("Renamed Alice Quarterly retreat budget.xlsx to 2027 Retreat Budget.xlsx and moved it into Shared Projects.") + + +def t13(r: Recorder): + r.login("david.k@test.com") + r.account_nav("Recycle Bin") + r.click(r.row("David Old outline.txt").locator("form[action$='/restore'] button"), "restore the file") + r.done("Restored David Old outline.txt from Trash.") + + +def t14(r: Recorder): + r.login("alice.j@test.com") + r.account_nav("My 4shared") + r.click(".folder-card:has-text('Shared Projects')", "open the folder holding the file") + r.click(r.row("Alice Field recording notes.docx").locator("a:has-text('Share')"), "open the share page") + r.fill("#label", "Audio volunteers") + r.select("#permission", "Preview and download") + r.click("button:has-text('Create share link')", "create the link") + r.done("Created a share link labeled Audio volunteers with Preview and download permission for Alice Field recording notes.docx.") + + +def t15(r: Recorder): + r.login("bob.c@test.com") + r.header_search("Beginner Map Reading Workbook") + r.click(r.result("Beginner Map Reading Workbook.pdf"), "open the detail page") + r.fill("textarea[name='body']", "The coordinate exercises are ideal for our Saturday workshop.") + r.click("button:has-text('Post comment')", "post the comment") + r.done("Posted the comment 'The coordinate exercises are ideal for our Saturday workshop.' on Beginner Map Reading Workbook.pdf.") + + +def t16(r: Recorder): + r.login("bob.c@test.com") + r.click(".site-footer a:has-text('Premium')", "open Premium plans") + r.click("a:has-text('Choose annual')", "choose the annual Premium 100 GB plan") + r.fill("#cardholder", "Bob Chen") + r.fill("#card_number", "4242 4242 4242 4242") + r.click("button:has-text('Confirm')", "confirm the checkout") + r.click("a:has-text('Go to My 4shared')", "open My 4shared") + r.done("My 4shared now shows plan Premium with a 100 GB storage allowance (0 B of 100.0 GB used).") + + +def t17(r: Recorder): + r.login("carol.d@test.com") + r.account_nav("My 4shared") + r.fill("form[action='/folder/new'] input[name='name']", "Workshop Handouts") + r.click("form[action='/folder/new'] button", "create the folder") + r.click(".file-manager-toolbar a:has-text('Upload files')", "open the upload form") + r.fill("#filename", "spring-workshop-outline.pdf") + r.select("#folder_id", "Workshop Handouts") + r.fill("#size_kb", "384") + r.fill("#description", "Draft outline for the spring neighborhood workshop.") + r.select("#visibility", "Private") + r.click("button:has-text('Upload file')", "submit the upload") + row = r.row("spring-workshop-outline.pdf") + r.act("input", {"index": 0, "text": "final-spring-workshop-outline.pdf"}, lambda: row.locator("form[action$='/rename'] input[name='filename']").fill("final-spring-workshop-outline.pdf")) + r.click(row.locator("form[action$='/rename'] button"), "rename the file") + r.click(r.row("final-spring-workshop-outline.pdf").locator("a:has-text('Share')"), "open the share page") + r.fill("#label", "Planning committee") + r.select("#permission", "Preview only") + r.click("button:has-text('Create share link')", "create the link") + r.done("Created Workshop Handouts, uploaded spring-workshop-outline.pdf (384 KB, private), renamed it final-spring-workshop-outline.pdf and created a preview-only share link labeled Planning committee.") + + +def t18(r: Recorder): + r.click(".hero-categories a:has-text('Books')", "browse Books") + r.click(r.card("Pride and Prejudice.epub"), "open the first book") + r.navigate("/category/books") + r.click(r.card("Anne of Green Gables.epub"), "open the second book") + r.navigate("/category/books") + r.click(r.card("Twenty Thousand Leagues Under the Seas.epub"), "open the third book") + r.login("david.k@test.com") + r.navigate("/file/twenty-thousand-leagues-under-the-seas-epub-90") + r.click("button[aria-label='Save to My 4shared']", "save the longest book") + r.done("Twenty Thousand Leagues Under the Seas.epub has the most pages: 512 pages and 47 chapters (Pride and Prejudice 432/61, Anne of Green Gables 412/38). Saved it to My 4shared as david.") + + +def t19(r: Recorder): + r.login("alice.j@test.com") + r.header_search("archive metadata") + r.click(r.result("Field Recording Metadata Forms.zip"), "inspect a candidate") + r.navigate("/search?q=archive+metadata") + r.click(r.result("Small Archive Digitization Plan.pdf"), "inspect the next candidate") + r.click(".heart-button", "add to favorites") + r.click("form[action='/download/96'] button", "press Download") + r.done("Small Archive Digitization Plan.pdf, 46 pages in total; added to Favorites and downloaded.") + + +DRIVERS = {i: globals()[f"t{i}"] for i in range(20)} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--python", default=sys.executable, help="interpreter able to import the site (Flask etc.)") + ap.add_argument("--port", type=int, default=45004) + ap.add_argument("--out", default=str(HERE / "runs")) + ap.add_argument("--only", default="", help="comma-separated task numbers") + args = ap.parse_args() + out = Path(args.out).resolve() + out.mkdir(parents=True, exist_ok=True) + tasks = {int(row["id"].rsplit("--", 1)[1]): row for row in (json.loads(l) for l in TASKS_FILE.read_text().splitlines() if l.strip())} + wanted = [int(x) for x in args.only.split(",") if x.strip()] or sorted(tasks) + site = Site(args.python, args.port, out / "site.log") + failures = 0 + with sync_playwright() as p: + browser = p.chromium.launch() + for n in wanted: + run_dir = out / str(n) / "pass" + site.start() + ctx = browser.new_context(viewport={"width": 1440, "height": 900}) + page = ctx.new_page() + page.on("dialog", lambda d: d.accept()) + try: + rec = Recorder(page, run_dir, tasks[n], site.base, n) + DRIVERS[n](rec) + print(f"[task {n:2d}] {len(rec.steps)} steps -> {run_dir}") + except Exception as exc: # noqa: BLE001 + failures += 1 + page.screenshot(path=str(run_dir / "FAILED.png"), full_page=True) + print(f"[task {n:2d}] FAILED: {type(exc).__name__}: {exc}") + finally: + ctx.close() + site.stop() + shutil.copy2(SITE_DIR / "instance_seed" / "4shared.db", run_dir / "initial.db") + shutil.copy2(SITE_DIR / "instance" / "4shared.db", run_dir / "after.db") + browser.close() + shutil.rmtree(SITE_DIR / "instance", ignore_errors=True) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sites/4shared/verify/tests/run_matrix.py b/sites/4shared/verify/tests/run_matrix.py new file mode 100644 index 00000000..70bedd01 --- /dev/null +++ b/sites/4shared/verify/tests/run_matrix.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Validation matrix for the 4shared grading contract (CONTRIBUTING "C"). + +Consumes the PASS run dirs written by drive_tasks.py (//pass) and derives: + noop homepage only, empty answer, clean DB -> every verifier must FAIL + pass the genuine Playwright run -> must PASS + shortcut correct answer + correct DB, but the trajectory + never leaves the homepage (no on-site navigation) -> must FAIL + wrong genuine navigation, but a wrong answer / wrong + persisted row (per-task mutation below) -> must FAIL + state stateful tasks only: genuine trajectory + correct + answer, but after.db == seed (nothing persisted) -> must FAIL + +Every verifier is executed as a subprocess with --no_llm True (no LLM is ever needed). +Exit status is non-zero if any cell disagrees with the expectation. + +Usage (agent_demo uv env, so simpleArgParser is importable): + uv run python sites/4shared/verify/tests/run_matrix.py --runs sites/4shared/verify/tests/runs +""" +from __future__ import annotations + +import argparse +import json +import shutil +import sqlite3 +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +VERIFY_DIR = HERE.parent +STATEFUL = set(range(6, 20)) + +# Per-task "wrong" mutation: either a wrong final answer (answer=...) or a wrong +# persisted row (sql=[...] applied to a copy of the PASS after.db). +WRONG = { + 0: {"answer": "Morning Meadow Field Recording.mp3, uploaded by Open Source Desk."}, + 1: {"answer": "London Skyline at Blue Hour.jpg, uploader Archive Lantern, resolution 3840 × 2160."}, + 2: {"answer": "Pride and Prejudice.epub, displayed file size 4.2 MB."}, + 3: {"answer": "Rain Garden Planting Guide.pdf, 49 pages, uploader Community Library."}, + 4: {"answer": "ColorScope Palette Assistant.zip, version 5.0.1, license GPL-3.0."}, + 5: {"answer": "City Cycling Route Planning is longer: 27:03 versus 19:05 for Open Data Mapping Basics."}, + 6: {"sql": ["UPDATE downloads SET file_id = 82 WHERE id = (SELECT MAX(id) FROM downloads)", + "UPDATE files SET download_count = download_count - 1 WHERE id = 81", + "UPDATE files SET download_count = download_count + 1 WHERE id = 82"]}, + 7: {"sql": ["UPDATE favorites SET file_id = 39 WHERE id = (SELECT MAX(id) FROM favorites)"]}, + 8: {"sql": ["UPDATE saved_files SET file_id = 96 WHERE id = (SELECT MAX(id) FROM saved_files)"]}, + 9: {"sql": ["UPDATE users SET location = 'Portland, Maine' WHERE id = 1"]}, + 10: {"sql": ["UPDATE folders SET name = 'Survey Export' WHERE id = (SELECT MAX(id) FROM folders)"]}, + 11: {"sql": ["UPDATE files SET public = 1 WHERE id = (SELECT MAX(id) FROM files)"]}, + 12: {"sql": ["UPDATE files SET folder_id = 2 WHERE id = 123"]}, + 13: {"sql": ["UPDATE files SET deleted = 1 WHERE id = 146", "UPDATE files SET deleted = 0 WHERE id = 134"]}, + 14: {"sql": ["UPDATE shared_links SET permission = 'view' WHERE id = (SELECT MAX(id) FROM shared_links)"]}, + 15: {"sql": ["UPDATE comments SET body = 'The coordinate exercises are ideal for our Sunday workshop.' WHERE id = (SELECT MAX(id) FROM comments)"]}, + 16: {"answer": "My 4shared now shows plan Premium with a 500 GB storage allowance."}, + 17: {"sql": ["UPDATE shared_links SET permission = 'download' WHERE id = (SELECT MAX(id) FROM shared_links)"]}, + 18: {"answer": "Pride and Prejudice.epub has the most pages: 432 pages and 61 chapters. Saved it to My 4shared as david."}, + 19: {"answer": "Local History Interview Toolkit.pdf, 66 pages in total; added to Favorites and downloaded."}, +} + + +def run_verifier(n: int, run_dir: Path) -> dict: + cmd = [sys.executable, str(VERIFY_DIR / f"verify_{n}.py"), "--run_dir", str(run_dir), "--no_llm", "True"] + r = subprocess.run(cmd, capture_output=True, text=True) + try: + v = json.loads(r.stdout) + except json.JSONDecodeError: + v = {"pass": False, "reason": f"no JSON (rc={r.returncode}): {(r.stderr or r.stdout)[-300:]}", "evidence": []} + v["returncode"] = r.returncode + (run_dir / "verdict.json").write_text(json.dumps(v, indent=2, ensure_ascii=False), encoding="utf-8") + return v + + +def clone(src: Path, dst: Path) -> Path: + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("verdict.json")) + return dst + + +def load_traj(d: Path) -> dict: + return json.loads((d / "trajectory.json").read_text(encoding="utf-8")) + + +def save_traj(d: Path, t: dict) -> None: + (d / "trajectory.json").write_text(json.dumps(t, indent=2), encoding="utf-8") + + +def make_noop(pass_dir: Path, dst: Path) -> Path: + clone(pass_dir, dst) + t = load_traj(dst) + t["steps"] = [{"step": 0, "url": t["start_url"], "title": "4shared", "thought": "", "action": "done", + "params": {"text": "", "success": False}, "screenshot_before": "step_000.png", "screenshot_after": "step_001.png"}] + t["final_answer"] = "" + t["success_self_report"] = False + save_traj(dst, t) + shutil.copy2(dst / "initial.db", dst / "after.db") # clean DB + return dst + + +def make_shortcut(pass_dir: Path, dst: Path) -> Path: + """Correct answer, correct DB, but every recorded URL is the homepage.""" + clone(pass_dir, dst) + t = load_traj(dst) + home = t["start_url"] + kept = [] + for s in t["steps"]: + s = dict(s) + s["url"] = home + if s["action"] in {"navigate"}: + s["params"] = {"url": home} + kept.append(s) + t["steps"] = kept + save_traj(dst, t) + return dst + + +def make_wrong(n: int, pass_dir: Path, dst: Path) -> Path: + clone(pass_dir, dst) + spec = WRONG[n] + if "answer" in spec: + t = load_traj(dst) + t["final_answer"] = spec["answer"] + t["steps"][-1]["params"]["text"] = spec["answer"] + save_traj(dst, t) + if "sql" in spec: + con = sqlite3.connect(dst / "after.db") + try: + for stmt in spec["sql"]: + con.execute(stmt) + con.commit() + finally: + con.close() + return dst + + +def make_state(pass_dir: Path, dst: Path) -> Path: + clone(pass_dir, dst) + shutil.copy2(dst / "initial.db", dst / "after.db") + return dst + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--runs", default=str(HERE / "runs")) + ap.add_argument("--only", default="") + args = ap.parse_args() + runs = Path(args.runs).resolve() + wanted = [int(x) for x in args.only.split(",") if x.strip()] or sorted(int(p.name) for p in runs.iterdir() if p.name.isdigit()) + rows = [] + bad = 0 + for n in wanted: + pass_dir = runs / str(n) / "pass" + if not (pass_dir / "trajectory.json").is_file(): + print(f"task {n}: no PASS run dir at {pass_dir}; run drive_tasks.py first") + bad += 1 + continue + cells = { + "noop": (make_noop(pass_dir, runs / str(n) / "noop"), False), + "pass": (pass_dir, True), + "shortcut": (make_shortcut(pass_dir, runs / str(n) / "shortcut"), False), + "wrong": (make_wrong(n, pass_dir, runs / str(n) / "wrong"), False), + } + if n in STATEFUL: + cells["state"] = (make_state(pass_dir, runs / str(n) / "state"), False) + row = {"task": n} + for name, (d, expect) in cells.items(): + v = run_verifier(n, d) + ok = bool(v.get("pass")) == expect and v["returncode"] == (0 if expect else 1) + row[name] = ("PASS" if v.get("pass") else "FAIL") + ("" if ok else " (UNEXPECTED)") + ("" if v.get("pass") else f" [{v.get('reason')}]") + if not ok: + bad += 1 + rows.append(row) + cols = ["noop", "pass", "shortcut", "wrong", "state"] + print("| task | " + " | ".join(cols) + " |") + print("|---|" + "|".join("---" for _ in cols) + "|") + for row in rows: + print(f"| 4shared--{row['task']} | " + " | ".join(row.get(c, "n/a") for c in cols) + " |") + print(f"\n{'ALL EXPECTATIONS MET' if not bad else f'{bad} UNEXPECTED CELL(S)'}") + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sites/4shared/verify/verify_0.py b/sites/4shared/verify/verify_0.py new file mode 100644 index 00000000..a6bae7d2 --- /dev/null +++ b/sites/4shared/verify/verify_0.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--0 (read-only). + +Search the Music catalog for nature ambience; find the track whose detail page says +10:45 / 48 kHz WAV source / -16 LUFS; report exact filename + uploader. + +Checks: identity | searched Music (or /category/music) | opened the target detail +page | answer names the exact filename + uploader | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_search_or_category, # noqa: E402 + check_trajectory_identity, contains_all, contains_filename, fail_closed, + final_answer, load_run, parse_args, resolve_snapshots) + +TASK_ID = "4shared--0" +SLUG = "mountain-stream-in-late-summer-mp3-13" +FILENAME = "Mountain Stream in Late Summer.mp3" +UPLOADER = "Atlas Media Lab" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "searched_music_catalog", + any_tokens=["nature", "ambience", "ambient", "stream", "water", "field recording"], + categories=["music"]) + check_detail_visited(j, t, SLUG) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_uploader", contains_all(fa, [UPLOADER]), f"expected={UPLOADER!r} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 — any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_1.py b/sites/4shared/verify/verify_1.py new file mode 100644 index 00000000..1253bc99 --- /dev/null +++ b/sites/4shared/verify/verify_1.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--1 (read-only). + +Browse Images; find the city skyline photo whose detail page lists ISO 200 and +exposure 1/80 s; report exact filename, uploader and resolution. + +Checks: identity | browsed Images (or searched skyline) | opened the target detail +page | answer has exact filename + uploader + 3840 x 2160 | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_search_or_category, # noqa: E402 + check_trajectory_identity, contains_all, contains_filename, contains_resolution, + fail_closed, final_answer, load_run, parse_args, resolve_snapshots) + +TASK_ID = "4shared--1" +SLUG = "new-york-skyline-at-sunset-jpg-55" +FILENAME = "New York Skyline at Sunset.jpg" +UPLOADER = "Open Culture Shelf" +WIDTH, HEIGHT = 3840, 2160 + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "browsed_images", any_tokens=["skyline", "city", "new york", "iso"], categories=["images"]) + check_detail_visited(j, t, SLUG) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_uploader", contains_all(fa, [UPLOADER]), f"expected={UPLOADER!r} answer={fa[:200]!r}") + j.check("answer_has_resolution", contains_resolution(fa, WIDTH, HEIGHT), f"expected={WIDTH}x{HEIGHT} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_10.py b/sites/4shared/verify/verify_10.py new file mode 100644 index 00000000..de7fdb3a --- /dev/null +++ b/sites/4shared/verify/verify_10.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--10 (stateful: folder create). + +Log in as bob; in My files create a root-level folder named "Survey Exports". + +Checks: identity | signed in as bob | opened /my-files | DB: folders gained exactly +one row (bob, parent NULL, name "Survey Exports"); every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, parse_args, + resolve_snapshots, table_delta) + +TASK_ID = "4shared--10" +EMAIL, USER_ID = "bob.c@test.com", 2 +FOLDER_NAME = "Survey Exports" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_my_files", "/my-files") + delta = table_delta(initial_db, after_db, "folders") + added = added_rows(initial_db, after_db, "folders") + j.check("folders_exact_delta", len(added) == 1 and not delta["removed"] and not delta["changed"], + f"added={len(added)} removed={len(delta['removed'])} changed={len(delta['changed'])}") + row = added[0] if added else {} + j.check("new_folder_is_bobs_root_survey_exports", + bool(row) and int(row["user_id"]) == USER_ID and row["parent_id"] is None and str(row["name"]).strip() == FOLDER_NAME, + f"row={ {k: row.get(k) for k in ('user_id', 'parent_id', 'name')} if row else None!r}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "folders"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_11.py b/sites/4shared/verify/verify_11.py new file mode 100644 index 00000000..01a99a34 --- /dev/null +++ b/sites/4shared/verify/verify_11.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--11 (stateful: upload record). + +Log in as carol; upload a private file record accessibility-session-notes.pdf, +640 KB, with the given description, into the Work folder. + +Checks: identity | signed in as carol | opened /upload | DB: files gained exactly one +row owned by carol in her Work folder (id 9) with that filename, 655360 bytes, the +exact description, private, category Documents; other files rows and every other +table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, + normalize_text, parse_args, resolve_snapshots, rows_unchanged_except, table_delta) + +TASK_ID = "4shared--11" +EMAIL, USER_ID = "carol.d@test.com", 3 +WORK_FOLDER_ID = 9 +FILENAME = "accessibility-session-notes.pdf" +SIZE_BYTES = 640 * 1024 +DESCRIPTION = "Notes and action items from the accessibility session." + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_upload_form", "/upload") + delta = table_delta(initial_db, after_db, "files") + added = added_rows(initial_db, after_db, "files") + j.check("files_exact_delta", len(added) == 1 and not delta["removed"] and not delta["changed"], + f"added={len(added)} removed={len(delta['removed'])} changed={len(delta['changed'])}") + r = added[0] if added else {} + j.check("upload_owner_and_folder", bool(r) and int(r["owner_id"]) == USER_ID and r["folder_id"] == WORK_FOLDER_ID, + f"owner={r.get('owner_id')} folder={r.get('folder_id')} expected_folder={WORK_FOLDER_ID}") + j.check("upload_filename", bool(r) and str(r["filename"]).strip() == FILENAME, f"filename={r.get('filename')!r}") + j.check("upload_size_640kb", bool(r) and int(r["size_bytes"]) == SIZE_BYTES, f"size_bytes={r.get('size_bytes')} expected={SIZE_BYTES}") + j.check("upload_description", bool(r) and normalize_text(r["description"]) == normalize_text(DESCRIPTION), f"description={r.get('description')!r}") + j.check("upload_private_documents", bool(r) and not r["public"] and not r["deleted"] and r["category"] == "Documents", + f"public={r.get('public')} deleted={r.get('deleted')} category={r.get('category')!r}") + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [r["id"]] if r else []), "pre-existing files rows identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "files"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_12.py b/sites/4shared/verify/verify_12.py new file mode 100644 index 00000000..68cbe190 --- /dev/null +++ b/sites/4shared/verify/verify_12.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--12 (stateful: rename + move). + +Log in as alice; rename "Alice Quarterly retreat budget.xlsx" (file 123, in Work) to +"2027 Retreat Budget.xlsx" and move it into Shared Projects (folder 3). + +Checks: identity | signed in as alice | opened /my-files | DB: file 123 changed ONLY +in filename/extension/folder_id/modified_at with the new name and folder 3; other +files rows and every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, parse_args, + resolve_snapshots, row_by_id, row_changed_only_in, rows_unchanged_except) + +TASK_ID = "4shared--12" +EMAIL, USER_ID = "alice.j@test.com", 1 +FILE_ID = 123 +NEW_NAME = "2027 Retreat Budget.xlsx" +DEST_FOLDER_ID = 3 # alice's "Shared Projects" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_my_files", "/my-files") + ok, diff = row_changed_only_in(initial_db, after_db, "files", FILE_ID, ("filename", "extension", "folder_id", "modified_at")) + after = row_by_id(after_db, "files", FILE_ID) or {} + j.check("file_renamed", str(after.get("filename", "")).strip() == NEW_NAME and after.get("extension") == "xlsx", f"after_filename={after.get('filename')!r}") + j.check("file_moved_to_shared_projects", after.get("folder_id") == DEST_FOLDER_ID, f"after_folder_id={after.get('folder_id')} expected={DEST_FOLDER_ID}") + j.check("file_row_changed_only_expected_columns", ok and bool(diff), f"diff={diff!r}") + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [FILE_ID]), f"files rows other than {FILE_ID} identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "files"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_13.py b/sites/4shared/verify/verify_13.py new file mode 100644 index 00000000..4ce82ddc --- /dev/null +++ b/sites/4shared/verify/verify_13.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--13 (stateful: restore from Trash). + +Log in as david; open Trash and restore "David Old outline.txt" (file 146). + +Checks: identity | signed in as david | opened /trash | DB: file 146 changed ONLY in +deleted (1 -> 0) and modified_at; other files rows and every other table +row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, parse_args, + resolve_snapshots, row_by_id, row_changed_only_in, rows_unchanged_except) + +TASK_ID = "4shared--13" +EMAIL, USER_ID = "david.k@test.com", 4 +FILE_ID = 146 + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_trash", "/trash") + before, after = row_by_id(initial_db, "files", FILE_ID) or {}, row_by_id(after_db, "files", FILE_ID) or {} + j.check("initial_file_in_trash", bool(before) and bool(before.get("deleted")), f"initial_deleted={before.get('deleted')!r}") + j.check("file_restored", bool(after) and not after.get("deleted"), f"after_deleted={after.get('deleted')!r}") + ok, diff = row_changed_only_in(initial_db, after_db, "files", FILE_ID, ("deleted", "modified_at")) + j.check("file_row_changed_only_deleted_flag", ok and "deleted" in diff, f"diff={diff!r}") + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [FILE_ID]), f"files rows other than {FILE_ID} identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "files"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_14.py b/sites/4shared/verify/verify_14.py new file mode 100644 index 00000000..a0ddb694 --- /dev/null +++ b/sites/4shared/verify/verify_14.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--14 (stateful: share link). + +Log in as alice; open "Alice Field recording notes.docx" (file 125) and create a share +link labeled "Audio volunteers" with Preview and download permission. + +Checks: identity | signed in as alice | opened /file/125/share | DB: shared_links +gained exactly one row (alice, file 125, permission "download", label +"Audio volunteers"); every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, + normalize_text, parse_args, resolve_snapshots, table_delta) + +TASK_ID = "4shared--14" +EMAIL, USER_ID = "alice.j@test.com", 1 +FILE_ID = 125 +LABEL = "Audio volunteers" +PERMISSION = "download" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_share_page", f"/file/{FILE_ID}/share") + delta = table_delta(initial_db, after_db, "shared_links") + added = added_rows(initial_db, after_db, "shared_links") + j.check("shared_links_exact_delta", len(added) == 1 and not delta["removed"] and not delta["changed"], + f"added={len(added)} removed={len(delta['removed'])} changed={len(delta['changed'])}") + r = added[0] if added else {} + j.check("link_is_alice_file_125", bool(r) and int(r["user_id"]) == USER_ID and int(r["file_id"]) == FILE_ID, + f"user={r.get('user_id')} file={r.get('file_id')}") + j.check("link_permission_download", bool(r) and r["permission"] == PERMISSION, f"permission={r.get('permission')!r}") + j.check("link_label", bool(r) and normalize_text(r["label"]) == normalize_text(LABEL), f"label={r.get('label')!r}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "shared_links"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_15.py b/sites/4shared/verify/verify_15.py new file mode 100644 index 00000000..8375d45c --- /dev/null +++ b/sites/4shared/verify/verify_15.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--15 (stateful: comment). + +Log in as bob; find Beginner Map Reading Workbook (file 93) and post the comment +"The coordinate exercises are ideal for our Saturday workshop." + +Checks: identity | signed in as bob | opened the target detail page | DB: comments +gained exactly one row (bob, file 93, exact body); every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_detail_visited, check_signed_in_as, # noqa: E402 + check_tables_unchanged, check_trajectory_identity, fail_closed, load_run, + normalize_text, parse_args, resolve_snapshots, table_delta) + +TASK_ID = "4shared--15" +EMAIL, USER_ID = "bob.c@test.com", 2 +FILE_ID = 93 +SLUG = "beginner-map-reading-workbook-pdf-93" +BODY = "The coordinate exercises are ideal for our Saturday workshop." + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_detail_visited(j, t, SLUG) + delta = table_delta(initial_db, after_db, "comments") + added = added_rows(initial_db, after_db, "comments") + j.check("comments_exact_delta", len(added) == 1 and not delta["removed"] and not delta["changed"], + f"added={len(added)} removed={len(delta['removed'])} changed={len(delta['changed'])}") + r = added[0] if added else {} + j.check("comment_is_bob_on_file_93", bool(r) and int(r["user_id"]) == USER_ID and int(r["file_id"]) == FILE_ID, + f"user={r.get('user_id')} file={r.get('file_id')}") + j.check("comment_body_exact", bool(r) and normalize_text(r["body"]) == normalize_text(BODY), f"body={r.get('body')!r}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "comments"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_16.py b/sites/4shared/verify/verify_16.py new file mode 100644 index 00000000..bc756f84 --- /dev/null +++ b/sites/4shared/verify/verify_16.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--16 (stateful: premium checkout; task reworded +by the reviewer to name the 100 GB plan). + +Log in as bob; upgrade to the annual Premium 100 GB plan with cardholder Bob Chen and +the demo card; confirm the plan name and storage allowance shown on My 4shared. + +Checks: identity | signed in as bob | opened /premium, /premium/checkout and then +/account | DB: plan_orders gained exactly one row (bob, "Premium 100 GB", annual, +77.88, last4 4242); bob's users row changed ONLY in plan ("Premium") and +storage_limit_mb (102400); other users and every other table row-identical | +answer names Premium and 100 GB. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, affirmative_search, check_paths_in_order, # noqa: E402 + check_signed_in_as, check_tables_unchanged, check_trajectory_identity, + check_visited_path, contains_all, fail_closed, final_answer, load_run, + normalize_text, parse_args, resolve_snapshots, row_by_id, row_changed_only_in, + rows_unchanged_except, table_delta) + +TASK_ID = "4shared--16" +EMAIL, USER_ID = "bob.c@test.com", 2 +PLAN_NAME, PERIOD, AMOUNT, LAST4 = "Premium 100 GB", "annual", 77.88, "4242" +ACCOUNT_PLAN, STORAGE_MB = "Premium", 102400 + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_premium_checkout", "/premium/checkout") + check_visited_path(j, t, "visited_account_after_upgrade", "/account") + check_paths_in_order(j, t, "checkout_before_account_confirmation", ["/premium/checkout", "/account"]) + delta = table_delta(initial_db, after_db, "plan_orders") + added = added_rows(initial_db, after_db, "plan_orders") + j.check("plan_orders_exact_delta", len(added) == 1 and not delta["removed"] and not delta["changed"], + f"added={len(added)} removed={len(delta['removed'])} changed={len(delta['changed'])}") + r = added[0] if added else {} + j.check("order_is_bob_premium_100gb_annual", + bool(r) and int(r["user_id"]) == USER_ID and r["plan_name"] == PLAN_NAME and r["billing_period"] == PERIOD + and abs(float(r["amount"]) - AMOUNT) < 0.005 and str(r["card_last4"]) == LAST4, + f"order={ {k: r.get(k) for k in ('user_id', 'plan_name', 'billing_period', 'amount', 'card_last4')} if r else None!r}") + ok, diff = row_changed_only_in(initial_db, after_db, "users", USER_ID, ("plan", "storage_limit_mb")) + after = row_by_id(after_db, "users", USER_ID) or {} + j.check("bob_plan_and_storage_updated", ok and after.get("plan") == ACCOUNT_PLAN and int(after.get("storage_limit_mb") or 0) == STORAGE_MB, + f"plan={after.get('plan')!r} storage_limit_mb={after.get('storage_limit_mb')} diff={diff!r}") + j.check("other_users_unchanged", rows_unchanged_except(initial_db, after_db, "users", [USER_ID]), "users rows other than bob identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x not in {"plan_orders", "users"}]) + fa = final_answer(t) + j.check("answer_names_premium_plan", contains_all(fa, ["premium"]), f"answer={fa[:200]!r}") + j.check("answer_has_100gb_allowance", affirmative_search(r"(?= uploaded_at i.e. renamed after upload); shared_links +1 (carol, that +file, permission "view", label "Planning committee") created after the rename; every +other table and pre-existing row identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, + navigated_to_path, normalize_text, parse_args, resolve_snapshots, + rows_unchanged_except, table_delta) + +TASK_ID = "4shared--17" +EMAIL, USER_ID = "carol.d@test.com", 3 +FOLDER_NAME = "Workshop Handouts" +FINAL_NAME = "final-spring-workshop-outline.pdf" +SIZE_BYTES = 384 * 1024 +DESCRIPTION = "Draft outline for the spring neighborhood workshop." +LABEL, PERMISSION = "Planning committee", "view" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_my_files", "/my-files") + check_visited_path(j, t, "visited_upload_form", "/upload") + for table in ("folders", "files", "shared_links"): + d = table_delta(initial_db, after_db, table) + j.check(f"{table}_exact_delta", len(d["added"]) == 1 and not d["removed"] and not d["changed"], + f"table={table} added={len(d['added'])} removed={len(d['removed'])} changed={len(d['changed'])}") + folder = (added_rows(initial_db, after_db, "folders") or [{}])[0] + f = (added_rows(initial_db, after_db, "files") or [{}])[0] + link = (added_rows(initial_db, after_db, "shared_links") or [{}])[0] + j.check("new_folder_is_carols_root_workshop_handouts", + bool(folder) and int(folder["user_id"]) == USER_ID and folder["parent_id"] is None and str(folder["name"]).strip() == FOLDER_NAME, + f"folder={ {k: folder.get(k) for k in ('user_id', 'parent_id', 'name')} if folder else None!r}") + j.check("new_file_in_new_folder_owned_by_carol", + bool(f) and bool(folder) and int(f["owner_id"]) == USER_ID and f["folder_id"] == folder["id"], + f"owner={f.get('owner_id')} folder_id={f.get('folder_id')} new_folder_id={folder.get('id')}") + j.check("new_file_final_name", bool(f) and str(f["filename"]).strip() == FINAL_NAME and f["extension"] == "pdf", f"filename={f.get('filename')!r}") + j.check("new_file_size_384kb", bool(f) and int(f["size_bytes"]) == SIZE_BYTES, f"size_bytes={f.get('size_bytes')} expected={SIZE_BYTES}") + j.check("new_file_description", bool(f) and normalize_text(f["description"]) == normalize_text(DESCRIPTION), f"description={f.get('description')!r}") + j.check("new_file_private_documents", bool(f) and not f["public"] and not f["deleted"] and f["category"] == "Documents", + f"public={f.get('public')} deleted={f.get('deleted')} category={f.get('category')!r}") + j.check("file_renamed_after_upload", bool(f) and str(f["modified_at"]) >= str(f["uploaded_at"]), f"uploaded_at={f.get('uploaded_at')} modified_at={f.get('modified_at')}") + j.check("share_link_for_new_file_preview_only", + bool(link) and bool(f) and int(link["user_id"]) == USER_ID and link["file_id"] == f["id"] + and link["permission"] == PERMISSION and normalize_text(link["label"]) == normalize_text(LABEL), + f"link={ {k: link.get(k) for k in ('user_id', 'file_id', 'permission', 'label')} if link else None!r}") + j.check("share_link_created_after_rename", bool(link) and bool(f) and str(link["created_at"]) >= str(f["modified_at"]), + f"file_modified_at={f.get('modified_at')} link_created_at={link.get('created_at')}") + j.check("visited_new_file_share_page", bool(f) and navigated_to_path(t, f"/file/{f['id']}/share"), f"required_path=/file/{f.get('id')}/share") + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [f["id"]] if f else []), "pre-existing files rows identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x not in {"folders", "files", "shared_links"}]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_18.py b/sites/4shared/verify/verify_18.py new file mode 100644 index 00000000..7dbc6588 --- /dev/null +++ b/sites/4shared/verify/verify_18.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--18 (comparison + stateful save). + +Compare Pride and Prejudice (432 p / 61 ch), Anne of Green Gables (412 p / 38 ch) and +Twenty Thousand Leagues Under the Seas (512 p / 47 ch) by opening each detail page; +report the one with the most pages, its page count and chapter count; then log in +as david and save that book to My 4shared. + +Checks: identity | all THREE detail pages opened | answer names Twenty Thousand +Leagues as the longest with 512 pages and 47 chapters | signed in as david | DB: +david had not saved file 90; saved_files gained exactly (david, 90); every other +table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_detail_visited, check_signed_in_as, # noqa: E402 + check_tables_unchanged, check_trajectory_identity, claims_winner, + contains_filename, contains_number, fail_closed, final_answer, load_run, + parse_args, resolve_snapshots, saved_file_ids, table_delta) + +TASK_ID = "4shared--18" +EMAIL, USER_ID = "david.k@test.com", 4 +SLUGS = ["pride-and-prejudice-epub-77", "anne-of-green-gables-epub-73", "twenty-thousand-leagues-under-the-seas-epub-90"] +WINNER_ID, WINNER_FILENAME, WINNER_KEY = 90, "Twenty Thousand Leagues Under the Seas.epub", "Twenty Thousand Leagues" +LOSER_KEYS = ["Pride and Prejudice", "Anne of Green Gables"] +PAGES, CHAPTERS = 512, 47 + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + for slug in SLUGS: + check_detail_visited(j, t, slug) + fa = final_answer(t) + j.check("answer_names_longest_book", contains_filename(fa, WINNER_FILENAME) or claims_winner(fa, WINNER_KEY, LOSER_KEYS, cue=r"most|longest|largest|more|highest|biggest"), + f"expected={WINNER_FILENAME!r} answer={fa[:200]!r}") + j.check("answer_not_crediting_a_loser", claims_winner(fa, WINNER_KEY, LOSER_KEYS, cue=r"most|longest|largest|more|highest|biggest"), f"answer={fa[:200]!r}") + j.check("answer_has_page_count", contains_number(fa, PAGES), f"expected={PAGES} answer={fa[:200]!r}") + j.check("answer_has_chapter_count", contains_number(fa, CHAPTERS), f"expected={CHAPTERS} answer={fa[:200]!r}") + check_signed_in_as(j, t, EMAIL) + before, after = saved_file_ids(initial_db, USER_ID), saved_file_ids(after_db, USER_ID) + j.check("initial_target_not_saved", WINNER_ID not in before, f"initial_saved={sorted(before)!r}") + j.check("target_saved_for_david", WINNER_ID in after, f"after_saved={sorted(after)!r}") + delta = table_delta(initial_db, after_db, "saved_files") + added = added_rows(initial_db, after_db, "saved_files") + j.check("saved_files_exact_delta", + len(added) == 1 and not delta["removed"] and not delta["changed"] + and int(added[0]["user_id"]) == USER_ID and int(added[0]["file_id"]) == WINNER_ID, + f"added={[(r['user_id'], r['file_id']) for r in added]!r} removed={len(delta['removed'])} changed={len(delta['changed'])}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "saved_files"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_19.py b/sites/4shared/verify/verify_19.py new file mode 100644 index 00000000..cbbb91a3 --- /dev/null +++ b/sites/4shared/verify/verify_19.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--19 (search + favorite + download + report). + +Log in as alice; broad search for archive metadata resources; the document with a +12-week sample schedule and a risk register on page 39 (file 96); add it to +Favorites, download it, report exact filename and total page count. + +Checks: identity | signed in as alice | searched (archive/metadata) | opened the +target detail page | reached /download/96 | DB: favorites gained exactly (alice, 96); +exactly one new downloads row for file 96 by alice; download_count +1 on file 96 +only; every other table row-identical | answer has exact filename + 46 pages. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_detail_visited, check_download_recorded, # noqa: E402 + check_search_or_category, check_signed_in_as, check_tables_unchanged, + check_trajectory_identity, check_visited_path, contains_filename, contains_number, + fail_closed, favorite_file_ids, final_answer, load_run, parse_args, + resolve_snapshots, rows_unchanged_except, table_delta) + +TASK_ID = "4shared--19" +EMAIL, USER_ID = "alice.j@test.com", 1 +FILE_ID = 96 +SLUG = "small-archive-digitization-plan-pdf-96" +FILENAME = "Small Archive Digitization Plan.pdf" +PAGES = 46 + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_search_or_category(j, t, "searched_archive_metadata", any_tokens=["archive", "metadata", "digitization", "digitisation"], categories=["documents"]) + check_detail_visited(j, t, SLUG) + check_visited_path(j, t, "reached_download_page", f"/download/{FILE_ID}") + before, after = favorite_file_ids(initial_db, USER_ID), favorite_file_ids(after_db, USER_ID) + j.check("initial_target_not_favorited", FILE_ID not in before, f"initial_favorites={sorted(before)!r}") + delta = table_delta(initial_db, after_db, "favorites") + added = added_rows(initial_db, after_db, "favorites") + j.check("favorites_exact_delta", + len(added) == 1 and not delta["removed"] and not delta["changed"] + and int(added[0]["user_id"]) == USER_ID and int(added[0]["file_id"]) == FILE_ID, + f"added={[(r['user_id'], r['file_id']) for r in added]!r} removed={len(delta['removed'])} changed={len(delta['changed'])} after={sorted(after)!r}") + check_download_recorded(j, initial_db, after_db, FILE_ID, user_id=USER_ID, allow_anonymous=False) + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [FILE_ID]), f"files rows other than {FILE_ID} identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x not in {"favorites", "downloads", "files"}]) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_page_count", contains_number(fa, PAGES), f"expected={PAGES} answer={fa[:200]!r}") + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_2.py b/sites/4shared/verify/verify_2.py new file mode 100644 index 00000000..8b2068fc --- /dev/null +++ b/sites/4shared/verify/verify_2.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--2 (read-only; task re-anchored by the reviewer). + +Search Books for classic fiction; which exact filename has 12 chapters and editorial +notes beginning after page 116? Report its displayed file size. + +Checks: identity | searched Books / classic fiction | opened the target detail page +| answer has exact filename + displayed size | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_search_or_category, # noqa: E402 + check_trajectory_identity, contains_filename, contains_size, fail_closed, + final_answer, load_run, parse_args, resolve_snapshots) + +TASK_ID = "4shared--2" +SLUG = "the-time-machine-epub-72" +FILENAME = "The Time Machine.epub" +SIZE = "2.1 MB" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "searched_books_classic_fiction", any_tokens=["classic", "fiction", "novel"], categories=["books"]) + check_detail_visited(j, t, SLUG) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_displayed_size", contains_size(fa, SIZE), f"expected={SIZE!r} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_3.py b/sites/4shared/verify/verify_3.py new file mode 100644 index 00000000..9966b543 --- /dev/null +++ b/sites/4shared/verify/verify_3.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--3 (read-only). + +Broad search for garden planning resources; the PDF with three planting zones and a +maintenance calendar on page 49; report filename, page count and uploader. + +Checks: identity | searched (garden/planning) | opened the target detail page | +answer has exact filename + 58 pages + uploader | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_search_or_category, # noqa: E402 + check_trajectory_identity, contains_all, contains_filename, contains_number, + fail_closed, final_answer, load_run, parse_args, resolve_snapshots) + +TASK_ID = "4shared--3" +SLUG = "rain-garden-planting-guide-pdf-97" +FILENAME = "Rain Garden Planting Guide.pdf" +PAGES = 58 +UPLOADER = "Community Library" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "searched_garden_planning", any_tokens=["garden", "planting", "planning"], categories=["documents"]) + check_detail_visited(j, t, SLUG) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_page_count", contains_number(fa, PAGES), f"expected={PAGES} answer={fa[:200]!r}") + j.check("answer_has_uploader", contains_all(fa, [UPLOADER]), f"expected={UPLOADER!r} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_4.py b/sites/4shared/verify/verify_4.py new file mode 100644 index 00000000..61b9e275 --- /dev/null +++ b/sites/4shared/verify/verify_4.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--4 (read-only). + +Broad search for accessibility and design resources; the app package whose detail +page mentions a WCAG contrast preview; report exact filename, version and license. + +Checks: identity | searched (accessibility/design) | opened the target detail page | +answer has exact filename + version 5.0.0 + a license shown on the page (the page +shows both the catalog license "Open-source package" and "GPL-3.0" in the notes; +either is accepted) | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_search_or_category, # noqa: E402 + check_trajectory_identity, contains_any, contains_filename, fail_closed, + final_answer, load_run, normalize_text, parse_args, resolve_snapshots) + +TASK_ID = "4shared--4" +SLUG = "colorscope-palette-assistant-zip-44" +FILENAME = "ColorScope Palette Assistant.zip" +VERSION = "5.0.0" +LICENSES = ["GPL-3.0", "GPL 3.0", "GPLv3", "Open-source package"] + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "searched_accessibility_design", + any_tokens=["accessib", "design", "wcag", "contrast", "palette", "color"], categories=["apps"]) + check_detail_visited(j, t, SLUG) + fa = final_answer(t) + j.check("answer_has_exact_filename", contains_filename(fa, FILENAME), f"expected={FILENAME!r} answer={fa[:200]!r}") + j.check("answer_has_version", VERSION in normalize_text(fa) and "5.0.0" in normalize_text(fa), f"expected={VERSION!r} answer={fa[:200]!r}") + j.check("answer_has_license", contains_any(fa, LICENSES), f"expected_any={LICENSES!r} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_5.py b/sites/4shared/verify/verify_5.py new file mode 100644 index 00000000..d97f4679 --- /dev/null +++ b/sites/4shared/verify/verify_5.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--5 (read-only, comparison). + +In Videos compare Open Data Mapping Basics (27:03) with City Cycling Route Planning +(19:05) by opening both detail pages; which is longer, and both runtimes. + +Checks: identity | BOTH detail pages opened | answer has both runtimes and names +Open Data Mapping Basics as the longer one | every table unchanged. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, check_detail_visited, check_read_only, check_trajectory_identity, # noqa: E402 + claims_winner, contains_runtime, fail_closed, final_answer, load_run, + parse_args, resolve_snapshots) + +TASK_ID = "4shared--5" +WINNER_SLUG, WINNER_KEY, WINNER_RUNTIME = "open-data-mapping-basics-mp4-22", "Open Data Mapping Basics", "27:03" +LOSER_SLUG, LOSER_KEY, LOSER_RUNTIME = "city-cycling-route-planning-mp4-29", "City Cycling Route Planning", "19:05" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_detail_visited(j, t, WINNER_SLUG) + check_detail_visited(j, t, LOSER_SLUG) + fa = final_answer(t) + j.check("answer_has_winner_runtime", contains_runtime(fa, WINNER_RUNTIME), f"expected={WINNER_RUNTIME!r} answer={fa[:200]!r}") + j.check("answer_has_loser_runtime", contains_runtime(fa, LOSER_RUNTIME), f"expected={LOSER_RUNTIME!r} answer={fa[:200]!r}") + j.check("answer_names_longer_video", claims_winner(fa, WINNER_KEY, [LOSER_KEY]), f"expected_longer={WINNER_KEY!r} answer={fa[:200]!r}") + check_read_only(j, initial_db, after_db) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_6.py b/sites/4shared/verify/verify_6.py new file mode 100644 index 00000000..3e4d6a8f --- /dev/null +++ b/sites/4shared/verify/verify_6.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--6 (stateful: download). + +Find The Federalist Papers in Books, verify 85 essays + searchable topic index on +its detail page, then use the Download button. + +Checks: identity | searched/browsed Books | opened the detail page | the POST result +page /download/81 was reached after the detail page | DB: exactly one new downloads +row for file 81 (signed-in or anonymous), download_count +1 on that file only, every +other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, check_detail_visited, check_download_recorded, # noqa: E402 + check_paths_in_order, check_search_or_category, check_tables_unchanged, + check_trajectory_identity, check_visited_path, fail_closed, load_run, + parse_args, resolve_snapshots, rows_unchanged_except) + +TASK_ID = "4shared--6" +FILE_ID = 81 +SLUG = "the-federalist-papers-epub-81" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_search_or_category(j, t, "searched_federalist", any_tokens=["federalist"], categories=["books"]) + check_detail_visited(j, t, SLUG) + check_visited_path(j, t, "reached_download_page", f"/download/{FILE_ID}") + check_paths_in_order(j, t, "detail_before_download", [f"/file/{SLUG}", f"/download/{FILE_ID}"]) + check_download_recorded(j, initial_db, after_db, FILE_ID, user_id=None, allow_anonymous=True) + j.check("other_files_unchanged", rows_unchanged_except(initial_db, after_db, "files", [FILE_ID]), f"files rows other than {FILE_ID} identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x not in {"downloads", "files"}]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_7.py b/sites/4shared/verify/verify_7.py new file mode 100644 index 00000000..dc3b45dc --- /dev/null +++ b/sites/4shared/verify/verify_7.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--7 (stateful: favorite). + +Log in as alice, find ArchivePeek File Inspector, add it to Favorites, open +Favorites to confirm. + +Checks: identity | signed in as alice | opened the target detail page | opened +/favorites after it | DB: alice did not already favorite file 38; favorites gained +exactly one row (alice, 38); every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_detail_visited, check_paths_in_order, # noqa: E402 + check_signed_in_as, check_tables_unchanged, check_trajectory_identity, + check_visited_path, fail_closed, favorite_file_ids, load_run, parse_args, + resolve_snapshots, table_delta) + +TASK_ID = "4shared--7" +EMAIL, USER_ID = "alice.j@test.com", 1 +FILE_ID = 38 +SLUG = "archivepeek-file-inspector-zip-38" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_detail_visited(j, t, SLUG) + check_visited_path(j, t, "visited_favorites_page", "/favorites") + check_paths_in_order(j, t, "workflow_in_order", ["/login", f"/file/{SLUG}", "/favorites"]) + before, after = favorite_file_ids(initial_db, USER_ID), favorite_file_ids(after_db, USER_ID) + j.check("initial_target_not_favorited", FILE_ID not in before, f"initial_favorites={sorted(before)!r}") + j.check("target_favorited_for_alice", FILE_ID in after, f"after_favorites={sorted(after)!r}") + delta = table_delta(initial_db, after_db, "favorites") + added = added_rows(initial_db, after_db, "favorites") + j.check("favorites_exact_delta", + len(added) == 1 and not delta["removed"] and not delta["changed"] + and int(added[0]["user_id"]) == USER_ID and int(added[0]["file_id"]) == FILE_ID, + f"added={[(r['user_id'], r['file_id']) for r in added]!r} removed={len(delta['removed'])} changed={len(delta['changed'])}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "favorites"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_8.py b/sites/4shared/verify/verify_8.py new file mode 100644 index 00000000..2e13a699 --- /dev/null +++ b/sites/4shared/verify/verify_8.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--8 (stateful: save to My 4shared). + +Log in as alice, find Rain Garden Planting Guide, save it to My 4shared, open Saved +files to confirm. + +Checks: identity | signed in as alice | opened the target detail page | opened +/saved after it | DB: alice had not already saved file 97; saved_files gained exactly +one row (alice, 97); every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, added_rows, check_detail_visited, check_paths_in_order, # noqa: E402 + check_signed_in_as, check_tables_unchanged, check_trajectory_identity, + check_visited_path, fail_closed, load_run, parse_args, resolve_snapshots, + saved_file_ids, table_delta) + +TASK_ID = "4shared--8" +EMAIL, USER_ID = "alice.j@test.com", 1 +FILE_ID = 97 +SLUG = "rain-garden-planting-guide-pdf-97" + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_detail_visited(j, t, SLUG) + check_visited_path(j, t, "visited_saved_page", "/saved") + check_paths_in_order(j, t, "workflow_in_order", ["/login", f"/file/{SLUG}", "/saved"]) + before, after = saved_file_ids(initial_db, USER_ID), saved_file_ids(after_db, USER_ID) + j.check("initial_target_not_saved", FILE_ID not in before, f"initial_saved={sorted(before)!r}") + j.check("target_saved_for_alice", FILE_ID in after, f"after_saved={sorted(after)!r}") + delta = table_delta(initial_db, after_db, "saved_files") + added = added_rows(initial_db, after_db, "saved_files") + j.check("saved_files_exact_delta", + len(added) == 1 and not delta["removed"] and not delta["changed"] + and int(added[0]["user_id"]) == USER_ID and int(added[0]["file_id"]) == FILE_ID, + f"added={[(r['user_id'], r['file_id']) for r in added]!r} removed={len(delta['removed'])} changed={len(delta['changed'])}") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "saved_files"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_9.py b/sites/4shared/verify/verify_9.py new file mode 100644 index 00000000..17182512 --- /dev/null +++ b/sites/4shared/verify/verify_9.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for 4shared--9 (stateful: profile edit). + +Log in as alice; set location to "Portland, Oregon" and bio to +"Community archive volunteer and urban sketcher." + +Checks: identity | signed in as alice | opened /account/edit | DB: alice's users row +changed ONLY in location + bio to exactly those values (display name untouched); +other users and every other table row-identical. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (ALL_TABLES, Judge, check_signed_in_as, check_tables_unchanged, # noqa: E402 + check_trajectory_identity, check_visited_path, fail_closed, load_run, + normalize_text, parse_args, resolve_snapshots, row_by_id, row_changed_only_in, + rows_unchanged_except) + +TASK_ID = "4shared--9" +EMAIL, USER_ID = "alice.j@test.com", 1 +LOCATION = "Portland, Oregon" +BIO = "Community archive volunteer and urban sketcher." + + +def run_checks(j, t, initial_db, after_db): + check_trajectory_identity(j, t, TASK_ID) + check_signed_in_as(j, t, EMAIL) + check_visited_path(j, t, "visited_account_settings", "/account/edit") + ok, diff = row_changed_only_in(initial_db, after_db, "users", USER_ID, ("location", "bio")) + after = row_by_id(after_db, "users", USER_ID) or {} + j.check("alice_location_updated", normalize_text(after.get("location")) == normalize_text(LOCATION), f"after_location={after.get('location')!r}") + j.check("alice_bio_updated", normalize_text(after.get("bio")) == normalize_text(BIO), f"after_bio={after.get('bio')!r}") + j.check("alice_row_changed_only_location_bio", ok and bool(diff), f"diff={diff!r}") + j.check("other_users_unchanged", rows_unchanged_except(initial_db, after_db, "users", [USER_ID]), "users rows other than alice identical") + check_tables_unchanged(j, initial_db, after_db, [x for x in ALL_TABLES if x != "users"]) + + +def main(): + a = parse_args() + try: + t = load_run(a.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(a, TASK_ID) + j = Judge(TASK_ID, a.no_llm) + try: + run_checks(j, t, initial_db, after_db) + except Exception as exc: # noqa: BLE001 + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/4shared/verify/verify_lib.py b/sites/4shared/verify/verify_lib.py new file mode 100644 index 00000000..58660d41 --- /dev/null +++ b/sites/4shared/verify/verify_lib.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic utilities for 4shared task verification. + +Philosophy: DETERMINISTIC FIRST, no LLM in the verdict path. + 1. Run-package identity (anti-tamper): exact task id, agent_done, non-empty + final answer, every recorded URL on the same loopback origin AND port as + start_url, every referenced screenshot present and PNG-framed. + 2. Navigation gate (anti knowledge-shortcut): the agent MUST have opened the + on-site page(s) that carry the answer (exact /file/ paths, /search, + /category/, /login, /my-files, ...), in the required order where the + task implies one. + 3. Answer check: token / number / filename containment against ground truth + that is HARDCODED in each verify_N.py (never in tasks.jsonl). + 4. SQLite after-state (stateful tasks): exact allowed row deltas against the + initial snapshot; every other table and every unrelated row must be + byte-for-row identical. Read-only tasks require ALL tables unchanged. + 5. The llm_* helpers are kept for API parity with merriam_webster's lib; no + verifier in this suite depends on them, and --no_llm short-circuits them. + +Input signature (per task): + --run_dir DIR agent run: trajectory.json + screenshots/step_NNN.png + (+ optional initial.db / after.db snapshots inside DIR) + --initial_db PATH initial-state SQLite DB (default: /initial.db, else + docker cp instance_seed from --container) + --after_db PATH after-state SQLite DB (default: /after.db, else + docker cp instance from --container) + --container NAME docker container to fetch DBs from ($WH_CONTAINER or wh-review) + --no_llm True skip LLM helpers (they are unused anyway) +Output: JSON {task_id, pass, reason, evidence[]} on stdout; exit 0 PASS / 1 FAIL. + +NOTE: no `from __future__ import annotations` here on purpose — simpleArgParser +reads the VerifyArgs dataclass annotations at runtime and postponed annotations +would turn `str` into the string "str" (argparse: "'str' is not callable"). +""" +import atexit +import base64 +import ipaddress +import json +import os +import re +import sqlite3 +import subprocess +import sys +import tempfile +import unicodedata +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + +SITE = "4shared" +DEFAULT_CONTAINER = os.environ.get("WH_CONTAINER", "wh-review") + +EXPECTED_TABLES = {"comments", "downloads", "favorites", "files", "folders", + "plan_orders", "saved_files", "shared_links", "users"} +SEED_COUNTS = {"users": 4, "files": 146, "folders": 16, "favorites": 16, "saved_files": 12, + "downloads": 8, "shared_links": 4, "comments": 12, "plan_orders": 1} +SEED_PUBLIC_FILES = 122 +USER_IDS = {"alice.j@test.com": 1, "bob.c@test.com": 2, "carol.d@test.com": 3, "david.k@test.com": 4} +ALL_TABLES = ("users", "folders", "files", "favorites", "saved_files", "downloads", + "shared_links", "comments", "plan_orders") +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +# ---------------------------------------------------------------- CLI +import simpleArgParser as sap # noqa: E402 (available in the agent_demo env) + + +def parse_args(): + @dataclass + class VerifyArgs: + run_dir: str = "" + initial_db: str = "" + after_db: str = "" + container: str = DEFAULT_CONTAINER + no_llm: bool = False + + def post_process(self): + if not self.run_dir: + raise SystemExit("--run_dir is required") + run = Path(self.run_dir) + if not self.initial_db and (run / "initial.db").is_file(): + self.initial_db = str(run / "initial.db") + if not self.after_db and (run / "after.db").is_file(): + self.after_db = str(run / "after.db") + return sap.parse_args(VerifyArgs) + + +# ---------------------------------------------------------------- trajectory +def load_run(run_dir) -> dict: + d = Path(run_dir) + traj = json.loads((d / "trajectory.json").read_text(encoding="utf-8")) + if not isinstance(traj, dict): + raise ValueError("trajectory.json must contain a JSON object") + traj["_run_dir"] = d + traj["_shots"] = {p.name: p for p in sorted((d / "screenshots").glob("step_*.png"))} if (d / "screenshots").is_dir() else {} + return traj + + +def final_answer(traj) -> str: + return str(traj.get("final_answer") or "").strip() + + +def trajectory_urls(traj) -> list[str]: + urls: list[str] = [] + if traj.get("start_url"): + urls.append(str(traj["start_url"])) + for s in traj.get("steps") or []: + if not isinstance(s, dict): + continue + for key in ("url", "url_before", "url_after"): + if s.get(key): + urls.append(str(s[key])) + params = s.get("params") if isinstance(s.get("params"), dict) else {} + if s.get("action") == "navigate" and params.get("url"): + urls.append(str(params["url"])) + if traj.get("final_url"): + urls.append(str(traj["final_url"])) + return urls + + +def step_urls(traj) -> list[str]: + return [str(s.get("url", "")) for s in traj.get("steps") or [] if isinstance(s, dict)] + + +def is_site_url(url: str) -> bool: + """HTTP(S) URL on a loopback host (any port).""" + p = urlparse(str(url or "")) + if p.scheme not in {"http", "https"} or not p.hostname: + return False + host = p.hostname.casefold() + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def same_origin_as_start(url: str, start_url: str) -> bool: + """Loopback + same scheme/host/port as start_url (walmart-style hardening).""" + try: + o, s = urlparse(str(url or "")), urlparse(str(start_url or "")) + except ValueError: + return False + return (is_site_url(url) and is_site_url(start_url) and o.scheme == s.scheme + and (o.hostname or "").casefold() == (s.hostname or "").casefold() + and o.port == s.port and not o.username and not o.password) + + +def site_urls(traj) -> list[str]: + return [u for u in trajectory_urls(traj) if is_site_url(u)] + + +def normalized_url_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def navigated_to_path(traj, path: str) -> bool: + """Exact mirror path (query ignored) somewhere in the recorded history.""" + want = normalized_url_path(path) + return any(normalized_url_path(u) == want for u in site_urls(traj)) + + +def navigated_to(traj, substr: str, times: int = 1) -> bool: + return sum(1 for u in site_urls(traj) if substr in u) >= times + + +def navigated_any(traj, substrs: Iterable[str]) -> bool: + return any(navigated_to(traj, s) for s in substrs) + + +def detail_visited(traj, slug: str) -> bool: + return navigated_to_path(traj, f"/file/{slug}") + + +def query_params(url: str) -> dict[str, list[str]]: + return parse_qs(urlparse(str(url or "")).query, keep_blank_values=True) + + +def search_visited(traj, any_tokens: Iterable[str] = (), all_tokens: Iterable[str] = (), + category: str | None = None) -> bool: + """Some /search visit whose q contains every `all_tokens` and any of `any_tokens` + (normalized substrings) and, if given, whose category param equals `category`.""" + any_t = [normalize_text(t) for t in any_tokens] + all_t = [normalize_text(t) for t in all_tokens] + for u in site_urls(traj): + if normalized_url_path(u) != "/search": + continue + q = query_params(u) + qtext = normalize_text(" ".join(q.get("q") or [])) + if all_t and not all(t in qtext for t in all_t): + continue + if any_t and not any(t in qtext for t in any_t): + continue + if category is not None and normalize_text(category) not in [normalize_text(c) for c in q.get("category") or []]: + continue + return True + return False + + +def category_visited(traj, category: str) -> bool: + """/category/ OR a /search visit filtered to that category.""" + return navigated_to_path(traj, f"/category/{category.lower()}") or search_visited(traj, category=category) + + +def paths_in_order(traj, paths: Sequence[str]) -> bool: + """Each path (exact, query ignored) appears after the previous one in history.""" + urls = site_urls(traj) + cursor = 0 + for want in paths: + want = normalized_url_path(want) + for i in range(cursor, len(urls)): + if normalized_url_path(urls[i]) == want: + cursor = i + 1 + break + else: + return False + return True + + +def first_index_of_path(traj, path: str) -> int | None: + want = normalized_url_path(path) + for i, u in enumerate(site_urls(traj)): + if normalized_url_path(u) == want: + return i + return None + + +def typed_texts(traj) -> list[str]: + out = [] + for s in traj.get("steps") or []: + if isinstance(s, dict) and s.get("action") == "input": + params = s.get("params") if isinstance(s.get("params"), dict) else {} + if params.get("text") is not None: + out.append(str(params["text"])) + return out + + +def typed_email(traj, email: str) -> bool: + want = normalize_text(email) + return any(normalize_text(t) == want for t in typed_texts(traj)) + + +def _shot(traj, name): + if not name: + return None + p = traj["_shots"].get(Path(str(name)).name) + return p if (p and p.exists()) else None + + +def last_shot(traj): + for s in reversed(traj.get("steps") or []): + p = _shot(traj, s.get("screenshot_after")) or _shot(traj, s.get("screenshot_before")) + if p: + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + + +def screenshots_ok(traj) -> tuple[bool, str]: + steps = traj.get("steps") + if not isinstance(steps, list) or not steps: + return False, "no steps" + checked = 0 + for i, s in enumerate(steps): + if not isinstance(s, dict): + return False, f"step {i} is not an object" + for key in ("screenshot_before", "screenshot_after"): + name = s.get(key) + rel = Path(str(name or "")) + if not name or rel.is_absolute() or ".." in rel.parts: + return False, f"step {i} has unsafe {key}={name!r}" + p = _shot(traj, name) + if p is None: + return False, f"step {i} is missing {key}={name!r}" + try: + head = p.read_bytes()[:8] + except OSError as exc: + return False, f"step {i} {key} unreadable: {exc}" + if head != PNG_MAGIC or p.stat().st_size <= 8: + return False, f"step {i} {key} is not a PNG" + checked += 1 + return True, f"{checked} PNG screenshots present" + + +# ---------------------------------------------------------------- text matching +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"') + text = text.replace("×", "x").replace("–", "-").replace("—", "-").replace("−", "-") + return re.sub(r"\s+", " ", text).strip().casefold() + + +def norm(s): # merriam-compatible alias + return normalize_text(s) + + +def _negated(text: str, m: re.Match) -> bool: + before = re.split(r"[.!?;:\n]+|\b(?:but|however|instead|whereas|while)\b", text[:m.start()], flags=re.I)[-1] + after = text[m.end():m.end() + 40] + return bool(re.search(r"\b(?:not|no|never|without|wrong|incorrect|isn't|wasn't|isnt|wasnt|neither|nor)\b", before, re.I) + or re.match(r"\s*(?:is|was|are|were|has|have)?\s*(?:not|wrong|incorrect)\b", after, re.I)) + + +def affirmative_search(pattern: str, text: str, flags: int = 0) -> bool: + return any(not _negated(text, m) for m in re.finditer(pattern, text, flags)) + + +def contains_all(text: Any, tokens: Iterable[Any]) -> bool: + t = normalize_text(text) + return all(bool(tok) and affirmative_search(re.escape(tok), t) for tok in (normalize_text(x) for x in tokens)) + + +def contains_any(text: Any, tokens: Iterable[Any]) -> bool: + t = normalize_text(text) + return any(bool(tok) and affirmative_search(re.escape(tok), t) for tok in (normalize_text(x) for x in tokens)) + + +def answer_equals(final, expected) -> bool: + return normalize_text(final) == normalize_text(expected) + + +def contains_filename(text: Any, filename: str) -> bool: + """The exact filename (stem + extension) appears; whitespace/case/quote tolerant.""" + t = normalize_text(text) + want = normalize_text(filename) + return affirmative_search(r"(? bool: + """`number` as a standalone integer (not inside a longer digit run, a decimal, a + time like 10:45, a ratio like 1/80, or a version like 5.0.0); word form for <= 20.""" + t = normalize_text(text) + n = str(int(number)) + pattern = rf"(? bool: + """`27:03` also accepted as `27 min 3 s`, `27 minutes 03 seconds`, `27m03s`.""" + m, s = mmss.split(":") + t = normalize_text(text) + pats = [rf"(? bool: + """`4.2 MB` tolerant of `4.2MB`, `4.2 mb`, `4.2 megabytes`.""" + num, unit = human.split() + t = normalize_text(text) + unit_pat = {"kb": r"(?:kb|kib|kilobytes?)", "mb": r"(?:mb|mib|megabytes?)", "gb": r"(?:gb|gib|gigabytes?)", "b": r"(?:b|bytes?)"}[unit.lower()] + return affirmative_search(rf"(? bool: + t = normalize_text(text) + return affirmative_search(rf"(? str | None: + t = normalize_text(text) + best = None + for k in keys: + i = t.find(normalize_text(k)) + if i >= 0 and (best is None or i < best[0]): + best = (i, k) + return best[1] if best else None + + +def claims_winner(text: Any, winner_key: str, loser_keys: Sequence[str], + cue: str = r"longer|longest|most|more|highest|largest|biggest", + inverse_cue: str = r"shorter|shortest|less|fewer|fewest|smaller|smallest|lower|lowest") -> bool: + """Deterministic reading of a comparison claim. + + The answer must name `winner_key`. In every sentence that carries a comparison + cue, the item the sentence credits is resolved as: the item nearest BEFORE the + cue (the grammatical subject: "X is longer than Y"); if none precedes it, the + item nearest AFTER it ("the longer one is X"); "former"/"latter" near the cue + select the first/last item named in that sentence. A positive cue must credit + the winner and an inverse cue must not. With no cue sentence at all, the winner + must simply be the first item mentioned. + """ + t = normalize_text(text) + w = normalize_text(winner_key) + keys = [w] + [normalize_text(k) for k in loser_keys] + if w not in t: + return False + saw_cue = False + # Split only on sentence punctuation followed by whitespace/end so that the + # "." inside filenames ("…Seas.epub", "…Summer.mp3") never cuts a sentence. + for sentence in re.split(r"[.!?;\n]+(?=\s|$)", t): + positions = sorted((sentence.find(k), k) for k in keys if k in sentence) + if not positions: + continue + for m in re.finditer(rf"\b(?:{cue}|{inverse_cue})\b", sentence): + saw_cue = True + window = sentence[max(0, m.start() - 40):m.end() + 40] + if re.search(r"\blatter\b", window): + credited = positions[-1][1] + elif re.search(r"\bformer\b", window): + credited = positions[0][1] + else: + before = [k for p, k in positions if p < m.start()] + after = [k for p, k in positions if p > m.start()] + credited = before[-1] if before else (after[0] if after else None) + inverse = re.fullmatch(rf"(?:{inverse_cue})", m.group()) + if credited is not None and ((credited != w) if not inverse else (credited == w)): + return False + if not saw_cue: + return first_mentioned(t, keys) == w + return True + + +# ---------------------------------------------------------------- SQLite +def db_query(db_path, sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + con = sqlite3.connect(str(db_path)) + con.row_factory = sqlite3.Row + try: + return con.execute(sql, params).fetchall() + finally: + con.close() + + +def fetch_db(container: str, kind: str) -> str: + if kind not in {"instance", "instance_seed"}: + raise ValueError(f"unsupported DB kind: {kind}") + fd, dest = tempfile.mkstemp(prefix=f"{SITE}_{kind}_", suffix=".db") + os.close(fd) + src = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + r = subprocess.run(["docker", "cp", src, dest], capture_output=True, text=True) + if r.returncode: + Path(dest).unlink(missing_ok=True) + raise RuntimeError(f"docker cp {src} failed: {r.stderr.strip() or r.stdout.strip()}") + atexit.register(Path(dest).unlink, missing_ok=True) + return dest + + +def resolve_db(explicit: str | None, container: str, kind: str) -> str | None: + if explicit: + return explicit if Path(explicit).is_file() else None + try: + return fetch_db(container, kind) + except (OSError, RuntimeError): + return None + + +def table_rows(db_path, table: str) -> list[tuple]: + if table not in EXPECTED_TABLES: + raise ValueError(f"unsupported table: {table}") + return [tuple(r) for r in db_query(db_path, f"SELECT * FROM {table} ORDER BY id")] + + +def table_columns(db_path, table: str) -> list[str]: + if table not in EXPECTED_TABLES: + raise ValueError(f"unsupported table: {table}") + return [r["name"] for r in db_query(db_path, f"PRAGMA table_info({table})")] + + +def row_by_id(db_path, table: str, row_id: int) -> dict | None: + if table not in EXPECTED_TABLES: + raise ValueError(f"unsupported table: {table}") + rows = db_query(db_path, f"SELECT * FROM {table} WHERE id = ?", (int(row_id),)) + return dict(rows[0]) if rows else None + + +def rows_where(db_path, table: str, where: str, params: Sequence[Any] = ()) -> list[dict]: + if table not in EXPECTED_TABLES: + raise ValueError(f"unsupported table: {table}") + return [dict(r) for r in db_query(db_path, f"SELECT * FROM {table} WHERE {where} ORDER BY id", params)] + + +def table_delta(initial_db, after_db, table: str) -> dict[str, list]: + before = {int(r[0]): r for r in table_rows(initial_db, table)} + after = {int(r[0]): r for r in table_rows(after_db, table)} + common = before.keys() & after.keys() + return {"added": [after[k] for k in sorted(after.keys() - before.keys())], + "removed": [before[k] for k in sorted(before.keys() - after.keys())], + "changed": [(before[k], after[k]) for k in sorted(common) if before[k] != after[k]]} + + +def added_rows(initial_db, after_db, table: str) -> list[dict]: + cols = table_columns(after_db, table) + return [dict(zip(cols, r)) for r in table_delta(initial_db, after_db, table)["added"]] + + +def tables_unchanged(initial_db, after_db, tables: Iterable[str]) -> dict[str, bool]: + return {t: table_rows(initial_db, t) == table_rows(after_db, t) for t in tables} + + +def rows_unchanged_except(initial_db, after_db, table: str, excluded_ids: Iterable[int]) -> bool: + ex = {int(i) for i in excluded_ids} + before = [r for r in table_rows(initial_db, table) if int(r[0]) not in ex] + after = [r for r in table_rows(after_db, table) if int(r[0]) not in ex] + return before == after + + +def row_changed_only_in(initial_db, after_db, table: str, row_id: int, allowed_cols: Iterable[str]) -> tuple[bool, dict]: + """Row `row_id` differs from initial ONLY in `allowed_cols`; returns (ok, diff).""" + b, a = row_by_id(initial_db, table, row_id), row_by_id(after_db, table, row_id) + if b is None or a is None: + return False, {"missing": True} + diff = {k: (b[k], a[k]) for k in b if b[k] != a.get(k)} + return set(diff) <= set(allowed_cols), diff + + +def _schema_objects(db_path) -> list[tuple]: + return [tuple(r) for r in db_query( + db_path, "SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type, name")] + + +def validate_snapshot_contract(initial_db, after_db) -> None: + for label, db in (("initial", initial_db), ("after", after_db)): + tables = {r["name"] for r in db_query(db, "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'")} + if tables != EXPECTED_TABLES: + raise ValueError(f"{label} DB has unexpected tables: {sorted(tables)}") + if _schema_objects(initial_db) != _schema_objects(after_db): + raise ValueError("initial and after database schemas differ") + counts = {t: len(table_rows(initial_db, t)) for t in SEED_COUNTS} + if counts != SEED_COUNTS: + raise ValueError(f"initial DB row counts differ from the 4shared seed: expected={SEED_COUNTS} observed={counts}") + public = db_query(initial_db, "SELECT COUNT(*) AS n FROM files WHERE public = 1 AND deleted = 0")[0]["n"] + if public != SEED_PUBLIC_FILES: + raise ValueError(f"initial DB public file count {public} != {SEED_PUBLIC_FILES}") + for email, uid in USER_IDS.items(): + rows = db_query(initial_db, "SELECT id FROM users WHERE email = ?", (email,)) + if not rows or int(rows[0]["id"]) != uid: + raise ValueError(f"initial DB lacks benchmark user {email} with id {uid}") + + +def resolve_snapshots(args, task_id: str) -> tuple[str, str]: + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + if not initial_db or not after_db: + fail_closed(task_id, "database_unavailable", "both initial and after 4shared database snapshots are required") + try: + validate_snapshot_contract(initial_db, after_db) + except (OSError, sqlite3.Error, ValueError) as exc: + fail_closed(task_id, "snapshot_contract_invalid", str(exc)) + return str(initial_db), str(after_db) + + +def user_id_for_email(db_path, email: str) -> int | None: + rows = db_query(db_path, "SELECT id FROM users WHERE lower(email) = lower(?)", (email,)) + return int(rows[0]["id"]) if rows else None + + +def favorite_file_ids(db_path, user_id: int) -> set[int]: + return {int(r["file_id"]) for r in db_query(db_path, "SELECT file_id FROM favorites WHERE user_id = ?", (user_id,))} + + +def saved_file_ids(db_path, user_id: int) -> set[int]: + return {int(r["file_id"]) for r in db_query(db_path, "SELECT file_id FROM saved_files WHERE user_id = ?", (user_id,))} + + +def public_catalog_unchanged_except(initial_db, after_db, file_ids: Iterable[int] = ()) -> bool: + """Every seeded public catalog row is identical except the listed ids.""" + ex = {int(i) for i in file_ids} + b = [r for r in rows_where(initial_db, "files", "public = 1") if r["id"] not in ex] + a_map = {r["id"]: r for r in rows_where(after_db, "files", "public = 1")} + return all(a_map.get(r["id"]) == r for r in b) + + +# ---------------------------------------------------------------- LLM utilities (parity only; unused by the verdicts) +_NO_LLM = False + + +def _llm_config(): + return os.environ.get("OPENAI_API_KEY", ""), os.environ.get("OPENAI_BASE_URL", ""), os.environ.get("JUDGE_MODEL", "") + + +def _chat(messages, max_tokens=1024): + if _NO_LLM: + return None + key, base, model = _llm_config() + if not (key and base and model): + return None + payload = {"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": 1.0} + req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}) + try: + data = json.loads(urllib.request.urlopen(req, timeout=180).read()) + return data["choices"][0]["message"]["content"] + except Exception: + return None + + +def _verdict(out): + if not out: + return False, "" + s = out.strip() + return s.upper().startswith("PASS"), s + + +def llm_text_match(agent_answer, ground_truth, question): + if _NO_LLM: + return False, "[skipped: --no_llm]" + return _verdict(_chat([{"role": "user", "content": + f"You are a STRICT binary grader.\nQuestion: {question}\nGround-truth answer (ANCHOR): {ground_truth}\n" + f"Agent's answer: {agent_answer}\nLine 1: PASS or FAIL. Line 2: one-sentence reason."}])) + + +def llm_screenshot_shows(shot_path, must_show, question=""): + if _NO_LLM: + return False, "[skipped: --no_llm]" + b64 = base64.b64encode(Path(shot_path).read_bytes()).decode() + return _verdict(_chat([{"role": "user", "content": [ + {"type": "text", "text": f"Only visibly rendered pixels count. Question: {question}\nExpected content: {must_show}\nLine 1: PASS or FAIL. Line 2: evidence."}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])) + + +# ---------------------------------------------------------------- judge harness +class Judge: + def __init__(self, task_id: str, no_llm: bool = False): + global _NO_LLM + _NO_LLM = bool(no_llm) + self.task_id = task_id + self.no_llm = no_llm + self.ok = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, cond: Any, evidence: str = "", llm: bool = False) -> bool: + if llm and self.no_llm: + self.evidence.append(f"[SKIP] {name} (--no_llm)") + return True + if cond: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(cond) + + def emit(self) -> None: + print(json.dumps({"task_id": self.task_id, "pass": self.ok, + "reason": self.reason or "all checks passed", "evidence": self.evidence}, + ensure_ascii=False, indent=2)) + sys.exit(0 if self.ok else 1) + + +def fail_closed(task_id: str, reason: str, detail: str) -> None: + print(json.dumps({"task_id": task_id, "pass": False, "infra_error": True, "reason": reason, + "evidence": [f"[FAIL] {reason}: {detail}"]}, ensure_ascii=False, indent=2)) + raise SystemExit(1) + + +# ---------------------------------------------------------------- composite checks +def check_trajectory_identity(j: Judge, traj: dict, task_id: str) -> None: + fa = final_answer(traj) + j.check("final_answer_nonempty", bool(fa), f"final_answer={fa[:160]!r}") + j.check("trajectory_task_matches", str(traj.get("task_id") or "").strip() == task_id, + f"expected={task_id!r} observed={traj.get('task_id')!r}") + j.check("trajectory_completed", traj.get("terminated") is True and traj.get("termination_reason") == "agent_done", + f"terminated={traj.get('terminated')!r} reason={traj.get('termination_reason')!r}") + steps = traj.get("steps") + j.check("trajectory_has_steps", isinstance(steps, list) and bool(steps), f"steps={len(steps) if isinstance(steps, list) else 'invalid'}") + urls = trajectory_urls(traj) + bad = [u for u in urls if not same_origin_as_start(u, traj.get("start_url", ""))] + j.check("all_urls_on_start_origin", bool(urls) and not bad, + f"start_url={traj.get('start_url')!r} n_urls={len(urls)} off_origin={bad[:3]!r}") + ok, ev = screenshots_ok(traj) + j.check("screenshots_present_png", ok, ev) + + +def check_signed_in_as(j: Judge, traj: dict, email: str) -> None: + j.check("visited_login_page", navigated_to_path(traj, "/login"), "required_path=/login") + j.check("typed_account_email", typed_email(traj, email), f"expected_email={email!r} typed={typed_texts(traj)[:6]!r}") + + +def check_visited_path(j: Judge, traj: dict, name: str, path: str) -> bool: + return j.check(name, navigated_to_path(traj, path), f"required_path={path}") + + +def check_detail_visited(j: Judge, traj: dict, slug: str) -> bool: + return j.check(f"visited_detail_{slug}", detail_visited(traj, slug), f"required_path=/file/{slug}") + + +def check_search_or_category(j: Judge, traj: dict, name: str, any_tokens: Iterable[str] = (), categories: Iterable[str] = ()) -> bool: + ok = search_visited(traj, any_tokens=any_tokens) or any(navigated_to_path(traj, f"/category/{c.lower()}") for c in categories) + return j.check(name, ok, f"required=/search?q~{list(any_tokens)!r} or /category/{list(categories)!r}; observed={[u for u in site_urls(traj) if '/search' in u or '/category/' in u][:6]!r}") + + +def check_paths_in_order(j: Judge, traj: dict, name: str, paths: Sequence[str]) -> bool: + return j.check(name, paths_in_order(traj, paths), f"required_order={list(paths)!r}; observed={[normalized_url_path(u) for u in site_urls(traj)]!r}") + + +def check_tables_unchanged(j: Judge, initial_db, after_db, tables: Iterable[str], prefix: str = "") -> None: + for t, same in tables_unchanged(initial_db, after_db, tables).items(): + j.check(f"{prefix}{t}_unchanged", same, + f"table={t} initial_rows={len(table_rows(initial_db, t))} after_rows={len(table_rows(after_db, t))}") + + +def check_read_only(j: Judge, initial_db, after_db) -> None: + """Read-only tasks: every table row-identical (the seed is the whole world).""" + check_tables_unchanged(j, initial_db, after_db, ALL_TABLES, prefix="read_only_") + + +def check_download_recorded(j: Judge, initial_db, after_db, file_id: int, user_id: int | None = None, allow_anonymous: bool = True) -> None: + """Exactly one new downloads row for file_id (+1 download_count on that file).""" + added = added_rows(initial_db, after_db, "downloads") + mine = [r for r in added if int(r["file_id"]) == int(file_id)] + # user_id=None means "any account (or anonymous when allowed)"; a concrete + # user_id must match exactly (anonymous only if allow_anonymous). + who_ok = all((user_id is None and (allow_anonymous or r["user_id"] is not None)) + or (user_id is not None and (r["user_id"] == user_id or (allow_anonymous and r["user_id"] is None))) + for r in mine) + j.check("download_row_added", len(added) == 1 and len(mine) == 1 and who_ok, + f"added_downloads={[(r['file_id'], r['user_id']) for r in added]!r} expected_file={file_id} expected_user={user_id}") + b, a = row_by_id(initial_db, "files", file_id), row_by_id(after_db, "files", file_id) + ok, diff = row_changed_only_in(initial_db, after_db, "files", file_id, ("download_count",)) + j.check("download_count_incremented", ok and b and a and int(a["download_count"]) == int(b["download_count"]) + 1, + f"file={file_id} before={b and b['download_count']} after={a and a['download_count']} diff={diff!r}") diff --git a/websyn_start.sh b/websyn_start.sh index f575dbc2..7b20da46 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -7,7 +7,8 @@ SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers - fedex webmd_doctor healthline kaggle) + fedex webmd_doctor healthline kaggle + 4shared) BASE_PORT=40000 SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids