Build Forgejo-backed community prototype

This commit is contained in:
kacper 2026-04-12 20:15:33 -04:00
parent 797ae5ea35
commit 6671a01d26
16 changed files with 2485 additions and 293 deletions

View file

@ -1,7 +1,11 @@
APP_BASE_URL=http://kacper-dev-pod:8800
FORGEJO_BASE_URL=https://aksal.cloud FORGEJO_BASE_URL=https://aksal.cloud
FORGEJO_TOKEN= FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID=
FORGEJO_OAUTH_CLIENT_SECRET=
FORGEJO_OAUTH_SCOPES=openid profile
FORGEJO_REPO_SCAN_LIMIT=30 FORGEJO_REPO_SCAN_LIMIT=30
FORGEJO_RECENT_ISSUE_LIMIT=6 FORGEJO_RECENT_ISSUE_LIMIT=6
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0 FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
CALENDAR_FEED_URLS= CALENDAR_FEED_URLS=
CALENDAR_EVENT_LIMIT=6 CALENDAR_EVENT_LIMIT=3

View file

@ -62,6 +62,10 @@ cp .env.example .env
Useful variables: Useful variables:
- `FORGEJO_BASE_URL=https://aksal.cloud` - `FORGEJO_BASE_URL=https://aksal.cloud`
- `APP_BASE_URL=http://kacper-dev-pod:8800`
- `FORGEJO_OAUTH_CLIENT_ID=...`
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile`
- `FORGEJO_TOKEN=...` - `FORGEJO_TOKEN=...`
- `CALENDAR_FEED_URLS=webcal://...` - `CALENDAR_FEED_URLS=webcal://...`
- `HOST=0.0.0.0` - `HOST=0.0.0.0`
@ -69,7 +73,10 @@ Useful variables:
Notes: Notes:
- `FORGEJO_TOKEN` is required for live repo and discussion reads on `aksal.cloud`. - Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL.
- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token server-side and may use it only after enforcing public-repository checks.
- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public.
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds. - `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
- Do not commit `.env` or `.env.local`. - Do not commit `.env` or `.env.local`.

View file

@ -38,17 +38,34 @@ HOST=0.0.0.0 PORT=8800 ./scripts/start.sh
Optional live Forgejo configuration: Optional live Forgejo configuration:
```bash ```bash
export APP_BASE_URL="http://kacper-dev-pod:8800"
export FORGEJO_BASE_URL="https://aksal.cloud" export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_TOKEN="your-forgejo-api-token" export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
export FORGEJO_OAUTH_SCOPES="openid profile"
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics" export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
``` ```
`APP_BASE_URL` must match the URL you use in the browser. Create the OAuth app in Forgejo with this redirect URI:
```text
http://kacper-dev-pod:8800/api/auth/forgejo/callback
```
`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments.
Or put those values in `.env`: Or put those values in `.env`:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Sign in through `/signin` using Forgejo OAuth, or query the API directly with:
```bash
curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype
```
### Frontend ### Frontend
```bash ```bash

269
app.py
View file

@ -1,14 +1,26 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import FastAPI from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from auth import (
OAuthStateRecord,
clear_login_session,
consume_oauth_state,
create_login_session,
create_oauth_state,
current_session_user,
resolve_forgejo_token,
)
from forgejo_client import ForgejoClient, ForgejoClientError
from live_prototype import build_live_prototype_payload from live_prototype import build_live_prototype_payload
from settings import get_settings from settings import Settings, get_settings
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist" DIST_DIR = BASE_DIR / "frontend" / "dist"
@ -29,8 +41,135 @@ def create_app() -> FastAPI:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@app.get("/api/prototype") @app.get("/api/prototype")
async def prototype() -> JSONResponse: async def prototype(request: Request) -> JSONResponse:
return JSONResponse(await build_live_prototype_payload(get_settings())) settings = get_settings()
session_user = current_session_user(request)
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse(
await build_live_prototype_payload(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
session_user=session_user,
),
)
@app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse:
session_user = current_session_user(request)
if session_user:
return JSONResponse(_auth_payload(session_user, "session"))
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server":
return JSONResponse(_auth_payload(None, "none"))
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
user = await client.fetch_current_user()
except ForgejoClientError as error:
raise HTTPException(status_code=401, detail=str(error)) from error
return JSONResponse(_auth_payload(user, auth_source))
@app.get("/api/auth/forgejo/start")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings()
if not _oauth_configured(settings):
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
redirect_uri = _oauth_redirect_uri(request, settings)
state, code_challenge = create_oauth_state(redirect_uri, return_to)
async with ForgejoClient(settings) as client:
try:
oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error:
return _signin_error_redirect(str(error))
authorization_endpoint = str(oidc.get("authorization_endpoint") or "")
if not authorization_endpoint:
return _signin_error_redirect("Forgejo did not return an OAuth authorization endpoint.")
query = urlencode(
{
"client_id": settings.forgejo_oauth_client_id or "",
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(settings.forgejo_oauth_scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
return RedirectResponse(f"{authorization_endpoint}?{query}", status_code=303)
@app.get("/api/auth/forgejo/callback")
async def forgejo_auth_callback(
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
if error:
return _signin_error_redirect(f"Forgejo sign-in failed: {error}")
if not code or not state:
return _signin_error_redirect("Forgejo did not return the expected sign-in data.")
settings = get_settings()
if not _oauth_configured(settings):
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
oauth_state = consume_oauth_state(state)
if oauth_state is None:
return _signin_error_redirect("The Forgejo sign-in request expired. Try again.")
try:
access_token = await _exchange_forgejo_code(settings, code, oauth_state)
user = await _fetch_forgejo_oidc_user(settings, access_token)
except ForgejoClientError as exchange_error:
return _signin_error_redirect(str(exchange_error))
response = RedirectResponse(oauth_state.return_to, status_code=303)
create_login_session(response, access_token, user)
return response
@app.delete("/api/auth/session")
async def delete_auth_session(request: Request) -> JSONResponse:
response = JSONResponse(_auth_payload(None, "none"))
clear_login_session(request, response)
return response
@app.post("/api/discussions/replies")
async def create_discussion_reply(
request: Request,
payload: dict[str, object] = Body(...),
) -> JSONResponse:
owner = _required_string(payload, "owner")
repo = _required_string(payload, "repo")
body = _required_string(payload, "body")
issue_number = _required_positive_int(payload, "number")
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server":
raise HTTPException(
status_code=401,
detail="Sign in or send an Authorization token before replying.",
)
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
raise HTTPException(
status_code=403,
detail="This site only writes to public Forgejo repositories.",
)
comment = await client.create_issue_comment(owner, repo, issue_number, body)
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return JSONResponse(_discussion_reply(comment))
if DIST_DIR.exists(): if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets" assets_dir = DIST_DIR / "assets"
@ -53,3 +192,123 @@ def create_app() -> FastAPI:
app = create_app() app = create_app()
def _required_string(payload: dict[str, object], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise HTTPException(status_code=400, detail=f"{key} is required.")
return value.strip()
def _required_positive_int(payload: dict[str, object], key: str) -> int:
value = payload.get(key)
if isinstance(value, bool):
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
if isinstance(value, int):
parsed = value
elif isinstance(value, str) and value.isdigit():
parsed = int(value)
else:
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
if parsed < 1:
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
return parsed
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
if not body:
body = "No comment body provided."
return {
"id": int(comment.get("id", 0)),
"author": author.get("login", "Unknown author"),
"avatar_url": author.get("avatar_url", ""),
"body": body,
"created_at": comment.get("created_at", ""),
"html_url": comment.get("html_url", ""),
}
def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]:
oauth_configured = _oauth_configured(get_settings())
if not user:
return {
"authenticated": False,
"login": None,
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
}
return {
"authenticated": True,
"login": user.get("login", "Unknown user"),
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
}
def _oauth_configured(settings: Settings) -> bool:
return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret)
def _oauth_redirect_uri(request: Request, settings: Settings) -> str:
if settings.app_base_url:
return f"{settings.app_base_url}/api/auth/forgejo/callback"
return str(request.url_for("forgejo_auth_callback"))
def _signin_error_redirect(message: str) -> RedirectResponse:
return RedirectResponse(f"/signin?{urlencode({'error': message})}", status_code=303)
async def _exchange_forgejo_code(
settings: Settings,
code: str,
oauth_state: OAuthStateRecord,
) -> str:
async with ForgejoClient(settings) as client:
oidc = await client.fetch_openid_configuration()
token_endpoint = str(oidc.get("token_endpoint") or "")
if not token_endpoint:
raise ForgejoClientError("Forgejo did not return an OAuth token endpoint.")
token_payload = await client.exchange_oauth_code(
token_endpoint=token_endpoint,
client_id=settings.forgejo_oauth_client_id or "",
client_secret=settings.forgejo_oauth_client_secret or "",
code=code,
redirect_uri=oauth_state.redirect_uri,
code_verifier=oauth_state.code_verifier,
)
access_token = token_payload.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise ForgejoClientError("Forgejo did not return an access token.")
return access_token
async def _fetch_forgejo_oidc_user(settings: Settings, access_token: str) -> dict[str, Any]:
async with ForgejoClient(settings) as client:
oidc = await client.fetch_openid_configuration()
userinfo_endpoint = str(oidc.get("userinfo_endpoint") or "")
if not userinfo_endpoint:
raise ForgejoClientError("Forgejo did not return an OIDC UserInfo endpoint.")
userinfo = await client.fetch_userinfo(userinfo_endpoint, access_token)
login = userinfo.get("preferred_username") or userinfo.get("name") or userinfo.get("sub")
if not isinstance(login, str) or not login:
raise ForgejoClientError("Forgejo did not return a usable user identity.")
return {
"login": login,
"avatar_url": userinfo.get("picture", ""),
"email": userinfo.get("email", ""),
}

142
auth.py Normal file
View file

@ -0,0 +1,142 @@
from __future__ import annotations
import secrets
import time
from base64 import urlsafe_b64encode
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
from fastapi import Request, Response
from settings import Settings
SESSION_COOKIE_NAME = "robot_u_session"
SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14
OAUTH_STATE_MAX_AGE_SECONDS = 60 * 10
@dataclass
class SessionRecord:
forgejo_token: str | None
user: dict[str, Any]
created_at: float
@dataclass
class OAuthStateRecord:
redirect_uri: str
return_to: str
code_verifier: str
created_at: float
_SESSIONS: dict[str, SessionRecord] = {}
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]:
header_token = _authorization_token(request.headers.get("authorization"))
if header_token:
return header_token, "authorization"
session = _session_from_request(request)
if session and session.forgejo_token:
return session.forgejo_token, "session"
if settings.forgejo_token:
return settings.forgejo_token, "server"
return None, "none"
def current_session_user(request: Request) -> dict[str, Any] | None:
session = _session_from_request(request)
return session.user if session else None
def create_login_session(
response: Response,
forgejo_token: str | None,
user: dict[str, Any],
) -> None:
session_id = secrets.token_urlsafe(32)
_SESSIONS[session_id] = SessionRecord(
forgejo_token=forgejo_token,
user=user,
created_at=time.time(),
)
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS,
path="/",
)
def clear_login_session(request: Request, response: Response) -> None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if session_id:
_SESSIONS.pop(session_id, None)
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
def create_oauth_state(redirect_uri: str, return_to: str) -> tuple[str, str]:
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
_OAUTH_STATES[state] = OAuthStateRecord(
redirect_uri=redirect_uri,
return_to=_safe_return_path(return_to),
code_verifier=code_verifier,
created_at=time.time(),
)
return state, code_challenge(code_verifier)
def consume_oauth_state(state: str) -> OAuthStateRecord | None:
record = _OAUTH_STATES.pop(state, None)
if not record:
return None
if time.time() - record.created_at > OAUTH_STATE_MAX_AGE_SECONDS:
return None
return record
def code_challenge(code_verifier: str) -> str:
digest = sha256(code_verifier.encode("ascii")).digest()
return urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _authorization_token(value: str | None) -> str | None:
if not value:
return None
parts = value.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}:
return parts[1].strip() or None
return None
def _safe_return_path(value: str) -> str:
if not value.startswith("/") or value.startswith("//"):
return "/"
return value
def _session_from_request(request: Request) -> SessionRecord | None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
return None
session = _SESSIONS.get(session_id)
if not session:
return None
if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS:
_SESSIONS.pop(session_id, None)
return None
return session

