Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.
SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).
Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.
Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
877 lines
29 KiB
Python
877 lines
29 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import fcntl
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import asynccontextmanager, contextmanager
|
|
from pathlib import Path
|
|
from fastapi import FastAPI, Request, WebSocket
|
|
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,
|
|
UPLOADS_DIR,
|
|
PORT,
|
|
SERVICE_LOCK_FILE,
|
|
INIT_LOCK_FILE,
|
|
ensure_data_dirs,
|
|
)
|
|
from devplacepy.database import (
|
|
init_db,
|
|
get_table,
|
|
db,
|
|
refresh_snapshot,
|
|
get_users_by_uids,
|
|
get_comment_counts_by_post_uids,
|
|
get_vote_counts,
|
|
get_news_images_by_uids,
|
|
get_setting,
|
|
get_int_setting,
|
|
interleave_by_author,
|
|
get_user_post_count,
|
|
get_user_stars,
|
|
get_blocked_uids,
|
|
get_top_authors,
|
|
get_trending_topics,
|
|
)
|
|
from devplacepy.templating import templates, jinja_unread_count
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.responses import respond, wants_json, json_error
|
|
from devplacepy.schemas import LandingOut, ValidationErrorOut
|
|
from fastapi.responses import JSONResponse
|
|
from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
|
|
from devplacepy.seo import base_seo_context, site_url, website_schema
|
|
from devplacepy.routers import (
|
|
auth,
|
|
battles,
|
|
feed,
|
|
posts,
|
|
topics,
|
|
comments,
|
|
projects,
|
|
profile,
|
|
messages,
|
|
notifications,
|
|
votes,
|
|
avatar,
|
|
awards,
|
|
follow,
|
|
relations,
|
|
admin,
|
|
seo,
|
|
issues,
|
|
news,
|
|
gists,
|
|
quizzes,
|
|
uploads,
|
|
media,
|
|
push,
|
|
leaderboard,
|
|
reactions,
|
|
reports,
|
|
bookmarks,
|
|
polls,
|
|
docs,
|
|
openai_gateway,
|
|
devii,
|
|
zips,
|
|
forks,
|
|
proxy,
|
|
tools,
|
|
xmlrpc,
|
|
devrant,
|
|
dbapi,
|
|
pubsub,
|
|
game,
|
|
workspaces,
|
|
)
|
|
from devplacepy.services.manager import service_manager
|
|
from devplacepy.services.background import background
|
|
from devplacepy.services.news import NewsService
|
|
from devplacepy.services.bot import BotsService
|
|
from devplacepy.services.openai_gateway import GatewayService
|
|
from devplacepy.services.devii import DeviiService
|
|
from devplacepy.services.jobs.zip_service import ZipService
|
|
from devplacepy.services.jobs.fork_service import ForkService
|
|
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
|
from devplacepy.services.jobs.planning_service import PlanningReportService
|
|
from devplacepy.services.jobs.seo.service import SeoService
|
|
from devplacepy.services.jobs.seo_meta_service import SeoMetaService
|
|
from devplacepy.services.jobs.award_service import AwardService
|
|
from devplacepy.services.backup import BackupService
|
|
from devplacepy.services.dbapi.service import DbApiJobService
|
|
from devplacepy.services.pubsub import PubSubService
|
|
from devplacepy.services.notification_relay import NotificationRelayService
|
|
from devplacepy.services.opinionwar.service import OpinionWarService
|
|
from devplacepy.services.live_view_relay import LiveViewRelayService
|
|
from devplacepy.services.presence_relay import PresenceRelayService
|
|
from devplacepy.services import presence
|
|
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
|
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
|
from devplacepy.services.jobs.isslop.service import IsslopService
|
|
from devplacepy.services.gitea.service import IssueTrackerService
|
|
from devplacepy.services.containers.service import ContainerService
|
|
from devplacepy.services.containers.workspace_service import WorkspaceService
|
|
from devplacepy.services.xmlrpc import XmlrpcService
|
|
from devplacepy.services.audit import AuditService
|
|
from devplacepy.services.moderation.service import ModerationService
|
|
from devplacepy.services.moderation.screening import ContentRefused
|
|
from devplacepy.services.acceptance.service import AcceptanceService
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.push import PushService
|
|
from devplacepy.services.telegram import TelegramService
|
|
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_rate_limit_store = defaultdict(list)
|
|
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
|
|
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))
|
|
|
|
|
|
_service_lock_handle = None
|
|
|
|
|
|
@contextmanager
|
|
def init_lock():
|
|
handle = open(INIT_LOCK_FILE, "w")
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
handle.close()
|
|
|
|
|
|
def acquire_service_lock() -> bool:
|
|
global _service_lock_handle
|
|
handle = open(SERVICE_LOCK_FILE, "w")
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
handle.close()
|
|
return False
|
|
_service_lock_handle = handle
|
|
return True
|
|
|
|
|
|
INLINE_MEDIA_EXTENSIONS = {
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".webp",
|
|
".bmp",
|
|
".tiff",
|
|
".mp4",
|
|
".webm",
|
|
".ogv",
|
|
".mov",
|
|
".m4v",
|
|
".mp3",
|
|
}
|
|
|
|
|
|
class UploadStaticFiles(StaticFiles):
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
disposition = (
|
|
"inline"
|
|
if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS
|
|
else "attachment"
|
|
)
|
|
response.headers["Content-Disposition"] = disposition
|
|
response.headers["Cache-Control"] = "public, max-age=604800"
|
|
return response
|
|
|
|
|
|
class CachedStaticFiles(StaticFiles):
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
if Path(path).name == "service-worker.js":
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
else:
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
return response
|
|
|
|
|
|
class FallbackStaticFiles(StaticFiles):
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
if Path(path).name == "service-worker.js":
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
else:
|
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
return response
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
ensure_data_dirs()
|
|
with init_lock():
|
|
init_db()
|
|
from devplacepy.push import ensure_certificates
|
|
|
|
ensure_certificates()
|
|
service_manager.register(NewsService())
|
|
service_manager.register(BotsService())
|
|
service_manager.register(GatewayService())
|
|
service_manager.register(DeviiService())
|
|
service_manager.register(ZipService())
|
|
service_manager.register(ForkService())
|
|
service_manager.register(SeoService())
|
|
service_manager.register(SeoMetaService())
|
|
service_manager.register(AwardService())
|
|
service_manager.register(BackupService())
|
|
service_manager.register(DbApiJobService())
|
|
service_manager.register(PubSubService())
|
|
service_manager.register(NotificationRelayService())
|
|
service_manager.register(OpinionWarService())
|
|
service_manager.register(LiveViewRelayService())
|
|
service_manager.register(PresenceRelayService())
|
|
service_manager.register(DeepsearchService())
|
|
service_manager.register(IsslopService())
|
|
service_manager.register(IssueCreateService())
|
|
service_manager.register(PlanningReportService())
|
|
service_manager.register(IssueTrackerService())
|
|
service_manager.register(ContainerService())
|
|
service_manager.register(WorkspaceService())
|
|
service_manager.register(XmlrpcService())
|
|
service_manager.register(AuditService())
|
|
service_manager.register(ModerationService())
|
|
service_manager.register(AcceptanceService())
|
|
service_manager.register(PushService())
|
|
service_manager.register(TelegramService())
|
|
service_manager.register(TelegramOutboxService())
|
|
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
|
await background.start()
|
|
if acquire_service_lock():
|
|
service_manager.set_lock_owner(True)
|
|
service_manager.supervise()
|
|
logger.info(f"Background services started in worker pid {os.getpid()}")
|
|
else:
|
|
logger.info(
|
|
f"Worker pid {os.getpid()} declined service lock; another worker owns background services"
|
|
)
|
|
from devplacepy.services.statistics.tracking import start_visit_flusher
|
|
|
|
start_visit_flusher()
|
|
logger.info(f"DevPlace started on port {PORT}")
|
|
yield
|
|
logger.info("Shutting down services...")
|
|
from devplacepy.services.statistics.tracking import flush_visits
|
|
|
|
flush_visits()
|
|
await service_manager.shutdown_all()
|
|
await background.stop()
|
|
from devplacepy.services.containers import forward
|
|
|
|
await forward.close_client()
|
|
from devplacepy import push
|
|
|
|
await push.shutdown_providers()
|
|
|
|
|
|
app = FastAPI(
|
|
title="DevPlace",
|
|
docs_url="/swagger",
|
|
redoc_url=None,
|
|
openapi_url="/openapi.json",
|
|
lifespan=lifespan,
|
|
)
|
|
app.mount(
|
|
"/static/uploads",
|
|
UploadStaticFiles(directory=str(UPLOADS_DIR), check_dir=False),
|
|
name="uploads",
|
|
)
|
|
app.mount(
|
|
f"/static/v{STATIC_VERSION}",
|
|
CachedStaticFiles(directory=str(STATIC_DIR)),
|
|
name="static_versioned",
|
|
)
|
|
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
@app.exception_handler(404)
|
|
async def not_found(request: Request, exc):
|
|
if wants_json(request):
|
|
return json_error(404, "Not found")
|
|
seo_ctx = base_seo_context(
|
|
request,
|
|
title="Not Found - DevPlace",
|
|
description="The page you requested does not exist.",
|
|
robots="noindex",
|
|
)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"error.html",
|
|
{
|
|
**seo_ctx,
|
|
"request": request,
|
|
"error_code": 404,
|
|
"error_message": "Page not found",
|
|
},
|
|
status_code=404,
|
|
)
|
|
|
|
|
|
@app.exception_handler(500)
|
|
async def server_error(request: Request, exc):
|
|
logger.exception("500 error on %s %s", request.method, request.url.path)
|
|
if wants_json(request):
|
|
return json_error(500, "Internal server error")
|
|
seo_ctx = base_seo_context(
|
|
request,
|
|
title="Server Error - DevPlace",
|
|
description="Something went wrong.",
|
|
robots="noindex",
|
|
)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"error.html",
|
|
{
|
|
**seo_ctx,
|
|
"request": request,
|
|
"error_code": 500,
|
|
"error_message": "Internal server error",
|
|
},
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@app.exception_handler(ContentRefused)
|
|
async def content_refused(request: Request, exc: ContentRefused):
|
|
logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
|
|
if wants_json(request):
|
|
return json_error(400, exc.message, categories=list(exc.categories))
|
|
seo_ctx = base_seo_context(
|
|
request,
|
|
title="Content not published - DevPlace",
|
|
description=exc.message,
|
|
robots="noindex",
|
|
)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"error.html",
|
|
{
|
|
**seo_ctx,
|
|
"request": request,
|
|
"error_code": 400,
|
|
"error_message": exc.message,
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
|
|
_AUTH_FORM_PAGES = {
|
|
"/auth/signup": ("signup.html", "Join DevPlace"),
|
|
"/auth/login": ("login.html", "Sign In"),
|
|
"/auth/forgot-password": ("forgot_password.html", "Reset Password"),
|
|
}
|
|
|
|
_FRIENDLY_ERRORS = {
|
|
("username", "too_short"): "Username must be between 3 and 32 characters",
|
|
("username", "too_long"): "Username must be between 3 and 32 characters",
|
|
("password", "too_short"): "Password must be at least 6 characters",
|
|
}
|
|
|
|
|
|
def _friendly_error(err):
|
|
field = err["loc"][-1] if err.get("loc") else ""
|
|
key = (field, err.get("type", "").replace("string_", ""))
|
|
if key in _FRIENDLY_ERRORS:
|
|
return _FRIENDLY_ERRORS[key]
|
|
msg = err.get("msg", "Invalid input")
|
|
prefix = "Value error, "
|
|
return msg[len(prefix) :] if msg.startswith(prefix) else msg
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def on_validation_error(request: Request, exc: RequestValidationError):
|
|
errors = [_friendly_error(e) for e in exc.errors()]
|
|
if wants_json(request):
|
|
fields: dict = {}
|
|
for raw, friendly in zip(exc.errors(), errors):
|
|
name = raw["loc"][-1] if raw.get("loc") else "_"
|
|
fields.setdefault(str(name), []).append(friendly)
|
|
return JSONResponse(
|
|
ValidationErrorOut(fields=fields, messages=errors).model_dump(mode="json"),
|
|
status_code=422,
|
|
)
|
|
path = request.url.path
|
|
page = _AUTH_FORM_PAGES.get(path)
|
|
if page is None and path.startswith("/auth/reset-password/"):
|
|
page = ("reset_password.html", "Set New Password")
|
|
if page:
|
|
template_name, title = page
|
|
context = {
|
|
**base_seo_context(request, title=title, robots="noindex,nofollow"),
|
|
"request": request,
|
|
"errors": errors,
|
|
}
|
|
try:
|
|
form = await request.form()
|
|
context.update({k: v for k, v in form.items() if isinstance(v, str)})
|
|
except Exception:
|
|
pass
|
|
if "token" in request.path_params:
|
|
context["token"] = request.path_params["token"]
|
|
return templates.TemplateResponse(
|
|
request, template_name, context, status_code=400
|
|
)
|
|
referer = safe_next(request.headers.get("referer"), "/feed")
|
|
return RedirectResponse(url=referer, status_code=303)
|
|
|
|
|
|
app.include_router(auth.router, prefix="/auth")
|
|
app.include_router(feed.router, prefix="/feed")
|
|
app.include_router(posts.router, prefix="/posts")
|
|
app.include_router(topics.router, prefix="/topics")
|
|
app.include_router(comments.router, prefix="/comments")
|
|
app.include_router(projects.router, prefix="/projects")
|
|
app.include_router(profile.router, prefix="/profile")
|
|
app.include_router(messages.router, prefix="/messages")
|
|
app.include_router(notifications.router, prefix="/notifications")
|
|
app.include_router(votes.router, prefix="/votes")
|
|
app.include_router(reactions.router, prefix="/reactions")
|
|
app.include_router(reports.router, prefix="/reports")
|
|
app.include_router(bookmarks.router, prefix="/bookmarks")
|
|
app.include_router(polls.router, prefix="/polls")
|
|
app.include_router(avatar.router, prefix="/avatar")
|
|
app.include_router(awards.router, prefix="/awards")
|
|
app.include_router(follow.router, prefix="/follow")
|
|
app.include_router(relations.router)
|
|
app.include_router(leaderboard.router, prefix="/leaderboard")
|
|
app.include_router(admin.router, prefix="/admin")
|
|
app.include_router(seo.router)
|
|
app.include_router(push.router)
|
|
app.include_router(docs.router)
|
|
app.include_router(openai_gateway.router, prefix="/openai")
|
|
app.include_router(devii.router, prefix="/devii")
|
|
app.include_router(issues.router, prefix="/issues")
|
|
app.include_router(gists.router, prefix="/gists")
|
|
app.include_router(news.router, prefix="/news")
|
|
app.include_router(uploads.router, prefix="/uploads")
|
|
app.include_router(media.router, prefix="/media")
|
|
app.include_router(zips.router, prefix="/zips")
|
|
app.include_router(forks.router, prefix="/forks")
|
|
app.include_router(proxy.router, prefix="/p")
|
|
app.include_router(tools.router, prefix="/tools")
|
|
app.include_router(xmlrpc.router, prefix="/xmlrpc")
|
|
app.include_router(devrant.router, prefix="/api")
|
|
app.include_router(dbapi.router, prefix="/dbapi")
|
|
app.include_router(pubsub.router, prefix="/pubsub")
|
|
app.include_router(game.router, prefix="/game")
|
|
app.include_router(quizzes.router, prefix="/quizzes")
|
|
app.include_router(battles.router, prefix="/battles")
|
|
app.include_router(workspaces.router, prefix="/workspaces")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def refresh_db_snapshot(request: Request, call_next):
|
|
if not request.url.path.startswith(("/static", "/avatar")):
|
|
refresh_snapshot()
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def await_pending_corrections(request: Request, call_next):
|
|
response = await call_next(request)
|
|
pending = request.scope.get(PENDING_SCOPE_KEY)
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
return response
|
|
|
|
|
|
def _frame_ancestors() -> str:
|
|
from devplacepy.services.containers.workspace import naming
|
|
|
|
tunnel_domain = naming.domain()
|
|
if not tunnel_domain:
|
|
return "'self'"
|
|
return f"'self' https://*.{tunnel_domain}"
|
|
|
|
|
|
@app.middleware("http")
|
|
async def add_security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
if not response.headers.get("X-Robots-Tag"):
|
|
response.headers["X-Robots-Tag"] = "index, follow"
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
if not request.url.path.startswith("/p/"):
|
|
response.headers["Content-Security-Policy"] = (
|
|
"object-src 'none'; base-uri 'self'; "
|
|
f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
|
|
)
|
|
if request.url.path.startswith("/admin"):
|
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Expires"] = "0"
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def rate_limit_middleware(request: Request, call_next):
|
|
if RATE_LIMIT_DISABLED:
|
|
return await call_next(request)
|
|
if request.method in (
|
|
"POST",
|
|
"PUT",
|
|
"DELETE",
|
|
"PATCH",
|
|
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
|
|
settings = _hot_settings()
|
|
limit = _worker_rate_limit(settings["rate_limit_per_minute"])
|
|
window = settings["rate_limit_window_seconds"]
|
|
ip = client_ip(request, default="unknown")
|
|
now = time.time()
|
|
window_start = now - window
|
|
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",
|
|
result="denied",
|
|
summary=f"request from {ip} blocked by rate limit",
|
|
metadata={"ip": ip, "limit": limit, "window_seconds": window},
|
|
)
|
|
retry_after = {"Retry-After": str(window)}
|
|
if wants_json(request):
|
|
response = json_error(429, "Rate limit exceeded. Try again later.")
|
|
response.headers["Retry-After"] = str(window)
|
|
return response
|
|
return HTMLResponse(
|
|
"Rate limit exceeded. Try again later.",
|
|
status_code=429,
|
|
headers=retry_after,
|
|
)
|
|
timestamps.append(now)
|
|
_rate_limit_store[ip] = timestamps
|
|
return await call_next(request)
|
|
|
|
|
|
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def maintenance_middleware(request: Request, call_next):
|
|
if _hot_settings()["maintenance_mode"] != "1":
|
|
return await call_next(request)
|
|
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
|
|
return await call_next(request)
|
|
user = get_current_user(request)
|
|
if user and user.get("role") == "Admin":
|
|
return await call_next(request)
|
|
message = get_setting(
|
|
"maintenance_message",
|
|
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
|
|
)
|
|
audit.record(
|
|
request,
|
|
"security.maintenance.block",
|
|
user=user,
|
|
result="denied",
|
|
summary="non-admin request blocked by maintenance mode",
|
|
)
|
|
if wants_json(request):
|
|
return json_error(503, message)
|
|
seo_ctx = base_seo_context(
|
|
request, title="Maintenance - DevPlace", description=message, robots="noindex"
|
|
)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"error.html",
|
|
{**seo_ctx, "request": request, "error_code": 503, "error_message": message},
|
|
status_code=503,
|
|
)
|
|
|
|
|
|
_TERMS_ALLOWED_PREFIXES = (
|
|
"/static",
|
|
"/avatar",
|
|
"/auth",
|
|
"/docs",
|
|
"/reports",
|
|
"/block",
|
|
"/mute",
|
|
"/openai",
|
|
)
|
|
|
|
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
|
|
|
|
|
|
def _terms_exempt(path: str) -> bool:
|
|
if path.startswith(_TERMS_ALLOWED_PREFIXES):
|
|
return True
|
|
return path.startswith("/profile/") and (
|
|
path.endswith("/delete") or path.endswith("/consent")
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def terms_acceptance_gate(request: Request, call_next):
|
|
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
|
|
return await call_next(request)
|
|
from devplacepy.routers.auth.terms import (
|
|
TERMS_ACCEPTANCE_CODE,
|
|
current_terms_version,
|
|
needs_acceptance,
|
|
)
|
|
|
|
user = get_current_user(request)
|
|
if not needs_acceptance(user):
|
|
return await call_next(request)
|
|
message = "Accept the updated Terms of Service to continue."
|
|
if wants_json(request):
|
|
return json_error(
|
|
403,
|
|
message,
|
|
code=TERMS_ACCEPTANCE_CODE,
|
|
redirect="/auth/accept-terms",
|
|
terms_version=current_terms_version(),
|
|
)
|
|
return RedirectResponse(url="/auth/accept-terms", status_code=303)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def track_presence(request: Request, call_next):
|
|
path = request.url.path
|
|
if not path.startswith(("/static", "/avatar")):
|
|
user = get_current_user(request)
|
|
if user:
|
|
presence.touch(user["uid"])
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def visit_statistics(request: Request, call_next):
|
|
from devplacepy.services.statistics.tracking import track_visit
|
|
|
|
response = await call_next(request)
|
|
track_visit(request, response.status_code)
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def response_timing(request: Request, call_next):
|
|
start = time.perf_counter()
|
|
request.state.request_start = start
|
|
response = await call_next(request)
|
|
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
|
|
return response
|
|
|
|
|
|
class TunnelDispatchMiddleware:
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
@staticmethod
|
|
def _host(scope) -> str:
|
|
for key, value in scope.get("headers") or []:
|
|
if key == b"host":
|
|
return value.decode("latin-1")
|
|
return ""
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] not in ("http", "websocket"):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
from devplacepy.routers import tunnel as tunnel_router
|
|
from devplacepy.services.containers.workspace import naming
|
|
|
|
try:
|
|
if not naming.is_tunnel_host(self._host(scope)):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
except Exception:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
path = (scope.get("path") or "/").lstrip("/")
|
|
if scope["type"] == "websocket":
|
|
websocket = WebSocket(scope, receive, send)
|
|
await tunnel_router.handle_ws(websocket, path)
|
|
return
|
|
request = Request(scope, receive)
|
|
response = await tunnel_router.handle_http(request, path)
|
|
await response(scope, receive, send)
|
|
|
|
|
|
app.add_middleware(TunnelDispatchMiddleware)
|
|
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
|
|
|
|
|
|
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
|
|
|
|
|
|
def _landing_news():
|
|
cached = _home_cache.get("news")
|
|
if cached is not None:
|
|
return cached
|
|
articles = []
|
|
if "news" in db.tables:
|
|
news_table = get_table("news")
|
|
raw = list(
|
|
news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)
|
|
)
|
|
images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
|
|
for a in raw:
|
|
articles.append(
|
|
{
|
|
"uid": a["uid"],
|
|
"slug": a.get("slug", ""),
|
|
"title": a.get("title", ""),
|
|
"description": (a.get("description", "") or "")[:250],
|
|
"url": a.get("url", ""),
|
|
"source_name": a.get("source_name", ""),
|
|
"grade": a.get("grade", 0),
|
|
"featured": a.get("featured", 0),
|
|
"synced_at": a.get("synced_at", "") or "",
|
|
"time_ago": time_ago(a["synced_at"]),
|
|
"image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
|
|
}
|
|
)
|
|
_home_cache.set("news", articles)
|
|
return articles
|
|
|
|
|
|
def _landing_recent_posts(blocked):
|
|
if not blocked:
|
|
cached = _home_cache.get("posts")
|
|
if cached is not None:
|
|
return cached
|
|
posts = []
|
|
if "posts" in db.tables:
|
|
posts_table = get_table("posts")
|
|
clauses = []
|
|
if blocked:
|
|
clauses.append(posts_table.table.columns.user_uid.notin_(blocked))
|
|
raw_posts = list(
|
|
posts_table.find(
|
|
*clauses, deleted_at=None, order_by=["-created_at"], _limit=6
|
|
)
|
|
)
|
|
raw_posts = interleave_by_author(raw_posts)
|
|
if raw_posts:
|
|
post_uids = [p["uid"] for p in raw_posts]
|
|
author_uids = [p["user_uid"] for p in raw_posts]
|
|
authors = get_users_by_uids(author_uids)
|
|
comment_counts = get_comment_counts_by_post_uids(post_uids)
|
|
upvotes, downvotes = get_vote_counts(post_uids)
|
|
for p in raw_posts:
|
|
posts.append(
|
|
{
|
|
"post": p,
|
|
"author": authors.get(p["user_uid"]),
|
|
"time_ago": time_ago(p["created_at"]),
|
|
"comment_count": comment_counts.get(p["uid"], 0),
|
|
"stars": upvotes.get(p["uid"], 0) - downvotes.get(p["uid"], 0),
|
|
"slug": p.get("slug", "") or p["uid"],
|
|
}
|
|
)
|
|
if not blocked:
|
|
_home_cache.set("posts", posts)
|
|
return posts
|
|
|
|
|
|
@app.get("/")
|
|
async def landing(request: Request):
|
|
user = get_current_user(request)
|
|
|
|
landing_articles = _landing_news()
|
|
blocked = get_blocked_uids(user["uid"]) if user else frozenset()
|
|
landing_posts = _landing_recent_posts(blocked)
|
|
|
|
base = site_url(request)
|
|
seo_ctx = base_seo_context(
|
|
request,
|
|
title="DevPlace - The Developer Social Network",
|
|
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
|
|
breadcrumbs=[],
|
|
schemas=[website_schema(base)],
|
|
)
|
|
user_xp = user.get("xp", 0) or 0 if user else 0
|
|
user_level = user.get("level", 1) or 1 if user else 1
|
|
xp_progress_pct = (user_xp % 100) if user_xp else 0
|
|
unread_count = jinja_unread_count(user["uid"]) if user else 0
|
|
|
|
top_contributors = get_top_authors(5) if not blocked else []
|
|
trending_topics = get_trending_topics(6) if not blocked else []
|
|
|
|
return respond(
|
|
request,
|
|
"landing.html",
|
|
{
|
|
**seo_ctx,
|
|
"request": request,
|
|
"user": user,
|
|
"is_authenticated": bool(user),
|
|
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
|
|
"user_stars": get_user_stars(user["uid"]) if user else 0,
|
|
"user_xp": user_xp,
|
|
"user_level": user_level,
|
|
"xp_progress_pct": xp_progress_pct,
|
|
"unread_count": unread_count,
|
|
"landing_articles": landing_articles,
|
|
"landing_posts": landing_posts,
|
|
"top_contributors": top_contributors,
|
|
"trending_topics": trending_topics,
|
|
},
|
|
model=LandingOut,
|
|
)
|