feat: add author-username search to feed/gists/projects listings and partial-index migration

Extend `database.text_search_clause` with an `author_field` parameter that resolves username matches to user UIDs, enabling author-username search across the three public listings (`/feed`, `/gists`, `/projects`). Update the corresponding API docs and README route descriptions to reflect the new search scope. Add six partial indexes (`idx_comments_target_live`, `idx_votes_target_live`, `idx_reactions_target_live`, `idx_gists_live_created`, `idx_projects_live_created`, `idx_attachments_user_created_live`) to optimize filtered queries on non-deleted rows. Introduce a hot-settings cache (`_hot_settings`) with a 2-second TTL for `maintenance_mode`, `rate_limit_per_minute`, and `rate_limit_window_seconds`, plus a periodic rate-limit store sweep (`_sweep_rate_limit_store`) to evict stale IP entries every 60 seconds. Add `GZipMiddleware` to the FastAPI app for response compression.
This commit is contained in:
2026-06-19 22:24:51 +00:00
parent d10f1af118
commit e393722600
22 changed files with 269 additions and 52 deletions
+103 -14
View File
@@ -1276,6 +1276,49 @@ def init_db():
for table in SOFT_DELETE_TABLES:
ensure_soft_delete_columns(table)
_index(
db,
"comments",
"idx_comments_target_live",
["target_type", "target_uid"],
where="deleted_at IS NULL",
)
_index(
db,
"votes",
"idx_votes_target_live",
["target_uid", "value"],
where="deleted_at IS NULL",
)
_index(
db,
"reactions",
"idx_reactions_target_live",
["target_type", "target_uid"],
where="deleted_at IS NULL",
)
_index(
db,
"gists",
"idx_gists_live_created",
["created_at"],
where="deleted_at IS NULL",
)
_index(
db,
"projects",
"idx_projects_live_created",
["created_at"],
where="deleted_at IS NULL",
)
_index(
db,
"attachments",
"idx_attachments_user_created_live",
["user_uid", "created_at"],
where="deleted_at IS NULL",
)
_index(db, "seo_metadata", "idx_seo_metadata_status", ["status", "deleted_at"])
if "news" in tables:
@@ -1754,15 +1797,32 @@ def search_users_by_username(q, *, exclude_uid=None, limit=10):
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
rows = db.query(
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
return {r["target_uid"]: r["c"] for r in rows}
result = {}
misses = []
for uid in post_uids:
cached = _comment_count_cache.get(uid)
if cached is None:
misses.append(uid)
else:
result[uid] = cached
if misses:
placeholders, params = _in_clause(misses)
rows = db.query(
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
fetched = {r["target_uid"]: r["c"] for r in rows}
for uid in misses:
count = fetched.get(uid, 0)
_comment_count_cache.set(uid, count)
result[uid] = count
return result
def get_post_counts_by_user_uids(user_uids):
@@ -2909,7 +2969,7 @@ VOTABLE_TARGETS: dict[str, str] = {
STAR_TARGETS: set[str] = {"post", "project", "gist"}
_authors_cache = TTLCache(ttl=60, max_size=200)
_authors_cache = TTLCache(ttl=300, max_size=200)
def _ranked_authors() -> list:
@@ -2944,9 +3004,21 @@ def _ranked_authors() -> list:
author["stars"] = total
authors.append(author)
_authors_cache.set("ranked", authors)
_authors_cache.set(
"rank_map",
{author["uid"]: position for position, author in enumerate(authors, start=1)},
)
return authors
def _rank_map() -> dict:
cached = _authors_cache.get("rank_map")
if cached is not None:
return cached
_ranked_authors()
return _authors_cache.get("rank_map") or {}
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
@@ -2962,10 +3034,7 @@ def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
def get_user_rank(user_uid: str):
for position, author in enumerate(_ranked_authors(), start=1):
if author["uid"] == user_uid:
return position
return None
return _rank_map().get(user_uid)
def get_user_stars(user_uid: str) -> int:
@@ -3150,12 +3219,30 @@ def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
PAGE_SIZE = 25
def text_search_clause(table, search, fields=("title", "description")):
def get_uids_by_username_match(search, limit=200):
term = (search or "").strip()
if not term or "users" not in db.tables:
return []
rows = db.query(
"SELECT uid FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{term}%",
limit=limit,
)
return [row["uid"] for row in rows]
def text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
if not search or not search.strip() or not table.exists:
return None
columns = table.table.columns
like = f"%{search.strip()}%"
matches = [columns[field].ilike(like) for field in fields if field in columns]
if author_field and author_field in columns:
author_uids = get_uids_by_username_match(search)
if author_uids:
matches.append(columns[author_field].in_(author_uids))
return or_(*matches) if matches else None
@@ -3293,13 +3380,15 @@ def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
limit=pagination["per_page"],
offset=offset,
)
usernames = {u["uid"]: u["username"] for u in get_table("users").find()}
rows = list(rows)
uploaders = get_users_by_uids([row.get("user_uid") for row in rows])
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
item["deleted_at"] = row.get("deleted_at", "")
item["uploader"] = usernames.get(row.get("user_uid"), "unknown")
uploader = uploaders.get(row.get("user_uid"))
item["uploader"] = uploader["username"] if uploader else "unknown"
items.append(item)
return items, pagination
+3 -3
View File
@@ -727,7 +727,7 @@ four ways to sign requests.
"string",
False,
"",
"Search post title and content.",
"Search post title, content, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
@@ -1011,7 +1011,7 @@ four ways to sign requests.
"string",
False,
"",
"Search title and description.",
"Search project title, description, and author username.",
),
field(
"project_type",
@@ -1431,7 +1431,7 @@ four ways to sign requests.
"string",
False,
"",
"Search gist title and description.",
"Search gist title, description, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
+50 -8
View File
@@ -12,6 +12,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware
from devplacepy.config import (
STATIC_DIR,
STATIC_VERSION,
@@ -119,6 +120,39 @@ RATE_WINDOW = 60
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
HOT_SETTINGS_TTL = 2.0
_hot_settings_value: dict = {}
_hot_settings_at = 0.0
_last_rate_sweep = 0.0
RATE_SWEEP_INTERVAL = 60.0
def _hot_settings() -> dict:
global _hot_settings_value, _hot_settings_at
now = time.monotonic()
if not _hot_settings_value or now - _hot_settings_at >= HOT_SETTINGS_TTL:
_hot_settings_value = {
"maintenance_mode": get_setting("maintenance_mode", "0"),
"rate_limit_per_minute": max(
1, get_int_setting("rate_limit_per_minute", RATE_LIMIT)
),
"rate_limit_window_seconds": max(
1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW)
),
}
_hot_settings_at = now
return _hot_settings_value
def _sweep_rate_limit_store(window_start: float) -> None:
stale = [
ip
for ip, timestamps in _rate_limit_store.items()
if not timestamps or timestamps[-1] <= window_start
]
for ip in stale:
del _rate_limit_store[ip]
def _worker_rate_limit(limit: int) -> int:
return max(1, -(-limit // WEB_WORKERS))
@@ -445,17 +479,21 @@ async def rate_limit_middleware(request: Request, call_next):
"DELETE",
"PATCH",
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
limit = _worker_rate_limit(
max(1, get_int_setting("rate_limit_per_minute", RATE_LIMIT))
)
window = max(1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW))
settings = _hot_settings()
limit = _worker_rate_limit(settings["rate_limit_per_minute"])
window = settings["rate_limit_window_seconds"]
ip = request.headers.get("X-Real-IP") or (
request.client.host if request.client else "unknown"
)
now = time.time()
window_start = now - window
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
if len(_rate_limit_store[ip]) >= limit:
global _last_rate_sweep
if now - _last_rate_sweep >= RATE_SWEEP_INTERVAL:
_sweep_rate_limit_store(window_start)
_last_rate_sweep = now
timestamps = [t for t in _rate_limit_store.get(ip, ()) if t > window_start]
if len(timestamps) >= limit:
_rate_limit_store[ip] = timestamps
audit.record(
request,
"security.rate_limit.block",
@@ -473,7 +511,8 @@ async def rate_limit_middleware(request: Request, call_next):
status_code=429,
headers=retry_after,
)
_rate_limit_store[ip].append(now)
timestamps.append(now)
_rate_limit_store[ip] = timestamps
return await call_next(request)
@@ -482,7 +521,7 @@ _MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/open
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if get_setting("maintenance_mode", "0") != "1":
if _hot_settings()["maintenance_mode"] != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
@@ -522,6 +561,9 @@ async def response_timing(request: Request, call_next):
return response
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=6)
_home_cache = TTLCache(ttl=60, max_size=4)
+3 -1
View File
@@ -33,7 +33,9 @@ def get_feed_posts(
):
posts_table = get_table("posts")
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
search_match = text_search_clause(posts_table, search, ("title", "content"))
search_match = text_search_clause(
posts_table, search, ("title", "content"), author_field="user_uid"
)
search_clauses = [search_match] if search_match is not None else []
if tab == "following":
+3 -1
View File
@@ -77,7 +77,9 @@ def get_gists_list(user_uid=None, language=None, search="", before=None, viewer=
if language:
filters["language"] = language
search_match = text_search_clause(gists_table, search, ("title", "description"))
search_match = text_search_clause(
gists_table, search, ("title", "description"), author_field="user_uid"
)
clauses = [search_match] if search_match is not None else []
total = gists_table.count(*clauses, deleted_at=None, **filters)
+3 -1
View File
@@ -79,7 +79,9 @@ def get_projects_list(
if projects.exists:
columns = projects.table.columns
clauses.append(columns.deleted_at.is_(None))
search_match = text_search_clause(projects, search, ("title", "description"))
search_match = text_search_clause(
projects, search, ("title", "description"), author_field="user_uid"
)
if search_match is not None:
clauses.append(search_match)
if "is_private" in columns:
+2 -2
View File
@@ -401,11 +401,11 @@ class BaseService(ABC):
def _metrics(self, state: dict) -> dict:
raw = state.get("metrics")
if not raw:
return {}
return self._safe_metrics()
try:
return json.loads(raw)
except (ValueError, TypeError):
return {}
return self._safe_metrics()
def describe(self) -> dict:
state = self._read_state()
+3 -3
View File
@@ -112,7 +112,7 @@ ACTIONS: tuple[Action, ...] = (
params=(
query("tab", "Feed tab to view."),
query("topic", "Filter by topic."),
query("search", "Search post title and content."),
query("search", "Search post title, content, and author username."),
query("before", "Pagination cursor."),
),
),
@@ -222,7 +222,7 @@ ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
query("tab", "Projects tab."),
query("search", "Search query."),
query("search", "Search project title, description, and author username."),
query("user_uid", "Filter by owner uid."),
query("project_type", "Filter by project type."),
query("before", "Pagination cursor."),
@@ -1107,7 +1107,7 @@ ACTIONS: tuple[Action, ...] = (
params=(
query("language", "Filter by language."),
query("user_uid", "Filter by owner uid."),
query("search", "Search gist title and description."),
query("search", "Search gist title, description, and author username."),
query("before", "Pagination cursor."),
),
),
+3 -1
View File
@@ -59,7 +59,9 @@ def list_rants(sort: str, limit: int, skip: int, viewer: Optional[dict]) -> list
def search_rants(term: str, viewer: Optional[dict], limit: int = 50) -> list:
posts_table = get_table("posts")
clause = text_search_clause(posts_table, term, ("title", "content"))
clause = text_search_clause(
posts_table, term, ("title", "content"), author_field="user_uid"
)
if clause is None:
return []
posts = list(
@@ -23,7 +23,6 @@ LINKER_MAX_TOKENS = 600
MAX_CONTEXT_CHARS = 11000
SCORE_MAX = 100
CONFIDENCE_BASELINE = 0.35
DIVERSITY_PAGES_PER_DOMAIN = 2.0
@dataclass
@@ -50,7 +49,7 @@ def source_diversity(pages: list) -> float:
domains.discard("")
if not domains:
return 0.0
ratio = len(domains) / max(1.0, len(pages) / DIVERSITY_PAGES_PER_DOMAIN)
ratio = len(domains) / len(pages)
return round(min(1.0, ratio), 3)
+1 -1
View File
@@ -30,7 +30,7 @@ class AiUsageMonitor {
});
}
this.subscribe();
this.poller = new Poller(() => this.poll(), 60000);
this.poller = new Poller(() => this.poll(), 60000, { pauseHidden: true });
}
subscribe() {
+1 -1
View File
@@ -25,7 +25,7 @@ class BackupMonitor {
this.bindScheduleForm();
this.bindActions();
this.subscribe();
this.poller = new Poller(() => this.poll(), this.pollMs);
this.poller = new Poller(() => this.poll(), this.pollMs, { pauseHidden: true });
}
subscribe() {
+2 -2
View File
@@ -105,7 +105,7 @@ export class ContainerInstance {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 20000);
}, 20000, { pauseHidden: true });
}
applyDetail(detail) {
@@ -144,7 +144,7 @@ export class ContainerInstance {
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
}, 20000);
}, 20000, { pauseHidden: true });
}
renderLogs(logs) {
+4 -1
View File
@@ -15,7 +15,10 @@ export class ContainerList {
init() {
this.bindActions();
this.bindCreate();
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, {
immediate: false,
pauseHidden: true,
});
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("container.list", (data) => this.render(data.instances || []));
+4 -1
View File
@@ -17,7 +17,10 @@ export class ContainerManager {
this.instances = data.instances || [];
this.bind();
this.renderInstances();
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, {
immediate: false,
pauseHidden: true,
});
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe(`project.${this.slug}.containers`, (payload) => {
+4 -3
View File
@@ -62,8 +62,9 @@ export class LocalTime {
}
refreshRelative() {
document
.querySelectorAll('[data-dt][data-dt-mode="ago"]')
.forEach((el) => this.apply(el));
if (document.hidden) return;
const relative = document.querySelectorAll('[data-dt][data-dt-mode="ago"]');
if (!relative.length) return;
relative.forEach((el) => this.apply(el));
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ class ServiceMonitor {
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poller = new Poller(() => this.poll(), 20000);
this.poller = new Poller(() => this.poll(), 20000, { pauseHidden: true });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";