Merge: rebrand to Siftlode
Some checks failed
CI / frontend (push) Failing after 33s
CI / backend (push) Failing after 30s

This commit is contained in:
npeter83 2026-06-14 04:40:22 +02:00
commit 94a51b1b43
25 changed files with 90 additions and 90 deletions

View file

@ -1,4 +1,4 @@
# Subfeed # Siftlode
Self-hosted, multi-user web app for browsing **your own YouTube subscriptions** the way you Self-hosted, multi-user web app for browsing **your own YouTube subscriptions** the way you
actually want: precise filtering and sorting (by language, topic, length, age, watch state…), actually want: precise filtering and sorting (by language, topic, length, age, watch state…),
@ -66,7 +66,7 @@ server) does **not** require re-fetching from YouTube. Copy your `.env` (keep th
`TOKEN_ENCRYPTION_KEY` and Google client so stored tokens stay valid), then: `TOKEN_ENCRYPTION_KEY` and Google client so stored tokens stay valid), then:
```sh ```sh
./scripts/backup.sh # -> backups/subfeed-<timestamp>.dump (Windows: scripts\backup.ps1) ./scripts/backup.sh # -> backups/siftlode-<timestamp>.dump (Windows: scripts\backup.ps1)
./scripts/restore.sh backups/<file> # on the new host after `docker compose up` ./scripts/restore.sh backups/<file> # on the new host after `docker compose up`
``` ```

View file

@ -8,7 +8,7 @@ _DEFAULT_SECRET_KEY = "change-me-session-key"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Subfeed" app_name: str = "Siftlode"
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed" database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
@ -35,7 +35,7 @@ class Settings(BaseSettings):
smtp_port: int = 587 smtp_port: int = 587
smtp_user: str = "" smtp_user: str = ""
smtp_password: str = "" smtp_password: str = ""
smtp_from: str = "" # e.g. "Subfeed <addr@gmail.com>"; falls back to smtp_user smtp_from: str = "" # e.g. "Siftlode <addr@gmail.com>"; falls back to smtp_user
# --- Sync / YouTube Data API --- # --- Sync / YouTube Data API ---
# Optional API key for public reads (channels/videos/playlistItems). When set it is # Optional API key for public reads (channels/videos/playlistItems). When set it is

View file

@ -56,20 +56,20 @@ def send_access_approved(email: str) -> bool:
admin = _admin_contact() admin = _admin_contact()
body = ( body = (
"Hi,\n\n" "Hi,\n\n"
"Your request to use Subfeed has been approved — welcome aboard.\n\n" "Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Just open Subfeed and sign in with this Google account, and your YouTube\n" "Just open Siftlode and sign in with this Google account, and your YouTube\n"
"subscriptions feed will be ready.\n\n" "subscriptions feed will be ready.\n\n"
"If you weren't expecting this, you can ignore the message.\n\n" "If you weren't expecting this, you can ignore the message.\n\n"
"— Subfeed" "— Siftlode"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
) )
return _send([email], "You're in — your Subfeed access is approved", body, reply_to=admin) return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
def send_admin_new_request(admins: list[str], requester: str) -> bool: def send_admin_new_request(admins: list[str], requester: str) -> bool:
body = ( body = (
f"{requester} just requested access to Subfeed.\n\n" f"{requester} just requested access to Siftlode.\n\n"
"Open Subfeed → Settings → Account → Access requests to approve or deny it.\n\n" "Open Siftlode → Settings → Account → Access requests to approve or deny it.\n\n"
"(Reply to this email to reach the requester directly.)\n" "(Reply to this email to reach the requester directly.)\n"
) )
return _send(admins, f"Subfeed access request from {requester}", body, reply_to=requester) return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)

View file

@ -32,12 +32,12 @@ from app.scheduler import shutdown_scheduler, start_scheduler
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info("Subfeed starting up") log.info("Siftlode starting up")
start_scheduler() start_scheduler()
try: try:
yield yield
finally: finally:
log.info("Subfeed shutting down") log.info("Siftlode shutting down")
shutdown_scheduler() shutdown_scheduler()

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subfeed</title> <title>Siftlode</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }

