@@ -0,0 +1 @@
|
||||
# Web Service (stub)
|
||||
@@ -0,0 +1,616 @@
|
||||
import os
|
||||
|
||||
import devplacepy.db_client
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import (
|
||||
PORT,
|
||||
STATIC_DIR,
|
||||
STATIC_VERSION,
|
||||
UPLOADS_DIR,
|
||||
ensure_data_dirs,
|
||||
)
|
||||
from devplacepy.db_client import (
|
||||
db,
|
||||
get_blocked_uids,
|
||||
get_comment_counts_by_post_uids,
|
||||
get_int_setting,
|
||||
get_news_images_by_uids,
|
||||
get_setting,
|
||||
get_table,
|
||||
get_user_post_count,
|
||||
get_user_stars,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
interleave_by_author,
|
||||
)
|
||||
from devplacepy.responses import json_error, respond, wants_json
|
||||
from devplacepy.routers import (
|
||||
admin,
|
||||
auth,
|
||||
avatar,
|
||||
awards,
|
||||
bookmarks,
|
||||
comments,
|
||||
dbapi,
|
||||
devrant,
|
||||
docs,
|
||||
feed,
|
||||
follow,
|
||||
game,
|
||||
gists,
|
||||
issues,
|
||||
leaderboard,
|
||||
media,
|
||||
messages,
|
||||
news,
|
||||
notifications,
|
||||
polls,
|
||||
posts,
|
||||
profile,
|
||||
projects,
|
||||
proxy,
|
||||
push,
|
||||
reactions,
|
||||
relations,
|
||||
seo,
|
||||
uploads,
|
||||
votes,
|
||||
)
|
||||
from devplacepy.schemas import LandingOut, ValidationErrorOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import client_ip, get_current_user, safe_next, time_ago
|
||||
from devplacepy_services.web.ingress import mount_ingress
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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()
|
||||
from devplacepy.services.statistics.tracking import start_visit_flusher
|
||||
|
||||
start_visit_flusher()
|
||||
|
||||
from devplacepy.config import SERVICE_LOCK_FILE
|
||||
from devplacepy.services.dbapi.service import DbApiJobService
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.weblock import acquire_web_lock
|
||||
|
||||
web_lock_owner = acquire_web_lock(SERVICE_LOCK_FILE)
|
||||
if web_lock_owner:
|
||||
service_manager.register(DbApiJobService())
|
||||
service_manager.set_lock_owner(True)
|
||||
service_manager.supervise()
|
||||
|
||||
logger.info(f"DevPlace web started on port {PORT}")
|
||||
yield
|
||||
logger.info("Shutting down web...")
|
||||
if web_lock_owner:
|
||||
await service_manager.shutdown_all()
|
||||
from devplacepy.services.statistics.tracking import flush_visits
|
||||
|
||||
flush_visits()
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
_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"]) if a.get("synced_at") else "",
|
||||
"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")
|
||||
fetch_limit = 24 if blocked else 6
|
||||
raw_posts = list(
|
||||
posts_table.find(deleted_at=None, order_by=["-created_at"], _limit=fetch_limit)
|
||||
)
|
||||
if blocked:
|
||||
raw_posts = [p for p in raw_posts if p["user_uid"] not in blocked]
|
||||
raw_posts = raw_posts[: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
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
from devplacepy_services.base.middleware import (
|
||||
InternalCallCounterMiddleware,
|
||||
RequestStatsMiddleware,
|
||||
SanitizeErrorsMiddleware,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="DevPlace",
|
||||
docs_url="/swagger",
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(SanitizeErrorsMiddleware)
|
||||
app.add_middleware(InternalCallCounterMiddleware)
|
||||
app.add_middleware(RequestStatsMiddleware)
|
||||
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(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)
|
||||
|
||||
mount_ingress(app)
|
||||
|
||||
app.include_router(auth.router, prefix="/auth")
|
||||
app.include_router(feed.router, prefix="/feed")
|
||||
app.include_router(posts.router, prefix="/posts")
|
||||
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(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(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(proxy.router, prefix="/p")
|
||||
app.include_router(devrant.router, prefix="/api")
|
||||
app.include_router(dbapi.router, prefix="/dbapi")
|
||||
app.include_router(game.router, prefix="/game")
|
||||
|
||||
@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
|
||||
|
||||
@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["X-Frame-Options"] = "DENY"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"object-src 'none'; base-uri 'self'; "
|
||||
"frame-ancestors 'none'; 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,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=6)
|
||||
|
||||
@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, uncensored environment.",
|
||||
breadcrumbs=[],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
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,
|
||||
"landing_articles": landing_articles,
|
||||
"landing_posts": landing_posts,
|
||||
},
|
||||
model=LandingOut,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_web_app()
|
||||
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import uuid_utils
|
||||
import websockets
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from devplacepy_services.base.config import service_url
|
||||
from devplacepy_services.base.errors import error_response
|
||||
from devplacepy_services.base.manifest import INGRESS_ROUTES as _INGRESS_SPECS
|
||||
from devplacepy_services.base.proxy import HOP_HEADERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INGRESS_ROUTES = [(route.prefix, route.service) for route in _INGRESS_SPECS]
|
||||
|
||||
TIMEOUTS = {
|
||||
"xmlrpc": 120.0,
|
||||
}
|
||||
|
||||
|
||||
def _forward_headers_from_scope(scope) -> dict[str, str]:
|
||||
original = Headers(scope=scope)
|
||||
headers = {
|
||||
k: v for k, v in original.items() if k.lower() not in HOP_HEADERS
|
||||
}
|
||||
headers["X-Request-Id"] = headers.get("X-Request-Id") or uuid_utils.uuid7().hex
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
# Mirror nginx's `proxy_set_header Host $host` (Appendix F) so an
|
||||
# upstream-generated absolute URL (redirect, url_for) reflects the
|
||||
# public :10500 endpoint the browser is actually talking to, not the
|
||||
# upstream service's own internal bind address/port.
|
||||
original_host = original.get("host")
|
||||
if original_host:
|
||||
headers["Host"] = original_host
|
||||
return headers
|
||||
|
||||
|
||||
def _forward_headers(request: Request) -> dict[str, str]:
|
||||
return _forward_headers_from_scope(request.scope)
|
||||
|
||||
|
||||
_WS_HANDSHAKE_HEADERS = frozenset(
|
||||
{
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-version",
|
||||
"sec-websocket-extensions",
|
||||
"sec-websocket-protocol",
|
||||
"sec-websocket-accept",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _forward_ws_headers(scope) -> dict[str, str]:
|
||||
headers = _forward_headers_from_scope(scope)
|
||||
for key in list(headers):
|
||||
if key.lower() in _WS_HANDSHAKE_HEADERS:
|
||||
del headers[key]
|
||||
return headers
|
||||
|
||||
|
||||
def _target_path(scope, prefix: str) -> str:
|
||||
# Starlette's Mount rewrites scope["root_path"] to the cumulative mount
|
||||
# prefix but leaves scope["path"] as the FULL original request path (it
|
||||
# does not strip the prefix) - so the full path alone is already the
|
||||
# correct upstream path; concatenating root_path in front double-prefixes it.
|
||||
return scope.get("path", "") or prefix
|
||||
|
||||
|
||||
def _upstream_http_url(service: str, path: str, query: str) -> str:
|
||||
base = service_url(service).rstrip("/")
|
||||
url = f"{base}{path}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
return url
|
||||
|
||||
|
||||
def _upstream_ws_url(service: str, path: str, query: str) -> str:
|
||||
parsed = urlparse(service_url(service))
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
netloc = parsed.netloc
|
||||
url = f"{scheme}://{netloc}{path}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
return url
|
||||
|
||||
|
||||
class IngressProxy:
|
||||
def __init__(self, prefix: str, service: str) -> None:
|
||||
self.prefix = prefix
|
||||
self.service = service
|
||||
self.timeout = TIMEOUTS.get(service, 30.0)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
if scope["type"] == "http":
|
||||
await self._proxy_http(scope, receive, send)
|
||||
elif scope["type"] == "websocket":
|
||||
await self._proxy_ws(scope, receive, send)
|
||||
|
||||
async def _proxy_http(self, scope, receive, send) -> None:
|
||||
request = Request(scope, receive)
|
||||
path = _target_path(scope, self.prefix)
|
||||
query = scope.get("query_string", b"").decode()
|
||||
url = _upstream_http_url(self.service, path, query)
|
||||
body = await request.body()
|
||||
headers = _forward_headers(request)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout, follow_redirects=False
|
||||
) as client:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("ingress %s upstream error: %s", self.prefix, exc)
|
||||
response = error_response(
|
||||
502, "Upstream service unavailable", "upstream_error"
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
out_headers = {
|
||||
k: v
|
||||
for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
|
||||
}
|
||||
response = Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
headers=out_headers,
|
||||
media_type=upstream.headers.get("content-type"),
|
||||
)
|
||||
for cookie in upstream.headers.get_list("set-cookie"):
|
||||
response.headers.append("set-cookie", cookie)
|
||||
await response(scope, receive, send)
|
||||
|
||||
async def _proxy_ws(self, scope, receive, send) -> None:
|
||||
client_ws = WebSocket(scope, receive, send)
|
||||
path = _target_path(scope, self.prefix)
|
||||
query = scope.get("query_string", b"").decode()
|
||||
upstream_url = _upstream_ws_url(self.service, path, query)
|
||||
headers = _forward_ws_headers(scope)
|
||||
await client_ws.accept()
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url,
|
||||
open_timeout=10,
|
||||
max_size=None,
|
||||
additional_headers=headers,
|
||||
) as upstream:
|
||||
await _pump(client_ws, upstream)
|
||||
except Exception as exc:
|
||||
logger.debug("ingress ws %s failed: %s", self.prefix, exc)
|
||||
try:
|
||||
await client_ws.close(code=1011)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _pump(client_ws: WebSocket, upstream) -> None:
|
||||
async def client_to_upstream():
|
||||
try:
|
||||
while True:
|
||||
message = await client_ws.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message.get("text") is not None:
|
||||
await upstream.send(message["text"])
|
||||
elif message.get("bytes") is not None:
|
||||
await upstream.send(message["bytes"])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
async def upstream_to_client():
|
||||
try:
|
||||
async for message in upstream:
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
await client_ws.send_bytes(bytes(message))
|
||||
else:
|
||||
await client_ws.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await client_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||
|
||||
|
||||
def mount_ingress(app) -> None:
|
||||
for prefix, service in INGRESS_ROUTES:
|
||||
proxy = IngressProxy(prefix, service)
|
||||
# A bare hit on the prefix itself (no trailing slash, nothing after -
|
||||
# e.g. a POST to /xmlrpc or GET /tools) never matches Mount's own
|
||||
# path regex (it requires a "/" plus content after the prefix), so
|
||||
# Starlette's router-level redirect_slashes fallback 307s it to
|
||||
# "<prefix>/" before the Mount ever sees it. If the upstream service's
|
||||
# own router registers an exact route at its mount root (as /tools
|
||||
# and /xmlrpc both do), THAT redirects back to the bare prefix -
|
||||
# an infinite loop between the two opposite trailing-slash
|
||||
# conventions. Registering an explicit Route at the exact prefix
|
||||
# bypasses Mount's regex/redirect fallback entirely for that one path.
|
||||
app.router.routes.append(Route(prefix, endpoint=proxy, methods=None))
|
||||
app.mount(prefix, proxy)
|
||||
@@ -0,0 +1,21 @@
|
||||
from devplacepy_services.base.health import health_router
|
||||
from devplacepy_services.base.service import BaseMicroservice
|
||||
from devplacepy_services.web.factory import create_web_app
|
||||
|
||||
|
||||
class WebService(BaseMicroservice):
|
||||
name = "web"
|
||||
title = "Web"
|
||||
default_port = 10500
|
||||
workers = "auto"
|
||||
stateful = False
|
||||
depends_on = ["database", "pubsub"]
|
||||
|
||||
def build_app(self):
|
||||
app = create_web_app()
|
||||
app.include_router(health_router(self))
|
||||
return app
|
||||
|
||||
|
||||
_service = WebService()
|
||||
app = _service.build_app()
|
||||
Reference in New Issue
Block a user