A dead-simple materials board for students. No accounts, no login — anyone with the link can browse, download, and contribute course files across three sections: Notes, Slides, Past Papers.
Workspaces (multi-tenant). The site root (/) lets anyone create or
open a workspace — an isolated board with its own courses, owner secret,
and optional upload passphrase. Workspaces never see each other. Creating one
yields a one-time owner link (the only credential), the same capability-URL
idea as the per-file delete token.
Built with Laravel 13 + Inertia + Vue 3, Tailwind v4, SQLite.
- Anonymous upload — pick a section, optional title and your name, attach one or several files at once. PDF / Word / PowerPoint / image, up to 25 MB each. Files that would exceed the workspace cap are skipped while the rest save. Identical files already on the board (matched by content hash) are skipped as duplicates.
- Uploader delete — a single-file upload gets a secret token; the success banner shows a one-time "Undo upload" link, good only for that page view. Holding the token is the only credential. Batch uploads (multiple files at once) don't get this link yet.
- Owner mode — visit
/<workspace>?owner=SECRETto unlock a per-row "Delete" on every file for the session; "Lock board" leaves owner mode on the current device. The secret is per-workspace, stored only as a bcrypt hash, and shown once at creation. - Optional upload passphrase — each workspace can gate uploads behind its own passphrase (entered once per session). Empty = open (the default).
- Optional Telegram notifications — set
TELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDto post a one-line notice on every upload. Outbound only, sent after the response and failure-swallowed. Empty = disabled. - Reporting & operator moderation — every file has a Report button; reports
surface on an operator dashboard at
/operator(gated byOPERATOR_SECRET). The operator can Remove any file (blocklisting its content hash) or Dismiss false reports. - Anonymous download — files grouped by section with file-type icons, addressed by an unguessable per-file token (not a sequential id).
- Download a whole section — one streamed zip per section (Notes / Slides / Past Papers), the exam-week "give me all the past papers" case.
- In-browser preview — click a file's title to open PDFs and images inline
(
?view=1); other types download. Gated to safe types so uploads can't be served inline as an XSS vector. - QR code — every board link has a scannable QR with a downloadable PNG for slides and handouts.
- Live search + sort — multi-word, separator-tolerant search over title and
filename (so "past paper" finds
past_papers_2023.pdf); sort Newest / Oldest / A–Z. - Storage meter — owners see how much of the workspace cap is used before uploads start bouncing.
- Dark mode, mobile-optimised, and accessible (WCAG AA contrast,
ariaon form errors).
- PHP 8.3+, Composer
- Node 18+ / npm
- SQLite (default; no DB server needed)
composer install
npm install
cp .env.example .env # if .env is missing
php artisan key:generate
touch database/database.sqlite
php artisan migrate
# No storage:link needed — uploads live on the private disk and stream
# through the app.
npm run build # or `npm run dev` for hot reload
php artisan serveThe site root (/) is the workspace landing: create a workspace (save the
one-time owner link) or open an existing one by name. Create and manage
courses in owner mode (/<workspace>?owner=SECRET): add, edit code/title
(the slug stays fixed so shared /c/<slug> links keep working), reorder, and
bulk-delete files. Course slugs are unique within a workspace.
Lose the owner link and the board is unrecoverable — unless the owner set a
recovery email (encrypted at rest, owner-mode only). Visit
/<workspace>/recover, enter that email, and a signed, single-use restore link
(valid for an hour) is emailed to the stored address. Opening it rotates the
owner secret, retires the old link, and shows the new one once. Until it is
opened nothing changes — so knowing someone's recovery address is not enough
to revoke their access. Responses are identical whether the email matches or
not (no enumeration), rate-limited, and the feature is hidden when the mail
driver is log or array.
- Owner secret stored only as a bcrypt hash; checks are timing-safe via
Hash::check. Both ways of presenting it — the unlock form and a?owner=link — share one per-workspace rate limit, so a wrong guess costs the same whichever door it comes through. Recovery rotates the secret only when the mailed restore link is used. - Two tokens per file. The public download address (
download_token) and the uploader's private delete capability (manage_token) are separate columns. The page hands every visitor the former and never the latter. - Recovery email encrypted at rest; recovery responses don't reveal whether an email matched (no enumeration).
- HTTP security headers via
SecureHeadersmiddleware:nosniff,SAMEORIGIN,Referrer-Policy, restrictivePermissions-Policy. Production also adds HSTS and a locked-down CSP (default-src 'self', scripts by per-request nonce, fonts self-hosted) — omitted in dev where Vite and plainhttp://would break under them. - Workspace isolation enforced at the query layer by the
WorkspaceScopeglobal scope;workspace_idis never mass-assignable. Per-workspace session keys (ws_owner_{id},ws_upload_ok_{id}) so unlocking one never leaks into another. - Operator kill-switch gated by
OPERATOR_SECRET: timing-safe, rate-limited, session-held (never in a URL);/operator404s when unset. - Abuse throttling — per-IP limits on uploads, workspace creation, abuse
reports, section ZIPs and operator login; a fail-closed host-disk check; and
a content-hash blocklist refusing re-upload of operator-removed files.
Per-IP means the IP the app believes, so set
TRUSTED_PROXIESto the proxy actually in front (Cloudflare's ranges, or127.0.0.1for a local reverse proxy). Left empty,X-Forwarded-Foris ignored and every client behind a proxy shares the proxy's address; set to*— never do this — any client could pick its own. - Upload hardening — files are validated by real content type (
mimetypes, via magic bytes) as well as extension, so a renamed executable is rejected; client filenames are sanitised before storage (no control chars / header injection); and per-workspace byte and file-count caps bound abuse. Files are not virus-scanned — treat downloads as untrusted (see the Terms page).
For production: set APP_DEBUG=false, SESSION_ENCRYPT=true,
SESSION_SECURE_COOKIE=true, TRUSTED_PROXIES, and serve over HTTPS. The
owner link is redeemed by a ?owner= GET that immediately redirects to the
clean URL, so the secret appears in exactly one request — but that request
does reach the web server's access log. If you keep those logs, strip or
hash the query string for lines containing owner=. Note that
SESSION_SECURE_COOKIE=true requires HTTPS — over plain http:// the
session cookie is dropped and every POST fails with a 419 (leave it unset in
local dev on http).
MIT — free to use, self-host, and modify.
The licence covers this code only. Any hosted instance is operated independently of it, and neither the licence nor this repository grants rights to a hosted instance's workspaces, boards, or uploaded files — those belong to whoever runs and uses that instance.
