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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ RUNGU_SECURE_COOKIE=false
# Set to * to allow all (dev only — NOT safe for production).
RUNGU_CORS_ORIGINS=

# Rate limiting — max requests per minute per IP. 0 disables a limiter.
# API is broad (board browsing); auth is strict (OAuth login/callback abuse).
# Defaults: API 300/min, auth 30/min.
# RUNGU_RATE_LIMIT_PER_MIN=300
# RUNGU_AUTH_RATE_LIMIT_PER_MIN=30

# Trust X-Forwarded-For for rate-limit client IPs. Enable ONLY behind a
# reverse proxy that overwrites the header (nginx/Caddy/Cloudflare).
# Default: false (use the socket address).
# RUNGU_TRUST_PROXY=false

# Admin emails — comma-separated. Users with these emails get admin role on login.
# Example: ADMIN_EMAILS=admin@example.com,root@example.com
ADMIN_EMAILS=
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

## [Unreleased]

### Added

- **Rate limiting** (in-memory, per-IP fixed window): two independent
limiters — strict on `/auth/*` (default 30/min) and broader on `/api/*`
(default 300/min). Configurable via `RUNGU_AUTH_RATE_LIMIT_PER_MIN` /
`RUNGU_RATE_LIMIT_PER_MIN`; `0` disables. Previously deferred (#52) due
to an MSRV-blocked `governor` dependency — reimplemented without external
crates. Closes #43.

### Changed

- **`list_posts` now populates `user_voted`** for the requesting user via a
single batched lookup. Previously the board list always reported
`user_voted: false`, so it could not show which posts the current user had
voted on (only `GET /posts/{id}` set it). Replaces the latent N+1 with one
indexed query over the `votes` primary key.
- **PostgreSQL full-text search**: the Postgres backend now uses a generated
`tsvector` column + GIN index (`search_tsv @@ plainto_tsquery(?)`) instead
of an unindexed `LOWER(...) LIKE` full scan. SQLite still uses FTS5.
- **`get_project_by_slug` is cached** (5-minute TTL, in-memory `moka`) — it
was hit on nearly every board/vote/comment request. Create/update/delete
invalidate the cache.

## [0.2.1] - 2026-06-27

### Added
Expand Down
84 changes: 84 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ clap = { version = "4", features = ["derive"] }
# Time
chrono = { version = "0.4", features = ["serde"] }

# In-memory cache (project-by-slug lookup cache)
moka = { version = "0.12", features = ["future"] }

# UUID
uuid = { version = "1", features = ["v4", "serde"] }

Expand Down
4 changes: 4 additions & 0 deletions crates/rungu-api/src/post_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub async fn list_posts(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(query): Query<ListPostsQuery>,
user: rungu_auth::OptionalCurrentUser,
) -> Result<impl IntoResponse, ApiError> {
let project =
state.store.get_project_by_slug(&slug).await?.ok_or_else(|| ApiError::not_found("Project not found"))?;
Expand Down Expand Up @@ -86,6 +87,7 @@ pub async fn list_posts(
category,
query: query.q.as_deref(),
since: None,
user_id: user.user.as_ref().map(|cu| cu.id.as_str()),
offset,
limit: per_page,
};
Expand Down Expand Up @@ -336,6 +338,7 @@ async fn fetch_bucket(
category: None,
query: None,
since: None,
user_id: None,
offset: 0,
limit,
};
Expand Down Expand Up @@ -407,6 +410,7 @@ pub async fn get_project_changelog(
category: None,
query: None,
since,
user_id: None,
offset,
limit: per_page,
};
Expand Down
1 change: 1 addition & 0 deletions crates/rungu-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ tracing = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
async-trait = "0.1"
moka = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
Expand Down
12 changes: 12 additions & 0 deletions crates/rungu-core/migrations/postgres/001_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ CREATE TABLE IF NOT EXISTS votes (
PRIMARY KEY (user_id, post_id)
);

-- Full-text search: generated tsvector + GIN index.
-- SQLite uses the FTS5 virtual table; on PostgreSQL we derive a `tsvector`
-- from title+description and index it with GIN so `@@ plainto_tsquery(?)`
-- is an indexed scan instead of a full-table `LIKE`.
-- Added via ALTER ... ADD COLUMN IF NOT EXISTS so the migration stays
-- idempotent (the whole file is re-run on every startup).
ALTER TABLE posts ADD COLUMN IF NOT EXISTS search_tsv tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX IF NOT EXISTS idx_posts_search_tsv ON posts USING GIN (search_tsv);

-- Comments (threaded)
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
Expand Down
Loading
Loading