1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
No description
Find a file
TheGeneralist 64f94c6eda
feat: archive paywalled articles through Freedium (#30)
* feat(core): add via_freedium to CaptureConfig; route WebPage captures through Freedium mirror

When via_freedium is true and the locator is not already a Freedium URL,
perform_capture passes https://freedium-mirror.cfd/<original-url> to
singlefile::save() so paywalled articles are fetched via the mirror.
The original locator is kept for requested_locator and canonical_locator
in the DB entry. An empty cookie map is used for the mirror fetch to
prevent original-domain credentials from being sent to freedium-mirror.cfd.

* feat(server): expose via_freedium in capture API (default on)

CaptureBody gains via_freedium: Option<bool>; absent defaults to true.
The rearchive handler sets it to false — existing entries should not be
silently re-fetched through a mirror.

* feat(frontend): add Freedium mirror toggle to capture advanced options

- freediumEnabled state defaults to true (on by default)
- via_freedium forwarded through submitCapture to the capture API
- Toggle rendered last in the advanced panel, matching existing rows
- Built frontend static assets included

* fix(core): Freedium capture fixes — title, notifications, reader images

- extract_html_title: take().read_to_end() up to 256 KiB (single read() can
  short-read, leaving title at byte ~106 K undiscovered); regression test added
- Strip " - Freedium" suffix before storing entry title
- is_freedium_fetch keys off actual fetch_url host
- Remove Freedium toast overlay ([data-sonner-toaster]) before capture
- Resolve lazy images (data-zoom-src etc.) before Readability in reader mode

* fix(core): extract HTML title after font stripping, not before

SingleFile embeds fonts as base64 data URIs in <style> blocks in the
<head>, pushing the <title> tag to ~1.2 MB in the raw temp file.
The 256 KiB read window in extract_html_title missed it.

Font extraction rewrites the HTML in-place (2.4 MB → 1.26 MB) before
hashing, so the title is accessible at byte ~106 KB in that content.

Fix: add extract_html_title_str() that operates on a &str; call it on
the in-memory rewritten string after font extraction (server path).
CLI path (no font extraction) falls back to result.title as before.

* fix(core): inline reader-mode images via Rust post-processing

SingleFile cannot inline resources added to the DOM at before-capture
time — only resources tracked during the page's initial load cycle get
embedded. Article images in Readability output fall into this gap when
the page framework has already resolved their lazy src to a CDN URL
that isn't in SingleFile's resource cache for the new DOM elements.

Fix in three parts:
- Browser script: after body.innerHTML = article.content, stamp
  data-archivr-src=<absolute-proxy-url> on any image whose src is
  not an already-inlined large data URI. Remove loading attr. Don't
  touch src (let SingleFile try; Rust handles the rest).
- Rust (save_with): after SingleFile writes the file and before hashing,
  call inline_archivr_img_srcs() to scan for data-archivr-src markers.
- inline_archivr_img_srcs(): fetches each marked URL with blocking
  reqwest (10 s timeout, 5 redirects, image/* Content-Type guard,
  20 MiB cap), base64-encodes, replaces src with the data URI, and
  removes the marker attr. Non-fatal — fetch failures are logged.

Also: Freedium UI cleanup now removes footer, #progress, and empty
data-nosnippet wrappers in addition to the existing nav/toaster removal.

* fix(core): clean up Freedium chrome and drop reader-mode debug counters

Two fixes:

1. freedium_cleanup: remove remaining Freedium article chrome that
   survived the existing nav/footer/toaster pass:
   - <header class="p-6 bg-gray-50 ..."> — author/metadata bar with
     profile pictures and byline, a Freedium wrapper around the article
   - <section> containing [data-slot="dropdown-menu-trigger"] or
     [aria-haspopup="menu"] — the "Download article" dropdown

   Selectors target stable Tailwind utility class prefixes (p-6, bg-gray-50,
   bg-zinc-800) for the header and the WAI-ARIA menu role for the button,
   both resilient to minor Freedium UI updates.

2. Reader-mode meta tag: strip per-capture debug counters.
   _archivrReaderMark now writes 'applied' instead of
   'applied:pre_s=N,...:post_s=N,...' — the counters were useful during
   development but have no place as permanent archive content.

* fix(core): prevent lazy-resolver double-processing on Freedium reader images

_archivrResolveLazyImgs is called twice: before Readability (_pre) and
after the post-body stamp pass (_post). The stamp pass sets data-archivr-src
but previously left data-src/data-zoom-src/etc intact, so _post's
'lazySrc && _isPlaceholder' condition fired on the same images and
rewrote src to the CDN URL — which SingleFile then tried and failed to
fetch from the Freedium proxy context, redundantly.

Fix: strip all lazy attrs (data-src, data-lazy-src, data-zoom-src,
data-original, data-lazy) when stamping data-archivr-src. _post then
finds no lazySrc on those images and skips them cleanly. Rust owns
them via data-archivr-src. _post continues to handle any remaining
placeholder images not covered by the stamp pass (non-Freedium reader
captures).

* fix(core): address Codex review findings in reader image post-processor

P1 — Enforce size cap before buffering (singlefile.rs fetch_image_as_data_uri):
resp.bytes() buffered the entire response before checking MAX_BYTES, enabling
OOM on oversized or attacker-controlled images. Fix: reject via Content-Length
header when present, then stream at most max_bytes+1 bytes with Read::take so
the cap is enforced without materialising the full body first.

P2 — Decode HTML entities in extracted image URLs (inline_archivr_img_srcs):
The browser's HTML serialiser encodes & as &amp; in attribute values, so CDN
signed URLs with query parameters (e.g. ?a=1&b=2) arrived as &amp;-escaped
strings. reqwest sent the wrong URL, breaking signed or transformed CDN
images. Fix: unescape &amp; &lt; &gt; &quot; before passing to the fetcher.

P2 — Preserve authentication for same-origin lazy images (inline_archivr_img_srcs):
The post-processor created a bare reqwest client with no cookies, causing 401/403
for reader-mode captures of auth-gated sites whose lazy images are same-origin.
Fix: pass capture_url and cookies into inline_archivr_img_srcs; attach a Cookie
header only when domain_from_url(img_url) == domain_from_url(capture_url) and
cookies is non-empty. Third-party image hosts and Freedium fetches (which receive
empty cookies in capture.rs) are unaffected.

* test(core): extract helpers and add unit tests for Codex review fixes

Extract two testable pure functions from inline_archivr_img_srcs:
- html_attr_decode: decodes HTML character references in attribute values.
  Fixes decode order: &amp; runs LAST so &amp;lt; → &lt; (one layer
  removed), not < (two layers). Previous order caused double-decoding.
- same_origin_cookie_header: returns a Cookie header value only when
  img_url's domain matches capture_url's domain.

Add 11 unit tests covering:
- html_attr_decode: plain URL no-op, &amp; in CDN query params, single-layer
  decode (&amp;lt; → &lt; not <), double-encoded amp (&amp;amp; → &amp;),
  direct &lt;/&gt;/&quot; decode
- same_origin_cookie_header: same host attaches cookies, third-party returns
  None, empty cookies returns None, Freedium empty-cookies no-op
- bounded_read: Read::take stops at max+1 bytes (guard fires), allows
  exactly-at-limit payloads (guard does not fire)

* docs: refresh AGENTS.md and mental model; drop NEXT.md

AGENTS.md: mention Freedium mirror in the overview and capture flow,
add vendor/readability/ to Key Directories, drop the NEXT.md pointer.

ARCHIVR-MENTAL-MODEL.md: fix stale references to legacy static/ paths
in Where To Edit (frontend now lives in frontend/src/); add capture.rs
and auth.rs rows; replace the outdated 'Current Limitations' section
(which claimed no capture and no auth) with a Server Capabilities
section reflecting async capture jobs, the auth model, search, and
admin scope; add a Web Capture Pipeline section covering the Freedium
mirror, SingleFile+Chromium, vendored Readability, cleanup, and Rust
post-processing.

NEXT.md: remove; the roadmap tracking is stale and unused.
2026-07-19 17:45:07 +02:00
.github/workflows ci: weekly yt-dlp auto-update workflow 2026-07-03 18:28:18 +02:00
crates test(core): extract helpers and add unit tests for Codex review fixes 2026-07-19 15:18:16 +02:00
docker chore: add Dockerfile, docker-compose, and Docker hosting docs (#12) 2026-06-30 14:35:43 +02:00
docs feat: add video quality selection for yt-dlp captures (#17) 2026-07-05 20:42:41 +02:00
frontend feat(frontend): add Freedium mirror toggle to capture advanced options 2026-07-19 12:09:42 +02:00
modules/nixos fix: address 6 code-review findings 2026-06-29 21:30:45 +02:00
vendor feat: entry previews (#24) 2026-07-12 16:23:04 +02:00
.dockerignore chore: add Dockerfile, docker-compose, and Docker hosting docs (#12) 2026-06-30 14:35:43 +02:00
.gitignore ci: weekly yt-dlp auto-update workflow 2026-07-03 18:28:18 +02:00
AGENTS.md docs: refresh AGENTS.md and mental model; drop NEXT.md 2026-07-19 17:42:40 +02:00
ARCHIVR-MENTAL-MODEL.md docs: refresh AGENTS.md and mental model; drop NEXT.md 2026-07-19 17:42:40 +02:00
Cargo.lock feat: entry previews (#24) 2026-07-12 16:23:04 +02:00
Cargo.toml security: add per-IP sliding-window rate limit on POST /api/auth/login 2026-06-29 20:06:41 +02:00
docker-compose.yml chore: add Dockerfile, docker-compose, and Docker hosting docs (#12) 2026-06-30 14:35:43 +02:00
Dockerfile docker: add uBlock Lite + ISDCAC extensions, mirror flake.nix parity 2026-07-12 17:09:17 +02:00
flake.lock chore: yt-dlp 2026.06.09 → 2026.07.04 (#25) 2026-07-13 14:58:29 +02:00
flake.nix feat: uBlock Origin Lite + cookie consent extension + reader mode + ad placeholder cleanup (#21) 2026-07-08 23:26:48 +02:00

archivr

An open-source self-hosted archiving tool. Work in progress.

  • Archiving
    • Archiving media files from social media platforms
      • YouTube Videos
      • YouTube Playlists
      • YouTube Channels
      • Twitter Videos
      • Instagram
      • Facebook
      • TikTok
      • Reddit
      • Snapchat
      • YouTube Posts (postponed)
    • Archiving local files
    • Archiving Twitter Tweets, Threads, and Articles
    • Archiving files from cloud storage services (Google Drive, Dropbox, OneDrive) and from URLs
      • URLs
      • Google Drive
      • Dropbox
      • OneDrive
      • (Some of these could be postponed for later.)
    • Archive web pages (HTML, CSS, JS, images)
    • Archiving emails (???)
      • Gmail
      • Outlook
      • Yahoo Mail
  • Management
    • Deduplication
    • Tagging system
    • Search functionality
    • Categorization
    • Metadata extraction and storage
  • User Interface
    • Web-based UI
      • Authentication and login
      • Archive setup
      • Browse and view entries
      • Tag management and filtering
      • Search entries
      • View archive runs
      • Capture dialog
      • User settings and API tokens
      • Admin panel
  • Backup and Sync
    • Cloud backup (AWS S3, Google Cloud Storage)
    • Local backup

Motivation

There are two driving factors behind this project:

  • In the age of information, all data is ephemeral. Social media platforms frequently delete content, and cloud storage services can become inaccessible and unreliable. Being able to archive important data is very important for preserving personal memories and digital history.
  • I will be creating a small encyclopedia for my future family and kids. Therefore, I want to make sure that all the information I gather is preserved and accessible for future reference.

This project aims to provide a reliable solution for archiving important data from various sources, ensuring that users can preserve their digital assets for the long term.

Archive Inputs

archivr archive <path> currently accepts three kinds of inputs:

  • Local files via file://...
  • Direct platform URLs
  • Platform shorthand inputs such as tweet:..., yt:..., or instagram:...

Running Archivr

Archivr currently ships as two binaries:

  • archivr
    • The CLI for creating and writing to one archive.
    • Use this for init and archive.
  • archivr-server
    • The web server for reading one or more existing archives through the browser UI.
    • Use this after archives already exist.

With Nix, run the CLI with:

nix run .#archivr -- init ./my-archive --name "My Archive"
nix run .#archivr -- archive file:///absolute/path/to/file.pdf

Run the web server with:

nix run .#archivr-server -- ./archivr-server.toml

The server expects a TOML registry file. If no path is passed, it reads ./archivr-server.toml.

Example:

[[archives]]
id = "personal"
label = "Personal"
archive_path = "/absolute/path/to/my-archive/.archivr"

Then open:

http://127.0.0.1:8080

When installed through Nix, archivr-server is wrapped so it can find the static web UI assets automatically. The wrapper sets ARCHIVR_STATIC_DIR to the installed static asset directory. Running from source with cargo run -p archivr-server falls back to crates/archivr-server/static.

Security and Deployment

archivr-server is a local-only tool by default. It binds to 127.0.0.1:8080 and has no authentication or access control. Do not expose it to a public network or a shared LAN without understanding the risks.

Changing the bind address

You can set the bind address in your TOML config:

# Optional. Default: 127.0.0.1:8080
# Only change this if you know what you are doing — the server has no authentication.
bind = "127.0.0.1:9090"

Or override it with the ARCHIVR_BIND environment variable:

ARCHIVR_BIND=127.0.0.1:9090 nix run .#archivr-server -- ./archivr-server.toml

If the server is started with a non-loopback address (e.g. 0.0.0.0), it prints a warning to stderr:

warn: archivr-server is bound to 0.0.0.0:8080 — this server has no authentication. Only expose it on a trusted network.

When will auth be added?

Auth and session handling will be designed when remote or public hosting becomes a real requirement. Until then, keep the server on loopback. See crates/archivr-server/src/routes.rs for the route classification that will guide where middleware is applied.

Supported Platforms

  • Local files: file:///absolute/path/to/file.ext
  • YouTube media: standard video/short URLs, plus shorthand video inputs
  • X/Twitter media from Tweets: normal Tweet URLs or the tweet:media:ID shorthand
  • X/Twitter Tweet content scrape: Tweet and Thread shorthands. (These are saved as JSON files in raw_tweets/)
  • Instagram, Facebook, TikTok, Reddit, Snapchat: direct URLs or platform-prefixed shorthand passed through to yt-dlp

Video quality and audio-only downloads

When capturing via the web UI, entering a URL for a yt-dlp-backed source (YouTube, Instagram, TikTok, Facebook, Reddit, Snapchat, X media) triggers a metadata probe via GET /api/archives/:id/captures/probe. The quality selector then shows only the heights actually available in that video plus Best quality (default). An Audio only option is appended whenever the probe confirms an audio track exists. UI behaviour by probe outcome:

qualities has_audio UI shows
["1080p", "720p", …] true Best / heights / Audio only
["1080p", …] false Best / heights
[] true Audio only (pre-selected, no Best option)
[] false "No media detected"
probe fails (502) picker hidden, capture still submittable

The POST /api/archives/:id/captures endpoint accepts an optional quality field: "best", "audio", or any "NNNp" height string:

{ "locator": "https://www.youtube.com/watch?v=...", "quality": "720p" }
{ "locator": "https://www.youtube.com/watch?v=...", "quality": "audio" }

"audio" selects the most efficient native audio track without transcoding: Opus/WebM is preferred (smallest at equivalent quality), then AAC/M4A, then whatever yt-dlp considers best. The saved file's extension matches the native format (.webm for Opus, .m4a for AAC, etc.) — no ffmpeg re-encode, no size inflation. Any "NNNp" height is accepted; the server builds the yt-dlp format selector with an unconditional /best fallback so the download succeeds even if the exact height is unavailable. Omitting quality or passing "best" downloads at the highest available quality. Anything else is rejected with HTTP 400.

The probe endpoint (GET /api/archives/:id/captures/probe?locator=…) requires auth and returns 200 with:

{ "has_video": true,  "has_audio": true,  "qualities": ["1080p", "720p", "480p"] }
{ "has_video": false, "has_audio": true,  "qualities": [] }
{ "has_video": false, "has_audio": false, "qualities": [] }

has_video: false, has_audio: false means yt-dlp found no downloadable tracks (e.g. a tweet with no media). A 502 means yt-dlp itself failed (transient network error, rate-limit, unsupported extractor) — treat as inconclusive, not "no media."

Hosting on NixOS

The flake exposes a nixosModules.default output. Add it to your system flake and enable the service:

# flake.nix (your system flake)
{
  inputs.archivr.url = "github:thegeneralist/archivr";

  outputs = { nixpkgs, archivr, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        archivr.nixosModules.default
        {
          services.archivr-server = {
            enable = true;
            # listenAddress defaults to "127.0.0.1" (loopback only)
            # port defaults to 8080
            archives = [
              { id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
              { id = "work";     label = "Work";     path = "/srv/archivr/work/.archivr"; }
            ];
          };
        }
      ];
    };
  };
}

The module:

  • Creates an archivr system user and group.
  • Generates the TOML config from your options and stores the auth database under /var/lib/archivr-server/ (persists across upgrades).
  • Runs under a hardened systemd unit (ProtectSystem = strict, NoNewPrivileges, PrivateTmp, etc.). Archive directories are whitelisted for read-write access.
  • Restarts automatically on failure.

openFirewall — set to true to open the TCP port derived from bind. Only needed when binding to a non-loopback address:

services.archivr-server = {
  listenAddress = "0.0.0.0";
  port = 8080;            # explicit, though 8080 is the default
  openFirewall = true;
};

Archive directories must be readable and writable by the archivr user. Initialise them with archivr init first, then chown -R archivr:archivr /srv/archivr.

Hosting with Docker

A Dockerfile and docker-compose.yml are provided for self-hosting without Nix.

Quickstart

  1. Copy the example config and edit it:

    mkdir config
    cp docker/config.example.toml config/archivr-server.toml
    # edit config/archivr-server.toml — set archive id, label, and archive_path
    
  2. Initialize each archive on the persistent data volume before the first start. The image includes the archivr CLI for this purpose:

    docker compose run --rm archivr archivr init /data/archives/main /data/archives/main/.archivr/store --name "Main Archive"
    

    This creates /data/archives/main/.archivr/ with the metadata the server requires. A bare mkdir is not enough — the server reads name and store_path files that only archivr init writes.

  3. Start the server:

    docker compose up -d
    

    Then open http://localhost:8080.

Volumes

Mount Purpose
./config (read-only) Directory containing archivr-server.toml
archivr-data named volume Auth database (/data/archivr-auth.sqlite) and archive directories

Important: auth_db_path must be set explicitly in archivr-server.toml to a path on the writable data volume (e.g. /data/archivr-auth.sqlite). If left unset, the server defaults to writing the auth database next to the config file — which is on the read-only /config mount and will fail. The example config sets this correctly.

Twitter/X archiving

Supply a cookies file inside the config volume and set ARCHIVR_TWITTER_CREDENTIALS_FILE in docker-compose.yml:

environment:
  ARCHIVR_TWITTER_CREDENTIALS_FILE: /config/twitter-cookies.txt

Building the image locally

docker build -t archivr-server .

The image compiles the Rust binary in a separate build stage so only the runtime dependencies (Chromium, Node.js, Python) land in the final layer.

Supported Shorthand Inputs

  • YouTube video/short media:
    • yt:video/ID
    • youtube:video/ID
    • yt:short/ID
    • yt:shorts/ID
    • youtube:shorts/ID
  • X/Twitter tweet JSON content:
    • tweet:ID
    • x:tweet:ID
    • x:x:ID
    • twitter:x:ID
    • twitter:tweet:ID
  • X/Twitter media/video download:
    • tweet:media:ID
  • X/Twitter thread JSON content:
    • x:thread:ID
    • twitter:thread:ID
  • Other platform shorthands:
    • instagram:ID
    • facebook:ID
    • tiktok:ID
    • reddit:ID
    • snapchat:ID

Environment Variables

  • ARCHIVR_BIND
    • Optional.
    • Overrides the bind address from the TOML config. Useful in Docker where you need 0.0.0.0:8080 without editing the config file. Default: 127.0.0.1:8080.
  • ARCHIVR_STATIC_DIR
    • Optional.
    • Path to the directory of pre-built frontend assets served by the web UI. Set automatically by the Nix wrapper and the Docker image. When running from source with cargo run, falls back to crates/archivr-server/static.
  • ARCHIVR_YT_DLP
    • Optional.
    • Overrides the yt-dlp binary used for YouTube, X media posts, Instagram, Facebook, TikTok, Reddit, and Snapchat downloads.
  • ARCHIVR_SINGLE_FILE
    • Optional.
    • Overrides the single-file binary used for web page archiving. Set automatically by the Nix wrapper and the Docker image.
  • ARCHIVR_CHROME
    • Optional.
    • Overrides the Chromium/Chrome executable passed to single-file via --browser-executable-path. Set automatically by the Nix wrapper and the Docker image. Default: chromium.
  • ARCHIVR_CHROME_ARGS
    • Optional.
    • Space-separated extra flags appended to Chromium's --browser-args. The Docker image sets this to --no-sandbox because Chromium refuses to run as root without it. Leave unset when running natively (Nix, Linux desktop). A --window-size=1920,1080 is always passed to provide a realistic desktop viewport (so responsive @media rules and styles are evaluated and preserved correctly). Supply your own --window-size=... here to override.
  • ARCHIVR_TWITTER_CREDENTIALS_FILE
    • Required for tweet/thread scraping inputs such as tweet:ID and x:thread:ID.
    • Must point to a cookies file for the vendored scraper.
  • ARCHIVR_TWEET_SCRAPER
    • Optional.
    • Overrides the tweet scraper script path. Default: vendor/twitter/scrape_user_tweet_contents.py.
  • ARCHIVR_TWEET_PYTHON
    • Optional.
    • Overrides the Python executable used to run the tweet scraper. Default: python3.

Current Limitations

  • Arbitrary http:// or https:// URLs that return HTML are archived as self-contained single-file HTML snapshots via single-file-cli (requires Chromium). Plain file URLs (PDFs, images, zips, etc.) are downloaded directly. Requires single-file and a Chromium binary on PATH, or the ARCHIVR_SINGLE_FILE / ARCHIVR_CHROME env vars set.
  • Local files currently need to be passed as file://... paths.

License

This project is licensed under the MIT License. See the LICENSE file for details.