View file

@ -0,0 +1,70 @@
---
title: Building Robot U
summary: How the Robot U community site became a thin, friendly layer over Forgejo.
---
# Building Robot U
Robot U started with a simple goal: give a small robotics community one place to learn, share projects, and ask for help without inventing a second source of truth.
The site is intentionally built as a thin web layer over Forgejo. Forgejo stores the real community content. Repositories hold courses and posts. Issues hold discussions. OAuth lets members sign in with the same identity they already use for code and collaboration.
## What The Site Does
The first prototype focuses on the core community loop:
- Visitors can read public courses, blog posts, discussions, replies, and upcoming events without signing in.
- Members can sign in with Forgejo OAuth.
- Signed-in members can reply to discussions from the site while still posting as their real Forgejo account.
- Courses are discovered from public repositories that contain a `lessons/` folder.
- Blog content is discovered from public repositories that contain a `blogs/` folder.
- Discussions are backed by Forgejo issues, so the Forgejo API remains the durable storage layer.
- Events are loaded from an ICS calendar feed.
That means the website is not competing with Forgejo. It is presenting Forgejo content in a way that makes sense for learners.
## Why Courses Are Repositories
Courses need more than one page of text. A robotics lesson might include diagrams, code samples, CAD files, firmware, or wiring references. Keeping a lesson in a repository makes those materials easy to version, review, and download.
The current course convention is:
```text
lessons/
01-getting-started/
01-blink-an-led/
index.md
example-code/
wiring.png
```
Each lesson is just a folder with a Markdown file and any supporting assets it needs. Adding a lesson is a normal Git change. Editing a lesson is a normal Git change. Creating a new course is creating a new public repository with a `lessons/` folder.
## Why Discussions Are Issues
Forum posts and blockers should not disappear into a custom database if the website changes. Backing discussions with Forgejo issues gives the community a durable model that already supports authorship, replies, labels, links, and permissions.
The site reads public issues for general discussions and shows replies under each discussion. When a signed-in user replies from the website, the backend uses that user's Forgejo session token so the comment belongs to the real person, not a bot account.
## Anonymous Reading, Signed-In Writing
A key design rule is that reading should be easy. Public learning content should be visible without an account.
Writing is different. Replies and future content actions should require a real Forgejo identity. This keeps the contribution model clear:
- Public visitors can read.
- Members sign in with Forgejo to participate.
- Forgejo remains the place that enforces repository and issue permissions.
## What Is Next
This prototype is enough to prove the shape of the system, but there is plenty left to improve:
- Render individual blog posts as first-class pages.
- Add user profiles and progress tracking.
- Improve course navigation for longer curricula.
- Add search across courses, posts, and discussions.
- Use Forgejo webhooks and server-sent events so site activity updates quickly.
- Add better issue-to-lesson linking so discussions can appear directly under the related lesson.
The important part is that the foundation is simple. Robot U can grow as a community learning site without hiding the tools and workflows that make technical collaboration work.

View file

