Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ FRESHRSS_HOST=http://freshrss

FRESHRSS_USER=your-freshrss-username
FRESHRSS_PASS=your-freshrss-password

# Optional bearer token — when set, all protected endpoints require
# Authorization: Bearer <this-token>. Leave blank or omit to skip auth.
RSS_API_TOKEN=replace-with-a-long-random-secret
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Simple API to fetch rss feeds for github releases using fresh rss (powered by Fa
![alt text](example/image.png)

This allows you to easily integrate it with [https://gethomepage.dev/](https://gethomepage.dev/)
Using their [Custom API integration](https://gethomepage.dev/widgets/services/customapi/)
Using their [Custom API integration](https://gethomepage.dev/widgets/services/customapi/)

## API Documentation

Expand All @@ -19,6 +19,31 @@ The API provides interactive documentation at:
- Optional query parameters:
- `n` (integer, default `10`, valid range `1`–`100`) - Number of unread items to return
- `category` (string) - FreshRSS category label to scope unread items, e.g. `/freshrss/unread?category=Tech`
- `GET /health` - Container health endpoint (does not require authentication)

## Authentication

Bearer authentication is **optional**. When `RSS_API_TOKEN` is not set the API
accepts all requests — convenient for trusted/internal networks. Set
`RSS_API_TOKEN` to a long random secret to require an `Authorization: Bearer <token>`
header on every protected endpoint. `/health` always remains unauthenticated.

```bash
# With auth enabled (token set):
curl \
-H "Authorization: Bearer your-secret-token" \
"http://localhost:5000/freshrss/unread?n=10&category=Tech"

# Without auth (token not set):
curl "http://localhost:5000/freshrss/unread?n=10&category=Tech"
```

When auth is enabled, clients such as Homepage must send the same header:

```yaml
headers:
Authorization: Bearer your-secret-token
```

## Testing

Expand All @@ -42,6 +67,8 @@ Example of services.yaml:
type: customapi
name: Unread RSS
url: http://192.168.0.11:5000/freshrss/unread
headers:
Authorization: Bearer your-secret-token
display: dynamic-list
mappings:
name: feed
Expand All @@ -50,7 +77,7 @@ Example of services.yaml:

# Docker

Copy the example environment file and set all three required values:
Copy the example environment file and set the required values:

```bash
cp .env.example .env
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ services:
FRESHRSS_HOST: "${FRESHRSS_HOST:?Set FRESHRSS_HOST in .env (see README.md)}"
FRESHRSS_USER: "${FRESHRSS_USER:?Set FRESHRSS_USER in .env}"
FRESHRSS_PASS: "${FRESHRSS_PASS:?Set FRESHRSS_PASS in .env}"
# Optional — when unset the API runs without bearer authentication.
# Set this to a long random secret to require Authorization: Bearer <token>.
RSS_API_TOKEN: "${RSS_API_TOKEN:-}"
restart: unless-stopped
39 changes: 35 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging
import os
import secrets
import threading
from urllib.parse import quote, urlsplit

from fastapi import FastAPI, HTTPException, Query
from fastapi import Depends, FastAPI, HTTPException, Query, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import requests
import humanize
from datetime import datetime, timezone
Expand Down Expand Up @@ -34,12 +36,22 @@
FRESHRSS_HOST = os.environ.get("FRESHRSS_HOST")
FRESHRSS_USERNAME = os.environ.get("FRESHRSS_USER")
FRESHRSS_PASSWORD = os.environ.get("FRESHRSS_PASS")

_missing = [k for k, v in {"FRESHRSS_HOST": FRESHRSS_HOST, "FRESHRSS_USER": FRESHRSS_USERNAME, "FRESHRSS_PASS": FRESHRSS_PASSWORD}.items() if not v]
RSS_API_TOKEN = os.environ.get("RSS_API_TOKEN") # optional — when unset, auth is skipped

_missing = [
key
for key, value in {
"FRESHRSS_HOST": FRESHRSS_HOST,
"FRESHRSS_USER": FRESHRSS_USERNAME,
"FRESHRSS_PASS": FRESHRSS_PASSWORD,
}.items()
if not value
]
if _missing:
raise RuntimeError(f"Missing required environment variables: {', '.join(_missing)}")

app = FastAPI()
bearer_scheme = HTTPBearer(auto_error=False)

AUTH_TOKEN = None
AUTH_TOKEN_LOCK = threading.Lock()
Expand Down Expand Up @@ -101,6 +113,25 @@ def validate_freshrss_response(raw):
raise HTTPException(status_code=502, detail="FreshRSS returned an invalid unread response") from exc


def require_api_token(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
):
"""Require the configured bearer token when RSS_API_TOKEN is set.

When RSS_API_TOKEN is not configured, all requests pass through without
authentication — use this for trusted/internal networks. When set, every
protected endpoint demands a matching ``Authorization: Bearer <token>`` header.
"""
if not RSS_API_TOKEN:
return # auth is disabled
if credentials is None or not secrets.compare_digest(credentials.credentials, RSS_API_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API token",
headers={"WWW-Authenticate": "Bearer"},
)


def get_greader_token():
global AUTH_TOKEN
with AUTH_TOKEN_LOCK:
Expand Down Expand Up @@ -157,7 +188,7 @@ def health():
return {"status": "ok"}


@app.get("/freshrss/unread")
@app.get("/freshrss/unread", dependencies=[Depends(require_api_token)])
def freshrss_unread(
n: int = Query(default=10, ge=1, le=100),
category: str | None = Query(default=None, max_length=200),
Expand Down
79 changes: 78 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"FRESHRSS_PASS": "secret",
}

AUTH_TOKEN_VALUE = "api-test-token"


class FakeResponse:
def __init__(self, status_code=200, text="", payload=None, raise_error=None):
Expand Down Expand Up @@ -84,7 +86,11 @@ def test_import_requires_freshrss_environment(monkeypatch):
with pytest.raises(RuntimeError) as excinfo:
importlib.import_module("main")

assert all(name in str(excinfo.value) for name in REQUIRED_ENV)
message = str(excinfo.value)
assert "FRESHRSS_HOST" in message
assert "FRESHRSS_USER" in message
assert "FRESHRSS_PASS" in message
# RSS_API_TOKEN is optional — not required for import


# ── Health endpoint ───────────────────────────────────────────────────
Expand All @@ -98,6 +104,77 @@ def test_health_endpoint_returns_json_over_http(test_client):
assert response.json() == {"status": "ok"}


# ── Authentication ────────────────────────────────────────────────────


@pytest.mark.parametrize(
"headers",
[{}, {"Authorization": "Bearer wrong-token"}],
ids=["missing", "invalid"],
)
def test_unread_rejects_unauthorized_requests_without_contacting_freshrss(
test_client, monkeypatch, main_module, headers
):
main_module.RSS_API_TOKEN = AUTH_TOKEN_VALUE # enable auth for this test
monkeypatch.setattr(main_module, "get_greader_token", lambda: "unreachable")

def unexpected_request(*args, **kwargs):
pytest.fail("unauthorized requests must not contact FreshRSS")

monkeypatch.setattr(main_module.requests, "post", unexpected_request)
monkeypatch.setattr(main_module.requests, "get", unexpected_request)

response = test_client.get("/freshrss/unread", headers=headers)

assert response.status_code == 401
assert response.json() == {"detail": "Invalid or missing API token"}
assert response.headers["www-authenticate"] == "Bearer"


def test_unread_accepts_valid_credentials(test_client, monkeypatch, main_module):
main_module.RSS_API_TOKEN = AUTH_TOKEN_VALUE # enable auth for this test
calls = install_freshrss_transport(monkeypatch, main_module)

response = test_client.get(
"/freshrss/unread",
headers={"Authorization": "Bearer api-test-token"},
)

assert response.status_code == 200
assert response.json() == []
assert len(calls["post"]) == 1 # authenticated and logged in


def test_unread_allows_requests_when_token_not_configured(monkeypatch):
"""When RSS_API_TOKEN is unset, auth is skipped entirely."""
env_without_token = {k: v for k, v in REQUIRED_ENV.items() if k != "RSS_API_TOKEN"}
for name in REQUIRED_ENV:
monkeypatch.delenv(name, raising=False)
for name, value in env_without_token.items():
monkeypatch.setenv(name, value)
sys.modules.pop("main", None)
module = importlib.import_module("main")

# Stub FreshRSS transport so we don't need a real server
monkeypatch.setattr(
module.requests, "post",
lambda *args, **kwargs: FakeResponse(text="Auth=no-auth-token\n"),
)
monkeypatch.setattr(
module.requests, "get",
lambda *args, **kwargs: FakeResponse(payload={"items": []}),
)

with TestClient(module.app) as client:
# No Authorization header at all — should still work
response = client.get("/freshrss/unread")

assert response.status_code == 200
assert response.json() == []

sys.modules.pop("main", None)


# ── Unread endpoint (via TestClient) ──────────────────────────────────


Expand Down
Loading