Skip to content

Repository files navigation

Page Six

A better social link-sharing site.

Status

A working, test-covered Reddit clone: browsing with all the sorts + time windows, FTS5 search, voting, link/self posts, threaded comments, edit/delete, subscriptions, saved/hidden posts, profiles + karma, reply notifications, RSS in/out, bcrypt + CSRF auth, self-service account deletion, a forum-generalization layer (RBAC, an Admin Control Panel, reputation, an approval queue, tags, @mentions, OAuth), a Reddit-flavoured JSON API, and ops endpoints (/health + Prometheus /metrics).

Where things are written down:

  • TODO.md — what's next and what's known-incomplete. Not a log.
  • CHANGELOG.md — what has shipped, newest first.
  • docs/sqlite-features.md — the schema and query decisions, including the declined ones. Read before changing the schema: it records the rules that are easy to get wrong here, like rebuilding a table that other tables reference.

API

A Reddit-flavoured JSON API lives under /api. Responses are Reddit "Thing" envelopes — { "kind": "t3", "data": { … } } for a link (t1 comment, t2 account, t5 subreddit) — and { "kind": "Listing", "data": { "children": […], "after": …, "before": … } } for a page. Each Thing carries Reddit's base36 id / t?_<id> fullname plus an opaque, stable uuid — a public_id stamped on every row at insert (utils/public_id), using sqlean's uuid4() where the extension is loaded and openssl.rand otherwise.

  • Reads (public): GET /api/listing(/:sort), /api/r/:sub(/:sort), /api/r/:sub/about, /api/comments/:id (link + nested comment tree), /api/info?id=t3_…,t1_…, /api/search?q=, /api/subreddits(/:where), /api/subreddits/search?q=, /api/user/:username/about, /api/username_available?user=. Sorts (hot/new/top/best/ controversial/rising), ?t= time windows, and ?after/?before/?limit cursor pagination are supported. new pages by keyset and has no depth limit; the ranked sorts address the first 1000 items, since their rank moves with live vote counts (see TODO.md).
  • Account (logged in): GET /api/v1/me, /api/v1/me/karma, /api/me/saved.
  • Writes (logged in): POST /api/vote {id, dir}, /api/save, /api/unsave, /api/hide, /api/unhide, /api/subscribe, /api/submit, /api/comment, /api/del, /api/editusertext.

Writes authenticate with the browser session and the same CSRF token as the web forms (sent as the csrf_token field or an X-Csrf-Token header); OAuth bearer-token auth is a future addition.

Operations & observability

  • GET /health — JSON liveness/readiness probe: { "status": "ok", "db": "ok", … } with 200, or 503 if a trivial DB query fails.
  • GET /metrics — Prometheus text exposition (v0.0.4). Content gauges (pagesix_users, pagesix_posts, pagesix_comments, pagesix_votes, pagesix_subreddits, pagesix_posts_pending, …) plus pagesix_http_requests_total{status="2xx"} counters accumulated in a cross-worker metrics shared dict.
  • Dashboards with graphs — the Admin panel has a /admin/stats page (site-wide activity over 30 days + top subreddits) and each community's moderators get /r/:sub/stats (per-sub activity + top contributors). Charts are server-rendered inline SVG (no client JS), drawn from the v_daily_activity view and utils/stats.

See docs/sqlite-features.md for where SQLite triggers/views (and why not stored procedures) back this logic.

Configuration

Set through the environment (see app/config.lua):

Variable Required Purpose
LAPIS_ENV yes development, test, or production.
SESSION_SECRET in production Signs session cookies and CSRF tokens. LAPIS_SECRET is accepted as the older name for the same thing. Generate with openssl rand -hex 32.
ADMIN_USERNAMES no Comma-separated usernames allowed into /admin on first visit (one-time bootstrap; afterwards admins grant the role from /admin/users).
GITHUB_CLIENT_ID / _SECRET no Enables GitHub OAuth login when set.
GOOGLE_CLIENT_ID / _SECRET no Enables Google OAuth login when set.
SQLITE_EXTENSIONS no Colon-separated .so paths, overriding the bundled sqlean. Empty disables extension loading.
PAGESIX_SEED_DIR no Directory to read seed data (e.g. initial_subs.json) from.

Production refuses to boot without a session secret rather than starting and failing on the first signed cookie. Development falls back to a fixed insecure value, so no setup is needed to run locally.

Development

From the root directory:

Build:

docker build . -t pagesix

Run:

docker run \
    -dti \
    -v "./data:/var/data" \
    -v "./app:/var/www" \
    -e LAPIS_ENV="development" \
    -p 8080:80 \
    --name pagesix \
    --platform=linux/amd64 \
    -d pagesix

The entrypoint runs lapis server only, so create and seed the database from inside the container:

# Same two exports the entrypoint does -- `docker exec` bypasses it, and
# without them lapis can't find its rocks.
docker exec -w /var/www pagesix bash -lc \
  'eval "$(luarocks --lua-version=5.1 path)";
   export LUA_PATH="$LUA_PATH;/usr/local/openresty/lualib/?.lua";
   lapis migrate'

(wait patiently) then, visit: http://localhost:8080/

Testing & linting

There are two tiers. The project targets Lua 5.1 (prod runs OpenResty/LuaJIT); do not use a Homebrew system lua (now 5.5) for it — a 5.5 upgrade breaks busted/luacheck, and luacheck can't even parse under 5.5.

Fast inner loop (native, no Docker) — lint + the pure-Lua unit specs, in a self-contained Lua 5.1 toolchain under .lua/ (gitignored, never touches the system Lua):

./scripts/dev-setup.sh            # one-time: builds .lua/ via hererocks
source .lua/bin/activate
luacheck app                      # the exact CI lint step (0 warnings / 0 errors)
busted app/spec/sort_spec.lua     # pure-Lua specs (no lapis/DB needed)

Full suite (lapis + OpenResty + SQLite) — the model/SQL and HTTP integration specs need the OpenResty runtime (ngx, resty.*, LuaJIT FFI), so they run inside the Docker image, the same way CI does:

docker build -t pagesix-test .
# Mount the repo at /src and run busted from there (it needs the root .busted
# config + spec/). Set the LuaRocks paths the way the entrypoint does so the
# workers find lapis/lsqlite3/etc.
docker run --rm -v "$PWD:/src" -w /src --entrypoint bash pagesix-test -lc \
  'eval "$(luarocks --lua-version=5.1 path)"; export LUA_PATH="$LUA_PATH;/usr/local/openresty/lualib/?.lua"; busted -o utfTerminal'

(The mount needs the repo dir to be in Docker Desktop's File Sharing list.)

CI runs two workflows:

  • spec.yml — a stylua --check app formatting job; luacheck app then busted --coverage across the 5.1 / luajit / luajit-openresty matrix, with a 80% coverage gate; and a Docker build that runs the whole suite inside the production image.
  • lint.ymlactionlint, shellcheck, and hadolint for the workflows, shell scripts, and Dockerfile.

Note the Docker job builds a fresh database each run, so it proves the migrations work on a new install. It does not exercise the upgrade path over an existing database; the table rebuilds in [114][116] copy live data and are covered by specs that re-run them against seeded rows.

Notes

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

6 watching

Forks

Releases

Packages

Used by

Contributors

Languages