cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / README.md

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.