219 lines
9.1 KiB
Python
Raw Normal View History

2026-05-11 07:02:06 +02:00
import asyncio
2026-05-10 09:08:12 +02:00
import logging
2026-05-11 22:12:43 +02:00
import os
2026-05-12 15:07:34 +02:00
import time
from collections import defaultdict
2026-05-10 09:08:12 +02:00
from fastapi import FastAPI, Request
2026-05-12 15:07:34 +02:00
from fastapi.responses import HTMLResponse, RedirectResponse
2026-05-10 09:08:12 +02:00
from fastapi.staticfiles import StaticFiles
2026-05-23 05:44:04 +02:00
from fastapi.exceptions import RequestValidationError
2026-05-12 12:45:52 +02:00
from devplacepy.config import STATIC_DIR, PORT
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts
2026-05-10 09:08:12 +02:00
from devplacepy.templating import templates
2026-05-12 12:45:52 +02:00
from devplacepy.utils import get_current_user, time_ago
2026-05-11 05:30:51 +02:00
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
2026-05-12 15:07:34 +02:00
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads
2026-05-11 07:02:06 +02:00
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
2026-05-10 09:08:12 +02:00
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
2026-05-12 15:07:34 +02:00
_rate_limit_store = defaultdict(list)
2026-05-23 03:21:55 +02:00
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
2026-05-12 15:07:34 +02:00
RATE_WINDOW = 60
2026-05-23 01:50:31 +02:00
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Content-Disposition"] = "attachment"
return response
2026-05-10 09:08:12 +02:00
app = FastAPI(title="DevPlace")
2026-05-23 01:50:31 +02:00
app.mount("/static/uploads", UploadStaticFiles(directory=str(STATIC_DIR / "uploads"), check_dir=False), name="uploads")
2026-05-10 09:08:12 +02:00
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
2026-05-12 15:07:34 +02:00
@app.exception_handler(404)
async def not_found(request: Request, exc):
2026-05-23 01:50:31 +02:00
seo_ctx = base_seo_context(request, title="Not Found - DevPlace", description="The page you requested does not exist.", robots="noindex")
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 404, "error_message": "Page not found"}, status_code=404)
2026-05-12 15:07:34 +02:00
@app.exception_handler(500)
async def server_error(request: Request, exc):
2026-05-14 04:12:19 +02:00
logger.exception("500 error on %s %s", request.method, request.url.path)
2026-05-23 01:50:31 +02:00
seo_ctx = base_seo_context(request, title="Server Error - DevPlace", description="Something went wrong.", robots="noindex")
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 500, "error_message": "Internal server error"}, status_code=500)
2026-05-12 15:07:34 +02:00
2026-05-23 05:44:04 +02:00
_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()]
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 = request.headers.get("referer") or "/feed"
return RedirectResponse(url=referer, status_code=303)
2026-05-10 09:08:12 +02:00
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")
2026-05-10 21:33:53 +02:00
app.include_router(avatar.router, prefix="/avatar")
2026-05-11 00:41:41 +02:00
app.include_router(follow.router, prefix="/follow")
2026-05-11 05:30:51 +02:00
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
2026-05-11 07:02:06 +02:00
app.include_router(bugs.router, prefix="/bugs")
2026-05-12 15:07:34 +02:00
app.include_router(gists.router, prefix="/gists")
2026-05-11 22:12:43 +02:00
app.include_router(news.router, prefix="/news")
2026-05-11 07:02:06 +02:00
app.include_router(services_router.router, prefix="/admin/services")
2026-05-12 15:07:34 +02:00
app.include_router(uploads.router, prefix="/uploads")
2026-05-11 05:30:51 +02:00
@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"
return response
2026-05-10 09:08:12 +02:00
2026-05-12 15:07:34 +02:00
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
2026-05-23 01:50:31 +02:00
ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
2026-05-12 15:07:34 +02:00
now = time.time()
window_start = now - RATE_WINDOW
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
if len(_rate_limit_store[ip]) >= RATE_LIMIT:
return HTMLResponse("Rate limit exceeded. Try again later.", status_code=429)
_rate_limit_store[ip].append(now)
return await call_next(request)
2026-05-10 09:08:12 +02:00
@app.on_event("startup")
async def startup():
init_db()
2026-05-11 22:12:43 +02:00
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
news_service = NewsService()
service_manager.register(news_service)
asyncio.create_task(service_manager.start_all())
2026-05-12 12:45:52 +02:00
logger.info(f"DevPlace started on port {PORT}")
2026-05-10 09:08:12 +02:00
2026-05-11 07:02:06 +02:00
@app.on_event("shutdown")
async def shutdown():
logger.info("Shutting down services...")
await service_manager.stop_all()
2026-05-10 09:08:12 +02:00
@app.get("/")
async def landing(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/feed", status_code=302)
2026-05-11 22:12:43 +02:00
landing_articles = []
if "news" in db.tables:
news_table = get_table("news")
2026-05-13 21:17:57 +02:00
raw = list(news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6))
2026-05-11 22:12:43 +02:00
images_table = get_table("news_images") if "news_images" in db.tables else None
for a in raw:
image_url = ""
if images_table:
img = images_table.find_one(news_uid=a["uid"])
if img:
image_url = img["url"]
landing_articles.append({
2026-05-12 12:45:52 +02:00
"uid": a["uid"],
"slug": a.get("slug", ""),
2026-05-11 22:12:43 +02:00
"title": a.get("title", ""),
2026-05-12 12:45:52 +02:00
"description": (a.get("description", "") or "")[:250],
2026-05-11 22:12:43 +02:00
"url": a.get("url", ""),
"source_name": a.get("source_name", ""),
2026-05-12 12:45:52 +02:00
"grade": a.get("grade", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]),
2026-05-11 22:12:43 +02:00
"image_url": image_url,
})
2026-05-12 12:45:52 +02:00
landing_posts = []
if "posts" in db.tables:
posts_table = get_table("posts")
raw_posts = list(posts_table.find(order_by=["-created_at"], _limit=6))
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:
landing_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"],
})
2026-05-11 05:30:51 +02:00
base = site_url(request)
seo_ctx = base_seo_context(
request,
2026-05-23 01:50:31 +02:00
title="DevPlace - The Developer Social Network",
2026-05-11 05:30:51 +02:00
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "landing.html", {
2026-05-12 12:45:52 +02:00
**seo_ctx, "request": request,
2026-05-11 22:12:43 +02:00
"landing_articles": landing_articles,
2026-05-12 12:45:52 +02:00
"landing_posts": landing_posts,
2026-05-11 22:12:43 +02:00
})