View file

@ -11,7 +11,7 @@ RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
def fetch_channel_feed(channel_id: str) -> list[dict]: def fetch_channel_feed(channel_id: str) -> list[dict]:
url = RSS_URL.format(channel_id=channel_id) url = RSS_URL.format(channel_id=channel_id)
try: try:
resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Subfeed/1.0"}) resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
except httpx.HTTPError: except httpx.HTTPError:
return [] return []
if resp.status_code != 200: if resp.status_code != 200:

View file

@ -4,5 +4,5 @@ set -e
echo "Applying database migrations..." echo "Applying database migrations..."
alembic upgrade head alembic upgrade head
echo "Starting Subfeed API..." echo "Starting Siftlode API..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json

View file

@ -1,12 +1,12 @@
# Deploying Subfeed to the public VPS (asgard) # Deploying Siftlode to the public VPS (asgard)
The public test instance runs on the asgard VPS at **https://subfeed.b1fr0st.eu**, behind The public test instance runs on the asgard VPS at **https://siftlode.b1fr0st.eu**, behind
Caddy (which terminates TLS and reverse-proxies to the app on `127.0.0.1:8080`). Caddy (which terminates TLS and reverse-proxies to the app on `127.0.0.1:8080`).
## Layout on asgard ## Layout on asgard
``` ```
/srv/subfeed/ /srv/siftlode/
src/ git clone of this repo (build context + compose file) src/ git clone of this repo (build context + compose file)
.env secrets, mode 0600 — NEVER committed .env secrets, mode 0600 — NEVER committed
``` ```
@ -17,16 +17,16 @@ the small VPS.
## First-time setup ## First-time setup
1. DNS `A`/`AAAA` for `subfeed` → asgard (done via the porkbun CLI). 1. DNS `A`/`AAAA` for `siftlode` → asgard (done via the porkbun CLI).
2. Caddy vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` (+ the shared 2. Caddy vhost block `siftlode.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` (+ the shared
security-headers snippet), then reload Caddy. security-headers snippet), then reload Caddy.
3. `git clone` this repo to `/srv/subfeed/src`. 3. `git clone` this repo to `/srv/siftlode/src`.
4. Create `/srv/subfeed/.env` (mode 0600) with the required keys — see 4. Create `/srv/siftlode/.env` (mode 0600) with the required keys — see
[`.env.example`](../.env.example). Generate the secrets: [`.env.example`](../.env.example). Generate the secrets:
- `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(48))"` - `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(48))"`
- `TOKEN_ENCRYPTION_KEY`: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` - `TOKEN_ENCRYPTION_KEY`: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- `POSTGRES_PASSWORD`: any long random string. - `POSTGRES_PASSWORD`: any long random string.
- `OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback` - `OAUTH_REDIRECT_URL=https://siftlode.b1fr0st.eu/auth/callback`
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `YOUTUBE_API_KEY` from Google Cloud Console. - `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `YOUTUBE_API_KEY` from Google Cloud Console.
- `ALLOWED_EMAILS` / `ADMIN_EMAILS` = the invited Google accounts. - `ALLOWED_EMAILS` / `ADMIN_EMAILS` = the invited Google accounts.
@ -36,7 +36,7 @@ the small VPS.
## Rolling out a new version ## Rolling out a new version
```bash ```bash
/srv/subfeed/src/deploy/deploy.sh /srv/siftlode/src/deploy/deploy.sh
``` ```
It pulls `main`, rebuilds the image on the host, and restarts the stack (migrations run It pulls `main`, rebuilds the image on the host, and restarts the stack (migrations run

View file

@ -2,13 +2,13 @@
# Roll out the latest main branch to the public asgard instance. # Roll out the latest main branch to the public asgard instance.
# #
# Layout on asgard: # Layout on asgard:
# /srv/subfeed/src git clone of this repo (build context + compose file) # /srv/siftlode/src git clone of this repo (build context + compose file)
# /srv/subfeed/.env secrets, mode 0600 (never in git) # /srv/siftlode/.env secrets, mode 0600 (never in git)
# #
# Build runs on the host Docker daemon, so it isn't constrained by the CI runner's memory. # Build runs on the host Docker daemon, so it isn't constrained by the CI runner's memory.
set -euo pipefail set -euo pipefail
ROOT=/srv/subfeed ROOT=/srv/siftlode
cd "$ROOT/src" cd "$ROOT/src"
git fetch --quiet origin git fetch --quiet origin

View file

@ -6,11 +6,11 @@
# has a hard memory/CPU cap so the stack can't exhaust the small VPS. # has a hard memory/CPU cap so the stack can't exhaust the small VPS.
# #
# Secrets live OUTSIDE the repo, in an env file on the host. Deploy with: # Secrets live OUTSIDE the repo, in an env file on the host. Deploy with:
# docker compose -f docker-compose.prod.yml --env-file /srv/subfeed/.env up -d --build # docker compose -f docker-compose.prod.yml --env-file /srv/siftlode/.env up -d --build
# #
# Required keys in that env file: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars), # Required keys in that env file: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars),
# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, # TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
# OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS. # OAUTH_REDIRECT_URL=https://siftlode.b1fr0st.eu/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS.
services: services:
db: db:
@ -37,9 +37,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: subfeed-api:local image: siftlode-api:local
env_file: env_file:
- /srv/subfeed/.env - /srv/siftlode/.env
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed}
# The public instance owns the background scheduler (single writer). # The public instance owns the background scheduler (single writer).

View file

@ -1,4 +1,4 @@
# Server (Proxmox LXC) deployment of the full Subfeed stack. # Server (Proxmox LXC) deployment of the full Siftlode stack.
# #
# This is the "central" instance: it owns the single shared Postgres database and # This is the "central" instance: it owns the single shared Postgres database and
# runs the background scheduler (RSS poll, enrichment, recent/deep backfill) 24/7. # runs the background scheduler (RSS poll, enrichment, recent/deep backfill) 24/7.

View file

@ -1,6 +1,6 @@
# Subfeed — Public Deployment Plan (v1) # Siftlode — Public Deployment Plan (v1)
Status: **approved (planning)** — 2026-06-13. Goal: share the current Subfeed Status: **approved (planning)** — 2026-06-13. Goal: share the current Siftlode
build with one trusted tester (both users in Hungary), on safe public build with one trusted tester (both users in Hungary), on safe public
infrastructure, with a clean and trustworthy Google sign-in experience. infrastructure, with a clean and trustworthy Google sign-in experience.
@ -54,7 +54,7 @@ Therefore:
"unverified app" screen and how to proceed (Advanced → Continue). Button → "unverified app" screen and how to proceed (Advanced → Continue). Button →
incremental consent. Skippable. incremental consent. Skippable.
- **Step — write access (`youtube`)**: optional; needed to unsubscribe from - **Step — write access (`youtube`)**: optional; needed to unsubscribe from
within Subfeed; same pre-warning. Button → incremental consent. Skippable. within Siftlode; same pre-warning. Button → incremental consent. Skippable.
3. App **degrades gracefully**: no read scope → no feed (UI explains why); no 3. App **degrades gracefully**: no read scope → no feed (UI explains why); no
write scope → no unsubscribe button. write scope → no unsubscribe button.
@ -78,32 +78,32 @@ future verification submission cleaner.
## 3. Infrastructure — all on asgard ## 3. Infrastructure — all on asgard
``` ```
User browser ──HTTPS──> Caddy (asgard :443, subfeed.b1fr0st.eu) User browser ──HTTPS──> Caddy (asgard :443, siftlode.b1fr0st.eu)
│ reverse_proxy 127.0.0.1:8080 │ reverse_proxy 127.0.0.1:8080
subfeed-api (FastAPI + built SPA, Docker) siftlode-api (FastAPI + built SPA, Docker)
│ internal docker network only │ internal docker network only
subfeed-db (Postgres 16 — never published) siftlode-db (Postgres 16 — never published)
``` ```
- **DNS**: `porkbun add b1fr0st.eu A subfeed 88.218.78.254` (+ AAAA). Agent-owned - **DNS**: `porkbun add b1fr0st.eu A siftlode 88.218.78.254` (+ AAAA). Agent-owned
via `/usr/local/bin/porkbun` on the PVE host. via `/usr/local/bin/porkbun` on the PVE host.
- **TLS**: Caddy DNS-01 (porkbun plugin), same as forge/pages — auto cert for - **TLS**: Caddy DNS-01 (porkbun plugin), same as forge/pages — auto cert for
`subfeed.b1fr0st.eu`. `siftlode.b1fr0st.eu`.
- **Caddy**: new vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` - **Caddy**: new vhost block `siftlode.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }`
inheriting the existing security-header set (HSTS, X-Frame-Options, etc.). inheriting the existing security-header set (HSTS, X-Frame-Options, etc.).
- **OAuth redirect URI**: `https://subfeed.b1fr0st.eu/auth/callback` → - **OAuth redirect URI**: `https://siftlode.b1fr0st.eu/auth/callback` →
`OAUTH_REDIRECT_URL`. Registered in the Google OAuth client. `OAUTH_REDIRECT_URL`. Registered in the Google OAuth client.
- **Stack**: dedicated prod compose at `/srv/subfeed/`; app bound to - **Stack**: dedicated prod compose at `/srv/siftlode/`; app bound to
**127.0.0.1:8080 only** (behind Caddy, never a public port); Postgres on the **127.0.0.1:8080 only** (behind Caddy, never a public port); Postgres on the
internal docker network only (no host port). internal docker network only (no host port).
### Resource limits (critical on the 3.3 GB box) ### Resource limits (critical on the 3.3 GB box)
- `subfeed-api`: `mem_limit: 768m`, `cpus: 1.0`, `pids_limit: 256`, - `siftlode-api`: `mem_limit: 768m`, `cpus: 1.0`, `pids_limit: 256`,
`no-new-privileges`, `cap_drop: ALL`, read-only rootfs + tmpfs `/tmp`, `no-new-privileges`, `cap_drop: ALL`, read-only rootfs + tmpfs `/tmp`,
non-root user. non-root user.
- `subfeed-db`: `mem_limit: 512m`, `cpus: 0.75`, tuned - `siftlode-db`: `mem_limit: 512m`, `cpus: 0.75`, tuned
`shared_buffers`/`work_mem`. `shared_buffers`/`work_mem`.
--- ---
@ -124,9 +124,9 @@ off, icc off, log caps), auditd, key-only SSH, swap.
| **AI-written code flaws** | Dedicated security review of the codebase (§5). | | **AI-written code flaws** | Dedicated security review of the codebase (§5). |
- **Backups**: extend the existing `asgard-snapshot` timer with a `pg_dump` of - **Backups**: extend the existing `asgard-snapshot` timer with a `pg_dump` of
the subfeed DB (rides the nightly tar.zst; pulled to the home array by the siftlode DB (rides the nightly tar.zst; pulled to the home array by
`pull-asgard-backups`). `pull-asgard-backups`).
- **Monitoring**: Prometheus blackbox probe for `subfeed.b1fr0st.eu` + - **Monitoring**: Prometheus blackbox probe for `siftlode.b1fr0st.eu` +
container-up alert, mirroring the Forgejo probes. container-up alert, mirroring the Forgejo probes.
--- ---
@ -148,11 +148,11 @@ off, icc off, log caps), auditd, key-only SSH, swap.
The runner already runs **on the same asgard host** as the app: The runner already runs **on the same asgard host** as the app:
1. Push to a **private Forgejo repo** at `forge.b1fr0st.eu` (Subfeed is 1. Push to a **private Forgejo repo** at `forge.b1fr0st.eu` (Siftlode is
currently local-git only). currently local-git only).
2. **Actions workflow** on tag push: multi-stage Docker build → push to the 2. **Actions workflow** on tag push: multi-stage Docker build → push to the
built-in registry (`forge.b1fr0st.eu/peter/subfeed:<tag>`). built-in registry (`forge.b1fr0st.eu/peter/siftlode:<tag>`).
3. **Deploy step** in the same workflow: `cd /srv/subfeed && docker compose pull 3. **Deploy step** in the same workflow: `cd /srv/siftlode && docker compose pull
&& docker compose up -d` (local, since the runner is on asgard); migrations && docker compose up -d` (local, since the runner is on asgard); migrations
run via the entrypoint's `alembic upgrade head`. run via the entrypoint's `alembic upgrade head`.
@ -181,7 +181,7 @@ Result: `git tag vX && git push --tags` → live in minutes.
authorized domain `b1fr0st.eu`, privacy/ToS/homepage links), declare scopes, authorized domain `b1fr0st.eu`, privacy/ToS/homepage links), declare scopes,
**Publish app → Production**, verify domain in Search Console (TXT added via **Publish app → Production**, verify domain in Search Console (TXT added via
the porkbun CLI by the agent). the porkbun CLI by the agent).
5. Infra: DNS record, Caddy vhost, `/srv/subfeed/` compose stack with limits, 5. Infra: DNS record, Caddy vhost, `/srv/siftlode/` compose stack with limits,
backup hook, monitoring probe. Forgejo repo + Actions pipeline. backup hook, monitoring probe. Forgejo repo + Actions pipeline.
**Phase B — focused security review before publish** **Phase B — focused security review before publish**

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subfeed</title> <title>Siftlode</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,5 +1,5 @@
{ {
"name": "subfeed-frontend", "name": "siftlode-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -33,7 +33,7 @@ export default function Header({
className="text-xl font-bold tracking-tight select-none" className="text-xl font-bold tracking-tight select-none"
title="Feed" title="Feed"
> >
Sub<span className="text-accent">feed</span> Sift<span className="text-accent">lode</span>
</button> </button>
<SyncStatus /> <SyncStatus />

View file

@ -32,7 +32,7 @@ export default function Login() {
<div className="min-h-screen grid place-items-center bg-bg text-fg"> <div className="min-h-screen grid place-items-center bg-bg text-fg">
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center"> <div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
<div className="text-3xl font-bold tracking-tight"> <div className="text-3xl font-bold tracking-tight">
Sub<span className="text-accent">feed</span> Sift<span className="text-accent">lode</span>
</div> </div>
<p className="text-muted my-5 leading-relaxed"> <p className="text-muted my-5 leading-relaxed">
A self-hosted, filterable feed of your YouTube subscriptions sort, tag, and A self-hosted, filterable feed of your YouTube subscriptions sort, tag, and

View file

@ -18,9 +18,9 @@ function ConsentHeadsUp() {
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<p className="text-xs leading-relaxed text-fg/80"> <p className="text-xs leading-relaxed text-fg/80">
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "} Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
screen that's expected, because Subfeed hasn't gone through Google's full review. screen that's expected, because Siftlode hasn't gone through Google's full review.
It's safe to continue: click <span className="font-medium">Advanced</span> {" "} It's safe to continue: click <span className="font-medium">Advanced</span> {" "}
<span className="font-medium">Go to Subfeed</span>. You can revoke access anytime from <span className="font-medium">Go to Siftlode</span>. You can revoke access anytime from
your{" "} your{" "}
<a <a
href="https://myaccount.google.com/permissions" href="https://myaccount.google.com/permissions"
@ -90,7 +90,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</div> </div>
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2> <h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4"> <p className="text-sm text-muted leading-relaxed mt-2 mb-4">
You're signed in. To build your feed, Subfeed needs{" "} You're signed in. To build your feed, Siftlode needs{" "}
<span className="text-fg/90">read-only</span> access to the channels you're <span className="text-fg/90">read-only</span> access to the channels you're
subscribed to on YouTube. It never posts, deletes, or changes anything with this. subscribed to on YouTube. It never posts, deletes, or changes anything with this.
</p> </p>
@ -119,7 +119,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</div> </div>
<h2 className="text-lg font-semibold">You're connected</h2> <h2 className="text-lg font-semibold">You're connected</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4"> <p className="text-sm text-muted leading-relaxed mt-2 mb-4">
Your feed is ready. Optionally, you can let Subfeed{" "} Your feed is ready. Optionally, you can let Siftlode{" "}
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you <span className="text-fg/90">unsubscribe</span> from channels on YouTube for you
this needs an extra write permission. Skip it to stay read-only; you can always this needs an extra write permission. Skip it to stay read-only; you can always
enable it later in Settings. enable it later in Settings.

View file

@ -492,7 +492,7 @@ function AccessRow({
Granted Granted
</span> </span>
) : ( ) : (
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Subfeed."> <Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.">
<button <button
onClick={onEnable} onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition" className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
@ -532,8 +532,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<AccessRow <AccessRow
title="Read subscriptions (your feed)" title="Read subscriptions (your feed)"
granted={me.can_read} granted={me.can_read}
grantedHint="Granted. Subfeed reads the channels you follow to build your feed. It never changes your YouTube account with this." grantedHint="Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this."
enableHint="Read-only access to your subscriptions — required to build your feed. Subfeed can't modify anything with it." enableHint="Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it."
onEnable={() => { onEnable={() => {
window.location.href = "/auth/upgrade?access=read"; window.location.href = "/auth/upgrade?access=read";
}} }}
@ -541,8 +541,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<AccessRow <AccessRow
title="Unsubscribe (write)" title="Unsubscribe (write)"
granted={me.can_write} granted={me.can_write}
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Subfeed. It only writes when you ask it to." grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to."
enableHint="Lets Subfeed unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only." enableHint="Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
onEnable={() => { onEnable={() => {
window.location.href = "/auth/upgrade?access=write"; window.location.href = "/auth/upgrade?access=write";
}} }}

View file

@ -24,7 +24,7 @@ export default function LegalLayout({
<div className="min-h-screen bg-bg text-fg"> <div className="min-h-screen bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12"> <div className="mx-auto w-[min(92vw,720px)] py-12">
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8"> <a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
Sub<span className="text-accent">feed</span> Sift<span className="text-accent">lode</span>
</a> </a>
<div className="glass rounded-2xl p-8"> <div className="glass rounded-2xl p-8">
<h1 className="text-2xl font-semibold">{title}</h1> <h1 className="text-2xl font-semibold">{title}</h1>

View file

@ -4,14 +4,14 @@ export default function PrivacyPolicy() {
return ( return (
<LegalLayout title="Privacy Policy"> <LegalLayout title="Privacy Policy">
<p> <p>
Subfeed is a personal, non-commercial project operated by an individual in Hungary Siftlode is a personal, non-commercial project operated by an individual in Hungary
("we", "us"). It lets you view and organize your own YouTube subscriptions as a ("we", "us"). It lets you view and organize your own YouTube subscriptions as a
filterable feed. This policy explains what data Subfeed accesses, how it is used, and filterable feed. This policy explains what data Siftlode accesses, how it is used, and
the choices you have. By using Subfeed you agree to this policy. the choices you have. By using Siftlode you agree to this policy.
</p> </p>
<H2>What we access</H2> <H2>What we access</H2>
<p>Subfeed only accesses data you explicitly authorize, in two separate steps:</p> <p>Siftlode only accesses data you explicitly authorize, in two separate steps:</p>
<ul className="list-disc pl-5 space-y-1"> <ul className="list-disc pl-5 space-y-1">
<li> <li>
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google <span className="text-fg font-medium">Sign-in (always):</span> your basic Google
@ -31,13 +31,13 @@ export default function PrivacyPolicy() {
</li> </li>
</ul> </ul>
<p> <p>
You can use Subfeed with sign-in alone, and grant or decline each YouTube permission You can use Siftlode with sign-in alone, and grant or decline each YouTube permission
independently. independently.
</p> </p>
<H2>How we use it</H2> <H2>How we use it</H2>
<p> <p>
Your data is used exclusively to provide Subfeed's features to you: building and Your data is used exclusively to provide Siftlode's features to you: building and
displaying your personalized subscription feed, and carrying out actions (such as displaying your personalized subscription feed, and carrying out actions (such as
unsubscribing) that you initiate. We do not use it for advertising, profiling, or any unsubscribing) that you initiate. We do not use it for advertising, profiling, or any
purpose unrelated to the app. purpose unrelated to the app.
@ -45,7 +45,7 @@ export default function PrivacyPolicy() {
<H2>Limited Use disclosure</H2> <H2>Limited Use disclosure</H2>
<p> <p>
Subfeed's use and transfer of information received from Google APIs to any other app Siftlode's use and transfer of information received from Google APIs to any other app
will adhere to the{" "} will adhere to the{" "}
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy"> <LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
Google API Services User Data Policy Google API Services User Data Policy
@ -65,7 +65,7 @@ export default function PrivacyPolicy() {
<H2>Retention &amp; deletion</H2> <H2>Retention &amp; deletion</H2>
<p> <p>
We keep your data while your account is active. You can revoke Subfeed's access to your We keep your data while your account is active. You can revoke Siftlode's access to your
Google data at any time at{" "} Google data at any time at{" "}
<LegalLink href="https://myaccount.google.com/permissions"> <LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions myaccount.google.com/permissions
@ -80,13 +80,13 @@ export default function PrivacyPolicy() {
<H2>Cookies</H2> <H2>Cookies</H2>
<p> <p>
Subfeed sets a single, signed session cookie to keep you logged in. It is not used for Siftlode sets a single, signed session cookie to keep you logged in. It is not used for
tracking or advertising, and there are no third-party analytics cookies. tracking or advertising, and there are no third-party analytics cookies.
</p> </p>
<H2>YouTube &amp; Google</H2> <H2>YouTube &amp; Google</H2>
<p> <p>
Subfeed uses YouTube API Services. By using Subfeed you also agree to the{" "} Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, <LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>,
and your use of Google data is subject to the{" "} and your use of Google data is subject to the{" "}
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>. <LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.

View file

@ -4,21 +4,21 @@ export default function Terms() {
return ( return (
<LegalLayout title="Terms of Service"> <LegalLayout title="Terms of Service">
<p> <p>
Subfeed is a personal, non-commercial project operated by an individual in Hungary. It Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
provides a filterable view of your own YouTube subscriptions. By accessing or using provides a filterable view of your own YouTube subscriptions. By accessing or using
Subfeed, you agree to these Terms. If you do not agree, do not use the service. Siftlode, you agree to these Terms. If you do not agree, do not use the service.
</p> </p>
<H2>The service</H2> <H2>The service</H2>
<p> <p>
Subfeed is offered on an invite-only basis, free of charge, and "as is", without Siftlode is offered on an invite-only basis, free of charge, and "as is", without
warranties of any kind. It is a hobby project: features may change, and the service may warranties of any kind. It is a hobby project: features may change, and the service may
be slowed, interrupted, or discontinued at any time without notice. be slowed, interrupted, or discontinued at any time without notice.
</p> </p>
<H2>Your account &amp; acceptable use</H2> <H2>Your account &amp; acceptable use</H2>
<p> <p>
You sign in with your Google account and may only access Subfeed with an email that has You sign in with your Google account and may only access Siftlode with an email that has
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
overload it, and not to attempt to access other users' data. We may suspend or revoke overload it, and not to attempt to access other users' data. We may suspend or revoke
access at our discretion, for example in case of misuse. access at our discretion, for example in case of misuse.
@ -26,7 +26,7 @@ export default function Terms() {
<H2>Third-party services</H2> <H2>Third-party services</H2>
<p> <p>
Subfeed uses YouTube API Services. By using Subfeed you agree to be bound by the{" "} Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. <LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>.
Your data is handled as described in our{" "} Your data is handled as described in our{" "}
<a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for <a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for
@ -38,7 +38,7 @@ export default function Terms() {
<p> <p>
To the maximum extent permitted by law, the service is provided without warranty, and To the maximum extent permitted by law, the service is provided without warranty, and
the operator is not liable for any indirect, incidental, or consequential damages, or the operator is not liable for any indirect, incidental, or consequential damages, or
for any loss of data, arising from your use of Subfeed. Your sole remedy if you are for any loss of data, arising from your use of Siftlode. Your sole remedy if you are
dissatisfied is to stop using the service and revoke its access to your account. dissatisfied is to stop using the service and revoke its access to your account.
</p> </p>

View file

@ -1,10 +1,10 @@
# Dumps the Subfeed Postgres database to backups\subfeed-<timestamp>.dump (custom format). # Dumps the Siftlode Postgres database to backupssiftlode-<timestamp>.dump (custom format).
# Run from the project root: .\scripts\backup.ps1 # Run from the project root: .\scripts\backup.ps1
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" } $user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" }
$dbname = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "subfeed" } $dbname = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "subfeed" }
New-Item -ItemType Directory -Force -Path "backups" | Out-Null New-Item -ItemType Directory -Force -Path "backups" | Out-Null
$ts = Get-Date -Format "yyyyMMdd-HHmmss" $ts = Get-Date -Format "yyyyMMdd-HHmmss"
$out = "backups\subfeed-$ts.dump" $out = "backups\siftlode-$ts.dump"
docker compose exec -T db pg_dump -U $user -Fc $dbname | Set-Content -NoNewline -Encoding Byte $out docker compose exec -T db pg_dump -U $user -Fc $dbname | Set-Content -NoNewline -Encoding Byte $out
Write-Output "Wrote $out" Write-Output "Wrote $out"

View file

@ -1,11 +1,11 @@
#!/bin/sh #!/bin/sh
# Dumps the Subfeed Postgres database to backups/subfeed-<timestamp>.dump (custom format). # Dumps the Siftlode Postgres database to backups/siftlode-<timestamp>.dump (custom format).
# Run from the project root: ./scripts/backup.sh # Run from the project root: ./scripts/backup.sh
set -e set -e
USER_NAME="${POSTGRES_USER:-subfeed}" USER_NAME="${POSTGRES_USER:-subfeed}"
DB_NAME="${POSTGRES_DB:-subfeed}" DB_NAME="${POSTGRES_DB:-subfeed}"
mkdir -p backups mkdir -p backups
TS=$(date +%Y%m%d-%H%M%S) TS=$(date +%Y%m%d-%H%M%S)
OUT="backups/subfeed-$TS.dump" OUT="backups/siftlode-$TS.dump"
docker compose exec -T db pg_dump -U "$USER_NAME" -Fc "$DB_NAME" > "$OUT" docker compose exec -T db pg_dump -U "$USER_NAME" -Fc "$DB_NAME" > "$OUT"
echo "Wrote $OUT" echo "Wrote $OUT"

View file

@ -1,5 +1,5 @@
# Restores a Subfeed Postgres dump created by backup.ps1 into the running db container. # Restores a Siftlode Postgres dump created by backup.ps1 into the running db container.
# Usage: .\scripts\restore.ps1 backups\subfeed-YYYYMMDD-HHMMSS.dump # Usage: .\scripts\restore.ps1 backupssiftlode-YYYYMMDD-HHMMSS.dump
param([Parameter(Mandatory = $true)][string]$DumpFile) param([Parameter(Mandatory = $true)][string]$DumpFile)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" } $user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" }

View file

@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Restores a Subfeed Postgres dump created by backup.sh into the running db container. # Restores a Siftlode Postgres dump created by backup.sh into the running db container.
# Usage: ./scripts/restore.sh backups/subfeed-YYYYMMDD-HHMMSS.dump # Usage: ./scripts/restore.sh backups/siftlode-YYYYMMDD-HHMMSS.dump
set -e set -e
FILE="$1" FILE="$1"
if [ -z "$FILE" ]; then echo "Usage: restore.sh <dumpfile>"; exit 1; fi if [ -z "$FILE" ]; then echo "Usage: restore.sh <dumpfile>"; exit 1; fi