cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 215e9881058d37cf11c3f599c609ee4f55226d21
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Wed, 16 Sep 2026 22:08:48 +0530

fix#9

Diffstat:
A.gitignore | 14++++++++++++++
ADockerfile | 13+++++++++++++
ALICENSE | 21+++++++++++++++++++++
Aapp/gh_oidc.py | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/main.py | 685+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shortener.py | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/static/favicon.ico | 0
Aapp/static/icons/apple-touch-icon.png | 0
Aapp/static/icons/favicon-16x16.png | 0
Aapp/static/icons/favicon-32x32.png | 0
Aapp/static/icons/icon-192.png | 0
Aapp/static/icons/icon-512.png | 0
Aapp/static/icons/icon-maskable-192.png | 0
Aapp/static/icons/icon-maskable-512.png | 0
Aapp/static/icons/icon-monochrome-192.png | 0
Aapp/static/icons/icon-monochrome-512.png | 0
Aapp/static/icons/logo.svg | 13+++++++++++++
Aapp/static/icons/og-logo.png | 0
Aapp/static/manifest.json | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/static/sw.js | 27+++++++++++++++++++++++++++
Aapp/storage.py | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/_navbar.html | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/documentation.html | 351+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/gh_sync_docs.html | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/index.html | 2006+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/url_shortener.html | 415+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/templates/ytmusic.html | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arender.yaml | 14++++++++++++++
Arequirements.txt | 9+++++++++
Atest.html | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
30 files changed, 5367 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.venv/ +venv/ + +# Android TWA build (generated by Bubblewrap in CI, never commit the real keystore) +android/android.keystore +android/*.apk +android/app/ +android/*.aab +android/build/ +android/.gradle/ diff --git a/Dockerfile b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the app structure +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Amit Dutta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/gh_oidc.py b/app/gh_oidc.py @@ -0,0 +1,78 @@ +""" +Verifies GitHub Actions OIDC ID tokens so the backend can trust *who* is +pushing without any shared secret living in contributor repos. + +Why this instead of a PAT/App token per contributor: +- The token is minted per-workflow-run by GitHub itself and is only valid + for ~5 minutes. +- Its `repository_owner` / `repository` / `ref` claims are set by GitHub's + own infrastructure from the run's real context - a workflow author cannot + edit their own workflow YAML to make GitHub sign a token claiming to be a + different repo or a different owner. +- We never see (or need) a long-lived credential from the contributor. + +Docs: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect +""" + +import time +import httpx +import jwt +from jwt import PyJWKClient +from fastapi import HTTPException + +GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" +JWKS_URL = f"{GITHUB_OIDC_ISSUER}/.well-known/jwks" +EXPECTED_AUDIENCE = "cdn.amit.is-a.dev" + +_jwks_client = None +_jwks_client_ts = 0 +_JWKS_CLIENT_TTL = 3600 + + +def _get_jwks_client() -> PyJWKClient: + global _jwks_client, _jwks_client_ts + now = time.time() + if _jwks_client is None or now - _jwks_client_ts > _JWKS_CLIENT_TTL: + _jwks_client = PyJWKClient(JWKS_URL) + _jwks_client_ts = now + return _jwks_client + + +def verify_actions_token(token: str) -> dict: + """Verify a GitHub Actions OIDC token and return its trusted claims. + + Raises HTTPException(401) if the token is missing, expired, wrongly + scoped, or fails signature verification. + """ + if not token: + raise HTTPException(status_code=401, detail="Missing GitHub Actions OIDC token.") + + try: + signing_key = _get_jwks_client().get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=EXPECTED_AUDIENCE, + issuer=GITHUB_OIDC_ISSUER, + options={"require": ["exp", "iat", "repository", "repository_owner"]}, + ) + except jwt.PyJWTError as e: + raise HTTPException(status_code=401, detail=f"Invalid GitHub Actions token: {e}") + + repository = claims.get("repository", "") + owner = claims.get("repository_owner", "") + if "/" not in repository: + raise HTTPException(status_code=401, detail="Malformed token claims.") + + repo_owner_from_full, repo_name = repository.split("/", 1) + if repo_owner_from_full != owner or not owner or not repo_name: + raise HTTPException(status_code=401, detail="Token owner/repository mismatch.") + + return { + "owner": owner, + "repo": repo_name, + "actor": claims.get("actor", ""), + "ref": claims.get("ref", ""), + "workflow_ref": claims.get("job_workflow_ref", ""), + } diff --git a/app/main.py b/app/main.py @@ -0,0 +1,684 @@ +import os +import shutil +import time +import uuid +import io +import json +import zipfile +import tarfile +import tempfile +import asyncio +import socket +import ipaddress +from urllib.parse import urlparse, unquote +import httpx +from collections import deque +from pathlib import Path +from pydantic import BaseModel +from fastapi import FastAPI, Request, File, UploadFile, HTTPException, Form +from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse +from fastapi.templating import Jinja2Templates + +from .storage import ( + is_file, + get_file_info, + list_directory, + list_files_recursive, + upload_temp_file, + upload_folder_scoped, + repo_stats, + get_recent_activity, + search_files, + write_batch_manifest, + get_batch_manifest, + HF_REPO_ID, + format_size, +) +from .gh_oidc import verify_actions_token +from .shortener import shorten_url, get_destination_url + +app = FastAPI() +templates = Jinja2Templates(directory="app/templates") + +STATIC_DIR = Path(__file__).parent / "static" +_NO_STORE_FILES = {"manifest.json", "sw.js"} + +@app.get("/static/{filename}") +async def static_no_cache_root(filename: str): + if filename not in _NO_STORE_FILES: + raise HTTPException(status_code=404) + file_path = STATIC_DIR / filename + if not file_path.is_file(): + raise HTTPException(status_code=404) + return FileResponse( + file_path, + headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, + ) + +@app.get("/static/icons/{filename}") +async def static_icons(filename: str): + file_path = STATIC_DIR / "icons" / filename + if not file_path.is_file(): + raise HTTPException(status_code=404) + return FileResponse( + file_path, + headers={"Cache-Control": "public, max-age=86400"}, + ) + +@app.get("/favicon.ico") +async def favicon(): + return FileResponse( + STATIC_DIR / "favicon.ico", + headers={"Cache-Control": "public, max-age=86400"}, + ) + +CDN_BASE_URL = os.getenv("CDN_BASE_URL", "https://cdn.amit.is-a.dev") +RAW_DOMAIN = os.getenv("RAW_DOMAIN", "raw.cdn.amit.is-a.dev") +RAW_BASE_URL = os.getenv("RAW_BASE_URL", f"https://{RAW_DOMAIN}") +RAW_PREFIX = "raw/" + +async def render_context(extra: dict) -> dict: + stats = await repo_stats() + ctx = dict(extra) + ctx["repo_file_count"] = stats["file_count"] if stats else None + ctx["repo_size_str"] = stats["size_str"] if stats else None + ctx.setdefault("cdn_base_url", CDN_BASE_URL) + ctx.setdefault("raw_base_url", RAW_BASE_URL) + return ctx + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: HTTPException): + if request.url.hostname == RAW_DOMAIN: + return PlainTextResponse("404: File Not Found", status_code=404) + ctx = await render_context({"page": "404"}) + return templates.TemplateResponse(request, "index.html", ctx, status_code=404) + +async def _proxy_stream(client: httpx.AsyncClient, r: httpx.Response): + try: + async for chunk in r.aiter_raw(): + yield chunk + finally: + await client.aclose() + +async def stream_raw(path: str, request: Request): + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + client = httpx.AsyncClient(follow_redirects=True) + req_headers = {} + range_header = request.headers.get("range") + if range_header: + req_headers["Range"] = range_header + req = client.build_request("GET", hf_url, headers=req_headers) + r = await client.send(req, stream=True) + + if r.status_code not in (200, 206): + await client.aclose() + raise HTTPException(status_code=404, detail="File not found") + + headers = { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=31536000", + "X-Content-Type-Options": "nosniff", + "Accept-Ranges": "bytes", + } + + for h in ["Content-Type", "Content-Encoding", "Content-Length", "Etag", "Content-Range"]: + if h in r.headers: + headers[h] = r.headers[h] + + filename = path.split("/")[-1].lower() + if filename.endswith(".md") or filename.endswith(".txt"): + if headers.get("Content-Type", "application/octet-stream") == "application/octet-stream": + headers["Content-Type"] = "text/plain; charset=utf-8" + + return StreamingResponse(_proxy_stream(client, r), status_code=r.status_code, headers=headers) + +@app.get("/api/get/{path:path}") +async def download_file(path: str, request: Request): + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + client = httpx.AsyncClient(follow_redirects=True) + req_headers = {} + range_header = request.headers.get("range") + if range_header: + req_headers["Range"] = range_header + req = client.build_request("GET", hf_url, headers=req_headers) + r = await client.send(req, stream=True) + + if r.status_code not in (200, 206): + await client.aclose() + raise HTTPException(status_code=404, detail="Not found") + + filename = path.split("/")[-1] + headers = { + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Type": r.headers.get("Content-Type", "application/octet-stream"), + "Accept-Ranges": "bytes", + } + for h in ["Content-Length", "Content-Encoding", "Etag", "Content-Range"]: + if h in r.headers: + headers[h] = r.headers[h] + + return StreamingResponse(_proxy_stream(client, r), status_code=r.status_code, headers=headers) + +UPLOAD_LIMIT_PER_MINUTE = 50 * 1024 * 1024 +UPLOAD_LIMIT_PER_HOUR = 300 * 1024 * 1024 + +class _UploadRateLimiter: + def __init__(self): + self._usage: dict[str, deque] = {} + + def _prune(self, ip: str, now: float): + dq = self._usage.get(ip) + if not dq: + return + while dq and now - dq[0][0] > 3600: + dq.popleft() + + def check_and_record(self, ip: str, size: int): + now = time.time() + dq = self._usage.setdefault(ip, deque()) + self._prune(ip, now) + minute_used = sum(s for t, s in dq if now - t <= 60) + hour_used = sum(s for t, s in dq) + + if minute_used + size > UPLOAD_LIMIT_PER_MINUTE: + raise HTTPException( + status_code=429, + detail=f"Upload rate limit exceeded: {format_size(UPLOAD_LIMIT_PER_MINUTE)}/minute. Try again shortly.", + ) + if hour_used + size > UPLOAD_LIMIT_PER_HOUR: + raise HTTPException( + status_code=429, + detail=f"Upload rate limit exceeded: {format_size(UPLOAD_LIMIT_PER_HOUR)}/hour. Try again later.", + ) + + dq.append((now, size)) + +_upload_limiter = _UploadRateLimiter() +BATCH_MIN_FILES = 2 +ALLOWED_UPLOAD_FOLDERS = {"uploads", "third-party"} + +def _validate_folder(folder: str) -> str: + folder = (folder or "uploads").strip().strip("/") + if folder not in ALLOWED_UPLOAD_FOLDERS: + raise HTTPException( + status_code=400, + detail=f"Invalid folder. Must be one of: {', '.join(sorted(ALLOWED_UPLOAD_FOLDERS))}.", + ) + return folder + +@app.post("/api/put") +async def handle_upload( + request: Request, + files: list[UploadFile] = File(...), + folder: str = Form("uploads"), +): + folder = _validate_folder(folder) + client_ip = request.client.host if request.client else "unknown" + results = [] + + for file in files: + temp_path = f"/tmp/{uuid.uuid4()}-{file.filename}" + with open(temp_path, "wb") as f: + shutil.copyfileobj(file.file, f) + size = os.path.getsize(temp_path) + + try: + _upload_limiter.check_and_record(client_ip, size) + except HTTPException: + os.remove(temp_path) + raise + + try: + hf_path = await upload_temp_file(temp_path, file.filename, folder) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + results.append({ + "filename": file.filename, + "hf_path": hf_path, + "size": size, + "cdn_url": f"{CDN_BASE_URL}/{hf_path}", + "raw_url": f"{RAW_BASE_URL}/{hf_path}", + }) + + response = {"files": results} + if len(results) >= BATCH_MIN_FILES: + batch_id = uuid.uuid4().hex[:10] + await write_batch_manifest(batch_id, results) + response["batch_id"] = batch_id + response["batch_url"] = f"{CDN_BASE_URL}/pack/{batch_id}" + + return response + +GH_SYNC_LIMIT_PER_HOUR = 2 * 1024 * 1024 * 1024 +_gh_sync_limiter = _UploadRateLimiter() + +def _safe_extract_tar(tar: tarfile.TarFile, dest: str): + dest_real = os.path.realpath(dest) + for member in tar.getmembers(): + member_path = os.path.realpath(os.path.join(dest, member.name)) + if not member_path.startswith(dest_real + os.sep) and member_path != dest_real: + raise HTTPException(status_code=400, detail="Archive contains an unsafe path.") + if member.issym() or member.islnk(): + raise HTTPException(status_code=400, detail="Archive contains symlinks, which are not allowed.") + tar.extractall(dest) + +@app.get("/gh-sync") +async def gh_sync_docs(request: Request): + return templates.TemplateResponse(request, "gh_sync_docs.html", {"page": "gh-sync"}) + +@app.get("/documentation") +async def documentation(request: Request): + return templates.TemplateResponse(request, "documentation.html", {"page": "documentation"}) + +@app.post("/api/gh-sync") +async def gh_sync(request: Request, token: str = Form(...), archive: UploadFile = File(...)): + claims = verify_actions_token(token) + owner, repo = claims["owner"], claims["repo"] + client_ip = request.client.host if request.client else "unknown" + + contents = await archive.read() + _gh_sync_limiter.check_and_record(f"gh-sync:{owner}/{repo}:{client_ip}", len(contents)) + + with tempfile.TemporaryDirectory() as tmp_upload, tempfile.TemporaryDirectory() as tmp_extract: + archive_path = os.path.join(tmp_upload, "payload.tar.gz") + with open(archive_path, "wb") as f: + f.write(contents) + + try: + with tarfile.open(archive_path, "r:gz") as tar: + _safe_extract_tar(tar, tmp_extract) + except tarfile.TarError: + raise HTTPException(status_code=400, detail="Could not read archive (expected a .tar.gz).") + + dest_prefix = await upload_folder_scoped(tmp_extract, owner, repo) + + return { + "synced_to": dest_prefix, + "cdn_url": f"{CDN_BASE_URL}/{dest_prefix}/", + "raw_url_prefix": f"{RAW_BASE_URL}/{dest_prefix}/", + "note": "cdn_url is the browsable folder listing. raw_url_prefix isn't a link by itself - append an individual filename to it to get that file's direct raw URL.", + "ref": claims["ref"], + } + +@app.get("/ytmusic") +async def url_ytmusic_page(request: Request): + ctx = await render_context({"page": "ytmusic"}) + return templates.TemplateResponse(request, "ytmusic.html", ctx) + +# --------------------------------------------------------------------------------- +# YTMusic routes: Local extraction using yt-dlp (No Third-Party APIs) +# --------------------------------------------------------------------------------- +import yt_dlp + +class YtRequest(BaseModel): + url: str + +@app.post("/api/yt/download") +async def yt_download_local(request: Request, body: YtRequest): + url = body.url.strip() + if not url: + raise HTTPException(status_code=400, detail="Missing YouTube URL.") + + # Configure yt-dlp to extract the best audio-only URL without downloading + ydl_opts = { + 'format': 'm4a/bestaudio/best', + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + 'noplaylist': True, + } + + def _extract(): + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + return ydl.extract_info(url, download=False) + + try: + # Run in a thread pool so it doesn't freeze the FastAPI async event loop + info = await asyncio.to_thread(_extract) + + dl_url = info.get('url') + if not dl_url and 'requested_formats' in info: + dl_url = info['requested_formats'][0]['url'] + + if not dl_url: + raise ValueError("No audio stream URL could be found in the video metadata.") + + return {"url": dl_url, "title": info.get("title", "Audio")} + + except yt_dlp.utils.DownloadError as e: + raise HTTPException(status_code=400, detail=f"Extraction failed: {str(e)}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") +# --------------------------------------------------------------------------------- + +@app.get("/shorten") +async def url_shortener_page(request: Request): + ctx = await render_context({"page": "shorten"}) + return templates.TemplateResponse(request, "url_shortener.html", ctx) + +class ShortenRequest(BaseModel): + url: str + +@app.post("/api/shorten") +async def api_shorten(request: Request, body: ShortenRequest): + url = body.url.strip() + _assert_public_url(url) + short_id = await shorten_url(url) + return { + "id": short_id, + "masked_url": f"{CDN_BASE_URL}/mask/{short_id}", + } + +@app.get("/mask/{short_id}") +async def mask_redirect(request: Request, short_id: str): + destination = await get_destination_url(short_id) + if destination is None: + raise HTTPException(status_code=404, detail="Short URL not found") + return RedirectResponse( + destination, + status_code=307, + headers={"Referrer-Policy": "no-referrer"} + ) + +_MAX_URL_UPLOAD_BYTES = UPLOAD_LIMIT_PER_HOUR + +def _assert_public_url(url: str): + parsed = urlparse(url) + if parsed.scheme != "https": + raise HTTPException(status_code=400, detail="Only https:// URLs are supported.") + if not parsed.hostname: + raise HTTPException(status_code=400, detail="Invalid URL.") + hostname_lower = parsed.hostname.lower() + if hostname_lower == "localhost" or hostname_lower.endswith(".localhost"): + raise HTTPException( + status_code=400, + detail="URLs pointing to private/internal addresses are not allowed.", + ) + try: + addrinfos = socket.getaddrinfo(parsed.hostname, None) + except socket.gaierror: + raise HTTPException(status_code=400, detail="Could not resolve host.") + + for family, _, _, _, sockaddr in addrinfos: + ip = ipaddress.ip_address(sockaddr[0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ): + raise HTTPException( + status_code=400, + detail="URLs pointing to private/internal addresses are not allowed.", + ) + +class UploadUrlRequest(BaseModel): + url: str + folder: str = "uploads" + +@app.post("/api/put-url") +async def handle_upload_from_url(request: Request, body: UploadUrlRequest): + folder = _validate_folder(body.folder) + client_ip = request.client.host if request.client else "unknown" + url = body.url.strip() + _assert_public_url(url) + parsed = urlparse(url) + filename = os.path.basename(unquote(parsed.path)) or f"download-{uuid.uuid4().hex[:8]}" + temp_path = f"/tmp/{uuid.uuid4()}-{filename}" + downloaded = 0 + + async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client: + try: + async with client.stream("GET", url) as r: + if r.status_code != 200: + raise HTTPException(status_code=400, detail=f"Source returned status {r.status_code}.") + content_length = r.headers.get("Content-Length") + if content_length and int(content_length) > _MAX_URL_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"File too large (limit {format_size(_MAX_URL_UPLOAD_BYTES)} per fetch).", + ) + cd = r.headers.get("Content-Disposition", "") + if "filename=" in cd: + filename = cd.split("filename=")[-1].strip('"; ') or filename + temp_path = f"/tmp/{uuid.uuid4()}-{filename}" + + with open(temp_path, "wb") as f: + async for chunk in r.aiter_bytes(): + downloaded += len(chunk) + if downloaded > _MAX_URL_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"File too large (limit {format_size(_MAX_URL_UPLOAD_BYTES)} per fetch).", + ) + f.write(chunk) + except httpx.RequestError: + if os.path.exists(temp_path): + os.remove(temp_path) + raise HTTPException(status_code=400, detail="Could not fetch the URL.") + except HTTPException: + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + try: + _upload_limiter.check_and_record(client_ip, downloaded) + except HTTPException: + os.remove(temp_path) + raise + + try: + hf_path = await upload_temp_file(temp_path, filename, folder) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + return { + "files": [{ + "filename": filename, + "hf_path": hf_path, + "size": downloaded, + "cdn_url": f"{CDN_BASE_URL}/{hf_path}", + "raw_url": f"{RAW_BASE_URL}/{hf_path}", + }] + } + +@app.get("/api/pack-info/{path:path}") +async def zip_stats(path: str): + clean_path = path.strip("/") + try: + files = await list_files_recursive(clean_path) + except ValueError as e: + raise HTTPException(status_code=413, detail=str(e)) + if not files: + raise HTTPException(status_code=404, detail="Folder is empty or not found") + total_size = sum(f["size"] for f in files) + return { + "file_count": len(files), + "total_size": total_size, + "size_str": format_size(total_size), + } + +@app.get("/api/pack/{path:path}") +async def download_zip(path: str): + clean_path = path.strip("/") + try: + files = await list_files_recursive(clean_path) + except ValueError as e: + raise HTTPException(status_code=413, detail=str(e)) + if not files: + raise HTTPException(status_code=404, detail="Folder is empty or not found") + + prefix_len = len(clean_path.rstrip("/")) + 1 if clean_path else 0 + ZIP_FETCH_CONCURRENCY = 8 + semaphore = asyncio.Semaphore(ZIP_FETCH_CONCURRENCY) + + async def fetch(client: httpx.AsyncClient, f: dict): + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{f['path']}" + async with semaphore: + r = await client.get(hf_url) + return f, r + + buffer = io.BytesIO() + async with httpx.AsyncClient(follow_redirects=True) as client: + results = await asyncio.gather(*(fetch(client, f) for f in files)) + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for f, r in results: + if r.status_code != 200: + continue + arcname = f["path"][prefix_len:] if prefix_len else f["path"] + zf.writestr(arcname, r.content) + + buffer.seek(0) + zip_filename = (clean_path.rstrip("/").split("/")[-1] if clean_path else HF_REPO_ID.split("/")[-1]) + ".zip" + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}, + ) + +@app.get("/api/find") +async def api_search(q: str = ""): + results = await search_files(q) + return {"results": results} + +@app.get("/pack/{batch_id}") +async def batch_page(request: Request, batch_id: str): + manifest = await get_batch_manifest(batch_id) + if not manifest: + raise HTTPException(status_code=404, detail="Batch not found") + batch_files = [] + for f in manifest.get("files", []): + item = dict(f) + item["size_str"] = format_size(f.get("size")) + batch_files.append(item) + ctx = await render_context({ + "page": "batch", + "batch_id": batch_id, + "batch_files": batch_files, + }) + return templates.TemplateResponse(request, "index.html", ctx) + +@app.get("/api/pack-info-batch/{batch_id}") +async def zip_stats_batch(batch_id: str): + manifest = await get_batch_manifest(batch_id) + if not manifest: + raise HTTPException(status_code=404, detail="Batch not found") + files = manifest.get("files", []) + if not files: + raise HTTPException(status_code=404, detail="Batch is empty") + total_size = sum(f.get("size", 0) or 0 for f in files) + return { + "file_count": len(files), + "total_size": total_size, + "size_str": format_size(total_size), + } + +@app.get("/api/pack-batch/{batch_id}") +async def download_zip_batch(batch_id: str): + manifest = await get_batch_manifest(batch_id) + if not manifest: + raise HTTPException(status_code=404, detail="Batch not found") + files = manifest.get("files", []) + if not files: + raise HTTPException(status_code=404, detail="Batch is empty") + ZIP_FETCH_CONCURRENCY = 8 + semaphore = asyncio.Semaphore(ZIP_FETCH_CONCURRENCY) + + async def fetch(client: httpx.AsyncClient, f: dict): + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{f['hf_path']}" + async with semaphore: + r = await client.get(hf_url) + return f, r + + seen_names = set() + buffer = io.BytesIO() + async with httpx.AsyncClient(follow_redirects=True) as client: + results = await asyncio.gather(*(fetch(client, f) for f in files)) + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for f, r in results: + if r.status_code != 200: + continue + arcname = f.get("filename") or f["hf_path"].split("/")[-1] + if arcname in seen_names: + arcname = f"{f['hf_path'].split('/')[-1].split('-')[0]}-{arcname}" + seen_names.add(arcname) + zf.writestr(arcname, r.content) + + buffer.seek(0) + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="batch-{batch_id}.zip"'}, + ) + +@app.api_route("/ping", methods=["GET", "HEAD"], response_class=PlainTextResponse) +async def ping(): + return "Server is awake!" + +@app.api_route("/{path:path}", methods=["GET", "HEAD"]) +async def serve(request: Request, path: str): + clean_path = path.strip("/") + + if request.url.hostname == RAW_DOMAIN: + if not clean_path: + return HTMLResponse("Specify a file path.", status_code=200) + try: + return await stream_raw(clean_path, request) + except HTTPException as e: + if e.status_code == 404: + entries = await list_directory(clean_path) + if entries: + return RedirectResponse(f"{CDN_BASE_URL}/{clean_path}/") + raise + + if clean_path == RAW_PREFIX.rstrip("/") or clean_path.startswith(RAW_PREFIX): + raw_path = clean_path[len(RAW_PREFIX):] + if not raw_path: + return HTMLResponse("/raw/ specify a file path after this prefix.", status_code=200) + return RedirectResponse(f"{RAW_BASE_URL}/{raw_path}") + + if clean_path == "new": + ctx = await render_context({"page": "upload"}) + return templates.TemplateResponse(request, "index.html", ctx) + if clean_path == "find": + ctx = await render_context({"page": "search"}) + return templates.TemplateResponse(request, "index.html", ctx) + if clean_path == "history": + ctx = await render_context({"page": "history"}) + return templates.TemplateResponse(request, "index.html", ctx) + + if clean_path: + info = await get_file_info(clean_path) + if info["exists"]: + filename = clean_path.split("/")[-1] + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + ctx = await render_context({ + "page": "file", + "path": clean_path, + "filename": filename, + "raw_base_url": RAW_BASE_URL, + "file_ext": ext, + "file_size_str": format_size(info["size"]), + }) + return templates.TemplateResponse(request, "index.html", ctx) + + items = await list_directory(clean_path) + if clean_path and not items: + raise HTTPException(status_code=404, detail="Not Found") + + extra_ctx = { + "page": "listing", + "path": clean_path, + "items": items, + "raw_base_url": RAW_BASE_URL, + } + if not clean_path: + extra_ctx["recent_activity"] = await get_recent_activity() + + ctx = await render_context(extra_ctx) + return templates.TemplateResponse(request, "index.html", ctx) +\ No newline at end of file diff --git a/app/shortener.py b/app/shortener.py @@ -0,0 +1,78 @@ +import os +import io +import random +import string +import asyncio +import re +import httpx +from huggingface_hub import HfApi + +HF_REPO_ID = os.getenv("HF_REPO_ID", "notamitgamer/cdn") +HF_TOKEN = os.getenv("HF_TOKEN") +api = HfApi(token=HF_TOKEN) if HF_TOKEN else None + +# Reusable HTTP client for fast connection pooling & HTTP keep-alive +_client = httpx.AsyncClient(follow_redirects=True, timeout=10.0) + +# Alphanumeric validator to guard against path traversal attempts +_ID_REGEX = re.compile(r"^[a-z0-9]{4,16}$") + +def generate_id(length: int = 8) -> str: + chars = string.ascii_lowercase + string.digits + return "".join(random.choices(chars, k=length)) + +async def shorten_url(destination_url: str) -> str: + """Save a destination URL under an in-memory byte payload to Hugging Face.""" + if not api: + raise RuntimeError("HF_TOKEN is not configured.") + + destination_url = destination_url.strip() + + # Collision-check loop with a safeguard cutoff + attempts = 0 + while True: + short_id = generate_id() + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_shortened/{short_id}" + r = await _client.head(hf_url) + if r.status_code == 404: + break + attempts += 1 + if attempts > 5: + # Fallback to longer ID if collisions occur + short_id = generate_id(length=12) + break + + # Upload directly from memory without writing to disk + payload = io.BytesIO(destination_url.encode("utf-8")) + + def _upload(): + api.upload_file( + path_or_fileobj=payload, + path_in_repo=f"_shortened/{short_id}", + repo_id=HF_REPO_ID, + repo_type="dataset", + token=HF_TOKEN, + commit_message=f"shorten: {short_id}", + ) + + await asyncio.to_thread(_upload) + return short_id + +async def get_destination_url(short_id: str) -> str | None: + """Look up the destination URL directly from Hugging Face resolve endpoints.""" + short_id = short_id.strip().lower() + if not _ID_REGEX.match(short_id): + return None + + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_shortened/{short_id}" + try: + r = await _client.get(hf_url) + if r.status_code == 200: + return r.text.strip() + except httpx.RequestError: + return None + return None + +def list_all_ids() -> list[str]: + """Stub kept for router compatibility.""" + return [] +\ No newline at end of file diff --git a/app/static/favicon.ico b/app/static/favicon.ico Binary files differ. diff --git a/app/static/icons/apple-touch-icon.png b/app/static/icons/apple-touch-icon.png Binary files differ. diff --git a/app/static/icons/favicon-16x16.png b/app/static/icons/favicon-16x16.png Binary files differ. diff --git a/app/static/icons/favicon-32x32.png b/app/static/icons/favicon-32x32.png Binary files differ. diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png Binary files differ. diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png Binary files differ. diff --git a/app/static/icons/icon-maskable-192.png b/app/static/icons/icon-maskable-192.png Binary files differ. diff --git a/app/static/icons/icon-maskable-512.png b/app/static/icons/icon-maskable-512.png Binary files differ. diff --git a/app/static/icons/icon-monochrome-192.png b/app/static/icons/icon-monochrome-192.png Binary files differ. diff --git a/app/static/icons/icon-monochrome-512.png b/app/static/icons/icon-monochrome-512.png Binary files differ. diff --git a/app/static/icons/logo.svg b/app/static/icons/logo.svg @@ -0,0 +1,13 @@ +<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"> + <rect width="512" height="512" fill="#000000"/> + <rect x="20" y="20" width="472" height="472" fill="none" stroke="#262624" stroke-width="2"/> + + <!-- bold diagonal slash, echoing the "amit/cdn" wordmark --> + <polygon points="252,64 316,64 260,448 196,448" fill="#3272f0"/> + + <!-- coral accent block, bottom-left --> + <rect x="84" y="352" width="72" height="72" fill="#ff6a42"/> + + <!-- off-white accent block, top-right --> + <rect x="356" y="88" width="72" height="72" fill="#f2f2f0"/> +</svg> diff --git a/app/static/icons/og-logo.png b/app/static/icons/og-logo.png Binary files differ. diff --git a/app/static/manifest.json b/app/static/manifest.json @@ -0,0 +1,60 @@ +{ + "name": "amit/cdn", + "short_name": "amit/cdn", + "description": "Static file host for amit's projects. Browse, upload, and grab direct links.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "any", + "lang": "en", + "icons": [ + { + "src": "/static/icons/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/static/icons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static/icons/icon-monochrome-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "monochrome" + }, + { + "src": "/static/icons/icon-monochrome-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "monochrome" + } + ] +} diff --git a/app/static/sw.js b/app/static/sw.js @@ -0,0 +1,27 @@ +// This service worker intentionally does NOT cache anything. +// It exists only so the site satisfies PWA "installability" requirements +// (Add to Home Screen / desktop install) while every request still goes +// straight to the network. Directory listings and files here can change +// at any time, so nothing is ever served from a cache. + +self.addEventListener("install", (event) => { + // Activate the new worker immediately, don't wait for old tabs to close. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // Defensive cleanup: if a cache was ever created by a previous + // version of this worker, delete it so nothing stale lingers. + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + await self.clients.claim(); + })() + ); +}); + +self.addEventListener("fetch", (event) => { + // Always hit the network. No cache.match, no cache.put — ever. + event.respondWith(fetch(event.request)); +}); diff --git a/app/storage.py b/app/storage.py @@ -0,0 +1,370 @@ +import os +import re +import time +import uuid +import json +import tempfile +import asyncio +import httpx +from huggingface_hub import HfApi + +HF_REPO_ID = os.getenv("HF_REPO_ID", "notamitgamer/cdn") +HF_TOKEN = os.getenv("HF_TOKEN") +CACHE_TTL = 60 +MAX_CACHE_SIZE = 500 +ZIP_MAX_FILES = 300 +ZIP_MAX_TOTAL_BYTES = 500 * 1024 * 1024 +REPO_STATS_CACHE_TTL = 300 +SEARCH_RESULT_LIMIT = 10 + +api = HfApi(token=HF_TOKEN) + +_cache = {} + +_key_locks: dict[str, asyncio.Lock] = {} + +def _get_key_lock(key: str) -> asyncio.Lock: + lock = _key_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _key_locks[key] = lock + + if len(_key_locks) > MAX_CACHE_SIZE: + _key_locks.pop(next(iter(_key_locks))) + return lock + +def _get_cache(key): + if key in _cache: + expiry, value = _cache[key] + if expiry > time.time(): + _cache[key] = _cache.pop(key) + return value + del _cache[key] + return None + +def _set_cache(key, value): + if key in _cache: + del _cache[key] + elif len(_cache) >= MAX_CACHE_SIZE: + oldest_key = next(iter(_cache)) + del _cache[oldest_key] + + _cache[key] = (time.time() + CACHE_TTL, value) + +async def is_file(path: str) -> bool: + info = await get_file_info(path) + return info["exists"] + +async def get_file_info(path: str) -> dict: + if not path: + return {"exists": False, "size": None, "content_type": None} + + cache_key = f"file_info_{path}" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + async with _get_key_lock(cache_key): + cached = _get_cache(cache_key) + if cached is not None: + return cached + + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + async with httpx.AsyncClient(follow_redirects=True) as client: + r = await client.head(hf_url) + exists = r.status_code == 200 + size = None + content_type = None + if exists: + try: + size = int(r.headers["Content-Length"]) if "Content-Length" in r.headers else None + except (ValueError, TypeError): + size = None + content_type = r.headers.get("Content-Type") + result = {"exists": exists, "size": size, "content_type": content_type} + _set_cache(cache_key, result) + return result + +def _fetch_tree(path: str): + return list(api.list_repo_tree( + repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", expand=False + )) + +def _fetch_tree_recursive(path: str = ""): + return list(api.list_repo_tree( + repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", recursive=True + )) + +_HIDDEN_PREFIXES = ("_batches",) + +def _is_hidden_path(path: str) -> bool: + return any(path == p or path.startswith(p + "/") for p in _HIDDEN_PREFIXES) + +async def list_directory(path: str): + cache_key = f"list_dir_{path}" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + try: + items = await asyncio.to_thread(_fetch_tree, path) + except Exception as e: + print(f"[storage] list_repo_tree failed for {path!r}: {e}") + return [] + + files_and_folders = [] + for item in items: + if _is_hidden_path(item.path): + continue + + name = item.path.split("/")[-1] + is_dir = not hasattr(item, "size") + size = getattr(item, "size", 0) + + size_str = format_size(size) if not is_dir else "-" + + files_and_folders.append({ + "name": name, + "path": item.path, + "is_dir": is_dir, + "size_str": size_str + }) + + files_and_folders.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) + + _set_cache(cache_key, files_and_folders) + return files_and_folders + +async def list_files_recursive(path: str): + try: + items = await asyncio.to_thread(_fetch_tree_recursive, path) + except Exception: + return [] + + files = [] + total_size = 0 + for item in items: + if _is_hidden_path(item.path): + continue + if hasattr(item, "size"): + size = getattr(item, "size", 0) or 0 + total_size += size + files.append({"path": item.path, "size": size}) + if len(files) > ZIP_MAX_FILES or total_size > ZIP_MAX_TOTAL_BYTES: + raise ValueError( + f"Folder too large to zip (limit: {ZIP_MAX_FILES} files / " + f"{ZIP_MAX_TOTAL_BYTES // (1024 * 1024)} MB)." + ) + return files + +def format_size(size_bytes) -> str: + if size_bytes is None: + return "-" + size = float(size_bytes) + units = ["B", "KB", "MB", "GB", "TB"] + idx = 0 + while size >= 1024 and idx < len(units) - 1: + size /= 1024 + idx += 1 + if idx == 0: + return f"{int(size)} {units[idx]}" + return f"{size:.2f} {units[idx]}" + +async def _get_full_tree(): + + cache_key = "full_tree" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + async with _get_key_lock(cache_key): + cached = _get_cache(cache_key) + if cached is not None: + return cached + + try: + raw_items = await asyncio.to_thread(_fetch_tree_recursive, "") + except Exception as e: + print(f"[storage] full tree fetch failed: {e}") + return [] + + items = [] + for item in raw_items: + if _is_hidden_path(item.path): + continue + + name = item.path.split("/")[-1] + is_dir = not hasattr(item, "size") + size = getattr(item, "size", 0) or 0 + items.append({ + "name": name, + "path": item.path, + "is_dir": is_dir, + "size": size, + "size_str": format_size(size) if not is_dir else "-", + }) + + _set_cache(cache_key, items) + return items + +async def repo_stats(): + cache_key = "repo_stats" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + items = await _get_full_tree() + if not items: + return None + + file_count = sum(1 for it in items if not it["is_dir"]) + total_size = sum(it["size"] for it in items if not it["is_dir"]) + + result = {"file_count": file_count, "size_str": format_size(total_size)} + _set_cache(cache_key, result) + return result + +_UPLOAD_FILE_RE = re.compile(r"^Upload (.+?)(?: with huggingface_hub)?$") +_GH_SYNC_RE = re.compile(r"^gh-sync: update (.+)$") + +def _do_list_repo_commits(): + return list(api.list_repo_commits(repo_id=HF_REPO_ID, repo_type="dataset")) + +async def get_recent_activity(limit: int = 3): + """Recently changed top-level paths, newest first, deduped by path. + + Derived from commit titles (list_repo_commits doesn't return changed + file paths directly), so this is best-effort: commits whose title + doesn't match one of this app's own upload patterns are skipped rather + than guessed at. Cached briefly since it's one extra HF API call. + """ + cache_key = "recent_activity" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + try: + commits = await asyncio.to_thread(_do_list_repo_commits) + except Exception: + return [] + + seen_paths = set() + result = [] + for commit in commits: + title = (commit.title or "").strip() + gh_match = _GH_SYNC_RE.match(title) + upload_match = _UPLOAD_FILE_RE.match(title) if not gh_match else None + if gh_match: + # already "owner/repo" - that whole pair is the unit, don't truncate it + top_level = gh_match.group(1).strip().strip("/") + elif upload_match: + raw_path = upload_match.group(1).strip().strip("/") + top_level = raw_path.split("/")[0] if raw_path else "" + else: + continue + if not top_level or _is_hidden_path(top_level): + continue + if top_level in seen_paths: + continue + seen_paths.add(top_level) + result.append({ + "path": top_level, + "iso_time": commit.created_at.isoformat() if commit.created_at else None, + }) + if len(result) >= limit: + break + + _set_cache(cache_key, result) + return result + +async def search_files(query: str): + q = query.strip().lower() + if not q: + return [] + + items = await _get_full_tree() + results = [ + it for it in items + if q in it["name"].lower() and it["name"] != ".gitattributes" + ] + results.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) + return [ + {"name": it["name"], "path": it["path"], "is_dir": it["is_dir"], "size_str": it["size_str"]} + for it in results[:SEARCH_RESULT_LIMIT] + ] + +def _do_upload(temp_path: str, hf_path: str): + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=hf_path, + repo_id=HF_REPO_ID, + repo_type="dataset", + token=HF_TOKEN + ) + +async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads") -> str: + slug = str(uuid.uuid4())[:8] + safe_filename = filename.replace(" ", "_") + hf_path = f"{folder}/{slug}-{safe_filename}" + + await asyncio.to_thread(_do_upload, temp_path, hf_path) + + _cache.clear() + return hf_path + +def _do_upload_folder_scoped(local_dir: str, dest_prefix: str): + # path_in_repo + delete_patterns are both scoped to dest_prefix, so this call + # can only ever create/update/delete files under that one folder - it never + # touches anything outside of it in the dataset repo. + api.upload_folder( + repo_id=HF_REPO_ID, + repo_type="dataset", + folder_path=local_dir, + path_in_repo=dest_prefix, + delete_patterns="*", + commit_message=f"gh-sync: update {dest_prefix}", + token=HF_TOKEN, + ) + +async def upload_folder_scoped(local_dir: str, owner: str, repo: str) -> str: + dest_prefix = f"{owner}/{repo}" + await asyncio.to_thread(_do_upload_folder_scoped, local_dir, dest_prefix) + _cache.clear() + return dest_prefix + +async def write_batch_manifest(batch_id: str, files: list[dict]) -> None: + manifest = { + "batch_id": batch_id, + "created": time.time(), + "files": files, + } + + fd, tmp_path = tempfile.mkstemp(suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(manifest, f) + await asyncio.to_thread(_do_upload, tmp_path, f"_batches/{batch_id}.json") + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + _cache.clear() + +async def get_batch_manifest(batch_id: str): + cache_key = f"batch_{batch_id}" + cached = _get_cache(cache_key) + if cached is not None: + return cached + + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_batches/{batch_id}.json" + async with httpx.AsyncClient(follow_redirects=True) as client: + r = await client.get(hf_url) + if r.status_code != 200: + return None + try: + manifest = r.json() + except ValueError: + return None + + _set_cache(cache_key, manifest) + return manifest diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html @@ -0,0 +1,248 @@ +<style> +/* Shared navbar - single source of truth, included via this partial on every + page. Self-contained with its own hardcoded values rather than each page's local theme variables, so it looks and behaves + identically everywhere regardless of what a given page's own :root defines. */ +.navbar-wrapper { + position: sticky; + top: 0; + z-index: 100; + background: #09090b; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 2.75rem; + box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.2); +} +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 5%; + max-width: 1000px; + margin: 0 auto; +} +.wordmark { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 500; + font-size: 1.1rem; + color: #f4f4f5 !important; + text-decoration: none !important; + letter-spacing: -0.01em; + transition: opacity 0.2s; +} +.wordmark:hover { opacity: 0.8; } +.wordmark-slash { color: #818cf8; } +.navlinks { + display: flex; + align-items: center; + gap: 0.6rem; +} +.navlinks a { + display: flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; + color: #b8b8c0; + border-radius: 8px; + transition: all 0.2s ease; + padding: 0; +} +.navlinks a:hover { + color: #f4f4f5; + background: rgba(255, 255, 255, 0.05); +} +.navlinks a.active { + color: #818cf8; + background: rgba(129, 140, 248, 0.12); +} +.breadcrumb-container { + max-width: 1000px; + margin: 0 auto; + padding: 0 5% 1rem 5%; + background: #09090b; +} +.breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + color: #b8b8c0; + font-weight: 500; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; +} +.breadcrumb a { + color: #f4f4f5; + text-decoration: none; + padding: 0.2rem 0.4rem; + margin: -0.2rem -0.4rem; + border-radius: 4px; + transition: background 0.15s, color 0.15s; +} +.breadcrumb a:hover { + color: #818cf8; + background: #1e1e22; + text-decoration: none; +} +.breadcrumb-collapsed { + position: relative; + display: inline-flex; +} +.breadcrumb-ellipsis { + background: #121214; + border: 1px solid rgba(255, 255, 255, 0.08); + color: #b8b8c0; + border-radius: 6px; + padding: 0.25rem 0.75rem; + cursor: pointer; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; + line-height: 1.4; + transition: all 0.2s; +} +.breadcrumb-ellipsis:hover { + color: #f4f4f5; + border-color: rgba(255, 255, 255, 0.15); + background: #1e1e22; +} +.breadcrumb-menu { + display: none; + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 180px; + max-width: 70vw; + max-height: 260px; + overflow-y: auto; + background: #121214; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 0.5rem; + flex-direction: column; + gap: 0.25rem; + z-index: 20; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); +} +.breadcrumb-menu.open { display: flex; } +.breadcrumb-menu a { + padding: 0.75rem 1rem; + border-radius: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} +.copy-path-btn { + background: #121214; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: #b8b8c0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem; + margin-left: 0.5rem; + transition: all 0.2s; +} +.copy-path-btn:hover { + color: #f4f4f5; + border-color: rgba(255, 255, 255, 0.15); + background: #1e1e22; +} +.copy-path-btn:active { + transform: scale(0.95); +} +@media (max-width: 480px) { + .navbar { padding: 1rem 1.25rem; } + .navlinks { gap: 0.25rem; } +} +</style> +<div class="navbar-wrapper"> + <header class="navbar"> + <a href="/" class="wordmark">amit<span class="wordmark-slash">/</span>cdn</a> + <nav class="navlinks"> + <a href="/find" class="{{ 'active' if page == 'search' }}" title="Find" aria-label="Find"{{ ' aria-current="page"'|safe if page == 'search' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> + </a> + <a href="/new" class="{{ 'active' if page == 'upload' }}" title="New / Upload" aria-label="New / Upload"{{ ' aria-current="page"'|safe if page == 'upload' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> + </a> + <a href="/gh-sync" class="{{ 'active' if page == 'gh-sync' }}" title="GitHub Actions Sync" aria-label="GitHub Actions Sync"{{ ' aria-current="page"'|safe if page == 'gh-sync' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> + </a> + <a href="/documentation" class="{{ 'active' if page == 'documentation' }}" title="Documentation" aria-label="Documentation"{{ ' aria-current="page"'|safe if page == 'documentation' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> + </a> + <a href="/history" class="{{ 'active' if page == 'history' }}" title="History" aria-label="History"{{ ' aria-current="page"'|safe if page == 'history' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> + </a> + <a href="/shorten" class="{{ 'active' if page == 'shorten' }}" title="URL Shortener" aria-label="URL Shortener"{{ ' aria-current="page"'|safe if page == 'shorten' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> + </a> + <a href="/ytmusic" class="{{ 'active' if page == 'ytmusic' }}" title="Youtube Music Download" aria-label="Youtube Music Download"{{ ' aria-current="page"'|safe if page == 'ytmusic' }}> + <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></svg> + </a> + </nav> + </header> + {% if not (page == 'listing' and not path) %} + <div class="breadcrumb-container" data-cdn-base="{{ cdn_base_url }}"> + <div class="breadcrumb" id="breadcrumb"> + {% set current = namespace(val='') %} + {% set ns = namespace(items=[{'name': 'root', 'href': '/'}]) %} + {% if path %} + {% set path_parts = path.split('/') %} + {% if page == 'file' %}{% set path_parts = path_parts[:-1] %}{% endif %} + {% for part in path_parts %} + {% set current.val = current.val + part + '/' %} + {% set ns.items = ns.items + [{'name': part, 'href': '/' + current.val[:-1]}] %} + {% endfor %} + {% endif %} + {% if page == 'upload' %} + {% set ns.items = ns.items + [{'name': 'new', 'href': '/new'}] %} + {% elif page == 'search' %} + {% set ns.items = ns.items + [{'name': 'find', 'href': '/find'}] %} + {% elif page == 'history' %} + {% set ns.items = ns.items + [{'name': 'history', 'href': '/history'}] %} + {% elif page == 'documentation' %} + {% set ns.items = ns.items + [{'name': 'documentation', 'href': '/documentation'}] %} + {% elif page == 'gh-sync' %} + {% set ns.items = ns.items + [{'name': 'gh-sync', 'href': '/gh-sync'}] %} + {% elif page == 'shorten' %} + {% set ns.items = ns.items + [{'name': 'shorten', 'href': '/shorten'}] %} + {% elif page == 'ytmusic' %} + {% set ns.items = ns.items + [{'name': 'ytmusic', 'href': '/ytmusic'}] %} + {% elif page == 'file' %} + {% set ns.items = ns.items + [{'name': filename, 'href': none}] %} + {% endif %} + + {% if ns.items|length > 4 %} + {% set hidden = ns.items[1:-2] %} + <a href="{{ ns.items[0].href }}">{{ ns.items[0].name }}</a> + <div class="breadcrumb-collapsed"> + <button type="button" class="breadcrumb-ellipsis" onclick="event.stopPropagation(); toggleBreadcrumbMenu(this)" aria-label="Show hidden path segments">&hellip;</button> + <div class="breadcrumb-menu"> + {% for c in hidden %} + <a href="{{ c.href }}">{{ c.name }}</a> + {% endfor %} + </div> + </div> + {% for c in ns.items[-2:] %} + / {% if c.href %}<a href="{{ c.href }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %} + {% endfor %} + {% else %} + {% for c in ns.items %} + {% if loop.first %}<a href="{{ c.href }}">{{ c.name }}</a> + {% else %}/ {% if c.href %}<a href="{{ c.href }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %} + {% endif %} + {% endfor %} + {% endif %} + + {% if path or page == 'file' %} + <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> + </button> + {% endif %} + </div> + </div> + {% endif %} +</div> diff --git a/app/templates/documentation.html b/app/templates/documentation.html @@ -0,0 +1,351 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Documentation · amit/cdn</title> +<meta name="description" content="Full endpoint reference for cdn.amit.is-a.dev - what each route does, what it needs, what it returns, and how it works."> +<link rel="icon" href="/favicon.ico" sizes="any"> +<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> +<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> +<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> +<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> +<meta name="theme-color" content="#09090b"> +<meta name="color-scheme" content="dark"> +<link rel="preconnect" href="https://fonts.googleapis.com"> +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> +<link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> +<style> + :root{--bg:#09090b;--surface:#121214;--surface-2:#1e1e22;--border:#27272a;--text:#f4f4f5;--muted:#a1a1aa;--accent:#818cf8;--get:#4ade80;--post:#818cf8;--err:#f87171;} + *{box-sizing:border-box;} + body{background:var(--bg);color:var(--text);font-family:'Work Sans',system-ui,sans-serif;margin:0;line-height:1.6;} + .wrap{max-width:1000px;margin:0 auto;padding:48px 20px 96px;} + h1{font-family:'Zilla Slab',Georgia,serif;font-weight:600;font-size:1.9rem;margin-bottom:.2em;} + h2{font-size:1.15rem;margin:0 0 1rem;font-family:'JetBrains Mono',monospace;} + h3{font-size:.95rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin:1.1em 0 .4em;} + p{color:var(--muted);margin:.4em 0;} + code{font-family:'JetBrains Mono',monospace;background:var(--surface-2);padding:.15em .4em;border-radius:4px;font-size:.85em;color:var(--text);word-break:break-word;overflow-wrap:anywhere;} + pre{font-family:'JetBrains Mono',monospace;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;font-size:.82rem;margin:.5em 0;} + a{color:var(--accent);} + .lede{max-width:70ch;} + .toc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.1rem 1.3rem;margin:1.6rem 0 2.4rem;} + .toc h3{margin-top:0;} + .toc ol{margin:0;padding-left:1.2em;color:var(--muted);columns:2;font-size:.92rem;} + .toc a{color:var(--text);text-decoration:none;} + .toc a:hover{color:var(--accent);} + .endpoint{border:1px solid var(--border);border-radius:10px;padding:1.4rem 1.5rem;margin-bottom:1.6rem;background:var(--surface);scroll-margin-top:20px;} + .route-row{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.6rem;} + .method{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:.78rem;padding:.2rem .5rem;border-radius:5px;letter-spacing:.03em;} + .method.get{background:rgba(74,222,128,.12);color:var(--get);} + .method.post{background:rgba(129,140,248,.15);color:var(--post);} + .route-path{font-family:'JetBrains Mono',monospace;font-size:1rem;color:var(--text);} + .domain-tag{font-family:'JetBrains Mono',monospace;font-size:.72rem;color:var(--muted);background:var(--surface-2);border:1px solid var(--border);border-radius:5px;padding:.15rem .4rem;} + .endpoint > p:first-of-type{margin-top:0;} + table{width:100%;border-collapse:collapse;margin:.5em 0;font-size:.88rem;} + th,td{text-align:left;padding:.4em .6em;border-bottom:1px solid var(--border);vertical-align:top;} + th{color:var(--muted);font-weight:500;font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;} + td{color:var(--text);} + td.desc{color:var(--muted);} + .err-list{list-style:none;padding:0;margin:.4em 0;} + .err-list li{padding:.3em 0;font-size:.88rem;overflow:hidden;} + .err-code{font-family:'JetBrains Mono',monospace;color:var(--err);font-weight:600;float:left;width:3.2em;margin-right:.6em;} + section{margin-top:2.6rem;} + section > h2 { font-family:'Zilla Slab',Georgia,serif; font-size:1.4rem; font-weight:600; color:var(--text); } + .footnote{font-size:.82rem;color:var(--muted);margin-top:3rem;border-top:1px solid var(--border);padding-top:1.2rem;} + footer{margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--border);font-size:.85rem;color:var(--muted);} +</style> +</head> +<body> +{% include "_navbar.html" %} +<div class="wrap"> + <h1>Documentation</h1> + <p class="lede">Every route this service exposes: what it's for, what it needs, what it returns, what makes it fail, and + how it actually works under the hood. This page is generated from reading the live source, not from memory or + guesswork — if something here is wrong, the code is the tie-breaker, and this page should get fixed to match it.</p> + + <div class="toc"> + <h3>Jump to</h3> + <ol> + <li><a href="#browsing">Browsing &amp; viewing</a></li> + <li><a href="#gh-sync">GitHub Actions sync</a></li> + <li><a href="#uploading">Uploading files</a></li> + <li><a href="#downloading">Downloading &amp; zips</a></li> + <li><a href="#batches">Multi-file batches</a></li> + <li><a href="#search">Search</a></li> + <li><a href="#raw-domain">The raw domain</a></li> + <li><a href="#utility">Utility</a></li> + <li><a href="#architecture">How storage works</a></li> + </ol> + </div> + + <section id="browsing"> + <h2>Browsing &amp; viewing</h2> + + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/{path}</span></div> + <p>The catch-all route behind the whole browsing UI on <code>cdn.amit.is-a.dev</code>. What it renders depends on + what <code>path</code> resolves to:</p> + <table> + <tr><th>If path is...</th><th class="desc">Renders</th></tr> + <tr><td>empty (<code>/</code>)</td><td class="desc">Root folder listing, plus the homepage hero/notice.</td></tr> + <tr><td>an existing file</td><td class="desc">A file detail page (preview where possible, size, copy-link/copy-raw buttons, download).</td></tr> + <tr><td>an existing folder</td><td class="desc">A folder listing (files and sub-folders, with search-this-folder and "Download all" zip).</td></tr> + <tr><td>nonexistent</td><td class="desc">404 page.</td></tr> + </table> + <h3>How it decides</h3> + <p>It calls <code>get_file_info(path)</code> first (a HEAD request against the Hugging Face dataset repo's + <code>resolve/main/</code> URL for that path). If that says the path exists as a file, it renders the file + page. Otherwise it calls <code>list_directory(path)</code> (a tree listing from the HF API); a non-empty + result renders the folder listing, an empty result on a non-root path is a 404.</p> + <h3>Errors</h3> + <ul class="err-list"><li><span class="err-code">404</span> Path doesn't exist as a file or a non-empty folder.</li></ul> + </div> + + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/new</span> <span class="method get">GET</span><span class="route-path">/find</span></div> + <p><code>/new</code> renders the upload UI (drag-and-drop → calls <code>/api/put</code> under the hood). + <code>/find</code> renders the search UI (calls <code>/api/find</code> as you type). Both are just the same + <code>index.html</code> template rendered with a different <code>page</code> context value — no separate + backend logic beyond that.</p> + </div> + </section> + + <section id="gh-sync"> + <h2>GitHub Actions sync</h2> + + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/gh-sync</span></div> + <p>Renders the human-readable setup guide for the feature below, including a copy-pasteable workflow file. + No parameters, no backend logic beyond rendering the template.</p> + </div> + + <div class="endpoint" id="post-gh-sync"> + <div class="route-row"><span class="method post">POST</span><span class="route-path">/api/gh-sync</span></div> + <p>Lets a GitHub Actions workflow push a repo's files to the CDN on every push, landing at + <code>/&lt;github-username&gt;/&lt;repo-name&gt;/</code> — with no PAT or shared secret required. Full setup + instructions (including the exact workflow YAML to copy) are at <a href="/gh-sync">/gh-sync</a>.</p> + + <h3>Request</h3> + <p><code>multipart/form-data</code> with two fields:</p> + <table> + <tr><th>Field</th><th class="desc">What it is</th></tr> + <tr><td><code>token</code></td><td class="desc">A GitHub Actions OIDC identity token, requested by the calling workflow via <code>core.getIDToken('cdn.amit.is-a.dev')</code>. This is the <em>only</em> source of identity this endpoint trusts.</td></tr> + <tr><td><code>archive</code></td><td class="desc">A <code>.tar.gz</code> of the repo's contents (the workflow builds this with <code>tar --exclude='.git' -czf ...</code>).</td></tr> + </table> + + <h3>How it works, step by step</h3> + <ol style="color:var(--muted)"> + <li>Verifies the token's signature against GitHub's own public keys (<code>token.actions.githubusercontent.com/.well-known/jwks</code>), checks it isn't expired, and checks the <code>aud</code> claim matches this service.</li> + <li>Reads <code>repository_owner</code> and <code>repository</code> straight out of the <em>verified</em> token claims — this is the destination folder, and it is never taken from anything else in the request. A workflow file cannot forge these claims because GitHub itself stamps them for the run.</li> + <li>Runs a per-repo, per-IP rate check (2&nbsp;GB/hour) as an abuse guard — this is not a content-size cap, just throttling.</li> + <li>Extracts the tar archive into a temp directory, rejecting anything that tries to write outside that directory (path traversal) or that is a symlink.</li> + <li>Uploads that folder to the underlying Hugging Face dataset repo with <code>path_in_repo</code> <strong>and</strong> <code>delete_patterns</code> both scoped to exactly <code>&lt;owner&gt;/&lt;repo&gt;/</code> — so the call can create, update, or delete files only inside that one folder, never anywhere else. Deleted files in the source repo are removed from the CDN folder too (this is a mirror, not an accumulate-forever upload).</li> + </ol> + + <h3>Response <code>200</code></h3> + <pre>{ + "synced_to": "jaasx009/College", + "cdn_url": "https://cdn.amit.is-a.dev/jaasx009/College/", + "raw_url_prefix": "https://raw.cdn.amit.is-a.dev/jaasx009/College/", + "note": "cdn_url is the browsable folder listing. raw_url_prefix isn't a link by itself - append an individual filename to it to get that file's direct raw URL.", + "ref": "refs/heads/main" +}</pre> + + <h3>Errors</h3> + <ul class="err-list"> + <li><span class="err-code">401</span> Token missing, expired, badly signed, wrong audience, or its owner/repository claims don't line up (malformed token).</li> + <li><span class="err-code">400</span> Archive isn't a readable <code>.tar.gz</code>, or it contains an unsafe path / a symlink.</li> + <li><span class="err-code">429</span> Per-repo/IP hourly byte limit (2&nbsp;GB) exceeded.</li> + </ul> + + <h3>Limits, currently</h3> + <p>No file-type restriction and no per-file or total size cap beyond the 2&nbsp;GB/hour abuse-rate guard above. + This is intentional for now, not an oversight.</p> + </div> + </section> + + <section id="uploading"> + <h2>Uploading files</h2> + + <div class="endpoint"> + <div class="route-row"><span class="method post">POST</span><span class="route-path">/api/put</span></div> + <p>Direct multipart file upload — what the <a href="/new">/new</a> page's drag-and-drop calls. For uploading + from a GitHub repo automatically, use <a href="#post-gh-sync">/api/gh-sync</a> instead; this endpoint is for + one-off manual uploads.</p> + <h3>Request</h3> + <table> + <tr><th>Field</th><th class="desc">What it is</th></tr> + <tr><td><code>files</code></td><td class="desc">One or more files (multipart, repeatable field).</td></tr> + <tr><td><code>folder</code></td><td class="desc">Optional. Must be exactly <code>uploads</code> or <code>third-party</code> — these are the only two folders this endpoint is allowed to write to. Defaults to <code>uploads</code>.</td></tr> + </table> + <h3>How it works</h3> + <p>Each file is streamed to a local temp path, checked against the rate limiter (50&nbsp;MB/minute, 300&nbsp;MB/hour + per client IP), then uploaded to Hugging Face at <code>&lt;folder&gt;/&lt;8-char-uuid&gt;-&lt;original-filename&gt;</code> + (spaces in the filename become underscores). The random prefix means two uploads can never collide, and the + filename portion is still readable. If 2 or more files were uploaded together, a <em>batch</em> is also created + (see <a href="#batches">Batches</a> below) so the whole set can be shared/downloaded as one link.</p> + <h3>Response <code>200</code></h3> + <pre>{ + "files": [ + {"filename": "photo.png", "hf_path": "uploads/ab12cd34-photo.png", + "size": 48213, "cdn_url": "...", "raw_url": "..."} + ], + "batch_id": "a1b2c3d4e5", // only present if 2+ files + "batch_url": "https://cdn.amit.is-a.dev/pack/a1b2c3d4e5" +}</pre> + <h3>Errors</h3> + <ul class="err-list"> + <li><span class="err-code">400</span> <code>folder</code> isn't <code>uploads</code> or <code>third-party</code>.</li> + <li><span class="err-code">429</span> Per-IP rate limit exceeded (50&nbsp;MB/min or 300&nbsp;MB/hour).</li> + </ul> + </div> + + <div class="endpoint"> + <div class="route-row"><span class="method post">POST</span><span class="route-path">/api/put-url</span></div> + <p>Uploads a file the server fetches from a URL you give it, instead of one you send directly. Same destination + rules and rate limiter as <code>/api/put</code>.</p> + <h3>Request</h3> + <p>JSON body:</p> + <pre>{ "url": "https://example.com/file.png", "folder": "uploads" }</pre> + <h3>How it works / safety</h3> + <p>Only <code>https://</code> URLs are accepted. Before fetching, the hostname is resolved and every returned + IP is checked — private, loopback, link-local, reserved, multicast, or unspecified addresses are all + rejected. This is an SSRF guard: without it, this endpoint could otherwise be used to make the server fetch + internal/local network addresses on the caller's behalf. The download is streamed with a size cap enforced + both from the <code>Content-Length</code> header (if present) and live as bytes come in, so a server lying + about its own content-length can't bypass the cap.</p> + <h3>Response <code>200</code></h3> + <p>Same single-file shape as one file from <code>/api/put</code>'s response (no batch, since it's always exactly one file).</p> + <h3>Errors</h3> + <ul class="err-list"> + <li><span class="err-code">400</span> Not <code>https://</code>, hostname doesn't resolve, resolves to a private/internal address, or the source returned a non-200.</li> + <li><span class="err-code">413</span> File exceeds the fetch size limit (currently the same 300&nbsp;MB/hour figure used as the upload cap).</li> + <li><span class="err-code">429</span> Per-IP rate limit exceeded.</li> + </ul> + </div> + </section> + + <section id="downloading"> + <h2>Downloading &amp; zips</h2> + + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/api/get/{path}</span></div> + <p>Forces a browser download of a single file (sends <code>Content-Disposition: attachment</code>), unlike + viewing the raw domain which lets the browser render the file inline if it can. Streams the file through + this server rather than redirecting to Hugging Face directly, and supports <code>Range</code> requests + (so video/audio seeking and resumable downloads work).</p> + <h3>Errors</h3> + <ul class="err-list"><li><span class="err-code">404</span> File doesn't exist.</li></ul> + </div> + + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/api/pack-info/{path}</span> <span class="method get">GET</span><span class="route-path">/api/pack/{path}</span></div> + <p><code>pack-info</code> returns the file count and total size a folder would zip to, without actually + building the zip — used by the UI to show a size estimate before you commit to downloading. <code>pack</code> + builds and streams the actual zip.</p> + <h3>How it works</h3> + <p>Lists every file under <code>path</code> recursively via the HF API, fetches up to 8 of them concurrently, + and streams them into a zip in memory (paths inside the zip are relative to the folder you asked for, not + absolute). There's a hard limit of <strong>300 files / 500&nbsp;MB total</strong> per zip — past that it + refuses rather than trying to build an enormous zip in memory.</p> + <h3>Errors</h3> + <ul class="err-list"> + <li><span class="err-code">404</span> Folder is empty or doesn't exist.</li> + <li><span class="err-code">413</span> Folder exceeds the 300-file / 500&nbsp;MB zip limit.</li> + </ul> + </div> + </section> + + <section id="batches"> + <h2>Multi-file batches</h2> + <p>When <a href="#uploading">/api/put</a> is given 2 or more files in one request, it also writes a small + "batch manifest" (just the list of resulting files) under a random 10-character batch ID, so that whole set + can be shared and managed as a single link instead of separate ones.</p> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/pack/{batch_id}</span></div> + <p>Renders a page listing every file in that batch, with per-file and "download all as zip" options.</p> + <h3>Errors</h3><ul class="err-list"><li><span class="err-code">404</span> No batch with that ID.</li></ul> + </div> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/api/pack-info-batch/{batch_id}</span> <span class="method get">GET</span><span class="route-path">/api/pack-batch/{batch_id}</span></div> + <p>Same idea as <code>/api/pack-info</code> / <code>/api/pack</code> above, but operating over the batch's file + list instead of a folder path. No 300-file/500MB check here since a batch is inherently one upload's worth + of files, not an arbitrary folder walk.</p> + <h3>Errors</h3> + <ul class="err-list"><li><span class="err-code">404</span> Batch doesn't exist, or exists but is empty.</li></ul> + </div> + </section> + + <section id="search"> + <h2>Search</h2> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/api/find?q=...</span></div> + <p>Powers the <a href="/find">/find</a> page. Takes a query string <code>q</code>, returns matching files + across the whole repo.</p> + <h3>Response <code>200</code></h3> + <pre>{ "results": [ { "...": "file entries" } ] }</pre> + <p>An empty or missing <code>q</code> is valid input, not an error — it just returns however + <code>search_files("")</code> resolves that (effectively little/nothing to match against, no exception thrown).</p> + </div> + </section> + + <section id="raw-domain"> + <h2>The raw domain</h2> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/{path}</span> <span class="domain-tag">raw.cdn.amit.is-a.dev</span></div> + <p>A separate hostname, not a path prefix on the main domain. Requests here are proxied straight through to + the underlying Hugging Face file (<code>resolve/main/&lt;path&gt;</code>), with no HTML wrapper — this is + meant for direct file links (<code>&lt;img src&gt;</code>, downloads, embeds), not browsing.</p> + <h3>If the path is a folder, not a file</h3> + <p>The direct file fetch 404s (Hugging Face's <code>resolve</code> only serves blobs). Before giving up, this + checks whether that path is actually a non-empty folder — if so, it 307-redirects to the equivalent listing + page on the main domain (<code>cdn.amit.is-a.dev/&lt;path&gt;/</code>) instead of just saying not-found. + A genuinely nonexistent path still 404s.</p> + <h3>Errors</h3> + <ul class="err-list"><li><span class="err-code">404</span> Path doesn't exist as a file or a non-empty folder. Response body is plain text, not the HTML 404 page (deliberately - this is meant to be machine/embed-friendly, not a browsing experience).</li></ul> + </div> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/raw/{path}</span> <span class="domain-tag">cdn.amit.is-a.dev</span></div> + <p>A convenience prefix on the <em>main</em> domain that just 307-redirects to the real raw domain — i.e. + <code>cdn.amit.is-a.dev/raw/foo.png</code> → <code>raw.cdn.amit.is-a.dev/foo.png</code>. Not itself a proxy.</p> + </div> + </section> + + <section id="utility"> + <h2>Utility</h2> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/ping</span></div> + <p>Health check. Always returns <code>200 "Server is awake!"</code> as plain text. Used to keep the instance + warm on host platforms that spin down idle services — has no other side effects.</p> + </div> + <div class="endpoint"> + <div class="route-row"><span class="method get">GET</span><span class="route-path">/static/{filename}</span> <span class="method get">GET</span><span class="route-path">/static/icons/{filename}</span> <span class="method get">GET</span><span class="route-path">/favicon.ico</span></div> + <p>Serve the site's own static assets (CSS-adjacent files, icons, favicon) straight off disk. Not related to + anything stored in the CDN's file repo — these ship with the app itself.</p> + </div> + </section> + + <section id="architecture"> + <h2>How storage actually works</h2> + <p class="lede">Everything above talks about "the CDN" as one thing, but there's no custom database or file + server behind it — every file this service serves, lists, or accepts lives in a single + <strong>Hugging Face dataset repository</strong>. This app is a FastAPI backend in front of that repo:</p> + <ul style="color:var(--muted)"> + <li>File listings (folders, search, file-exists checks) come from Hugging Face's repo-tree API, with a short-lived in-memory cache on this server's side so repeated browsing doesn't re-hit that API constantly.</li> + <li>Serving an actual file's bytes (viewing, raw links, downloads, zips) streams from the dataset repo's public <code>resolve/main/&lt;path&gt;</code> URL — this server proxies that stream rather than storing a separate copy.</li> + <li>Uploads (<code>/api/put</code>, <code>/api/put-url</code>, <code>/api/gh-sync</code>) all ultimately call the Hugging Face Hub API to commit files into that same dataset repo, each scoped to whatever folder/path rules that endpoint enforces.</li> + <li>There is one HF write token, held only by this server - never handed to uploaders, contributors, or GitHub Actions runs. Everything that writes to storage goes through this backend's own validation first (folder allow-lists, OIDC identity checks, rate limits) precisely because the write credential itself has no per-user restriction built in - the backend <em>is</em> the access control.</li> + </ul> + </section> + + <p class="footnote">If something on this page doesn't match what the service actually does, trust the code + (<code>app/main.py</code>, <code>app/storage.py</code>, <code>app/gh_oidc.py</code> in + <a href="https://github.com/notamitgamer/cdnV2">notamitgamer/cdnV2</a>) over this page, and open an issue/PR + so this page gets corrected.</p> + + <footer> + <p>&copy; 2025 - 2026 notamitgamer. All rights reserved.</p> + </footer> +</div> +</body> +</html> diff --git a/app/templates/gh_sync_docs.html b/app/templates/gh_sync_docs.html @@ -0,0 +1,208 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>GitHub Actions Sync · amit/cdn</title> +<meta name="description" content="Push any repo's files to the CDN automatically on every git push."> +<link rel="icon" href="/favicon.ico" sizes="any"> +<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> +<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> +<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> +<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> +<meta name="theme-color" content="#09090b"> +<meta name="color-scheme" content="dark"> + +<link rel="preconnect" href="https://fonts.googleapis.com"> +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> +<link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> +<style> + :root { + --bg: #09090b; + --surface: #121214; + --surface-2: #1e1e22; + --border: #27272a; + --text: #f4f4f5; + --muted: #a1a1aa; + --accent: #818cf8; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + *, + *::before, + *::after, + html { + -webkit-tap-highlight-color: transparent; + } + + body { + background: var(--bg); + color: var(--text); + font-family: 'Work Sans', system-ui, sans-serif; + margin: 0; + line-height: 1.6; + } + + .wrap { + max-width: 1000px; + margin: 0 auto; + padding: 48px 20px 96px; + } + + h1 { + font-family: 'Zilla Slab', Georgia, 'Times New Roman', serif; + font-weight: 600; + font-size: 1.9rem; + margin-bottom: .2em; + } + + h2 { + font-size: 1.25rem; + margin-top: 2.4em; + border-top: 1px solid var(--border); + padding-top: 1.2em; + } + + p { + color: var(--muted); + } + + code, pre { + font-family: 'JetBrains Mono', monospace; + } + + code { + background: var(--surface-2); + padding: .15em .4em; + border-radius: 4px; + font-size: .9em; + color: var(--text); + word-break: break-word; + overflow-wrap: anywhere; + } + + pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + overflow-x: auto; + font-size: .85rem; + } + + ul { + color: var(--muted); + } + + .badge { + display: inline-block; + background: var(--surface-2); + color: var(--accent); + border-radius: 6px; + padding: 2px 8px; + font-size: .75rem; + margin-left: 8px; + vertical-align: middle; + } + + a { + color: var(--accent); + } + + footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; + color: var(--muted); + } +</style> +</head> +<body> +{% include "_navbar.html" %} +<div class="wrap"> + <h1>Auto-sync any repo to the CDN <span class="badge">GitHub Actions</span></h1> + <p>Add one workflow file to a repo. On every push, its contents land in the CDN under + <code>/&lt;your-github-username&gt;/&lt;repo-name&gt;/</code> — no PAT, no shared secret, and it's + physically impossible for your workflow to write into anyone else's folder.</p> + + <h2>How it stays safe</h2> + <p>Instead of a token you paste in, your repo asks GitHub itself for a short-lived + <strong>OIDC identity token</strong> for the run. That token's "who and where" fields + (<code>repository_owner</code>, <code>repository</code>) are stamped by GitHub's own servers from the + real run context — a workflow file can't edit itself to lie about which repo it's running in. + The CDN backend verifies that token's signature, reads the owner/repo straight from it, and uses + <em>only that</em> to decide where your files go. Nothing in your request body or headers is trusted + for the destination path.</p> + + <h2>1. Add the workflow</h2> + <p>Create <code>.github/workflows/cdn-sync.yml</code> in the repo you want mirrored. The token is + short-lived and scoped to your repo already, but <code>core.setSecret()</code> tells GitHub to mask it + from the run's logs, and passing it through <code>env:</code> instead of straight into the shell + command keeps it out of the process list too — worth keeping both even though the token itself + is harmless if it leaks (it expires in minutes and only proves who you are, it can't push files on its own).</p> +<pre>{% raw %}name: Sync to CDN + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + id-token: write # required — this is what lets us prove who you are + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Request GitHub OIDC token + id: oidc + uses: actions/github-script@v7 + with: + script: | + const token = await core.getIDToken('cdn.amit.is-a.dev') + core.setSecret(token) + core.setOutput('token', token) + + - name: Package repo contents + run: tar --exclude='.git' -czf /tmp/payload.tar.gz . + + - name: Push to CDN + env: + OIDC_TOKEN: ${{ steps.oidc.outputs.token }} + run: | + curl -sf -X POST https://cdn.amit.is-a.dev/api/gh-sync \ + -F "token=$OIDC_TOKEN" \ + -F "archive=@/tmp/payload.tar.gz" +{% endraw %}</pre> + + <h2>2. Push</h2> + <p>That's it — no repo secrets to create. On the next push, your files land at:</p> + <pre>https://cdn.amit.is-a.dev/&lt;your-username&gt;/&lt;repo-name&gt;/</pre> + <p>Need a raw link to one specific file instead of the folder listing? Append its path to the raw + domain: <code>https://raw.cdn.amit.is-a.dev/&lt;your-username&gt;/&lt;repo-name&gt;/&lt;file&gt;</code>.</p> + + <h2>Notes</h2> + <ul> + <li>Any file type is accepted, and there's currently no limit on file size or on how many files a sync can push — send the whole repo as-is.</li> + <li>Each sync fully replaces the contents of <em>your own</em> <code>username/repo</code> folder only + (delete-then-upload), so removed files disappear from the CDN too. No other folder is ever touched.</li> + <li>Change <code>branches: [ main ]</code> to whichever branch you want mirrored.</li> + <li>Want it on every branch/tag push instead? Adjust the <code>on:</code> block — the destination + folder is still locked to your username/repo either way.</li> + </ul> + + <footer> + <p>&copy; 2025 - 2026 notamitgamer. All rights reserved.</p> + </footer> +</div> +</body> +</html> diff --git a/app/templates/index.html b/app/templates/index.html @@ -0,0 +1,2006 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{% if page == 'file' %}{{ filename }} · amit/cdn{% elif page == 'upload' %}New · amit/cdn{% elif page == 'search' %}Find · amit/cdn{% elif page == 'history' %}History · amit/cdn{% else %}amit/cdn · {{ page | capitalize }}{% endif %}</title> + <meta name="description" content="Static file host for amit's projects. Browse, upload, and grab direct links."> + + <link rel="icon" href="/favicon.ico" sizes="any"> + <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> + <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> + <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> + + <link rel="manifest" href="/static/manifest.json"> + <meta name="theme-color" content="#09090b"> + <meta name="color-scheme" content="dark"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> + <meta name="apple-mobile-web-app-title" content="amit/cdn"> + + <meta property="og:title" content="{% if page == 'upload' %}New · amit/cdn{% elif page == 'search' %}Find · amit/cdn{% elif page == 'history' %}History · amit/cdn{% else %}amit/cdn · {{ page | capitalize }}{% endif %}"> + <meta property="og:description" content="Static file host for amit's projects. Browse, upload, and grab direct links."> + <meta property="og:image" content="/static/icons/og-logo.png"> + <meta property="og:type" content="website"> + <meta name="twitter:card" content="summary"> + + <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script> + + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> + + <style> + :root { + --bg: #09090b; + --surface: #121214; + --surface-2: #1e1e22; + --surface-3: #27272a; + --text-main: #f4f4f5; + --text-dim: #b8b8c0; + + --accent-primary: #818cf8; + --accent-primary-hover: #a5b4fc; + --accent-secondary: #f97316; + --success: #10b981; + --danger: #ef4444; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.15); + + --font-heading: 'Zilla Slab', Georgia, 'Times New Roman', serif; + --font-body: 'Work Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + } + + * { box-sizing: border-box; } + * { -webkit-tap-highlight-color: transparent; } + + ::selection { background: var(--accent-primary); color: #ffffff; } + ::-webkit-scrollbar { width: 10px; height: 10px; } + ::-webkit-scrollbar-track { background: var(--bg); } + ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); } + ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } + + body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text-main); + margin: 0; + padding: 0 0 4rem 0; + line-height: 1.6; + font-size: 1rem; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + @media (max-width: 480px) { + body { font-size: 0.95rem; } + } + + .container { + width: 90%; + max-width: 1000px; + margin: 0 auto; + } + + @media (max-width: 480px) { + .container { + width: 100%; + padding: 0 1.25rem; + } + .breadcrumb-container { padding: 0 1.25rem 1rem; } + } + + h1 { + font-family: var(--font-heading); + font-size: clamp(2.2rem, 5vw, 3rem); + letter-spacing: -0.02em; + color: #ffffff; + font-weight: 600; + margin: 0 0 1.25rem 0; + line-height: 1.1; + max-width: 20ch; + } + + blockquote { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-primary); + border-radius: var(--radius-md); + margin: 0 0 2.25rem 0; + padding: 1rem 1.25rem; + color: var(--text-dim); + max-width: 68ch; + font-size: 0.95rem; + } + + .site-notice { margin-top: 0.5rem; } + + .recent-activity { + margin-bottom: 2.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + overflow: hidden; + } + .recent-activity-heading { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-dim); + font-size: 0.9rem; + font-weight: 500; + padding: 0.7rem 1rem; + } + .recent-activity-list { + list-style: none; + margin: 0; + padding: 0 1rem 0.6rem; + } + .recent-activity-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.35rem 0; + border-top: 1px solid var(--border); + font-size: 0.9rem; + } + .recent-activity-list li:first-child { border-top: none; } + .recent-activity-list a { text-decoration: none; } + .recent-activity-list a:hover { text-decoration: underline; } + .recent-activity-time { + color: var(--text-dim); + font-size: 0.8rem; + white-space: nowrap; + flex-shrink: 0; + } + + a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s;} + a:hover { color: var(--accent-primary-hover); text-decoration: underline; } + + .breadcrumb-container { + background: var(--bg); + max-width: 1000px; + margin: 0 auto; + padding: 0 5% 1rem 5%; + } + + .breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + color: var(--text-dim); + font-weight: 500; + font-family: var(--font-mono); + font-size: 0.85rem; + } + .breadcrumb a { + color: var(--text-main); + text-decoration: none; + padding: 0.2rem 0.4rem; + margin: -0.2rem -0.4rem; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + } + .breadcrumb a:hover { + color: var(--accent-primary); + background: var(--surface-2); + text-decoration: none; + } + + .breadcrumb-collapsed { + position: relative; + display: inline-flex; + } + .breadcrumb-ellipsis { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 6px; + padding: 0.25rem 0.75rem; + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.9rem; + line-height: 1.4; + transition: all 0.2s; + } + .breadcrumb-ellipsis:hover { + color: var(--text-main); + border-color: var(--border-hover); + background: var(--surface-2); + } + .breadcrumb-menu { + display: none; + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 180px; + max-width: 70vw; + max-height: 260px; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.5rem; + flex-direction: column; + gap: 0.25rem; + z-index: 20; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); + } + .breadcrumb-menu.open { display: flex; } + + .breadcrumb-menu a { + padding: 0.75rem 1rem; + border-radius: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + } + + .copy-path-btn { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem; + margin-left: 0.5rem; + transition: all 0.2s; + } + .copy-path-btn:hover { + color: var(--text-main); + border-color: var(--border-hover); + background: var(--surface-2); + } + .copy-path-btn:active { + transform: scale(0.95); + } + + .input-group { + position: relative; + display: flex; + align-items: center; + width: 100%; + } + .filter-input, .search-input, .url-input { + width: 100%; + padding: 0.85rem 2.5rem 0.85rem 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-main); + font-family: var(--font-mono); + font-size: 0.9rem; + transition: all 0.2s ease; + box-shadow: 0 2px 10px rgba(0,0,0,0.1) inset; + } + .search-input { font-size: 1rem; padding: 1rem 2.5rem 1rem 1.25rem; } + + .filter-input:focus, .search-input:focus, .url-input:focus { + outline: none; + background: var(--surface-2); + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15); + } + .filter-input::placeholder, .search-input::placeholder, .url-input::placeholder { + color: var(--text-dim); + } + + .input-clear-btn { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0.25rem; + display: none; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: color 0.2s, background 0.2s; + } + .input-group[data-has-text="true"] .input-clear-btn { + display: flex; + } + .input-clear-btn:hover { + color: var(--text-main); + background: var(--surface-3); + } + + .list-container { margin-bottom: 2rem; } + + .listing-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.25rem; + padding: 0.5rem 0; + font-family: var(--font-mono); + font-size: 0.85rem; + } + + .list-group { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.2); + margin-bottom: 2rem; + display: flex; + flex-direction: column; + } + + .list-group .input-group { + border-bottom: 1px solid var(--border); + background: var(--surface-2); + } + + .list-group .filter-input, .list-group .search-input { + background: transparent; + box-shadow: none; + border-radius: 0; + border: none; + } + + .list-group .filter-input:focus, .list-group .search-input:focus { + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 3px 0 0 var(--accent-primary); + } + + .list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text-main); + gap: 1rem; + transition: background 0.15s ease; + } + .list-item:last-child { + border-bottom: none; + } + .list-item:hover { + background: rgba(255, 255, 255, 0.03); + } + .list-item:active { + background: rgba(255, 255, 255, 0.05); + } + + .item-name { + display: flex; + align-items: center; + gap: 0.85rem; + word-break: break-all; + min-width: 0; + flex: 1; + font-family: var(--font-mono); + font-size: 0.95rem; + } + .item-name svg { + flex-shrink: 0; + transition: color 0.2s; + } + + .item-name svg.folder-icon { color: var(--accent-primary); } + .item-name svg.file-icon-list { color: var(--text-dim); } + + .list-item:hover .item-name svg.folder-icon { color: var(--accent-primary-hover); } + .list-item:hover .item-name svg.file-icon-list { color: var(--text-main); } + + .item-name a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .item-name a.folder-link { + color: var(--accent-primary); + font-weight: 600; + } + .item-name a.file-link { + color: var(--text-main); + font-weight: 500; + } + + .item-meta { + display: flex; + align-items: center; + gap: 1rem; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.8rem; + white-space: nowrap; + flex-shrink: 0; + } + + .size-label { + display: inline-block; + } + + .download-icon { + color: var(--text-dim); + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + min-width: 36px; + min-height: 36px; + margin: -0.5rem; + border-radius: 8px; + background: transparent; + } + .download-icon:hover { + color: var(--accent-primary); + background: rgba(129, 140, 248, 0.1); + } + .download-icon:active { + transform: scale(0.9); + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--surface-2); + color: var(--text-main); + padding: 0.65rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + font-family: var(--font-body); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease, transform 0.1s ease; + } + .btn:hover:not(:disabled) { + background: var(--surface-3); + border-color: var(--border-hover); + text-decoration: none; + color: var(--text-main); + } + .btn:active:not(:disabled) { + transform: scale(0.97); + } + .btn:disabled { opacity: 0.6; cursor: not-allowed; } + + .btn-primary { + background: var(--accent-primary) !important; + color: #ffffff !important; + border-color: var(--accent-primary) !important; + font-weight: 600; + transition: none !important; + } + .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) { + background: var(--accent-primary) !important; + border-color: var(--accent-primary) !important; + color: #ffffff !important; + } + .btn-primary svg { stroke: #ffffff !important; } + + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + padding: 0.65rem; + transition: all 0.2s ease; + } + .icon-btn:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--border-hover); + } + .icon-btn:hover:not(:disabled) svg { stroke: var(--accent-primary); } + .icon-btn:active:not(:disabled) { transform: scale(0.95); } + + .file-card { + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface); + margin-bottom: 2.5rem; + overflow: hidden; + } + + .file-preview { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--surface-2); + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 24px 24px; + border-bottom: 1px solid var(--border); + min-height: 200px; + padding: 3rem 1rem; + position: relative; + } + + .file-icon { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border-hover); + color: var(--text-main); + } + + .file-ext-tag { + position: absolute; + bottom: -0.6rem; + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.5px; + font-weight: 600; + color: #ffffff; + background: var(--accent-secondary); + padding: 0.2rem 0.6rem; + border-radius: 4px; + text-transform: uppercase; + } + + .file-info { + padding: 2rem; + text-align: center; + } + .file-name { + font-family: var(--font-heading); + margin: 0 0 0.75rem 0; + font-weight: 600; + font-size: 1.5rem; + color: #ffffff; + word-break: break-all; + } + .file-meta-row { + display: flex; + gap: 0.75rem; + justify-content: center; + margin-bottom: 1.5rem; + } + .file-pill { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--accent-primary); + border: 1px solid rgba(129, 140, 248, 0.2); + background: rgba(129, 140, 248, 0.05); + border-radius: 6px; + padding: 0.35rem 0.85rem; + letter-spacing: 0.5px; + } + .file-pill:nth-child(2) { + color: var(--text-main); + border-color: var(--border); + background: var(--surface-2); + } + .file-path { + font-family: var(--font-mono); + color: var(--text-dim); + margin: 0 auto 2.5rem auto; + font-size: 0.85rem; + word-break: break-all; + background: rgba(0, 0, 0, 0.3); + display: inline-block; + padding: 0.75rem 1.25rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); + max-width: 90%; + } + .file-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; + } + + .folder-picker { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + } + .folder-picker-label { + color: var(--text-dim); + font-size: 0.9rem; + font-weight: 500; + margin-right: 0.5rem; + } + .folder-option { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 99px; + padding: 0.4rem 1rem; + font-family: var(--font-mono); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + } + .folder-option:hover { + border-color: var(--text-main); + color: var(--text-main); + } + .folder-option.active { + background: var(--text-main); + border-color: var(--text-main); + color: var(--bg); + font-weight: 600; + } + + #dropzone { + border: 2px dashed var(--border-hover); + border-radius: var(--radius-lg); + padding: 4rem 2rem; + text-align: center; + cursor: pointer; + color: var(--text-dim); + margin-bottom: 1.5rem; + background: rgba(255, 255, 255, 0.02); + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + #dropzone svg { + width: 48px; + height: 48px; + color: var(--border-hover); + margin-bottom: 0.5rem; + transition: color 0.2s; + } + #dropzone:hover, #dropzone.dragover { + border-color: var(--accent-primary); + background: rgba(129, 140, 248, 0.08); + color: var(--accent-primary); + box-shadow: 0 0 0 4px rgba(129, 140, 248, 0.1); + } + #dropzone:hover svg, #dropzone.dragover svg { + color: var(--accent-primary); + } + + #results:not(:empty) { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.2); + } + + .file-row { + display: flex; justify-content: space-between; padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + background: transparent; + align-items: center; + font-family: var(--font-mono); + font-size: 0.85rem; + } + .file-row:last-child { + border-bottom: none; + } + .file-row.done { background: rgba(16, 185, 129, 0.05); } + .file-row.error { background: rgba(239, 68, 68, 0.05); } + + .turbo-progress-bar { + height: 3px; + background-color: var(--accent-primary); + } + + .container { transition: opacity 0.2s ease; } + body.turbo-loading .container { opacity: 0.6; pointer-events: none; } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 5rem 2rem; + color: var(--text-dim); + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + } + .list-group .empty-state { + border: none; + background: transparent; + padding: 4rem 2rem; + border-radius: 0; + } + .empty-state svg { opacity: 0.5; width: 48px; height: 48px; } + + @keyframes spin { 100% { transform: rotate(360deg); } } + .spin-anim { animation: spin 1s linear infinite; } + .upload-spinner { + display: inline-block; + width: 14px; height: 14px; + border: 2px solid var(--accent-primary); + border-top-color: transparent; + border-radius: 50%; + margin-right: 8px; + vertical-align: middle; + } + + .modal-overlay { + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + z-index: 1000; + } + .modal { + background: var(--surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-lg); + padding: 2rem; + max-width: 440px; + width: 90%; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + } + .modal h3 { + font-family: var(--font-heading); + margin-top: 0; margin-bottom: 1rem; + color: #fff; font-size: 1.4rem; + font-weight: 600; + } + .modal p { margin-bottom: 2rem; color: var(--text-dim); } + .modal-actions { + display: flex; justify-content: flex-end; gap: 0.75rem; + } + + footer { + margin-top: 5rem; + padding-top: 2rem; + border-top: 1px solid var(--border); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.8rem; + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + } + footer a { color: var(--text-main); font-weight: 500; } + footer a:hover { color: var(--accent-primary); } + + @media (max-width: 480px) { + .list-item { padding: 0.85rem 1rem; } + .file-info { padding: 1.5rem 1.25rem; } + + .file-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + .file-actions .btn { width: 100%; padding: 0.65rem 0.5rem; font-size: 0.85rem;} + } + + .history-tabs { + display: flex; + width: 100%; + border-bottom: 1px solid var(--border); + margin-bottom: 1.5rem; + } + .history-tab { + flex: 1; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-dim); + font-family: var(--font-body); + font-size: 0.95rem; + font-weight: 500; + padding: 0.85rem 1rem; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, background 0.2s; + } + .history-tab:hover { + color: var(--text-main); + background: rgba(255, 255, 255, 0.03); + } + .history-tab.active { + color: var(--text-main); + border-bottom-color: var(--accent-primary); + } + .history-slider-viewport { + width: 100%; + overflow: hidden; + } + .history-slider { + display: flex; + width: 200%; + transform: translateX(0); + transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; + } + .history-section { + width: 50%; + flex: 0 0 50%; + margin-bottom: 0; + min-width: 0; + } + .history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + .history-title { + font-family: var(--font-heading); + font-size: 1.4rem; + font-weight: 600; + margin: 0; + color: #ffffff; + } + .clear-history-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 0.8rem; + font-family: var(--font-mono); + padding: 0.3rem 0.6rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + } + .clear-history-btn:hover { + color: var(--danger); + border-color: var(--danger); + } + + @media (max-width: 480px) { + .history-tab { + font-size: 0.85rem; + padding: 0.75rem 0.5rem; + } + } + </style> +</head> +<body> + {% include "_navbar.html" %} + </div> + + <div class="container"> + {% if page == 'listing' and not path %} + <h1>A small file shelf for <a href="https://amit.is-a.dev">my</a> projects.</h1> + <blockquote class="site-notice"> + Files here can now be synced straight from GitHub on every push — click + <a href="/gh-sync">Sync</a> at the top to see how to set it up. Project assets live here; + files and paths may change over time, so use direct links with that in mind. + </blockquote> + {% if recent_activity %} + <div class="recent-activity" id="recent-activity"> + <div class="recent-activity-heading"> + <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> + <span>Recently modified</span> + </div> + <ul class="recent-activity-list"> + {% for entry in recent_activity %} + <li> + <a href="/{{ entry.path }}/">{{ entry.path }}/</a> + {% if entry.iso_time %}<time class="recent-activity-time" datetime="{{ entry.iso_time }}">{{ entry.iso_time }}</time>{% endif %} + </li> + {% endfor %} + </ul> + </div> + {% endif %} + {% endif %} + + {% if page == 'listing' %} + {% if items %} + <div class="listing-toolbar"> + <div style="color: var(--text-dim);"> + {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %} + {% set file_count = visible_items | rejectattr("is_dir") | list | length %} + {% set dir_count = visible_items | selectattr("is_dir") | list | length %} + + {% if dir_count > 0 %} + <strong style="color: var(--text-main);">{{ dir_count }}</strong> folder{{ 's' if dir_count != 1 else '' }} + {% endif %} + {% if dir_count > 0 and file_count > 0 %} &amp; {% endif %} + {% if file_count > 0 %} + <strong style="color: var(--text-main);">{{ file_count }}</strong> file{{ 's' if file_count != 1 else '' }} + {% endif %} + {% if dir_count == 0 and file_count == 0 %} + 0 items + {% endif %} + </div> + <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip"> + <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <line x1="12" y1="2" x2="12" y2="6"></line> + <line x1="12" y1="18" x2="12" y2="22"></line> + <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line> + <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line> + <line x1="2" y1="12" x2="6" y2="12"></line> + <line x1="18" y1="12" x2="22" y2="12"></line> + <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line> + <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line> + </svg> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> + <span id="zip-btn-text">Download all (.zip)</span> + </button> + </div> + {% endif %} + <div class="list-container"> + <div class="list-group"> + <div class="input-group" id="filter-group"> + <input type="text" id="filter-input" class="filter-input" placeholder="Search this folder... (press / to focus)" autocomplete="off" spellcheck="false"> + <button type="button" class="input-clear-btn" id="filter-clear" aria-label="Clear filter"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> + </button> + </div> + + <div id="file-list"> + {% if path %} + <div class="list-item" data-name=".."> + <div class="item-name"> + <svg class="folder-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg> + {% set parent_path = '/' %} + {% set path_parts = path.split('/') %} + {% if path_parts|length > 1 %} + {% set parent_path = '/' + path_parts[:-1]|join('/') %} + {% endif %} + <a href="{{ parent_path }}" class="folder-link">Go up a directory</a> + </div> + </div> + {% endif %} + + {% for item in items %} + {% if item.name != '.gitattributes' %} + <div class="list-item" data-name="{{ item.name }}"> + <div class="item-name"> + {% if item.is_dir %} + <svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> + <a href="/{{ item.path }}" class="folder-link" data-full-name="{{ item.name }}/">{{ item.name }}/</a> + {% else %} + <svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg> + <a href="/{{ item.path }}" class="file-link" data-full-name="{{ item.name }}">{{ item.name }}</a> + {% endif %} + </div> + <div class="item-meta"> + {% if not item.is_dir %} + <span class="size-label">{{ item.size_str }}</span> + <a href="/api/get/{{ item.path }}" class="download-icon" title="Download" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> + </a> + {% endif %} + </div> + </div> + {% endif %} + {% endfor %} + + {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %} + {% if not visible_items %} + <div class="empty-state"> + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> + <span>This directory is empty. <a href="/new" style="text-decoration: underline;">Upload a file</a> to get started.</span> + </div> + {% endif %} + </div> + </div> + </div> + + {% elif page == 'file' %} + <div class="file-card"> + <div class="file-preview file-preview-none"> + <div class="file-icon"> + {% if file_ext %}<span class="file-ext-tag">.{{ file_ext }}</span>{% endif %} + <svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg> + </div> + </div> + + <div class="file-info"> + <h2 class="file-name">{{ filename }}</h2> + <div class="file-meta-row"> + {% if file_ext %}<span class="file-pill">{{ file_ext | upper }}</span>{% endif %} + {% if file_size_str and file_size_str != '-' %}<span class="file-pill">{{ file_size_str }}</span>{% endif %} + </div> + <p class="file-path">{{ path }}</p> + + <div class="file-actions"> + <a href="/api/get/{{ path }}" class="btn btn-primary" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> + Download File + </a> + <a href="{{ raw_base_url }}/{{ path }}" class="btn" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> + Open Raw + </a> + <button onclick="copyToClipboard('{{ cdn_base_url }}/{{ path }}', this)" class="btn">Copy Link</button> + <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}', this)" class="btn">Copy Raw Link</button> + </div> + </div> + </div> + + {% elif page == 'batch' %} + <div class="listing-toolbar"> + <div style="color: var(--text-dim); font-size: 0.95rem;"> + <strong style="color: var(--text-main);">{{ batch_files | length }}</strong> file{{ 's' if batch_files | length != 1 else '' }} in this batch + </div> + <div style="display:flex; gap:0.75rem; flex-wrap: wrap;"> + <button onclick="copyToClipboard(window.location.href, this)" class="btn">Copy Batch Link</button> + <button id="download-zip-btn" class="btn btn-primary" data-batch-id="{{ batch_id }}" title="Download every file in this batch as a .zip"> + <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <line x1="12" y1="2" x2="12" y2="6"></line> + <line x1="12" y1="18" x2="12" y2="22"></line> + <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line> + <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line> + <line x1="2" y1="12" x2="6" y2="12"></line> + <line x1="18" y1="12" x2="22" y2="12"></line> + <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line> + <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line> + </svg> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> + <span id="zip-btn-text">Download all (.zip)</span> + </button> + </div> + </div> + <div class="list-container"> + <div class="list-group" id="file-list"> + {% for f in batch_files %} + <div class="list-item" data-name="{{ f.filename }}"> + <div class="item-name"> + <svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg> + <a href="/{{ f.hf_path }}" class="file-link" data-full-name="{{ f.filename }}">{{ f.filename }}</a> + </div> + <div class="item-meta"> + <span class="batch-size-label">{{ f.size_str }}</span> + <a href="/api/get/{{ f.hf_path }}" class="download-icon" title="Download" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> + </a> + </div> + </div> + {% endfor %} + + {% if not batch_files %} + <div class="empty-state"> + <span>This batch has no files.</span> + </div> + {% endif %} + </div> + </div> + + {% elif page == '404' %} + <div class="list-container" style="padding: 6rem 1.5rem; text-align: center;"> + <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="1.5" style="margin-bottom: 1.5rem;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> + <h1 style="margin: 0 0 1rem 0; color: var(--text-main);">404 - Not Found</h1> + <p style="color: var(--text-dim); margin-bottom: 2.5rem; font-size: 1.1rem;">The file or directory you're looking for doesn't exist.</p> + <a href="/" class="btn btn-primary">Go to Root</a> + </div> + + {% elif page == 'upload' %} + <div> + <blockquote style="margin-bottom: 2.5rem;"> + If you do not remember the link, go to the <a href="/uploads">uploads folder</a> to find your file. + </blockquote> + + <div class="folder-picker" id="folder-picker"> + <span class="folder-picker-label">Save to:</span> + <button type="button" class="folder-option active" data-folder="uploads">uploads</button> + <button type="button" class="folder-option" data-folder="third-party">third-party</button> + </div> + + <div style="display:flex; gap:0.75rem; margin-bottom: 1.5rem;"> + <input type="text" id="url-input" class="url-input" placeholder="Paste a link to fetch and upload…"> + <button type="button" class="icon-btn" id="url-upload-btn" title="Upload from URL" aria-label="Upload from URL"> + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07l-1.5 1.5"/> + <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07l1.5-1.5"/> + </svg> + </button> + </div> + + <div id="dropzone"> + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> + <div style="font-size: 1.1rem; font-weight: 500; color: var(--text-main);">Drag & drop files here</div> + <div style="font-size: 0.9rem;">or click to browse</div> + <input type="file" id="file-input" multiple style="display:none;"> + </div> + + <div id="error-box" style="display: none; color: var(--danger); margin-bottom: 1.5rem; padding: 1rem 1.25rem; border: 1px solid rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.1); border-radius: var(--radius-md);"></div> + <div id="results"></div> + </div> + + <script> + const dropzone = document.getElementById('dropzone'); + const fileInput = document.getElementById('file-input'); + const urlInput = document.getElementById('url-input'); + const urlUploadBtn = document.getElementById('url-upload-btn'); + const results = document.getElementById('results'); + const errorBox = document.getElementById('error-box'); + const folderPicker = document.getElementById('folder-picker'); + + const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024; + + // Records an upload to this browser's local /history log + function recordUpload(item) { + try { + var KEY = 'cdn_uploads_v1'; + var MAX = 50; + var entries = JSON.parse(localStorage.getItem(KEY) || '[]'); + entries.unshift({ + filename: item.filename, + hf_path: item.hf_path, + size: item.size, + cdn_url: item.cdn_url, + raw_url: item.raw_url, + ts: Date.now() + }); + localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX))); + } catch (e) {} + } + + function selectedFolder() { + const active = folderPicker.querySelector('.folder-option.active'); + return active ? active.dataset.folder : 'uploads'; + } + + folderPicker.querySelectorAll('.folder-option').forEach(btn => { + btn.addEventListener('click', () => { + folderPicker.querySelectorAll('.folder-option').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); dropzone.classList.remove('dragover'); + if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files.length) handleFiles(fileInput.files); + }); + + function showError(msg) { + errorBox.textContent = msg; + errorBox.style.display = 'block'; + } + + function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function handleFiles(fileList) { + errorBox.style.display = 'none'; + + const allFiles = Array.from(fileList); + const files = []; + const rejectedSize = []; + const rejectedName = []; + const allowedNameRegex = /^[a-zA-Z0-9_.\-]+$/; + + allFiles.forEach(f => { + if (f.size > MAX_FILE_SIZE_BYTES) { + rejectedSize.push(f.name); + } else if (!allowedNameRegex.test(f.name)) { + rejectedName.push(f.name); + } else { + files.push(f); + } + }); + + const errorMessages = []; + if (rejectedSize.length) { + errorMessages.push(`Skipped ${rejectedSize.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejectedSize.join(', ')}`); + } + if (rejectedName.length) { + errorMessages.push(`Skipped ${rejectedName.length} file(s) with invalid characters (only a-z, A-Z, 0-9, _, -, . allowed): ${rejectedName.join(', ')}`); + } + + if (errorMessages.length) { + showError(errorMessages.join(' | ')); + } + + if (!files.length) return; + + const rows = {}; + files.forEach(f => { + const row = document.createElement('div'); + row.className = 'file-row'; + row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${f.name}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;">uploading...</div>`; + results.prepend(row); + rows[f.name] = row; + }); + + const formData = new FormData(); + files.forEach(f => formData.append('files', f)); + formData.append('folder', selectedFolder()); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/put', true); + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const pct = Math.round((e.loaded / e.total) * 100); + files.forEach(f => { + const statusNode = rows[f.name].querySelector('.status'); + if (pct === 100) { + statusNode.innerHTML = `<span class="upload-spinner spin-anim"></span><span style="color: var(--accent-primary);">saving to HF...</span>`; + } else { + statusNode.textContent = pct + '%'; + } + }); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + data.files.forEach(item => { + const row = rows[item.filename]; + recordUpload(item); + if (!row) return; + row.classList.add('done'); + const filePath = item.hf_path; + row.innerHTML = ` + <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: var(--success); font-weight: 500;">${item.filename}</div> + <div style="white-space:nowrap; display:flex; gap:0.75rem;"> + <a href="/${filePath}">View File</a> + </div> + `; + }); + if (data.batch_url) { + const batchRow = document.createElement('div'); + batchRow.className = 'file-row done'; + batchRow.style.cssText = 'display:flex; flex-direction:column; align-items:flex-start; gap:0.5rem;'; + batchRow.innerHTML = ` + <div style="color: var(--text-dim); font-size: 0.9rem;">All ${data.files.length} files uploaded. Group link:</div> + <a href="${data.batch_url}" target="_blank" style="word-break: break-all; font-size: 1rem;">${data.batch_url}</a> + `; + results.prepend(batchRow); + } + } catch (err) { showError('Invalid response from server.'); } + } else { + let msg = 'Upload failed.'; + try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) {} + showError(msg); + files.forEach(f => { + rows[f.name].classList.add('error'); + rows[f.name].querySelector('.status').textContent = 'Failed'; + rows[f.name].querySelector('.status').style.color = 'var(--danger)'; + }); + } + fileInput.value = ''; + }; + xhr.onerror = () => { showError('Connection error.'); }; + xhr.send(formData); + } + + urlUploadBtn.addEventListener('click', () => { + const url = urlInput.value.trim(); + errorBox.style.display = 'none'; + if (!url) return; + + let label; + try { + label = decodeURIComponent(new URL(url).pathname.split('/').pop()) || url; + } catch (e) { + showError('That doesn\'t look like a valid URL.'); + return; + } + + const row = document.createElement('div'); + row.className = 'file-row'; + row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${label}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;"><span class="upload-spinner spin-anim"></span><span style="color: var(--accent-primary);">fetching & saving...</span></div>`; + results.prepend(row); + + urlUploadBtn.disabled = true; + + fetch('/api/put-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, folder: selectedFolder() }) + }) + .then(async (resp) => { + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data.detail || 'Upload from URL failed.'); + } + const item = data.files[0]; + recordUpload(item); + row.classList.add('done'); + const filePath = item.hf_path; + row.innerHTML = ` + <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: var(--success); font-weight: 500;">${item.filename}</div> + <div style="white-space:nowrap; display:flex; gap:0.75rem;"> + <a href="/${filePath}">View File</a> + </div> + `; + urlInput.value = ''; + }) + .catch((err) => { + row.classList.add('error'); + row.querySelector('.status').textContent = 'Failed'; + row.querySelector('.status').style.color = 'var(--danger)'; + showError(err.message); + }) + .finally(() => { urlUploadBtn.disabled = false; }); + }); + </script> + + {% elif page == 'history' %} + <div class="history-tabs" role="tablist" aria-label="History sections"> + <button type="button" class="history-tab active" id="history-tab-uploads" role="tab" aria-selected="true" aria-controls="history-uploads-panel" data-history-tab="0">Recent Uploads</button> + <button type="button" class="history-tab" id="history-tab-visited" role="tab" aria-selected="false" aria-controls="history-visited-panel" data-history-tab="1">Visited Files &amp; Folders</button> + </div> + + <div class="history-slider-viewport"> + <div class="history-slider" id="history-slider"> + <div class="history-section" id="history-uploads-panel" role="tabpanel" aria-labelledby="history-tab-uploads"> + <div class="history-header"> + <h2 class="history-title">Recent Uploads</h2> + <button type="button" class="clear-history-btn" onclick="clearLocalHistory('cdn_uploads_v1', 'history-uploads-list')">Clear Uploads</button> + </div> + <div class="list-group" id="history-uploads-list"></div> + </div> + + <div class="history-section" id="history-visited-panel" role="tabpanel" aria-labelledby="history-tab-visited"> + <div class="history-header"> + <h2 class="history-title">Visited Files &amp; Folders</h2> + <button type="button" class="clear-history-btn" onclick="clearLocalHistory('cdn_visited_v1', 'history-visited-list')">Clear Visits</button> + </div> + <div class="list-group" id="history-visited-list"></div> + </div> + </div> + </div> + + <script> + function renderHistory() { + function formatRelative(ts) { + if (!ts) return ''; + var diff = Math.round((Date.now() - ts) / 60000); + if (diff < 1) return 'just now'; + if (diff < 60) return diff + 'm ago'; + if (diff < 1440) return Math.round(diff / 60) + 'h ago'; + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + + function formatBytes(bytes) { + if (!bytes || bytes === '-') return '-'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + // Uploads + var uploadContainer = document.getElementById('history-uploads-list'); + var uploads = JSON.parse(localStorage.getItem('cdn_uploads_v1') || '[]'); + if (!uploads.length) { + uploadContainer.innerHTML = '<div class="empty-state"><span>No uploads logged yet on this browser.</span></div>'; + } else { + uploadContainer.innerHTML = uploads.map(function(item) { + return ` + <div class="list-item"> + <div class="item-name"> + <svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg> + <a href="/${item.hf_path}" class="file-link" data-full-name="${item.filename}">${item.filename}</a> + </div> + <div class="item-meta"> + <span class="size-label">${formatBytes(item.size)}</span> + <span style="color: var(--text-dim); font-size: 0.8rem;">${formatRelative(item.ts)}</span> + <a href="/api/get/${item.hf_path}" class="download-icon" title="Download" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> + </a> + </div> + </div> + `; + }).join(''); + } + + // Visited + var visitedContainer = document.getElementById('history-visited-list'); + var visited = JSON.parse(localStorage.getItem('cdn_visited_v1') || '[]'); + if (!visited.length) { + visitedContainer.innerHTML = '<div class="empty-state"><span>No visited links logged yet.</span></div>'; + } else { + visitedContainer.innerHTML = visited.map(function(item) { + var isDir = item.type === 'folder'; + var icon = isDir + ? '<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' + : '<svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>'; + var name = isDir ? item.path.split('/').pop() + '/' : (item.filename || item.path.split('/').pop()); + var link = '/' + item.path; + + return ` + <div class="list-item"> + <div class="item-name"> + ${icon} + <a href="${link}" class="${isDir ? 'folder-link' : 'file-link'}" data-full-name="${name}">${name}</a> + </div> + <div class="item-meta"> + <span style="color: var(--text-dim); font-size: 0.8rem;">${formatRelative(item.ts)}</span> + ${!isDir ? ` + <a href="/api/get/${item.path}" class="download-icon" title="Download" data-turbo="false"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> + </a>` : ''} + </div> + </div> + `; + }).join(''); + } + } + + function clearLocalHistory(key, containerId) { + localStorage.removeItem(key); + var container = document.getElementById(containerId); + if (container) { + container.innerHTML = '<div class="empty-state"><span>Cleared.</span></div>'; + } + } + + function switchHistoryTab(index) { + var slider = document.getElementById('history-slider'); + var tabs = document.querySelectorAll('.history-tab'); + if (!slider || !tabs.length) return; + + slider.style.transform = index === 1 ? 'translateX(-50%)' : 'translateX(0)'; + + tabs.forEach(function(tab, tabIndex) { + var active = tabIndex === index; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + } + + document.querySelectorAll('.history-tab').forEach(function(tab) { + tab.addEventListener('click', function() { + switchHistoryTab(Number(tab.dataset.historyTab)); + }); + }); + + renderHistory(); + document.addEventListener('turbo:load', function() { + switchHistoryTab(0); + renderHistory(); + }); + </script> + + {% elif page == 'search' %} + <div> + <div class="list-group"> + <div class="input-group" id="search-group"> + <input type="text" id="search-input" class="search-input" placeholder="Find a file anywhere in the CDN..." autocomplete="off" spellcheck="false" autofocus> + <button type="button" class="input-clear-btn" id="search-clear" aria-label="Clear search"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> + </button> + </div> + <div id="search-status" style="color: var(--text-dim); font-size: 0.9rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--surface-2); display: none;"></div> + + <div id="search-results"></div> + </div> + </div> + + <script> + (function () { + const searchInput = document.getElementById('search-input'); + const searchGroup = document.getElementById('search-group'); + const searchClear = document.getElementById('search-clear'); + const searchStatus = document.getElementById('search-status'); + const searchResults = document.getElementById('search-results'); + + let debounceTimer = null; + let currentController = null; + let requestSeq = 0; + + if (searchClear) { + searchClear.addEventListener('click', () => { + searchInput.value = ''; + searchGroup.removeAttribute('data-has-text'); + searchResults.innerHTML = ''; + searchStatus.textContent = ''; + searchStatus.style.display = 'none'; + searchInput.focus(); + }); + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&amp;') + .replace(/</g, '&lt;') + .replace(/>/g, '&gt;') + .replace(/"/g, '&quot;') + .replace(/'/g, '&#39;'); + } + + function fileIconSvg() { + return '<svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>'; + } + + function folderIconSvg() { + return '<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>'; + } + + function downloadIconSvg() { + return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>'; + } + + function renderResults(items) { + if (!items.length) { + searchResults.innerHTML = ''; + searchStatus.textContent = 'No matching files found. Try a different term.'; + searchStatus.style.display = 'block'; + return; + } + searchStatus.innerHTML = `<strong style="color:var(--text-main);">${items.length}</strong> result${items.length !== 1 ? 's' : ''}`; + searchStatus.style.display = 'block'; + + searchResults.innerHTML = items.map((item) => { + const href = '/' + item.path; + const icon = item.is_dir ? folderIconSvg() : fileIconSvg(); + const linkClass = item.is_dir ? 'folder-link' : 'file-link'; + const displayName = item.is_dir ? item.name + '/' : item.name; + const downloadBtn = item.is_dir ? '' : + '<a href="/api/get/' + escapeHtml(item.path) + '" class="download-icon" title="Download" data-turbo="false">' + downloadIconSvg() + '</a>'; + const metaContent = item.is_dir ? '' : ('<span class="size-label">' + escapeHtml(item.size_str) + '</span>' + downloadBtn); + + return '<div class="list-item" style="flex-direction: column; align-items: stretch; gap: 0.5rem; padding: 1rem 1.25rem;">' + + '<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">' + + '<div class="item-name">' + icon + + '<a href="' + escapeHtml(href) + '" class="' + linkClass + '" data-full-name="' + escapeHtml(displayName) + '">' + escapeHtml(displayName) + '</a></div>' + + '<div class="item-meta">' + metaContent + '</div>' + + '</div>' + + '<div style="color: var(--text-dim); font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; margin-left: 26px;">' + escapeHtml(item.path) + '</div>' + + '</div>'; + }).join(''); + + if (window.scheduleTruncate) window.scheduleTruncate(); + } + + searchInput.addEventListener('input', () => { + const term = searchInput.value.trim(); + clearTimeout(debounceTimer); + + if (searchInput.value.length > 0) { + searchGroup.setAttribute('data-has-text', 'true'); + } else { + searchGroup.removeAttribute('data-has-text'); + } + + if (!term) { + searchResults.innerHTML = ''; + searchStatus.textContent = ''; + searchStatus.style.display = 'none'; + return; + } + + searchStatus.innerHTML = '<span class="upload-spinner spin-anim" style="width:12px; height:12px; margin-right:6px;"></span> Searching...'; + searchStatus.style.display = 'block'; + + debounceTimer = setTimeout(() => { + const seq = ++requestSeq; + if (currentController) currentController.abort(); + currentController = new AbortController(); + + fetch('/api/find?q=' + encodeURIComponent(term), { signal: currentController.signal }) + .then(res => res.json()) + .then(data => { + if (seq !== requestSeq) return; + renderResults(data.results || []); + }) + .catch(err => { + if (err.name === 'AbortError') return; + if (seq !== requestSeq) return; + searchStatus.textContent = 'Search failed.'; + searchResults.innerHTML = ''; + }); + }, 300); + }); + })(); + </script> + {% endif %} + + <div id="zip-modal-overlay" class="modal-overlay" style="display: none;"> + <div class="modal"> + <h3>Confirm Download</h3> + <p id="zip-modal-text" style="color: var(--text-main); font-size: 1rem; line-height: 1.5;"></p> + <div class="modal-actions"> + <button type="button" class="btn" id="zip-modal-cancel">Cancel</button> + <a href="#" class="btn btn-primary" id="zip-modal-confirm" data-turbo="false">Download (.zip)</a> + </div> + </div> + </div> + + <div id="error-modal-overlay" class="modal-overlay" style="display: none;"> + <div class="modal"> + <h3 style="color: var(--danger); font-size: 1.3rem;">Notice</h3> + <p id="error-modal-text" style="color: var(--text-main); font-size: 1rem;"></p> + <div class="modal-actions"> + <button type="button" class="btn" id="error-modal-close">Close</button> + </div> + </div> + </div> + + <footer> + <div> + amit/cdn · built by <a href="https://github.com/notamitgamer">notamitgamer</a><br>nothing here is a permanent fixture. + </div> + {% if repo_file_count is not none %} + <div title="Across the whole repo"> + <strong style="color: var(--text-main);">{{ repo_file_count }}</strong> file{{ '' if repo_file_count == 1 else 's' }} · {{ repo_size_str }} total + </div> + {% endif %} + </footer> + </div> + + <script> + function copyToClipboard(text, btn) { + navigator.clipboard.writeText(text).then(() => { + if (!btn) return; + const originalStyle = btn.style.cssText; + btn.style.borderColor = 'var(--success)'; + btn.style.color = 'var(--success)'; + + setTimeout(() => { + btn.style.cssText = originalStyle; + }, 800); + }).catch(err => { + console.error('failed to copy: ', err); + }); + } + + function copyBreadcrumbPath(btn) { + const rawPath = btn.getAttribute('data-path') || ''; + const container = btn.closest('.breadcrumb-container'); + const base = (container && container.getAttribute('data-cdn-base')) || window.location.origin; + const fullUrl = base + '/' + rawPath; + + navigator.clipboard.writeText(fullUrl).then(() => { + const original = btn.innerHTML; + btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; + setTimeout(() => { btn.innerHTML = original; }, 1200); + }).catch(err => { + console.error('failed to copy: ', err); + }); + } + + function toggleBreadcrumbMenu(btn) { + const menu = btn.nextElementSibling; + if (!menu) return; + const isOpen = menu.classList.contains('open'); + document.querySelectorAll('.breadcrumb-menu.open').forEach(m => m.classList.remove('open')); + if (!isOpen) { + menu.classList.add('open'); + } + } + + if (!window.__CDN_App_Initialized) { + window.__CDN_App_Initialized = true; + + document.addEventListener('click', (e) => { + if (!e.target.closest('.breadcrumb-collapsed')) { + document.querySelectorAll('.breadcrumb-menu.open').forEach(m => m.classList.remove('open')); + } + }); + + document.addEventListener('input', (e) => { + if (e.target.id === 'filter-input') { + const term = e.target.value.toLowerCase(); + const fileList = document.querySelectorAll('#file-list .list-item[data-name]'); + const filterGroup = document.getElementById('filter-group'); + + if (filterGroup) { + if (e.target.value.length > 0) { + filterGroup.setAttribute('data-has-text', 'true'); + } else { + filterGroup.removeAttribute('data-has-text'); + } + } + + fileList.forEach(item => { + const name = item.getAttribute('data-name').toLowerCase(); + if (name === '..') return; + item.style.display = name.includes(term) ? 'flex' : 'none'; + }); + } + }); + + document.addEventListener('click', async (e) => { + const filterClearBtn = e.target.closest('#filter-clear'); + if (filterClearBtn) { + const filterInput = document.getElementById('filter-input'); + const filterGroup = document.getElementById('filter-group'); + if (filterInput && filterGroup) { + filterInput.value = ''; + filterGroup.removeAttribute('data-has-text'); + filterInput.dispatchEvent(new Event('input', { bubbles: true })); + filterInput.focus(); + } + return; + } + + const zipBtn = e.target.closest('#download-zip-btn'); + if (zipBtn) { + e.preventDefault(); + if (zipBtn.disabled) return; + + const batchId = zipBtn.getAttribute('data-batch-id'); + const path = zipBtn.getAttribute('data-path') || ''; + const statsUrl = batchId ? ('/api/pack-info-batch/' + batchId) : ('/api/pack-info/' + path); + const downloadUrl = batchId ? ('/api/pack-batch/' + batchId) : ('/api/pack/' + path); + const zipSpinner = document.getElementById('zip-spinner'); + const zipBtnText = document.getElementById('zip-btn-text'); + const zipModalOverlay = document.getElementById('zip-modal-overlay'); + + zipBtn.disabled = true; + if (zipSpinner) zipSpinner.style.display = 'block'; + if (zipBtnText) zipBtnText.textContent = 'Calculating...'; + + try { + const res = await fetch(statsUrl); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Failed to get file stats'); + } + + const label = batchId ? 'this batch' : (path ? path.split('/').pop() : 'root'); + document.getElementById('zip-modal-text').innerHTML = + `Are you sure you want to download <strong>${label}</strong>?<br><br>` + + `This will package <strong style="color:#fff;">${data.file_count} file(s)</strong> totaling <strong style="color:#fff;">${data.size_str}</strong> into a .zip.`; + + document.getElementById('zip-modal-confirm').href = downloadUrl; + zipModalOverlay.style.display = 'flex'; + } catch (err) { + const errorModalOverlay = document.getElementById('error-modal-overlay'); + if (errorModalOverlay) { + document.getElementById('error-modal-text').textContent = err.message; + errorModalOverlay.style.display = 'flex'; + } else { + alert(err.message); + } + } finally { + zipBtn.disabled = false; + if (zipSpinner) zipSpinner.style.display = 'none'; + if (zipBtnText) zipBtnText.textContent = 'Download all (.zip)'; + } + } + + if (e.target.closest('#zip-modal-cancel') || e.target.closest('#zip-modal-confirm')) { + const zipOverlay = document.getElementById('zip-modal-overlay'); + if (zipOverlay) zipOverlay.style.display = 'none'; + } + + if (e.target.closest('#error-modal-close')) { + const errOverlay = document.getElementById('error-modal-overlay'); + if (errOverlay) errOverlay.style.display = 'none'; + } + }); + + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/static/sw.js').catch(err => { + console.error('service worker registration failed:', err); + }); + }); + } + + document.addEventListener('turbo:load', () => { + if (window.Turbo && window.Turbo.setProgressBarDelay) { + window.Turbo.setProgressBarDelay(0); + } + }, { once: true }); + document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading')); + document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading')); + document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading')); + document.addEventListener('turbo:render', () => document.body.classList.remove('turbo-loading')); + document.addEventListener('turbo:load', () => document.body.classList.remove('turbo-loading')); + + document.addEventListener('keydown', (e) => { + if (e.key !== '/') return; + const tag = (document.activeElement && document.activeElement.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + const filterInput = document.getElementById('filter-input'); + if (filterInput) { + e.preventDefault(); + filterInput.focus(); + } + }); + + const MOBILE_BREAKPOINT = 480; + let measureCanvas = null; + + function getTextWidth(text, font) { + if (!measureCanvas) measureCanvas = document.createElement('canvas'); + const ctx = measureCanvas.getContext('2d'); + ctx.font = font; + return ctx.measureText(text).width; + } + + function middleTruncate(fullName, maxWidth, font) { + if (getTextWidth(fullName, font) <= maxWidth) return fullName; + + const dotIndex = fullName.lastIndexOf('.'); + const hasExt = dotIndex > 0 && dotIndex < fullName.length - 1; + const ext = hasExt ? fullName.slice(dotIndex) : ''; + const base = hasExt ? fullName.slice(0, dotIndex) : fullName; + const ellipsis = '...'; + + let head = base.length; + let tail = 0; + + while (head > 1) { + const candidate = base.slice(0, head) + ellipsis + ext; + if (getTextWidth(candidate, font) <= maxWidth) { + return candidate; + } + head--; + } + + let end = fullName.length; + while (end > 1) { + const candidate = fullName.slice(0, end) + ellipsis; + if (getTextWidth(candidate, font) <= maxWidth) return candidate; + end--; + } + return fullName.slice(0, 1) + ellipsis; + } + + function applyMiddleTruncation() { + const items = document.querySelectorAll('.item-name'); + if (!items.length) return; + + const isMobile = window.innerWidth <= MOBILE_BREAKPOINT; + + items.forEach((itemName) => { + const a = itemName.querySelector('a[data-full-name]'); + if (!a) return; + const fullName = a.getAttribute('data-full-name'); + if (!fullName) return; + + if (!isMobile) { + if (a.textContent !== fullName) a.textContent = fullName; + return; + } + + const containerWidth = itemName.clientWidth; + if (!containerWidth) return; + + const icon = itemName.querySelector('svg'); + const iconWidth = icon ? icon.getBoundingClientRect().width : 0; + const containerStyle = window.getComputedStyle(itemName); + const gap = parseFloat(containerStyle.columnGap || containerStyle.gap) || 0; + const safetyBuffer = 4; + + const availableWidth = containerWidth - iconWidth - gap - safetyBuffer; + if (availableWidth <= 0) return; + + const style = window.getComputedStyle(a); + const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + + const truncated = middleTruncate(fullName, availableWidth, font); + if (a.textContent !== truncated) a.textContent = truncated; + if (!a.title) a.title = fullName; + }); + } + + let truncateRAF = null; + function scheduleTruncate() { + if (truncateRAF) cancelAnimationFrame(truncateRAF); + truncateRAF = requestAnimationFrame(() => { + requestAnimationFrame(applyMiddleTruncation); + }); + } + + let resizeTimer = null; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(scheduleTruncate, 120); + }); + document.addEventListener('turbo:load', scheduleTruncate); + document.addEventListener('turbo:render', scheduleTruncate); + document.addEventListener('input', (e) => { + if (e.target.id === 'filter-input') scheduleTruncate(); + }); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(scheduleTruncate); + } else { + scheduleTruncate(); + } + + window.scheduleTruncate = scheduleTruncate; + + function applyResponsivePlaceholders() { + const isMobile = window.innerWidth <= MOBILE_BREAKPOINT; + const filterInput = document.getElementById('filter-input'); + if (filterInput) { + filterInput.placeholder = isMobile + ? 'Search this folder...' + : 'Search this folder... (press / to focus)'; + } + const searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.placeholder = isMobile + ? 'Find files...' + : 'Find a file anywhere in the CDN...'; + } + } + window.applyResponsivePlaceholders = applyResponsivePlaceholders; + applyResponsivePlaceholders(); + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(applyResponsivePlaceholders, 120); + }); + document.addEventListener('turbo:load', applyResponsivePlaceholders); + } + </script> + <script> + // --- Recently modified: convert timestamps to the visitor's own timezone. --- + (function () { + document.querySelectorAll('.recent-activity-time[datetime]').forEach(function (el) { + var d = new Date(el.getAttribute('datetime')); + if (isNaN(d.getTime())) return; + var now = new Date(); + var diffMs = now - d; + var diffMin = Math.round(diffMs / 60000); + var label; + if (diffMin < 1) label = 'just now'; + else if (diffMin < 60) label = diffMin + 'm ago'; + else if (diffMin < 60 * 24) label = Math.round(diffMin / 60) + 'h ago'; + else label = d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + el.textContent = label; + el.title = d.toLocaleString(); + }); + })(); + + // --- Visited history: silently logs file/folder pages viewed on this browser, + // shown on /history. localStorage only - there's no account system, so this is + // per-browser, same limitation as ordinary browser history. + {% if page in ('file', 'listing') and path %} + (function () { + try { + var KEY = 'cdn_visited_v1'; + var MAX = 50; + var entry = { + type: {{ ('file' if page == 'file' else 'folder')|tojson }}, + path: {{ path|tojson }}, + {% if page == 'file' %} + filename: {{ filename|tojson }}, + cdn_url: {{ (cdn_base_url ~ '/' ~ path)|tojson }}, + raw_url: {{ (raw_base_url ~ '/' ~ path)|tojson }}, + {% else %} + cdn_url: {{ (cdn_base_url ~ '/' ~ path ~ '/')|tojson }}, + {% endif %} + ts: Date.now() + }; + var entries = JSON.parse(localStorage.getItem(KEY) || '[]').filter(function (e) { return e.path !== entry.path; }); + entries.unshift(entry); + localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX))); + } catch (e) {} + })(); + {% endif %} + </script> +</body> +</html> diff --git a/app/templates/url_shortener.html b/app/templates/url_shortener.html @@ -0,0 +1,414 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>URL Shortener &mdash; amit/cdn</title> + <meta name="description" content="Shorten and mask any public URL."> + <link rel="icon" href="/favicon.ico" sizes="any"> + <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> + <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> + <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> + <link rel="manifest" href="/static/manifest.json"> + <meta name="theme-color" content="#09090b"> + <meta name="color-scheme" content="dark"> + <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> + <style> + :root { + --bg: #09090b; + --surface: #121214; + --surface-2: #1e1e22; + --surface-3: #27272a; + --text-main: #f4f4f5; + --text-dim: #b8b8c0; + --accent-primary: #818cf8; + --accent-primary-hover: #a5b4fc; + --success: #10b981; + --danger: #ef4444; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.15); + --font-heading: 'Zilla Slab', Georgia, 'Times New Roman', serif; + --font-body: 'Work Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + } + + * { -webkit-tap-highlight-color: transparent; } + * { box-sizing: border-box; } + body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text-main); + margin: 0; + padding: 0 0 4rem 0; + line-height: 1.6; + font-size: 1rem; + -webkit-font-smoothing: antialiased; + } + @media (max-width: 480px) { + body { font-size: 0.95rem; } + } + .container { + width: 90%; + max-width: 1000px; + margin: 0 auto; + } + h1 { + font-family: var(--font-heading); + font-size: clamp(2.2rem, 5vw, 3rem); + letter-spacing: -0.02em; + color: #ffffff; + font-weight: 600; + margin: 0 0 1.25rem 0; + line-height: 1.1; + max-width: 20ch; + } + blockquote { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-primary); + border-radius: var(--radius-md); + margin: 0 0 2.25rem 0; + padding: 1rem 1.25rem; + color: var(--text-dim); + max-width: 68ch; + font-size: 0.95rem; + } + a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s; } + a:hover { color: var(--accent-primary-hover); text-decoration: underline; } + .input-group { + position: relative; + display: flex; + align-items: center; + width: 100%; + } + .url-input { + width: 100%; + padding: 0.85rem 2.5rem 0.85rem 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-main); + font-family: var(--font-mono); + font-size: 0.9rem; + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "calt" 0; + } + .url-input:focus { + outline: none; + background: var(--surface-2); + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15); + } + .url-input::placeholder { color: var(--text-dim); } + .input-clear-btn { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0.25rem; + display: none; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: color 0.2s, background 0.2s; + } + .input-group[data-has-text="true"] .input-clear-btn { display: flex; } + .input-clear-btn:hover { color: var(--text-main); background: var(--surface-3); } + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--surface-2); + color: var(--text-main); + padding: 0.65rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + font-family: var(--font-body); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease, transform 0.1s ease; + text-decoration: none; + } + .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-hover); text-decoration: none; color: var(--text-main); } + .btn:active:not(:disabled) { transform: scale(0.97); } + .btn:disabled { opacity: 0.6; cursor: not-allowed; } + .btn-primary { background: var(--accent-primary) !important; color: #ffffff !important; border-color: var(--accent-primary) !important; font-weight: 600; transition: none !important; } + .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) { background: var(--accent-primary) !important; border-color: var(--accent-primary) !important; color: #ffffff !important; } + .btn-primary svg { stroke: #ffffff !important; } + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + padding: 0.65rem; + transition: all 0.2s ease; + } + .icon-btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--border-hover); } + .icon-btn:hover:not(:disabled) svg { stroke: var(--accent-primary); } + .icon-btn:active:not(:disabled) { transform: scale(0.95); } + .result-box { + margin-top: 2rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + } + .result-box h3 { + font-family: var(--font-heading); + font-size: 1.2rem; + margin: 0 0 1rem 0; + color: #fff; + } + .result-url { + font-family: var(--font-mono); + font-size: 0.95rem; + word-break: break-all; + color: var(--accent-primary); + background: var(--surface-2); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + .result-url a { color: var(--accent-primary); text-decoration: none; } + .result-url a:hover { text-decoration: underline; } + .error-box { + display: none; + color: var(--danger); + margin-top: 1rem; + padding: 1rem 1.25rem; + border: 1px solid rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.1); + border-radius: var(--radius-md); + } + .info-text { + color: var(--text-dim); + font-size: 0.9rem; + margin-top: 1rem; + } + .spin-anim { animation: spin 0.7s linear infinite; display: inline-flex; } + @keyframes spin { 100% { transform: rotate(360deg); } } + .btn-primary .btn-icon { display: inline-flex; } + .btn-primary.is-loading .btn-icon.btn-icon-default { display: none; } + .btn-primary .btn-icon-spinner { display: none; } + .btn-primary.is-loading .btn-icon-spinner { display: inline-flex; } + footer { + width: 90%; + max-width: 1000px; + margin: 4rem auto 0; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + font-size: .85rem; + color: var(--muted); + } + @media (max-width: 480px) { + .container { width: 100%; padding: 0 1.25rem; } + .result-url { flex-direction: column; align-items: flex-start; } + } + </style> +</head> +<body> + {% include "_navbar.html" %} + <main class="container"> + <h1>URL Shortener</h1> + <blockquote> + Paste any public <code>https://</code> URL and get back a masked, shareable link. + The destination is never exposed until someone visits the masked URL. + </blockquote> + + <div class="input-group" id="shorten-group"> + <input type="url" id="url-input" class="url-input" placeholder="Paste a URL to shorten (e.g. https://example.com)" autocomplete="off" spellcheck="false" required> + <button type="button" class="input-clear-btn" id="url-clear" aria-label="Clear URL"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> + </button> + </div> + + <div style="display:flex; gap:0.75rem; margin-top:1rem; align-items:center;"> + <button type="button" class="btn btn-primary" id="shorten-btn"> + <span class="btn-icon btn-icon-default"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> + </span> + <span class="btn-icon btn-icon-spinner spin-anim"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> + </span> + <span id="shorten-btn-label">Shorten URL</span> + </button> + </div> + + <div id="error-box" class="error-box" role="alert" aria-live="polite"></div> + + <div id="result" style="display:none;" class="result-box"> + <h3>Your masked URL</h3> + <div class="result-url"> + <a href="#" id="masked-link" target="_blank" rel="noopener noreferrer"></a> + <button type="button" class="btn" id="copy-btn">Copy</button> + </div> + <p class="info-text">This link will redirect to the destination URL when visited.</p> + </div> + </main> + + <footer> + <p>&copy; 2025 - 2026 notamitgamer. All rights reserved.</p> + </footer> + + <script> + (function() { + const PRIVATE_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2}|\[?::1\]?|.*\.localhost|.*\.local|.*\.lan)$/i; + + function isPrivateOrLocalHost(rawUrl) { + try { + const parsed = new URL(rawUrl); + return PRIVATE_HOST_RE.test(parsed.hostname); + } catch { + return false; + } + } + + function setupShortener() { + const urlInput = document.getElementById('url-input'); + const urlClear = document.getElementById('url-clear'); + const shortenBtn = document.getElementById('shorten-btn'); + const errorBox = document.getElementById('error-box'); + const resultDiv = document.getElementById('result'); + const maskedLink = document.getElementById('masked-link'); + const copyBtn = document.getElementById('copy-btn'); + + if (!urlInput || !shortenBtn || shortenBtn.dataset.initialized) return; + shortenBtn.dataset.initialized = 'true'; + + function showError(msg) { + errorBox.textContent = msg; + errorBox.style.display = 'block'; + } + + function clearError() { + errorBox.textContent = ''; + errorBox.style.display = 'none'; + } + + urlClear.addEventListener('click', () => { + urlInput.value = ''; + urlInput.focus(); + urlClear.parentElement.removeAttribute('data-has-text'); + clearError(); + resultDiv.style.display = 'none'; + }); + + urlInput.addEventListener('input', () => { + if (urlInput.value.length > 0) { + urlClear.parentElement.setAttribute('data-has-text', 'true'); + } else { + urlClear.parentElement.removeAttribute('data-has-text'); + } + }); + + urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + shortenBtn.click(); + } + }); + + shortenBtn.addEventListener('click', async () => { + const url = urlInput.value.trim(); + clearError(); + resultDiv.style.display = 'none'; + + if (!url) { showError('Please enter a URL.'); return; } + if (!/^https:\/\//i.test(url)) { showError('Only https:// URLs are supported.'); return; } + if (isPrivateOrLocalHost(url)) { showError('Localhost and private network URLs cannot be shortened.'); return; } + + shortenBtn.disabled = true; + shortenBtn.classList.add('is-loading'); + + try { + const res = await fetch('/api/shorten', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.detail || 'Failed to shorten URL.'); + } + + resultDiv.style.display = 'block'; + maskedLink.href = '/mask/' + data.id; + maskedLink.textContent = data.masked_url; + copyBtn.setAttribute('data-url', data.masked_url); + } catch (err) { + showError(err.message || 'An unexpected error occurred.'); + } finally { + shortenBtn.disabled = false; + shortenBtn.classList.remove('is-loading'); + } + }); + + copyBtn.addEventListener('click', async () => { + const url = copyBtn.getAttribute('data-url'); + if (!url) return; + + try { + await navigator.clipboard.writeText(url); + const originalText = copyBtn.textContent; + const originalStyle = copyBtn.style.cssText; + + copyBtn.style.borderColor = 'var(--success)'; + copyBtn.style.color = 'var(--success)'; + copyBtn.textContent = 'Copied!'; + + setTimeout(() => { + copyBtn.style.cssText = originalStyle; + copyBtn.textContent = originalText; + }, 1000); + } catch (err) { + console.error('Failed to copy: ', err); + } + }); + } + + // Bind to Turbo and standard page loads + document.addEventListener('turbo:load', setupShortener); + document.addEventListener('DOMContentLoaded', setupShortener); + + // Global '/' focus shortcut + if (!window.__shortenerKbdBound) { + window.__shortenerKbdBound = true; + document.addEventListener('keydown', (e) => { + if (e.key !== '/') return; + const tag = (document.activeElement && document.activeElement.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + + const currentUrlInput = document.getElementById('url-input'); + if (currentUrlInput) { + e.preventDefault(); + currentUrlInput.focus(); + } + }); + } + })(); + </script> +</body> +</html> +\ No newline at end of file diff --git a/app/templates/ytmusic.html b/app/templates/ytmusic.html @@ -0,0 +1,461 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>YouTube Music Downloader &mdash; amit/cdn</title> + <meta name="description" content="Get a direct download link for YouTube music."> + <link rel="icon" href="/favicon.ico" sizes="any"> + <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> + <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> + <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> + <link rel="manifest" href="/static/manifest.json"> + <meta name="theme-color" content="#09090b"> + <meta name="color-scheme" content="dark"> + <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> + <style> + :root { + --bg: #09090b; + --surface: #121214; + --surface-2: #1e1e22; + --surface-3: #27272a; + --text-main: #f4f4f5; + --text-dim: #b8b8c0; + --accent-primary: #818cf8; + --accent-primary-hover: #a5b4fc; + --success: #10b981; + --danger: #ef4444; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.15); + --font-heading: 'Zilla Slab', Georgia, 'Times New Roman', serif; + --font-body: 'Work Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + } + * { box-sizing: border-box; } + body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text-main); + margin: 0; + padding: 0 0 4rem 0; + line-height: 1.6; + font-size: 1rem; + -webkit-font-smoothing: antialiased; + } + @media (max-width: 480px) { + body { font-size: 0.95rem; } + } + .container { + width: 90%; + max-width: 1000px; + margin: 0 auto; + } + h1 { + font-family: var(--font-heading); + font-size: clamp(2.2rem, 5vw, 3rem); + letter-spacing: -0.02em; + color: #ffffff; + font-weight: 600; + margin: 0 0 1.25rem 0; + line-height: 1.1; + max-width: 20ch; + } + blockquote { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-primary); + border-radius: var(--radius-md); + margin: 0 0 2.25rem 0; + padding: 1rem 1.25rem; + color: var(--text-dim); + max-width: 68ch; + font-size: 0.95rem; + } + a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s; } + a:hover { color: var(--accent-primary-hover); text-decoration: underline; } + .input-group { + position: relative; + display: flex; + align-items: center; + width: 100%; + } + .url-input { + width: 100%; + padding: 0.85rem 2.5rem 0.85rem 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-main); + font-family: var(--font-mono); + font-size: 0.9rem; + transition: all 0.2s ease; + box-shadow: 0 2px 10px rgba(0,0,0,0.1) inset; + } + .url-input:focus { + outline: none; + background: var(--surface-2); + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15); + } + .url-input::placeholder { color: var(--text-dim); } + .input-clear-btn { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0.25rem; + display: none; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: color 0.2s, background 0.2s; + } + .input-group[data-has-text="true"] .input-clear-btn { display: flex; } + .input-clear-btn:hover { color: var(--text-main); background: var(--surface-3); } + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--surface-2); + color: var(--text-main); + padding: 0.65rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + font-family: var(--font-body); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease, transform 0.1s ease; + text-decoration: none; + } + .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-hover); text-decoration: none; color: var(--text-main); } + .btn:active:not(:disabled) { transform: scale(0.97); } + .btn:disabled { opacity: 0.6; cursor: not-allowed; } + .btn-primary { background: var(--accent-primary) !important; color: #ffffff !important; border-color: var(--accent-primary) !important; font-weight: 600; transition: none !important; } + .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) { background: var(--accent-primary) !important; border-color: var(--accent-primary) !important; color: #ffffff !important; } + .btn-primary svg { stroke: #ffffff !important; } + .result-box { + margin-top: 2rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + } + .result-box h3 { + font-family: var(--font-heading); + font-size: 1.2rem; + margin: 0 0 1rem 0; + color: #fff; + } + .result-url { + font-family: var(--font-mono); + font-size: 0.95rem; + word-break: break-all; + color: var(--accent-primary); + background: var(--surface-2); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + .result-url a { color: var(--accent-primary); text-decoration: none; } + .result-url a:hover { text-decoration: underline; } + .error-box { + display: none; + color: var(--danger); + margin-top: 1rem; + padding: 1rem 1.25rem; + border: 1px solid rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.1); + border-radius: var(--radius-md); + } + .info-text { + color: var(--text-dim); + font-size: 0.9rem; + margin-top: 1rem; + } + .spin-anim { animation: spin 0.7s linear infinite; display: inline-flex; } + @keyframes spin { 100% { transform: rotate(360deg); } } + .btn-primary .btn-icon { display: inline-flex; } + .btn-primary.is-loading .btn-icon.btn-icon-default { display: none; } + .btn-primary .btn-icon-spinner { display: none; } + .btn-primary.is-loading .btn-icon-spinner { display: inline-flex; } + footer { + width: 90%; + max-width: 1000px; + margin: 4rem auto 0; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + font-size: .85rem; + color: var(--muted); + } + @media (max-width: 480px) { + .container { width: 100%; padding: 0 1.25rem; } + .result-url { flex-direction: column; align-items: flex-start; } + } + </style> +</head> +<body> + {% include "_navbar.html" %} + <main class="container"> + <h1>YouTube Music Downloader</h1> + <blockquote> + Paste a YouTube or YouTube Music URL and get a 128 KBPS audio download link. + </blockquote> + + <div class="input-group" id="youtube-group"> + <input + type="url" + id="youtube-input" + class="url-input" + placeholder="Paste a YouTube URL (e.g. https://music.youtube.com/...)" + autocomplete="off" + spellcheck="false" + required + > + <button type="button" class="input-clear-btn" id="youtube-clear" aria-label="Clear URL"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <line x1="18" y1="6" x2="6" y2="18"/> + <line x1="6" y1="6" x2="18" y2="18"/> + </svg> + </button> + </div> + + <div style="display:flex; gap:0.75rem; margin-top:1rem; align-items:center;"> + <button type="button" class="btn btn-primary" id="download-btn"> + <span class="btn-icon btn-icon-default"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 3v12"/> + <path d="m7 10 5 5 5-5"/> + <path d="M5 21h14"/> + </svg> + </span> + <span class="btn-icon btn-icon-spinner spin-anim"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M21 12a9 9 0 1 1-6.219-8.56"/> + </svg> + </span> + <span id="download-btn-label">Get Download Link</span> + </button> + </div> + + <div id="status-box" class="info-text" style="display:none;" aria-live="polite"></div> + <div id="error-box" class="error-box" role="alert" aria-live="polite"></div> + + <div id="result" style="display:none;" class="result-box"> + <h3>Your download link</h3> + <div class="result-url"> + <a href="#" id="masked-link" target="_blank" rel="noreferrer" referrerpolicy="no-referrer"></a> + <button type="button" class="btn" id="copy-btn">Copy</button> + </div> + <p class="info-text">This link redirects to the official VidsSave download stream.</p> + </div> + </main> + + <footer> + <p>&copy; 2025 - 2026 notamitgamer. All rights reserved.</p> + </footer> + + <script> + (function() { + const AUTH = '20250901majwlqo'; + const DOMAIN = 'api-ak.vidssave.com'; + + function isYouTubeUrl(rawUrl) { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false; + const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + return ( + host === 'youtube.com' || + host === 'm.youtube.com' || + host === 'music.youtube.com' || + host === 'youtu.be' + ); + } catch { + return false; + } + } + + function setupYtMusic() { + const youtubeInput = document.getElementById('youtube-input'); + const youtubeClear = document.getElementById('youtube-clear'); + const downloadBtn = document.getElementById('download-btn'); + const downloadBtnLabel = document.getElementById('download-btn-label'); + const statusBox = document.getElementById('status-box'); + const errorBox = document.getElementById('error-box'); + const resultDiv = document.getElementById('result'); + const maskedLink = document.getElementById('masked-link'); + const copyBtn = document.getElementById('copy-btn'); + + if (!youtubeInput || !downloadBtn || downloadBtn.dataset.initialized) return; + downloadBtn.dataset.initialized = 'true'; + + function setStatus(message) { + statusBox.textContent = message; + statusBox.style.display = message ? 'block' : 'none'; + } + + function showError(message) { + errorBox.textContent = message; + errorBox.style.display = 'block'; + } + + function clearMessages() { + errorBox.style.display = 'none'; + resultDiv.style.display = 'none'; + setStatus(''); + } + + function setLoading(loading) { + downloadBtn.disabled = loading; + downloadBtn.classList.toggle('is-loading', loading); + downloadBtnLabel.textContent = loading ? 'Working...' : 'Get Download Link'; + } + + async function getDownloadLink(url) { + const response = await fetch('/api/yt/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const data = await response.json(); + if (!response.ok || !data.url) { + throw new Error(data.detail || 'Failed to extract download link.'); + } + return data.url; + } + + async function shortenUrl(url) { + const response = await fetch('/api/shorten', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.id || !data.masked_url) { + throw new Error(data.detail || 'The download link could not be shortened.'); + } + return data.masked_url; + } + + downloadBtn.addEventListener('click', async () => { + const url = youtubeInput.value.trim(); + clearMessages(); + + if (!url) { + showError('Please enter a YouTube URL.'); + return; + } + if (!isYouTubeUrl(url)) { + showError('Please enter a valid YouTube or YouTube Music URL.'); + return; + } + + setLoading(true); + + try { + setStatus('Extracting direct audio link natively...'); + const downloadUrl = await getDownloadLink(url); + + setStatus('Shortening link...'); + const maskedUrl = await shortenUrl(downloadUrl); + + maskedLink.href = maskedUrl; + maskedLink.textContent = maskedUrl; + copyBtn.setAttribute('data-url', maskedUrl); + + resultDiv.style.display = 'block'; + setStatus('Ready.'); + } catch (error) { + console.error(error); + showError(error.message || 'Something went wrong.'); + } finally { + setLoading(false); + } + }); + + youtubeClear.addEventListener('click', () => { + youtubeInput.value = ''; + youtubeInput.focus(); + youtubeClear.parentElement.removeAttribute('data-has-text'); + clearMessages(); + }); + + youtubeInput.addEventListener('input', () => { + if (youtubeInput.value.length > 0) { + youtubeClear.parentElement.setAttribute('data-has-text', 'true'); + } else { + youtubeClear.parentElement.removeAttribute('data-has-text'); + } + }); + + youtubeInput.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + downloadBtn.click(); + } + }); + + copyBtn.addEventListener('click', async () => { + const url = copyBtn.getAttribute('data-url'); + if (!url) return; + + try { + await navigator.clipboard.writeText(url); + const originalText = copyBtn.textContent; + const originalStyle = copyBtn.style.cssText; + + copyBtn.textContent = 'Copied!'; + copyBtn.style.borderColor = 'var(--success)'; + copyBtn.style.color = 'var(--success)'; + + setTimeout(() => { + copyBtn.style.cssText = originalStyle; + copyBtn.textContent = originalText; + }, 1000); + } catch (error) { + console.error('Copy failed:', error); + } + }); + } + + document.addEventListener('turbo:load', setupYtMusic); + document.addEventListener('DOMContentLoaded', setupYtMusic); + + if (!window.__ytmusicKbdBound) { + window.__ytmusicKbdBound = true; + document.addEventListener('keydown', event => { + if (event.key !== '/') return; + const tag = document.activeElement?.tagName || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + + const currentYtInput = document.getElementById('youtube-input'); + if (currentYtInput) { + event.preventDefault(); + currentYtInput.focus(); + } + }); + } + })(); + </script> +</body> +</html> +\ No newline at end of file diff --git a/render.yaml b/render.yaml @@ -0,0 +1,14 @@ +services: + - type: web + name: cdn-app + env: docker + plan: free + envVars: + - key: HF_REPO_ID + value: notamitgamer/cdn + - key: HF_TOKEN + sync: false + - key: RAW_DOMAIN + value: raw.cdn.amit.is-a.dev + - key: RAW_BASE_URL + value: https://raw.cdn.amit.is-a.dev diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +huggingface_hub +httpx +python-multipart +jinja2 +pyjwt[crypto] +yt-dlp +\ No newline at end of file diff --git a/test.html b/test.html @@ -0,0 +1,293 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>VidsSave API Test</title> +</head> +<body> + + <h2>VidsSave API Test</h2> + + <input + id="youtubeUrl" + type="text" + placeholder="YouTube URL" + style="width: 400px;" + > + + <button id="testButton">Get Download Link</button> + + <pre id="output"></pre> + + <script> + const API = "https://api.vidssave.com/api/contentsite_api"; + const AUTH = "20250901majwlqo"; + const DOMAIN = "api-ak.vidssave.com"; + + const output = document.getElementById("output"); + + function log(message) { + output.textContent += message + "\n"; + } + + async function test() { + output.textContent = ""; + + const youtubeUrl = + document.getElementById("youtubeUrl").value.trim(); + + if (!youtubeUrl) { + log("Enter a YouTube URL."); + return; + } + + try { + // ------------------------------------------------- + // 1. PARSE + // ------------------------------------------------- + + log("Parsing YouTube URL..."); + + const parseBody = new URLSearchParams({ + auth: AUTH, + domain: DOMAIN, + origin: "source", + link: youtubeUrl + }); + + const parseResponse = await fetch( + `${API}/media/parse`, + { + method: "POST", + headers: { + "Content-Type": + "application/x-www-form-urlencoded" + }, + body: parseBody + } + ); + + const parseResult = await parseResponse.json(); + + console.log("Parse response:", parseResult); + + if (parseResult.status !== 1) { + throw new Error( + "Parse failed: " + + JSON.stringify(parseResult) + ); + } + + const resources = + parseResult.data.resources || []; + + log(`Found ${resources.length} resources.`); + + // ------------------------------------------------- + // 2. FIND AUDIO RESOURCE + // ------------------------------------------------- + + let resource = null; + + for (const item of resources) { + if (!item) { + continue; + } + + const formats = item.available_formats; + + if (Array.isArray(formats) && formats.length > 0) { + for (const format of formats) { + const type = + String(format).toLowerCase(); + + if ( + type === "mp3" || + type === "m4a" || + type === "audio" + ) { + resource = { + ...item, + output_format: type + }; + + break; + } + } + } + + if (resource) { + break; + } + + if ( + String(item.type || "") + .toLowerCase() + .includes("audio") + ) { + resource = item; + break; + } + } + + if (!resource) { + throw new Error( + "Could not find an audio resource." + ); + } + + console.log("Selected resource:", resource); + + const resourceContent = + resource.resource_content; + + if (!resourceContent) { + throw new Error( + "Selected resource has no resource_content." + ); + } + + log("Audio resource found."); + + // ------------------------------------------------- + // 3. START DOWNLOAD + // ------------------------------------------------- + + log("Starting download task..."); + + const downloadData = { + auth: AUTH, + domain: DOMAIN, + request: resourceContent, + no_encrypt: 1 + }; + + if (resource.output_format) { + downloadData.output = + resource.output_format.toLowerCase(); + } + + const downloadBody = + new URLSearchParams(downloadData); + + const downloadResponse = await fetch( + `${API}/media/download`, + { + method: "POST", + headers: { + "Content-Type": + "application/x-www-form-urlencoded" + }, + body: downloadBody + } + ); + + const downloadResult = + await downloadResponse.json(); + + console.log( + "Download response:", + downloadResult + ); + + if ( + downloadResult.status !== 1 || + !downloadResult.data || + !downloadResult.data.task_id + ) { + throw new Error( + "Download task failed: " + + JSON.stringify(downloadResult) + ); + } + + const taskId = + downloadResult.data.task_id; + + log("Task created."); + log("Task ID: " + taskId); + + // ------------------------------------------------- + // 4. SSE + // ------------------------------------------------- + + log("Waiting for download link..."); + + const query = new URLSearchParams({ + auth: AUTH, + domain: DOMAIN, + task_id: taskId, + download_domain: "vidssave.com", + origin: "content_site" + }); + + const sseUrl = + `https://api.vidssave.com/sse/contentsite_api/media/download_query?${query}`; + + const eventSource = + new EventSource(sseUrl); + + eventSource.addEventListener( + "success", + event => { + try { + const result = + JSON.parse(event.data); + + console.log( + "SSE success:", + result + ); + + if ( + result.status === "success" && + result.download_link + ) { + eventSource.close(); + + log(""); + log("SUCCESS!"); + log(""); + log( + "Download link:" + ); + log( + result.download_link + ); + } + } catch (error) { + eventSource.close(); + + log( + "SSE parsing error: " + + error.message + ); + } + } + ); + + eventSource.onerror = () => { + eventSource.close(); + + log( + "SSE connection closed or failed." + ); + }; + + } catch (error) { + console.error(error); + + log(""); + log("ERROR:"); + log(error.message); + } + } + + document + .getElementById("testButton") + .addEventListener("click", test); + </script> + +</body> +</html> +\ No newline at end of file