@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, datetime, time from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
MAX_RECURRENCE_OCCURRENCES = 120
RECURRENCE_LOOKAHEAD_DAYS = 365
class CalendarFeedError(RuntimeError): class CalendarFeedError(RuntimeError):
pass pass
@ -29,7 +33,10 @@ class CalendarFeed:
async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed: async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed:
normalized_url = _normalize_calendar_url(url) normalized_url = _normalize_calendar_url(url)
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(normalized_url) response = await client.get(
normalized_url,
headers={"User-Agent": "RobotUCalendar/0.1 (+https://aksal.cloud)"},
)
if not response.is_success: if not response.is_success:
raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}") raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}")
@ -55,6 +62,7 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
lines = _unfold_ics_lines(raw_text) lines = _unfold_ics_lines(raw_text)
calendar_name = _calendar_name(lines, feed_url) calendar_name = _calendar_name(lines, feed_url)
now = datetime.now(UTC) now = datetime.now(UTC)
recurrence_until = now + timedelta(days=RECURRENCE_LOOKAHEAD_DAYS)
events: list[CalendarEvent] = [] events: list[CalendarEvent] = []
current_event: dict[str, str] | None = None current_event: dict[str, str] | None = None
@ -63,9 +71,14 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
current_event = {} current_event = {}
continue continue
if line == "END:VEVENT": if line == "END:VEVENT":
parsed_event = _event_from_properties(current_event or {}, calendar_name) events.extend(
if parsed_event and parsed_event.starts_at >= now: _events_from_properties(
events.append(parsed_event) current_event or {},
calendar_name,
now=now,
recurrence_until=recurrence_until,
),
)
current_event = None current_event = None
continue continue
if current_event is None or ":" not in line: if current_event is None or ":" not in line:
@ -105,23 +118,139 @@ def _calendar_host(feed_url: str) -> str:
return parsed.hostname or "Calendar" return parsed.hostname or "Calendar"
def _event_from_properties(properties: dict[str, str], calendar_name: str) -> CalendarEvent | None: def _events_from_properties(
properties: dict[str, str],
calendar_name: str,
*,
now: datetime,
recurrence_until: datetime,
) -> list[CalendarEvent]:
title = _decode_ics_text(properties.get("SUMMARY", "").strip()) title = _decode_ics_text(properties.get("SUMMARY", "").strip())
start_key = next((key for key in properties if key.startswith("DTSTART")), None) start_key = next((key for key in properties if key.startswith("DTSTART")), None)
if not title or not start_key: if not title or not start_key:
return None return []
starts_at = _parse_ics_datetime(start_key, properties[start_key]) starts_at = _parse_ics_datetime(start_key, properties[start_key])
if starts_at is None: if starts_at is None:
return None return []
location = _decode_ics_text(properties.get("LOCATION", "").strip()) location = _decode_ics_text(properties.get("LOCATION", "").strip())
return CalendarEvent( event_template = {
title=title, "title": title,
starts_at=starts_at, "source": calendar_name,
source=calendar_name, "mode": location or "Calendar",
mode=location or "Calendar", }
recurrence_rule = properties.get("RRULE")
if not recurrence_rule:
if starts_at >= now:
return [CalendarEvent(starts_at=starts_at, **event_template)]
return []
return [
CalendarEvent(starts_at=occurrence, **event_template)
for occurrence in _recurrence_occurrences(
starts_at,
recurrence_rule,
now=now,
recurrence_until=recurrence_until,
start_key=start_key,
) )
]
def _recurrence_occurrences(
starts_at: datetime,
raw_rule: str,
*,
now: datetime,
recurrence_until: datetime,
start_key: str,
) -> list[datetime]:
rule = _parse_recurrence_rule(raw_rule)
frequency = rule.get("FREQ", "").upper()
interval = _positive_int(rule.get("INTERVAL"), default=1)
count = _positive_int(rule.get("COUNT"), default=MAX_RECURRENCE_OCCURRENCES)
until = _parse_recurrence_until(rule.get("UNTIL"), start_key)
effective_until = min(
[candidate for candidate in (until, recurrence_until) if candidate is not None],
default=recurrence_until,
)
occurrences: list[datetime] = []
occurrence = starts_at
for index in range(count):
if occurrence > effective_until:
break
if occurrence >= now:
occurrences.append(occurrence)
if len(occurrences) >= MAX_RECURRENCE_OCCURRENCES:
break
next_occurrence = _next_recurrence_occurrence(occurrence, frequency, interval)
if next_occurrence is None or next_occurrence <= occurrence:
break
occurrence = next_occurrence
return occurrences
def _parse_recurrence_rule(raw_rule: str) -> dict[str, str]:
rule: dict[str, str] = {}
for segment in raw_rule.split(";"):
if "=" not in segment:
continue
key, value = segment.split("=", 1)
rule[key.strip().upper()] = value.strip()
return rule
def _positive_int(value: str | None, *, default: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError:
return default
return parsed if parsed > 0 else default
def _parse_recurrence_until(value: str | None, start_key: str) -> datetime | None:
if not value:
return None
return _parse_ics_datetime(start_key, value)
def _next_recurrence_occurrence(
occurrence: datetime,
frequency: str,
interval: int,
) -> datetime | None:
if frequency == "DAILY":
return occurrence + timedelta(days=interval)
if frequency == "WEEKLY":
return occurrence + timedelta(weeks=interval)
if frequency == "MONTHLY":
return _add_months(occurrence, interval)
if frequency == "YEARLY":
return _add_months(occurrence, interval * 12)
return None
def _add_months(value: datetime, months: int) -> datetime:
month_index = value.month - 1 + months
year = value.year + month_index // 12
month = month_index % 12 + 1
day = min(value.day, _days_in_month(year, month))
return value.replace(year=year, month=month, day=day)
def _days_in_month(year: int, month: int) -> int:
if month == 12:
next_month = datetime(year + 1, 1, 1)
else:
next_month = datetime(year, month + 1, 1)
this_month = datetime(year, month, 1)
return (next_month - this_month).days
def _parse_ics_datetime(key: str, value: str) -> datetime | None: def _parse_ics_datetime(key: str, value: str) -> datetime | None:
@ -132,13 +261,31 @@ def _parse_ics_datetime(key: str, value: str) -> datetime | None:
if value.endswith("Z"): if value.endswith("Z"):
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC) return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
if "T" in value: if "T" in value:
return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=UTC) return _with_ics_timezone(datetime.strptime(value, "%Y%m%dT%H%M%S"), key)
parsed_date = datetime.strptime(value, "%Y%m%d").date() parsed_date = datetime.strptime(value, "%Y%m%d").date()
return datetime.combine(parsed_date, time.min, tzinfo=UTC) return datetime.combine(parsed_date, time.min, tzinfo=UTC)
except ValueError: except ValueError:
return None return None
def _with_ics_timezone(value: datetime, key: str) -> datetime:
timezone_name = _ics_timezone_name(key)
if not timezone_name:
return value.replace(tzinfo=UTC)
try:
return value.replace(tzinfo=ZoneInfo(timezone_name))
except ZoneInfoNotFoundError:
return value.replace(tzinfo=UTC)
def _ics_timezone_name(key: str) -> str | None:
for segment in key.split(";")[1:]:
if segment.startswith("TZID="):
return segment.split("=", 1)[1].strip() or None
return None
def _decode_ics_text(value: str) -> str: def _decode_ics_text(value: str) -> str:
return ( return (
value.replace("\\n", "\n") value.replace("\\n", "\n")

View file

@ -14,8 +14,9 @@ class ForgejoClientError(RuntimeError):
class ForgejoClient: class ForgejoClient:
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None:
self._settings = settings self._settings = settings
self._forgejo_token = forgejo_token or settings.forgejo_token
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds) self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
async def __aenter__(self) -> ForgejoClient: async def __aenter__(self) -> ForgejoClient:
@ -30,6 +31,54 @@ class ForgejoClient:
absolute_url=True, absolute_url=True,
) )
async def exchange_oauth_code(
self,
*,
token_endpoint: str,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
code_verifier: str,
) -> dict[str, Any]:
response = await self._client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
)
if response.is_success:
payload = response.json()
if isinstance(payload, dict):
return payload
raise ForgejoClientError("Unexpected Forgejo OAuth token payload.")
message = self._extract_error_message(response)
raise ForgejoClientError(f"{response.status_code} from Forgejo OAuth: {message}")
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, Any]:
response = await self._client.get(
userinfo_endpoint,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
},
)
if response.is_success:
payload = response.json()
if isinstance(payload, dict):
return payload
raise ForgejoClientError("Unexpected Forgejo UserInfo payload.")
message = self._extract_error_message(response)
raise ForgejoClientError(f"{response.status_code} from Forgejo UserInfo: {message}")
async def fetch_current_user(self) -> dict[str, Any]: async def fetch_current_user(self) -> dict[str, Any]:
return await self._get_json("/api/v1/user", auth_required=True) return await self._get_json("/api/v1/user", auth_required=True)
@ -39,22 +88,37 @@ class ForgejoClient:
params={ params={
"limit": self._settings.forgejo_repo_scan_limit, "limit": self._settings.forgejo_repo_scan_limit,
"page": 1, "page": 1,
"private": "false",
"is_private": "false",
}, },
auth_required=True,
) )
data = payload.get("data", []) data = payload.get("data", [])
return [repo for repo in data if isinstance(repo, dict)] return [repo for repo in data if isinstance(repo, dict)]
async def search_recent_issues(self) -> list[dict[str, Any]]: async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]:
payload = await self._get_json( payload = await self._get_json(
"/api/v1/repos/issues/search", f"/api/v1/repos/{owner}/{repo}",
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected repository payload for {owner}/{repo}")
async def list_repo_issues(
self,
owner: str,
repo: str,
*,
limit: int,
) -> list[dict[str, Any]]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/issues",
params={ params={
"state": "open", "state": "open",
"page": 1, "page": 1,
"limit": self._settings.forgejo_recent_issue_limit, "limit": limit,
"type": "issues", "type": "issues",
"sort": "recentupdate",
}, },
auth_required=True,
) )
if isinstance(payload, list): if isinstance(payload, list):
return [issue for issue in payload if isinstance(issue, dict)] return [issue for issue in payload if isinstance(issue, dict)]
@ -65,7 +129,7 @@ class ForgejoClient:
if path: if path:
endpoint = f"{endpoint}/{path.strip('/')}" endpoint = f"{endpoint}/{path.strip('/')}"
payload = await self._get_json(endpoint, auth_required=True) payload = await self._get_json(endpoint)
if isinstance(payload, list): if isinstance(payload, list):
return [entry for entry in payload if isinstance(entry, dict)] return [entry for entry in payload if isinstance(entry, dict)]
if isinstance(payload, dict): if isinstance(payload, dict):
@ -80,16 +144,31 @@ class ForgejoClient:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
payload = await self._get_json( payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
auth_required=True,
) )
if isinstance(payload, list): if isinstance(payload, list):
return [comment for comment in payload if isinstance(comment, dict)] return [comment for comment in payload if isinstance(comment, dict)]
return [] return []
async def create_issue_comment(
self,
owner: str,
repo: str,
issue_number: int,
body: str,
) -> dict[str, Any]:
payload = await self._request_json(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
json_payload={"body": body},
auth_required=True,
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}")
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]: async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
payload = await self._get_json( payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}", f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
auth_required=True,
) )
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}") raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}")
@ -118,17 +197,41 @@ class ForgejoClient:
params: Mapping[str, str | int] | None = None, params: Mapping[str, str | int] | None = None,
auth_required: bool = False, auth_required: bool = False,
) -> dict[str, Any] | list[Any]: ) -> dict[str, Any] | list[Any]:
if auth_required and not self._settings.forgejo_token: return await self._request_json(
"GET",
path,
absolute_url=absolute_url,
params=params,
auth_required=auth_required,
)
async def _request_json(
self,
method: str,
path: str,
*,
absolute_url: bool = False,
params: Mapping[str, str | int] | None = None,
json_payload: Mapping[str, object] | None = None,
auth_required: bool = False,
) -> dict[str, Any] | list[Any]:
if auth_required and not self._forgejo_token:
raise ForgejoClientError( raise ForgejoClientError(
"This Forgejo instance requires an authenticated API token for repo and issue access.", "This Forgejo instance requires an authenticated API token for repo and issue access.",
) )
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}" url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
headers = {} headers = {}
if self._settings.forgejo_token: if self._forgejo_token:
headers["Authorization"] = f"token {self._settings.forgejo_token}" headers["Authorization"] = f"token {self._forgejo_token}"
response = await self._client.get(url, params=params, headers=headers) response = await self._client.request(
method,
url,
params=params,
headers=headers,
json=json_payload,
)
if response.is_success: if response.is_success:
return response.json() return response.json()

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent"; import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type { import type {
AuthState,
CourseCard, CourseCard,
CourseChapter, CourseChapter,
CourseLesson, CourseLesson,
@ -48,6 +49,22 @@ function parseDiscussionRoute(pathname: string): number | null {
return Number.isFinite(issueId) ? issueId : null; return Number.isFinite(issueId) ? issueId : null;
} }
function isSignInRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/signin";
}
function isCoursesIndexRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/courses";
}
function isDiscussionsIndexRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/discussions";
}
function isActivityRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/activity";
}
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null { function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/); const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
if (!match) { if (!match) {
@ -143,12 +160,17 @@ function usePathname() {
}, []); }, []);
function navigate(nextPath: string) { function navigate(nextPath: string) {
const normalized = normalizePathname(nextPath); const nextUrl = new URL(nextPath, window.location.origin);
if (normalized === pathname) { const normalized = normalizePathname(nextUrl.pathname);
const renderedPath = `${normalized}${nextUrl.search}`;
if (
normalized === pathname &&
renderedPath === `${window.location.pathname}${window.location.search}`
) {
return; return;
} }
window.history.pushState({}, "", normalized); window.history.pushState({}, "", renderedPath);
window.scrollTo({ top: 0, behavior: "auto" }); window.scrollTo({ top: 0, behavior: "auto" });
setPathname(normalized); setPathname(normalized);
} }
@ -168,6 +190,152 @@ function EmptyState(props: { copy: string }) {
return <p className="empty-state">{props.copy}</p>; return <p className="empty-state">{props.copy}</p>;
} }
function canUseInteractiveAuth(auth: AuthState): boolean {
return auth.authenticated && auth.can_reply;
}
function forgejoSignInUrl(returnTo: string): string {
const target = new URL("/api/auth/forgejo/start", window.location.origin);
target.searchParams.set("return_to", returnTo);
return `${target.pathname}${target.search}`;
}
function TopBar(props: {
auth: AuthState;
pathname: string;
onGoHome: () => void;
onGoCourses: () => void;
onGoDiscussions: () => void;
onGoActivity: () => void;
onGoSignIn: () => void;
onSignOut: () => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const {
auth,
pathname,
onGoHome,
onGoCourses,
onGoDiscussions,
onGoActivity,
onGoSignIn,
onSignOut,
} = props;
function navClass(targetPath: string) {
const normalized = normalizePathname(pathname);
const isActive =
targetPath === "/"
? normalized === "/"
: normalized === targetPath || normalized.startsWith(`${targetPath}/`);
return isActive ? "topbar-link active" : "topbar-link";
}
const normalizedPathname = normalizePathname(pathname);
const brandClass = normalizedPathname === "/" ? "topbar-brand active" : "topbar-brand";
const menuClass = isMenuOpen ? "topbar-menu open" : "topbar-menu";
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
function handleNavigation(callback: () => void) {
setIsMenuOpen(false);
callback();
}
return (
<header className="topbar">
<button
type="button"
className={brandClass}
aria-label="Go to Robot U home"
onClick={() => {
handleNavigation(onGoHome);
}}
>
Robot U
</button>
<button
type="button"
className="topbar-menu-button"
aria-controls="topbar-menu"
aria-expanded={isMenuOpen}
aria-label="Toggle navigation menu"
onClick={() => {
setIsMenuOpen((currentValue) => !currentValue);
}}
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</button>
<div id="topbar-menu" className={menuClass}>
<nav className="topbar-nav" aria-label="Primary navigation">
<button
type="button"
className={navClass("/courses")}
onClick={() => {
handleNavigation(onGoCourses);
}}
>
Courses
</button>
<button
type="button"
className={navClass("/discussions")}
onClick={() => {
handleNavigation(onGoDiscussions);
}}
>
Discussions
</button>
<button
type="button"
className={navClass("/activity")}
onClick={() => {
handleNavigation(onGoActivity);
}}
>
Activity
</button>
</nav>
<div className="topbar-auth">
{auth.authenticated ? (
<p>{auth.login}</p>
) : (
<p className="topbar-auth-muted">Not signed in</p>
)}
{auth.authenticated ? (
<button
type="button"
className="secondary-button"
onClick={() => {
handleNavigation(onSignOut);
}}
>
Sign out
</button>
) : (
<button
type="button"
className="secondary-button"
onClick={() => {
handleNavigation(onGoSignIn);
}}
>
Sign in
</button>
)}
</div>
</div>
</header>
);
}
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) { function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
const { course, onOpenCourse } = props; const { course, onOpenCourse } = props;
@ -247,20 +415,123 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) {
); );
} }
function ComposeBox() { function repoParts(fullName: string): { owner: string; repo: string } | null {
return ( const [owner, repo] = fullName.split("/", 2);
<form if (!owner || !repo) {
className="compose-box" return null;
onSubmit={(event) => { }
return { owner, repo };
}
async function responseError(response: Response, fallback: string): Promise<Error> {
const payload = (await response.json().catch(() => null)) as { detail?: string } | null;
return new Error(payload?.detail || fallback);
}
async function postDiscussionReply(
discussion: DiscussionCard,
body: string,
): Promise<DiscussionReply> {
const repo = repoParts(discussion.repo);
if (!repo) {
throw new Error("This discussion is missing repository information.");
}
const response = await fetch("/api/discussions/replies", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
owner: repo.owner,
repo: repo.repo,
number: discussion.number,
body,
}),
});
if (!response.ok) {
throw await responseError(response, `Reply failed with ${response.status}`);
}
return (await response.json()) as DiscussionReply;
}
function ComposeBox(props: {
discussion: DiscussionCard;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
auth: AuthState;
onGoSignIn: () => void;
}) {
const { discussion, onReplyCreated, auth, onGoSignIn } = props;
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedBody = body.trim();
const canReply = canUseInteractiveAuth(auth);
async function submitReply(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (!canReply) {
setError("Sign in before replying.");
return;
}
if (!trimmedBody || isSubmitting) {
return;
}
setError(null);
setIsSubmitting(true);
try {
const reply = await postDiscussionReply(discussion, trimmedBody);
onReplyCreated(discussion.id, reply);
setBody("");
} catch (replyError) {
const message =
replyError instanceof Error ? replyError.message : "Reply could not be posted.";
setError(message);
} finally {
setIsSubmitting(false);
}
}
return (
<form className="compose-box" onSubmit={submitReply}>
{!canReply ? (
<div className="signin-callout">
<p>
{auth.authenticated
? "Reply posting is unavailable for this session."
: "Sign in before replying."}
</p>
{!auth.authenticated ? (
<button type="button" className="secondary-button" onClick={onGoSignIn}>
Sign in
</button>
) : null}
</div>
) : null}
<textarea
className="compose-input"
placeholder="Write a reply"
value={body}
disabled={!canReply}
onInput={(event) => {
setBody(event.currentTarget.value);
}} }}
> />
<textarea className="compose-input" placeholder="Write a reply" />
<div className="compose-actions"> <div className="compose-actions">
<button type="submit" className="compose-button" disabled> <button
Post reply type="submit"
className="compose-button"
disabled={!canReply || !trimmedBody || isSubmitting}
>
{isSubmitting ? "Posting..." : "Post reply"}
</button> </button>
</div> </div>
{error ? <p className="compose-error">{error}</p> : null}
</form> </form>
); );
} }
@ -366,8 +637,14 @@ function LessonPage(props: {
); );
} }
function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => void }) { function DiscussionPage(props: {
const { discussion, onGoHome } = props; discussion: DiscussionCard;
auth: AuthState;
onGoHome: () => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
}) {
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
return ( return (
<section className="thread-view"> <section className="thread-view">
@ -406,12 +683,59 @@ function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => voi
<header className="subsection-header"> <header className="subsection-header">
<h2>Reply</h2> <h2>Reply</h2>
</header> </header>
<ComposeBox /> <ComposeBox
discussion={discussion}
auth={auth}
onGoSignIn={onGoSignIn}
onReplyCreated={onReplyCreated}
/>
</article> </article>
</section> </section>
); );
} }
function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: CourseCard) => void }) {
const { data, onOpenCourse } = props;
return (
<section className="page-section">
<SectionHeader title="Courses" />
{data.featured_courses.length > 0 ? (
<div className="card-grid">
{data.featured_courses.map((course) => (
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/lessons/` were found." />
)}
</section>
);
}
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
const { data, onOpenDiscussion } = props;
return (
<section className="page-section">
<SectionHeader title="Discussions" />
{data.recent_discussions.length > 0 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No visible Forgejo issues were returned for this account." />
)}
</section>
);
}
function HomeView(props: { function HomeView(props: {
data: PrototypeData; data: PrototypeData;
onOpenCourse: (course: CourseCard) => void; onOpenCourse: (course: CourseCard) => void;
@ -429,18 +753,7 @@ function HomeView(props: {
</p> </p>
</section> </section>
<section className="page-section"> <CoursesView data={data} onOpenCourse={onOpenCourse} />
<SectionHeader title="Courses" />
{data.featured_courses.length > 0 ? (
<div className="card-grid">
{data.featured_courses.map((course) => (
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/lessons/` were found." />
)}
</section>
<section className="two-column-grid"> <section className="two-column-grid">
<div className="page-section"> <div className="page-section">
@ -470,49 +783,242 @@ function HomeView(props: {
</div> </div>
</section> </section>
<section className="page-section"> <DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} />
<SectionHeader title="Discussions" />
{data.recent_discussions.length > 0 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No visible Forgejo issues were returned for this account." />
)}
</section>
</> </>
); );
} }
function AppContent(props: { function SignInPage(props: { auth: AuthState; onGoHome: () => void }) {
const { auth, onGoHome } = props;
const query = new URLSearchParams(window.location.search);
const error = query.get("error");
const returnTo = query.get("return_to") || "/";
function startForgejoSignIn() {
window.location.assign(forgejoSignInUrl(returnTo));
}
return (
<section className="signin-page">
<button type="button" className="back-link" onClick={onGoHome}>
Back
</button>
<article className="panel signin-panel">
<header className="thread-header">
<h1>Sign in</h1>
<p className="muted-copy">
Forgejo sign-in needs attention. You can retry the OAuth flow or return to the site.
</p>
</header>
<div className="signin-actions">
<button
type="button"
className="compose-button"
disabled={!auth.oauth_configured}
onClick={startForgejoSignIn}
>
Continue with Forgejo
</button>
{!auth.oauth_configured ? (
<p className="compose-error">Forgejo OAuth is not configured on this backend yet.</p>
) : null}
{error ? <p className="compose-error">{error}</p> : null}
</div>
</article>
</section>
);
}
async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData> {
const response = await fetch("/api/prototype", {
signal,
});
if (!response.ok) {
throw new Error(`Prototype request failed with ${response.status}`);
}
return (await response.json()) as PrototypeData;
}
function appendDiscussionReply(
currentData: PrototypeData | null,
discussionId: number,
reply: DiscussionReply,
): PrototypeData | null {
if (!currentData) {
return currentData;
}
return {
...currentData,
recent_discussions: currentData.recent_discussions.map((discussion) => {
if (discussion.id !== discussionId) {
return discussion;
}
return {
...discussion,
replies: discussion.replies + 1,
updated_at: reply.created_at || discussion.updated_at,
comments: [...discussion.comments, reply],
};
}),
};
}
interface ActivityEntry {
id: string;
title: string;
detail: string;
timestamp: string;
route: string | null;
}
function timestampMillis(value: string): number {
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function coursePath(course: CourseCard): string {
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`;
}
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = [];
for (const course of data.featured_courses) {
if (course.updated_at) {
activities.push({
id: `course:${course.repo}`,
title: `Course updated: ${course.title}`,
detail: `${course.repo} · ${course.lessons} lessons`,
timestamp: course.updated_at,
route: coursePath(course),
});
}
}
for (const post of data.recent_posts) {
if (post.updated_at) {
activities.push({
id: `post:${post.repo}`,
title: `Post repo updated: ${post.title}`,
detail: post.repo,
timestamp: post.updated_at,
route: null,
});
}
}
for (const discussion of data.recent_discussions) {
activities.push({
id: `discussion:${discussion.id}`,
title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} · ${discussion.replies} replies`,
timestamp: discussion.updated_at,
route: `/discussions/${discussion.id}`,
});
for (const reply of discussion.comments) {
activities.push({
id: `reply:${discussion.id}:${reply.id}`,
title: `${reply.author} replied`,
detail: discussion.title,
timestamp: reply.created_at,
route: `/discussions/${discussion.id}`,
});
}
}
return activities.sort(
(left, right) => timestampMillis(right.timestamp) - timestampMillis(left.timestamp),
);
}
function ActivityItem(props: { entry: ActivityEntry; onOpenRoute: (route: string) => void }) {
const { entry, onOpenRoute } = props;
if (entry.route) {
return (
<button
type="button"
className="activity-card activity-card-button"
onClick={() => {
if (entry.route) {
onOpenRoute(entry.route);
}
}}
>
<h3>{entry.title}</h3>
<p className="muted-copy">{entry.detail}</p>
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
</button>
);
}
return (
<article className="activity-card">
<h3>{entry.title}</h3>
<p className="muted-copy">{entry.detail}</p>
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
</article>
);
}
function ActivityView(props: { data: PrototypeData; onOpenRoute: (route: string) => void }) {
const activities = buildActivityFeed(props.data);
return (
<section className="page-section">
<SectionHeader title="Activity" />
{activities.length > 0 ? (
<div className="activity-list">
{activities.map((entry) => (
<ActivityItem key={entry.id} entry={entry} onOpenRoute={props.onOpenRoute} />
))}
</div>
) : (
<EmptyState copy="No public site activity has been loaded yet." />
)}
</section>
);
}
interface AppContentProps {
data: PrototypeData; data: PrototypeData;
pathname: string; pathname: string;
onOpenCourse: (course: CourseCard) => void; onOpenCourse: (course: CourseCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void; onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void; onOpenDiscussion: (id: number) => void;
onOpenRoute: (route: string) => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
onGoHome: () => void; onGoHome: () => void;
}) { onGoCourses: () => void;
const lessonRoute = parseLessonRoute(props.pathname); onGoDiscussions: () => void;
if (lessonRoute !== null) { }
const selectedCourse = findCourseByRoute(props.data.featured_courses, lessonRoute);
function LessonRouteView(
props: AppContentProps & {
route: { owner: string; repo: string; chapter: string; lesson: string };
},
) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
if (!selectedCourse) { if (!selectedCourse) {
return ( return (
<section className="page-message"> <section className="page-message">
<h1>Course not found.</h1> <h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}> <button type="button" className="back-link" onClick={props.onGoCourses}>
Back to courses Back to courses
</button> </button>
</section> </section>
); );
} }
const selectedLesson = findLessonByRoute(selectedCourse, lessonRoute); const selectedLesson = findLessonByRoute(selectedCourse, props.route);
if (!selectedLesson) { if (!selectedLesson) {
return ( return (
<section className="page-message"> <section className="page-message">
@ -542,14 +1048,13 @@ function AppContent(props: {
); );
} }
const courseRoute = parseCourseRoute(props.pathname); function CourseRouteView(props: AppContentProps & { route: { owner: string; repo: string } }) {
if (courseRoute !== null) { const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
const selectedCourse = findCourseByRoute(props.data.featured_courses, courseRoute);
if (!selectedCourse) { if (!selectedCourse) {
return ( return (
<section className="page-message"> <section className="page-message">
<h1>Course not found.</h1> <h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}> <button type="button" className="back-link" onClick={props.onGoCourses}>
Back to courses Back to courses
</button> </button>
</section> </section>
@ -559,12 +1064,65 @@ function AppContent(props: {
return ( return (
<CoursePage <CoursePage
course={selectedCourse} course={selectedCourse}
onGoHome={props.onGoHome} onGoHome={props.onGoCourses}
onOpenLesson={props.onOpenLesson} onOpenLesson={props.onOpenLesson}
/> />
); );
} }
function DiscussionRouteView(props: AppContentProps & { discussionId: number }) {
const selectedDiscussion = props.data.recent_discussions.find(
(discussion) => discussion.id === props.discussionId,
);
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Discussion not found.</h1>
<button type="button" className="back-link" onClick={props.onGoDiscussions}>
Back to discussions
</button>
</section>
);
}
return (
<DiscussionPage
discussion={selectedDiscussion}
auth={props.data.auth}
onGoHome={props.onGoDiscussions}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
/>
);
}
function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) {
return <SignInPage auth={props.data.auth} onGoHome={props.onGoHome} />;
}
if (isActivityRoute(props.pathname)) {
return <ActivityView data={props.data} onOpenRoute={props.onOpenRoute} />;
}
if (isCoursesIndexRoute(props.pathname)) {
return <CoursesView data={props.data} onOpenCourse={props.onOpenCourse} />;
}
if (isDiscussionsIndexRoute(props.pathname)) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
}
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
return <LessonRouteView {...props} route={lessonRoute} />;
}
const courseRoute = parseCourseRoute(props.pathname);
if (courseRoute !== null) {
return <CourseRouteView {...props} route={courseRoute} />;
}
const discussionId = parseDiscussionRoute(props.pathname); const discussionId = parseDiscussionRoute(props.pathname);
if (discussionId === null) { if (discussionId === null) {
return ( return (
@ -576,21 +1134,55 @@ function AppContent(props: {
); );
} }
const selectedDiscussion = props.data.recent_discussions.find( return <DiscussionRouteView {...props} discussionId={discussionId} />;
(discussion) => discussion.id === discussionId, }
);
if (!selectedDiscussion) { function LoadedApp(
props: AppContentProps & {
onGoActivity: () => void;
onSignOut: () => void;
},
) {
return ( return (
<section className="page-message"> <>
<h1>Discussion not found.</h1> <TopBar
<button type="button" className="back-link" onClick={props.onGoHome}> auth={props.data.auth}
Back to discussions pathname={props.pathname}
</button> onGoHome={props.onGoHome}
</section> onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
onGoActivity={props.onGoActivity}
onGoSignIn={props.onGoSignIn}
onSignOut={props.onSignOut}
/>
<main className="app-shell">
<AppContent
data={props.data}
pathname={props.pathname}
onOpenCourse={props.onOpenCourse}
onOpenLesson={props.onOpenLesson}
onOpenDiscussion={props.onOpenDiscussion}
onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
/>
</main>
</>
); );
} }
return <DiscussionPage discussion={selectedDiscussion} onGoHome={props.onGoHome} />; function AppStatusPage(props: { title: string; copy?: string }) {
return (
<main className="app-shell">
<section className="page-message">
<h1>{props.title}</h1>
{props.copy ? <p className="muted-copy">{props.copy}</p> : null}
</section>
</main>
);
} }
export default function App() { export default function App() {
@ -598,23 +1190,13 @@ export default function App() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname(); const { pathname, navigate } = usePathname();
useEffect(() => { async function loadPrototype(signal?: AbortSignal) {
const controller = new AbortController();
async function loadPrototype() {
try { try {
const response = await fetch("/api/prototype", { const payload = await fetchPrototypeData(signal);
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Prototype request failed with ${response.status}`);
}
const payload = (await response.json()) as PrototypeData;
setData(payload); setData(payload);
setError(null);
} catch (loadError) { } catch (loadError) {
if (controller.signal.aborted) { if (signal?.aborted) {
return; return;
} }
@ -624,7 +1206,9 @@ export default function App() {
} }
} }
loadPrototype(); useEffect(() => {
const controller = new AbortController();
loadPrototype(controller.signal);
return () => { return () => {
controller.abort(); controller.abort();
}; };
@ -634,6 +1218,22 @@ export default function App() {
navigate(`/discussions/${id}`); navigate(`/discussions/${id}`);
} }
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
}
function goCourses() {
navigate("/courses");
}
function goDiscussions() {
navigate("/discussions");
}
function goActivity() {
navigate("/activity");
}
function openCourse(course: CourseCard) { function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`); navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
} }
@ -644,31 +1244,45 @@ export default function App() {
); );
} }
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
function goHome() { function goHome() {
navigate("/"); navigate("/");
} }
async function signOut() {
await fetch("/api/auth/session", {
method: "DELETE",
});
await loadPrototype();
navigate("/");
}
if (error) {
return <AppStatusPage title="Backend data did not load." copy={error} />;
}
if (!data) {
return <AppStatusPage title="Loading content." />;
}
return ( return (
<main className="app-shell"> <LoadedApp
{error ? (
<section className="page-message">
<h1>Backend data did not load.</h1>
<p className="muted-copy">{error}</p>
</section>
) : data ? (
<AppContent
data={data} data={data}
pathname={pathname} pathname={pathname}
onOpenCourse={openCourse} onOpenCourse={openCourse}
onOpenLesson={openLesson} onOpenLesson={openLesson}
onOpenDiscussion={openDiscussion} onOpenDiscussion={openDiscussion}
onOpenRoute={navigate}
onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion}
onGoHome={goHome} onGoHome={goHome}
onGoCourses={goCourses}
onGoDiscussions={goDiscussions}
onGoActivity={goActivity}
onSignOut={signOut}
/> />
) : (
<section className="page-message">
<h1>Loading content.</h1>
</section>
)}
</main>
); );
} }

View file

@ -1,16 +1,29 @@
:root { :root {
color-scheme: dark; color-scheme: light;
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
--bg: #0d1117; --bg: linear-gradient(180deg, #fffaf0 0%, #f3ede1 100%);
--panel: #151b23; --panel: #fffdf8;
--panel-hover: #1a2230; --panel-hover: #f1eadf;
--border: #2b3442; --card: #ffffff;
--text: #edf2f7; --border: #ded5c7;
--muted: #9aa6b2; --text: #1f2933;
--accent: #84d7ff; --muted: #667085;
--accent: #0f6f8f;
--accent-strong: #0b5d78;
--accent-soft: rgba(15, 111, 143, 0.12);
--accent-border: rgba(15, 111, 143, 0.28);
--topbar-bg: rgba(255, 253, 248, 0.92);
--code-bg: #edf3f5;
--warning-bg: rgba(191, 125, 22, 0.12);
--warning-border: rgba(191, 125, 22, 0.32);
--warning-text: #7a4700;
--button-text: #ffffff;
--disabled-bg: #ece6dc;
--disabled-text: #8a8174;
--error: #a64234;
} }
* { * {
@ -52,6 +65,125 @@ textarea {
padding: 2rem 1rem 3rem; padding: 2rem 1rem 3rem;
} }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
border-bottom: 0.0625rem solid var(--accent-border);
padding: 0.75rem clamp(1rem, 4vw, 2rem);
background: var(--topbar-bg);
backdrop-filter: blur(1rem);
}
.topbar-menu {
display: flex;
flex: 1;
align-items: center;
gap: 1rem;
min-width: 0;
}
.topbar-brand,
.topbar-link,
.topbar-menu-button {
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
.topbar-brand {
border-radius: 999rem;
padding: 0.45rem 0.7rem;
color: var(--accent);
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.topbar-brand:hover,
.topbar-brand:focus-visible,
.topbar-brand.active {
color: var(--text);
background: var(--accent-soft);
outline: none;
}
.topbar-nav,
.topbar-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.topbar-nav {
flex: 1;
flex-wrap: wrap;
justify-content: flex-start;
}
.topbar-link {
border-radius: 999rem;
padding: 0.5rem 0.7rem;
color: var(--muted);
}
.topbar-link:hover,
.topbar-link:focus-visible,
.topbar-link.active {
color: var(--text);
background: var(--panel-hover);
outline: none;
}
.topbar-auth {
margin-left: auto;
justify-content: flex-end;
color: var(--muted);
font-size: 0.9rem;
}
.topbar-auth p {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-auth-muted {
display: none;
}
.topbar-menu-button {
display: none;
width: 2.5rem;
height: 2.5rem;
margin-left: auto;
align-items: center;
justify-content: center;
border-radius: 999rem;
flex-direction: column;
gap: 0.25rem;
}
.topbar-menu-button span {
width: 1.1rem;
height: 0.125rem;
border-radius: 999rem;
background: currentColor;
}
.topbar-menu-button:hover,
.topbar-menu-button:focus-visible {
color: var(--text);
background: var(--panel-hover);
outline: none;
}
.page-header, .page-header,
.page-section, .page-section,
.panel, .panel,
@ -123,13 +255,15 @@ textarea {
.card, .card,
.discussion-preview-card, .discussion-preview-card,
.activity-card,
.reply-card { .reply-card {
border: 0.0625rem solid var(--border); border: 0.0625rem solid var(--border);
border-radius: 0.75rem; border-radius: 0.75rem;
background: #111722; background: var(--card);
} }
.card, .card,
.activity-card,
.reply-card { .reply-card {
padding: 1rem; padding: 1rem;
} }
@ -148,11 +282,17 @@ textarea {
} }
.card h3, .card h3,
.discussion-preview-card h3 { .discussion-preview-card h3,
.activity-card h3 {
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
font-size: 1rem; font-size: 1rem;
} }
.activity-list {
display: grid;
gap: 0.75rem;
}
.discussion-preview-card { .discussion-preview-card {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -161,8 +301,17 @@ textarea {
color: inherit; color: inherit;
} }
.activity-card-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
}
.discussion-preview-card:hover, .discussion-preview-card:hover,
.discussion-preview-card:focus-visible { .discussion-preview-card:focus-visible,
.activity-card-button:hover,
.activity-card-button:focus-visible {
background: var(--panel-hover); background: var(--panel-hover);
outline: none; outline: none;
} }
@ -188,6 +337,7 @@ textarea {
.back-link, .back-link,
.secondary-link, .secondary-link,
.secondary-button,
.compose-button { .compose-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -205,6 +355,34 @@ textarea {
cursor: pointer; cursor: pointer;
} }
.auth-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
border: 0.0625rem solid var(--accent-border);
border-radius: 0.75rem;
padding: 0.75rem;
color: var(--muted);
background: var(--accent-soft);
}
.secondary-button {
flex: 0 0 auto;
color: var(--accent);
background: var(--panel);
cursor: pointer;
}
.secondary-button:hover,
.secondary-button:focus-visible,
.back-link:hover,
.back-link:focus-visible {
background: var(--panel-hover);
outline: none;
}
.thread-view { .thread-view {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@ -336,7 +514,7 @@ textarea {
.markdown-content code { .markdown-content code {
border-radius: 0.35rem; border-radius: 0.35rem;
padding: 0.12rem 0.35rem; padding: 0.12rem 0.35rem;
background: #0b1017; background: var(--code-bg);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.92em; font-size: 0.92em;
} }
@ -347,7 +525,7 @@ textarea {
border: 0.0625rem solid var(--border); border: 0.0625rem solid var(--border);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.85rem 0.95rem; padding: 0.85rem 0.95rem;
background: #0b1017; background: var(--code-bg);
} }
.markdown-content pre code { .markdown-content pre code {
@ -378,15 +556,57 @@ textarea {
gap: 0.85rem; gap: 0.85rem;
} }
.compose-input { .signin-page {
display: grid;
}
.signin-panel {
max-width: 42rem;
}
.signin-actions {
display: grid;
gap: 0.85rem;
justify-items: start;
}
.signin-callout {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 0.0625rem solid var(--warning-border);
border-radius: 0.75rem;
padding: 0.75rem;
color: var(--warning-text);
background: var(--warning-bg);
}
.form-label {
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
}
.compose-input,
.token-input {
width: 100%; width: 100%;
min-height: 9rem;
resize: vertical;
border: 0.0625rem solid var(--border); border: 0.0625rem solid var(--border);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.85rem 0.95rem; padding: 0.85rem 0.95rem;
color: var(--text); color: var(--text);
background: #0f141d; background: var(--card);
}
.compose-input {
min-height: 9rem;
resize: vertical;
}
.compose-input:focus,
.token-input:focus {
border-color: var(--accent);
outline: none;
} }
.compose-actions { .compose-actions {
@ -397,9 +617,35 @@ textarea {
} }
.compose-button { .compose-button {
color: #7a8696; border-color: var(--accent);
background: #101722; color: var(--button-text);
background: var(--accent);
cursor: pointer;
font-weight: 700;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.compose-button:hover:not(:disabled),
.compose-button:focus-visible:not(:disabled) {
border-color: var(--accent-strong);
background: var(--accent-strong);
outline: none;
}
.compose-button:disabled {
border-color: var(--border);
color: var(--disabled-text);
background: var(--disabled-bg);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.72;
}
.compose-error {
color: var(--error);
} }
.secondary-link { .secondary-link {
@ -407,12 +653,57 @@ textarea {
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.topbar {
display: grid;
grid-template-columns: auto auto;
gap: 0.75rem;
}
.topbar-menu-button {
display: inline-flex;
}
.topbar-menu {
display: none;
grid-column: 1 / -1;
width: 100%;
border-top: 0.0625rem solid var(--border);
padding-top: 0.75rem;
}
.topbar-menu.open {
display: grid;
gap: 0.75rem;
}
.topbar-nav,
.topbar-auth {
width: 100%;
justify-content: flex-start;
}
.topbar-nav {
align-items: stretch;
flex-direction: column;
}
.topbar-link {
width: 100%;
text-align: left;
}
.topbar-auth {
margin-left: 0;
}
.card-grid, .card-grid,
.two-column-grid { .two-column-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.compose-actions, .compose-actions,
.auth-bar,
.signin-callout,
.subsection-header { .subsection-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;

View file

@ -10,6 +10,14 @@ export interface SourceOfTruthCard {
description: string; description: string;
} }
export interface AuthState {
authenticated: boolean;
login: string | null;
source: string;
can_reply: boolean;
oauth_configured: boolean;
}
export interface CourseCard { export interface CourseCard {
title: string; title: string;
owner: string; owner: string;
@ -21,6 +29,7 @@ export interface CourseCard {
summary: string; summary: string;
status: string; status: string;
outline: CourseChapter[]; outline: CourseChapter[];
updated_at: string;
} }
export interface CourseChapter { export interface CourseChapter {
@ -44,6 +53,7 @@ export interface PostCard {
repo: string; repo: string;
kind: string; kind: string;
summary: string; summary: string;
updated_at: string;
} }
export interface EventCard { export interface EventCard {
@ -81,6 +91,7 @@ export interface DiscussionReply {
export interface PrototypeData { export interface PrototypeData {
hero: HeroData; hero: HeroData;
auth: AuthState;
source_of_truth: SourceOfTruthCard[]; source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[]; featured_courses: CourseCard[];
recent_posts: PostCard[]; recent_posts: PostCard[];

View file

@ -8,8 +8,16 @@ from forgejo_client import ForgejoClient, ForgejoClientError
from settings import Settings from settings import Settings
async def build_live_prototype_payload(settings: Settings) -> dict[str, object]: async def build_live_prototype_payload(
settings: Settings,
*,
forgejo_token: str | None = None,
auth_source: str = "none",
session_user: dict[str, Any] | None = None,
) -> dict[str, object]:
warnings: list[str] = [] warnings: list[str] = []
access_token = forgejo_token or settings.forgejo_token
has_user_token = bool(access_token) and auth_source in {"authorization", "session"}
source_cards = [ source_cards = [
{ {
"title": "Forgejo base URL", "title": "Forgejo base URL",
@ -17,11 +25,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
}, },
{ {
"title": "Access mode", "title": "Access mode",
"description": ( "description": _access_mode_description(access_token, auth_source),
"Server token configured for live API reads."
if settings.forgejo_token
else "Instance API requires auth. Set FORGEJO_TOKEN for live repo discovery."
),
}, },
] ]
@ -34,7 +38,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
}, },
) )
async with ForgejoClient(settings) as client: async with ForgejoClient(settings, forgejo_token=access_token) as client:
try: try:
oidc = await client.fetch_openid_configuration() oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error: except ForgejoClientError as error:
@ -49,32 +53,8 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
}, },
) )
if not settings.forgejo_token:
warnings.append(
"aksal.cloud blocks anonymous API calls, so the prototype needs FORGEJO_TOKEN "
"before it can load repos or issues.",
)
source_cards.append(
{
"title": "Discovery state",
"description": "Waiting for FORGEJO_TOKEN to enable live repo and issue reads.",
},
)
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
hero_summary=(
"Connected to aksal.cloud for identity and OIDC discovery, but live repo content "
"is gated until a Forgejo API token is configured on the backend."
),
)
try: try:
current_user, repos, issues = await asyncio.gather( repos = await client.search_repositories()
client.fetch_current_user(),
client.search_repositories(),
client.search_recent_issues(),
)
except ForgejoClientError as error: except ForgejoClientError as error:
warnings.append(str(error)) warnings.append(str(error))
source_cards.append( source_cards.append(
@ -86,23 +66,30 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
return _empty_payload( return _empty_payload(
source_cards=source_cards, source_cards=source_cards,
warnings=warnings, warnings=warnings,
auth=_auth_payload(
session_user, _display_auth_source(auth_source, session_user), settings
),
hero_summary=( hero_summary=(
"The backend reached aksal.cloud, but the configured token could not complete " "The backend reached aksal.cloud, but the configured token could not complete "
"the repo discovery flow." "the public repo discovery flow."
), ),
) )
current_user = await _current_user_for_auth_source(client, has_user_token, warnings)
public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
repo_summaries = await asyncio.gather( repo_summaries = await asyncio.gather(
*[ *[_summarize_repo(client, repo) for repo in public_repos],
_summarize_repo(client, repo)
for repo in repos
if not repo.get("fork") and not repo.get("private")
],
) )
content_repos = [summary for summary in repo_summaries if summary is not None] content_repos = [summary for summary in repo_summaries if summary is not None]
course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0] course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0]
post_repos = [summary for summary in content_repos if summary["blog_count"] > 0] post_repos = [summary for summary in content_repos if summary["blog_count"] > 0]
public_issues = await _recent_public_issues(
client,
public_repos,
settings.forgejo_recent_issue_limit,
)
if current_user is not None:
source_cards.append( source_cards.append(
{ {
"title": "Signed-in API identity", "title": "Signed-in API identity",
@ -114,10 +101,11 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"title": "Discovery state", "title": "Discovery state",
"description": ( "description": (
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, " f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
f"and {len(issues)} recent issues." f"and {len(public_issues)} recent public issues."
), ),
}, },
) )
auth_user = session_user or current_user
return { return {
"hero": { "hero": {
@ -125,7 +113,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"title": "Robot U is reading from your aksal.cloud Forgejo instance.", "title": "Robot U is reading from your aksal.cloud Forgejo instance.",
"summary": ( "summary": (
"This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, " "This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, "
"and recent issues available to the configured backend token." "and recent issues available to the active token."
), ),
"highlights": [ "highlights": [
"Repo discovery filters to public, non-fork repositories only", "Repo discovery filters to public, non-fork repositories only",
@ -133,27 +121,54 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"Recent discussions are loaded from live Forgejo issues", "Recent discussions are loaded from live Forgejo issues",
], ],
}, },
"auth": _auth_payload(
auth_user if has_user_token else session_user,
_display_auth_source(auth_source, session_user),
settings,
),
"source_of_truth": source_cards, "source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]], "featured_courses": [_course_card(summary) for summary in course_repos[:6]],
"recent_posts": [_post_card(summary) for summary in post_repos[:6]], "recent_posts": [_post_card(summary) for summary in post_repos[:6]],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit), "upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather( "recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in issues], *[_discussion_card(client, issue) for issue in public_issues],
), ),
"implementation_notes": [ "implementation_notes": [
"Live repo discovery is now driven by the Forgejo API instead of mock content.", "Live repo discovery is now driven by the Forgejo API instead of mock content.",
"Issues shown here are real Forgejo issues visible to the configured token.", "Issues shown here are loaded only from public Forgejo repositories.",
*warnings, *warnings,
], ],
} }
def _access_mode_description(access_token: str | None, auth_source: str) -> str:
if auth_source in {"authorization", "session"} and access_token:
return f"Authenticated through {auth_source} token."
if auth_source == "server" or access_token:
return "Reading public content through the server-side Forgejo token."
return "Reading public content anonymously."
async def _current_user_for_auth_source(
client: ForgejoClient,
has_user_token: bool,
warnings: list[str],
) -> dict[str, Any] | None:
if not has_user_token:
return None
try:
return await client.fetch_current_user()
except ForgejoClientError as error:
warnings.append(str(error))
return None
async def _summarize_repo( async def _summarize_repo(
client: ForgejoClient, client: ForgejoClient,
repo: dict[str, Any], repo: dict[str, Any],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
owner = repo.get("owner", {}) owner_login = _repo_owner_login(repo)
owner_login = owner.get("login")
repo_name = repo.get("name") repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str): if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None return None
@ -247,6 +262,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
"summary": summary["description"], "summary": summary["description"],
"status": "Live course repo", "status": "Live course repo",
"outline": summary["course_outline"], "outline": summary["course_outline"],
"updated_at": summary["updated_at"],
} }
@ -258,9 +274,65 @@ def _post_card(summary: dict[str, Any]) -> dict[str, object]:
"repo": summary["full_name"], "repo": summary["full_name"],
"kind": "Repo with /blogs/", "kind": "Repo with /blogs/",
"summary": f"{label}. {summary['description']}", "summary": f"{label}. {summary['description']}",
"updated_at": summary["updated_at"],
} }
async def _recent_public_issues(
client: ForgejoClient,
repos: list[dict[str, Any]],
limit: int,
) -> list[dict[str, Any]]:
issue_lists = await asyncio.gather(
*[_repo_issues(client, repo, limit) for repo in repos],
)
issues = [issue for issue_list in issue_lists for issue in issue_list]
return sorted(issues, key=lambda issue: str(issue.get("updated_at", "")), reverse=True)[:limit]
async def _repo_issues(
client: ForgejoClient,
repo: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
owner_login = _repo_owner_login(repo)
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return []
try:
issues = await client.list_repo_issues(owner_login, repo_name, limit=limit)
except ForgejoClientError:
return []
return [_with_repository(issue, repo, owner_login, repo_name) for issue in issues]
def _with_repository(
issue: dict[str, Any],
repo: dict[str, Any],
owner_login: str,
repo_name: str,
) -> dict[str, Any]:
issue_with_repo = dict(issue)
issue_with_repo["repository"] = {
"owner": owner_login,
"name": repo_name,
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
"private": False,
}
return issue_with_repo
def _repo_owner_login(repo: dict[str, Any]) -> str | None:
owner = repo.get("owner", {})
if isinstance(owner, dict) and isinstance(owner.get("login"), str):
return owner["login"]
if isinstance(owner, str):
return owner
return None
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]: def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
upcoming_events = sorted( upcoming_events = sorted(
[event for feed in calendar_feeds for event in feed.events], [event for feed in calendar_feeds for event in feed.events],
@ -341,10 +413,17 @@ def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
} }
def _display_auth_source(auth_source: str, session_user: dict[str, Any] | None) -> str:
if session_user:
return "session"
return auth_source
def _empty_payload( def _empty_payload(
*, *,
source_cards: list[dict[str, str]], source_cards: list[dict[str, str]],
warnings: list[str], warnings: list[str],
auth: dict[str, object],
hero_summary: str, hero_summary: str,
) -> dict[str, object]: ) -> dict[str, object]:
return { return {
@ -355,16 +434,43 @@ def _empty_payload(
"highlights": [ "highlights": [
"Forgejo remains the source of truth for content and discussions", "Forgejo remains the source of truth for content and discussions",
"The prototype now targets aksal.cloud by default", "The prototype now targets aksal.cloud by default",
"Live repo discovery unlocks as soon as a backend token is configured", "Public repo discovery works without signing in when Forgejo allows anonymous reads",
], ],
}, },
"auth": auth,
"source_of_truth": source_cards, "source_of_truth": source_cards,
"featured_courses": [], "featured_courses": [],
"recent_posts": [], "recent_posts": [],
"upcoming_events": [], "upcoming_events": [],
"recent_discussions": [], "recent_discussions": [],
"implementation_notes": warnings "implementation_notes": warnings
or ["Live repo discovery is ready, but no Forgejo token has been configured yet."], or ["Live repo discovery is ready, but Forgejo did not return public content."],
}
def _auth_payload(
user: dict[str, Any] | None,
source: str,
settings: Settings,
) -> dict[str, object]:
oauth_configured = bool(
settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret
)
if not user:
return {
"authenticated": False,
"login": None,
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
}
return {
"authenticated": True,
"login": user.get("login", "Unknown user"),
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
} }
@ -468,7 +574,9 @@ async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[
def _format_event_datetime(value: Any) -> str: def _format_event_datetime(value: Any) -> str:
return value.strftime("%b %-d, %-I:%M %p UTC") timezone_name = value.strftime("%Z") if hasattr(value, "strftime") else ""
suffix = f" {timezone_name}" if timezone_name else ""
return f"{value.strftime('%b %-d, %-I:%M %p')}{suffix}"
def _empty_lesson( def _empty_lesson(

View file

@ -5,6 +5,8 @@ status=0
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python_files=( python_files=(
"app.py" "app.py"
"auth.py"
"calendar_feeds.py"
"forgejo_client.py" "forgejo_client.py"
"live_prototype.py" "live_prototype.py"
"settings.py" "settings.py"
@ -43,7 +45,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \ uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \ deptry . \
--requirements-files requirements.txt \ --requirements-files requirements.txt \
--known-first-party app,forgejo_client,live_prototype,settings \ --known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,settings \
--per-rule-ignores DEP002=uvicorn \ --per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \ --extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \ --extend-exclude ".*/\\.venv/.*" \
@ -51,7 +53,7 @@ run_check \
run_check \ run_check \
"Vulture" \ "Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \ uv run --with "vulture>=2.15,<3.0.0" \
vulture app.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80 vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
run_check \ run_check \
"Backend Tests" \ "Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py" "${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"

View file

@ -7,8 +7,12 @@ from functools import lru_cache
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
app_base_url: str | None
forgejo_base_url: str forgejo_base_url: str
forgejo_token: str | None forgejo_token: str | None
forgejo_oauth_client_id: str | None
forgejo_oauth_client_secret: str | None
forgejo_oauth_scopes: tuple[str, ...]
forgejo_repo_scan_limit: int forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int forgejo_recent_issue_limit: int
forgejo_request_timeout_seconds: float forgejo_request_timeout_seconds: float
@ -33,16 +37,29 @@ def _parse_calendar_feed_urls(raw_value: str | None) -> tuple[str, ...]:
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip()) return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
def _parse_scopes(raw_value: str | None) -> tuple[str, ...]:
value = (raw_value or "").strip()
if not value:
return ("openid", "profile")
return tuple(scope for scope in value.replace(",", " ").split() if scope)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings( return Settings(
app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
if os.getenv("APP_BASE_URL")
else None,
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")), forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
forgejo_token=os.getenv("FORGEJO_TOKEN") or None, forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
forgejo_oauth_client_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")),
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")), forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")),
forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")), forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")),
forgejo_request_timeout_seconds=float( forgejo_request_timeout_seconds=float(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"), os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
), ),
calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")), calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")),
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "6")), calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "3")),
) )

