README.md (14930B)
1 # cdnV2 2 3 A small, self-hostable static file host. Browse folders in a clean web UI, upload files, grab direct 4 links, shorten URLs, and mirror any GitHub repo to the CDN from GitHub Actions — with the files 5 themselves stored in a [Hugging Face Storage Bucket](https://huggingface.co/docs/hub/storage-buckets), 6 so the app server stays stateless and cheap to run. 7 8 Live instance: <https://cdn.amit.is-a.dev> · License: [MIT](LICENSE) 9 10 ## Features 11 12 - **File browser** — folder listings, per-folder filter (press `/`), breadcrumbs, a recent-uploads and 13 visited-files history, and a global search page (`/find`). 14 - **Direct and raw links** — every file gets a CDN link, a download link, and a raw link served from a 15 separate raw domain with correct content types, CORS and long-lived caching. 16 - **Uploads** — drag-and-drop or paste a URL to fetch. Multi-file uploads get a shareable batch page. 17 - **Zip downloads** — download any folder or batch as a `.zip` (capped at 300 files / 500 MB). 18 - **URL shortener** — `/shorten`, with short links served from `/mask/<id>`. 19 - **GitHub sync** — a GitHub Actions workflow pushes a repo to `/<owner>/<repo>/` using GitHub's OIDC 20 token, so no long-lived secret is ever stored in the contributor's repo. See `/gh-sync` for the 21 workflow template. 22 - **Admin dashboard** — `/admin`: upload log, IP bans, GitHub owner allowlist, and file deletion. 23 - **Abuse protection** — per-IP rate limits, video-upload blocking (by extension and by file contents), 24 and optional VPN/proxy blocking. 25 - **Stats** — `/stats` shows file counts and storage used. 26 - **Fast navigation** — linked pages are prefetched in the background and cached/gzipped, so a click 27 is usually answered from the browser's own cache (details below). 28 - **PWA** — installable, with a service worker that deliberately caches nothing, so listings are 29 never stale. 30 31 ## How it works 32 33 ``` 34 browser / curl ──► FastAPI app (this repo) ──► Hugging Face Storage Bucket 35 │ UI, uploads, rate limits, (the actual files) 36 │ admin, GitHub OIDC check 37 └─ in-memory cache (60 s) for listings and file metadata 38 ``` 39 40 The app streams files from the bucket (with HTTP range support) rather than redirecting to it, so your 41 own domain stays in every link. Hidden prefixes (`_batches`, `_shortened`, `_logs`, `_admin`) hold 42 internal state and never appear in listings. 43 44 ## Quick start 45 46 You need Python 3.12+ and a Hugging Face access token with **write** access. 47 48 ```bash 49 git clone https://github.com/notamitgamer/cdnV2.git 50 cd cdnV2 51 python -m venv .venv && source .venv/bin/activate 52 pip install -r requirements.txt 53 54 cp .env.example .env # then edit it: at minimum HF_BUCKET_ID and HF_TOKEN 55 set -a; . ./.env; set +a 56 57 uvicorn app.main:app --reload # run from the repo root; open http://localhost:8000 58 ``` 59 60 The bucket is created automatically on first upload if it doesn't exist 61 (private if `HF_BUCKET_PRIVATE=1`). 62 63 The raw domain is matched by hostname. Locally, `raw.localhost` resolves to your machine in most 64 browsers and in `curl`, which is what `.env.example` uses. 65 66 ### Docker 67 68 ```bash 69 docker build -t cdnv2 . 70 docker run --rm -p 8000:8000 --env-file .env cdnv2 71 ``` 72 73 ### Deploy on Render 74 75 `render.yaml` defines a Docker web service. Set `HF_TOKEN` (and optionally `ADMIN_TOKEN` / 76 `ADMIN_DATA_KEY`) in the dashboard, and update `HF_BUCKET_ID`, `RAW_DOMAIN` and `RAW_BASE_URL` for your 77 own deployment. Point two hostnames at the service: the main one and the raw one. 78 79 ## Configuration 80 81 All configuration is through environment variables. See [`.env.example`](.env.example) for a commented 82 template. 83 84 | Variable | Default | Purpose | 85 | --- | --- | --- | 86 | `HF_TOKEN` | — | Hugging Face token with write access. **Required.** | 87 | `HF_BUCKET_ID` | `notamitgamer/cdn` | Bucket that stores the files, as `owner/name`. **Set this.** | 88 | `HF_BUCKET_PRIVATE` | `0` | Create the bucket as private if it does not exist. | 89 | `CDN_BASE_URL` | `https://cdn.amit.is-a.dev` | Public URL of the web UI, used when building links. | 90 | `RAW_DOMAIN` | `raw.cdn.amit.is-a.dev` | Hostname that serves raw files. | 91 | `RAW_BASE_URL` | `https://$RAW_DOMAIN` | Public base URL for raw links. | 92 | `OIDC_AUDIENCE` | `cdn.amit.is-a.dev` | Audience GitHub Actions workflows must request. Use your own hostname. | 93 | `GH_ALWAYS_ALLOW` | `notamitgamer` | Comma-separated GitHub owners that may sync without approval. | 94 | `ADMIN_TOKEN` | unset | Enables `/admin`. Leave unset to disable the dashboard, bans and allowlist. | 95 | `ADMIN_DATA_KEY` | `ADMIN_TOKEN` | Key used to encrypt saved admin state in the bucket. | 96 | `VPN_BLOCK` | `1` | Refuse uploads from VPN/proxy IPs. | 97 | `VPN_FAIL_OPEN` | `1` | Allow the upload if the VPN lookup itself fails. | 98 | `PROXYCHECK_KEY` | unset | Optional [proxycheck.io](https://proxycheck.io) key for VPN lookups. | 99 | `VPN_ALLOWLIST` | unset | Comma-separated IPs exempt from VPN blocking. | 100 | `TRUST_CF_CONNECTING_IP` | `0` | Trust `CF-Connecting-IP` (set to `1` behind Cloudflare). | 101 | `TRUSTED_PROXY_HOPS` | `1` | Number of trusted reverse proxies in front of the app. | 102 | `FAST_TREE_MAX_FILES` | `20000` | Above this many files, `/api/tree` is not shipped and instant folder navigation turns itself off. | 103 104 > **Self-hosting checklist:** the defaults above point at the original deployment. Before going live, 105 > set `HF_BUCKET_ID`, `CDN_BASE_URL`, `RAW_DOMAIN`/`RAW_BASE_URL`, `OIDC_AUDIENCE` and `GH_ALWAYS_ALLOW` 106 > to your own values, and review the hard-coded branding (`app/static/manifest.json`, the navbar and 107 > the documentation templates). 108 109 ### Getting client IPs right 110 111 Rate limits and bans are keyed on the client IP, so the app must see the real one. Behind Cloudflare set 112 `TRUST_CF_CONNECTING_IP=1`; behind other proxies set `TRUSTED_PROXY_HOPS` to the number of proxies you 113 trust. The admin dashboard shows how the server currently sees your connection. 114 115 ## HTTP API 116 117 | Method & path | Description | 118 | --- | --- | 119 | `GET /{path}` | Folder listing or file page. | 120 | `GET /api/get/{path}` | Download a file (range requests supported). | 121 | `POST /api/put` | Upload files (`multipart/form-data`: `files`, `folder`). | 122 | `POST /api/put-url` | Fetch a remote URL into the CDN. | 123 | `GET /api/find?q=…` | Search all files. | 124 | `GET /api/tree` | Every file as `[path, size]` (ETag + gzip) for instant folder navigation. | 125 | `GET /api/shell` | Folder page skeleton + HTML fragments the browser fills in locally. | 126 | `GET /api/pack/{path}` | Zip a folder (`/api/pack-info/{path}` returns its size first). | 127 | `POST /api/shorten` | Create a short link. | 128 | `POST /api/gh-sync` | Sync a GitHub repo (GitHub Actions OIDC token + tar.gz archive). | 129 | `GET /api/stats` | Storage statistics. | 130 131 Uploads go to either `uploads/` or `third-party/`. 132 133 Default limits per client IP: 200 MB/hour for file uploads, 50 MB/hour for URL uploads, and 10 requests 134 per minute / 50 per hour for the shortener. GitHub sync has no rate limit but accepts at most a 200 MB 135 compressed archive. Video files are refused. 136 137 ## Fast navigation 138 139 Moving around is meant to feel like a static site. Four things work together: 140 141 1. **Instant folders.** The browser keeps the whole file tree (`/api/tree`, a few KB gzipped) and a page 142 "shell" (`/api/shell`) in localStorage. Clicking a folder link builds that folder's page locally and 143 hands it to [Turbo](https://turbo.hotwired.dev), so there is **no request at all**. It works on the very 144 first click after a page loads, even before anything has been prefetched, and for returning visitors 145 even before the network answers, because last visit's tree is already stored. (`app/static/fasttree.js`) 146 2. **Link prefetching** for everything else (file pages, other pages). `app/static/prefetch.js` fetches 147 the pages linked from the current one as soon as the browser is idle. 148 3. **Browser-cached, gzipped pages.** Rendered pages are identical for everyone, so they are sent with 149 `Cache-Control: private, max-age=30` and gzip (`app/fast_html.py`). A prefetched page is answered from 150 the browser's cache; one that wasn't arrives about 5x smaller. The shared stylesheet and script are 151 separate cache-busted files (`static/app.css`, `static/app.js`, ~10 KB and ~12 KB gzipped, cached across 152 every page), so the page HTML itself is only markup: a small folder listing is ~38 KB raw, ~5 KB gzipped. 153 4. **Fewer storage round trips.** Opening a folder used to cost a wasted `HEAD` request plus the listing. 154 When the parent folder's listing is cached (it is, because that's the page you clicked from) the `HEAD` 155 is skipped, and simultaneous requests for one folder share a single Hugging Face call. 156 157 ### How instant folders stay correct 158 159 - **One copy of the markup.** The folder page's HTML lives in `app/templates/_listing.html` as Jinja 160 macros. `/api/shell` ships the server-rendered page skeleton plus each fragment with `@@placeholders@@`; 161 the browser only substitutes data (names, sizes, counts, the breadcrumb). There is no second copy of the 162 markup to drift. `tests/test_fast_tree.py` asserts that a locally built page equals the server's page for 163 a set of awkward folders (deep paths, `&` `'` `<` in names, emoji, rounding ties in sizes). 164 - **Fresh.** The tree is revalidated in the background on page load, every 15 s while you browse, when the 165 tab regains focus, and straight after an upload (an unchanged tree is a tiny `304`). If the page on screen 166 turns out to have been built from an older tree, it is quietly rebuilt. The server clears its own caches on 167 every upload, delete and sync, so `/api/tree` is current right away. 168 - **Falls back, never breaks.** No saved tree yet, a folder that isn't in the tree, a modified click 169 (Ctrl/Cmd/Shift/middle), a link with a query string or hash, `target`/`download` links, or any error all 170 just do the normal navigation, which is today's behaviour. File pages are not built locally. 171 - **Bounded.** Buckets with more than `FAST_TREE_MAX_FILES` files (default 20,000) don't ship a tree and 172 the feature switches itself off. It stores roughly 100-200 KB in localStorage. 173 - **Opt out** of it for a page with `<meta name="cdn-fast" content="off">`. 174 175 **Freshness trade-off:** pages that *are* fetched from the server are cached for 30 s in the browser, so 176 reopening one within 30 s can show a listing that is up to 30 s old. A hard refresh 177 (Ctrl/Cmd+Shift+R) always fetches fresh. 178 179 ### What gets prefetched 180 181 The prefetcher covers pages that instant folders can't build (file pages, other pages), and skips 182 folder links the tree already handles. It is deliberately conservative: 183 184 - Same-origin links only, and never `/api/*`, `/admin`, `/mask/*`, `/pack/*`, `/static/*`, `/raw/*`, 185 downloads, `target`/`download` links, or anything inside `data-turbo="false"`. 186 - At most **16 pages per page view**, **3 at a time**. Folder and file links on screen go first, 187 navbar and footer links last. Only while the tab is visible. 188 - When you click, in-flight prefetches are cancelled, except the one for the page you're opening. 189 - Disabled on Save-Data, 2g and `prefers-reduced-data`; reduced to 6 pages / 1 at a time on 3g. 190 191 To turn it off for a page, add `<meta name="cdn-prefetch" content="off">`; to exclude a link or a 192 whole section, add `data-no-prefetch`. To change the limits, edit `DEFAULTS` at the top of the file 193 (keep `ttlMs` below the 30 s cache lifetime in `app/fast_html.py`). For debugging, run 194 `__cdnPrefetch.stats()` in the browser console. Prefetches carry an `X-Purpose: prefetch` request 195 header, and they don't show up in the Recent/Visited history, which is recorded by the page itself. 196 197 ## Project layout 198 199 ``` 200 app/ 201 main.py routes, uploads, zip packing, GitHub sync endpoint 202 fast_html.py browser-cache + gzip headers for rendered pages 203 fast_tree.py /api/tree and /api/shell payloads for instant folder navigation 204 storage.py Hugging Face bucket access, listing, caching, search 205 upload_guard.py upload checks: video blocking, VPN detection, client IP 206 admin.py /admin dashboard, bans, GitHub allowlist (encrypted state) 207 gh_oidc.py GitHub Actions OIDC token verification 208 shortener.py URL shortener 209 stats.py /stats 210 templates/ Jinja2 pages: base.html (shared layout), index.html + _page_*.html (listing, file, batch, 211 upload, history, search, 404), _listing.html (fragments shared with fasttree.js), 212 _navbar/_breadcrumb/_icons partials, plus documentation / gh_sync_docs / stats / url_shortener 213 static/ app.css + app.js (the whole UI), icons, manifest, service worker, prefetch.js, fasttree.js 214 tests/ JS unit/DOM/end-to-end tests (node) + offline server checks (python) 215 Dockerfile · render.yaml · start.sh 216 ``` 217 218 ## Contributing 219 220 Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) first. Participation is 221 governed by the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability, follow 222 [SECURITY.md](SECURITY.md) instead of opening a public issue. 223 224 ## License 225 226 [MIT](LICENSE) © 2026 Amit Dutta. All runtime dependencies are under permissive licenses (MIT, BSD, 227 Apache-2.0, Unlicense). The only third-party browser asset is [Turbo](https://github.com/hotwired/turbo) (MIT), loaded from 228 jsDelivr. 229 230 ## UI notes 231 232 - **One stylesheet, one script.** All styling is in `app/static/app.css` (tokens at the top; light/dark via 233 `html[data-theme]`, set before first paint) and all behaviour in `app/static/app.js`. Both are loaded on every 234 page and versioned with a content hash (`?v=`), so a deploy never pairs new HTML with old CSS. 235 - **Delegated events only.** Turbo swaps `<body>` on each visit, so `app.js` binds everything on `document` 236 (via `data-action`, `data-copy`, ids) and re-runs an idempotent `init()` on `turbo:load`. 237 - **File previews** (`_page_file.html`): images, video, audio and PDF are plain elements loading from the raw 238 domain; text/code is fetched by `app.js` (first 256 KB) and rendered with `textContent`. HTML and SVG-as-text are 239 shown as source and never executed on the main origin. 240 - **File-type colours** come from the file name alone (`[data-name$=".png" i]` rules at the end of `app.css`), 241 so server-rendered and browser-built rows look identical without extra placeholders. 242 - **File pages and Turbo.** Turbo refuses to follow URLs that don't look like HTML (`/assets/app.js`), which would make 243 every tap on a file a full page reload. `app.js` therefore starts those visits itself (`Turbo.session.navigator.startVisit`, 244 with a plain-link fallback). A file page shows a whole-page skeleton and reveals everything at once when its preview is 245 ready (capped at 0.7 s); while a tapped file page is still being fetched, the same skeleton is shown after 140 ms, and is 246 never put in Turbo's cache.