forked from retoor/devplacepy
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:
+103
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || []));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user