View file

@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import os
import json
import unittest import unittest
from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from fastapi.responses import JSONResponse from fastapi.testclient import TestClient
from app import create_app from app import create_app
from settings import get_settings from settings import get_settings
@ -13,42 +13,400 @@ from settings import get_settings
class AppTestCase(unittest.TestCase): class AppTestCase(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.env_patcher = patch.dict(
os.environ,
{
"APP_BASE_URL": "http://testserver",
"FORGEJO_TOKEN": "",
"FORGEJO_OAUTH_CLIENT_ID": "client-id",
"FORGEJO_OAUTH_CLIENT_SECRET": "client-secret",
},
)
self.env_patcher.start()
get_settings.cache_clear() get_settings.cache_clear()
self.app = create_app() self.app = create_app()
self.client = TestClient(self.app)
def tearDown(self) -> None: def tearDown(self) -> None:
get_settings.cache_clear() get_settings.cache_clear()
self.env_patcher.stop()
def _get_route_response(self, path: str) -> JSONResponse:
route = next(route for route in self.app.routes if getattr(route, "path", None) == path)
return asyncio.run(route.endpoint())
def test_health(self) -> None: def test_health(self) -> None:
response = self._get_route_response("/health") response = self.client.get("/health")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.body), {"status": "ok"}) self.assertEqual(response.json(), {"status": "ok"})
def test_prototype_payload_shape(self) -> None: def test_prototype_payload_shape(self) -> None:
payload = { payload = {
"hero": {"title": "Robot U"}, "hero": {"title": "Robot U"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [], "featured_courses": [],
"recent_posts": [], "recent_posts": [],
"recent_discussions": [], "recent_discussions": [],
"upcoming_events": [], "upcoming_events": [],
"source_of_truth": [], "source_of_truth": [],
} }
with patch("app.build_live_prototype_payload", new=AsyncMock(return_value=payload)): builder = AsyncMock(return_value=payload)
response = self._get_route_response("/api/prototype") with patch("app.build_live_prototype_payload", new=builder):
payload = json.loads(response.body) response = self.client.get("/api/prototype")
response_payload = response.json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("hero", payload) self.assertIn("hero", response_payload)
self.assertIn("featured_courses", payload) self.assertIn("auth", response_payload)
self.assertIn("recent_posts", payload) self.assertIn("featured_courses", response_payload)
self.assertIn("recent_discussions", payload) self.assertIn("recent_posts", response_payload)
self.assertIn("upcoming_events", payload) self.assertIn("recent_discussions", response_payload)
self.assertIn("source_of_truth", payload) self.assertIn("upcoming_events", response_payload)
self.assertIn("source_of_truth", response_payload)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
def test_prototype_accepts_authorization_token(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "authorization",
"can_reply": True,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
response = self.client.get(
"/api/prototype",
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": False,
"login": None,
"source": "server",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
get_settings.cache_clear()
with (
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
patch("app.build_live_prototype_payload", new=builder),
):
response = self.client.get("/api/prototype")
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "server-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "server")
def test_auth_session_ignores_server_token_fallback(self) -> None:
get_settings.cache_clear()
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
response = self.client.get("/api/auth/session")
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(payload["authenticated"], False)
self.assertEqual(payload["login"], None)
self.assertEqual(payload["source"], "none")
self.assertEqual(payload["can_reply"], False)
def test_forgejo_auth_start_redirects_to_authorization_endpoint(self) -> None:
fake_client = _FakeForgejoClient()
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.get(
"/api/auth/forgejo/start?return_to=/discussions/7",
follow_redirects=False,
)
location = response.headers["location"]
query = parse_qs(urlparse(location).query)
self.assertEqual(response.status_code, 303)
self.assertTrue(location.startswith("https://aksal.cloud/login/oauth/authorize?"))
self.assertEqual(query["client_id"], ["client-id"])
self.assertEqual(query["redirect_uri"], ["http://testserver/api/auth/forgejo/callback"])
self.assertEqual(query["response_type"], ["code"])
self.assertEqual(query["scope"], ["openid profile"])
self.assertEqual(query["code_challenge_method"], ["S256"])
self.assertIn("state", query)
self.assertIn("code_challenge", query)
def test_forgejo_auth_callback_sets_session_cookie(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=fake_client):
start_response = self.client.get(
"/api/auth/forgejo/start?return_to=/discussions/7",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
callback_response = self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
self.assertEqual(callback_response.status_code, 303)
self.assertEqual(callback_response.headers["location"], "/discussions/7")
self.assertIn("robot_u_session", callback_response.cookies)
self.assertEqual(fake_client.exchanged_code, "auth-code")
session_response = self.client.get("/api/auth/session")
session_payload = session_response.json()
self.assertEqual(session_response.status_code, 200)
self.assertEqual(session_payload["authenticated"], True)
self.assertEqual(session_payload["login"], "kacper")
self.assertEqual(session_payload["source"], "session")
self.assertEqual(session_payload["can_reply"], True)
def test_prototype_uses_signed_in_forgejo_identity_token(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=fake_client):
start_response = self.client.get(
"/api/auth/forgejo/start",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "session",
"can_reply": True,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
def test_create_discussion_reply(self) -> None:
fake_client = _FakeForgejoClient(
comment={
"id": 123,
"body": "Thanks, this helped.",
"created_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
"user": {
"login": "Kacper",
"avatar_url": "https://aksal.cloud/avatar.png",
},
},
)
with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(
fake_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
)
self.assertEqual(payload["id"], 123)
self.assertEqual(payload["author"], "Kacper")
self.assertEqual(payload["body"], "Thanks, this helped.")
def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client):
start_response = self.client.get(
"/api/auth/forgejo/start",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
reply_client = _FakeForgejoClient(
comment={
"id": 123,
"body": "Thanks, this helped.",
"created_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
"user": {"login": "Kacper", "avatar_url": ""},
},
)
with patch("app.ForgejoClient", return_value=reply_client) as client_factory:
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
)
def test_create_discussion_reply_rejects_private_repo(self) -> None:
fake_client = _FakeForgejoClient(repo_private=True)
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "PrivateRobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(fake_client.created_comment)
def test_create_discussion_reply_rejects_server_token_fallback(self) -> None:
get_settings.cache_clear()
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
)
self.assertEqual(response.status_code, 401)
class _FakeForgejoClient:
def __init__(
self,
comment: dict[str, object] | None = None,
user: dict[str, object] | None = None,
access_token: str = "test-oauth-token",
repo_private: bool = False,
) -> None:
self._comment = comment
self._user = user or {"login": "test-user"}
self._access_token = access_token
self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
return None
async def create_issue_comment(
self,
owner: str,
repo: str,
issue_number: int,
body: str,
) -> dict[str, object]:
self.created_comment = (owner, repo, issue_number, body)
if self._comment is None:
raise AssertionError("Fake comment was not configured.")
return self._comment
async def fetch_current_user(self) -> dict[str, object]:
return self._user
async def fetch_repository(self, owner: str, repo: str) -> dict[str, object]:
return {
"owner": {"login": owner},
"name": repo,
"full_name": f"{owner}/{repo}",
"private": self._repo_private,
}
async def fetch_openid_configuration(self) -> dict[str, object]:
return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",
"token_endpoint": "https://aksal.cloud/login/oauth/access_token",
"userinfo_endpoint": "https://aksal.cloud/login/oauth/userinfo",
}
async def exchange_oauth_code(
self,
*,
token_endpoint: str,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
code_verifier: str,
) -> dict[str, object]:
self.exchanged_code = code
self.exchanged_token_request = {
"token_endpoint": token_endpoint,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
}
return {"access_token": self._access_token}
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, object]:
self.fetched_userinfo = {
"userinfo_endpoint": userinfo_endpoint,
"access_token": access_token,
}
return {
"preferred_username": self._user["login"],
"picture": self._user.get("avatar_url", ""),
"email": self._user.get("email", ""),
}
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -0,0 +1,42 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from calendar_feeds import _parse_ics
class CalendarFeedTestCase(unittest.TestCase):
def test_weekly_recurrence_returns_future_occurrences(self) -> None:
timezone = ZoneInfo("America/New_York")
starts_at = datetime.now(timezone).replace(
hour=13,
minute=0,
second=0,
microsecond=0,
) - timedelta(days=7)
ends_at = starts_at.replace(hour=16)
raw_calendar = f"""BEGIN:VCALENDAR
X-WR-CALNAME:Robot-U
BEGIN:VEVENT
SUMMARY:Robotics Club
DTSTART;TZID=America/New_York:{starts_at.strftime("%Y%m%dT%H%M%S")}
DTEND;TZID=America/New_York:{ends_at.strftime("%Y%m%dT%H%M%S")}
RRULE:FREQ=WEEKLY
LOCATION:44 Portland Street
END:VEVENT
END:VCALENDAR
"""
_calendar_name, events = _parse_ics(raw_calendar, "https://example.com/calendar.ics")
self.assertGreaterEqual(len(events), 1)
self.assertEqual(events[0].title, "Robotics Club")
self.assertGreaterEqual(events[0].starts_at, datetime.now(ZoneInfo("UTC")))
self.assertEqual(events[0].starts_at.tzinfo, timezone)
self.assertEqual(events[0].mode, "44 Portland Street")
if __name__ == "__main__":
unittest.main()