diff --git a/src/web/app.py b/src/web/app.py index 246282a..7777299 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import RedirectResponse from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.gzip import GZipMiddleware from loguru import logger from src.config import settings @@ -49,6 +50,36 @@ async def lifespan(app: FastAPI): app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan) +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add security headers to every response.""" + + # CSP allows inline scripts/styles (app uses them extensively in Jinja2 templates). + # frame-ancestors 'none' replaces X-Frame-Options for modern browsers. + _CSP = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https://*.supabase.co https://*.linkedin.com https://media.licdn.com; " + "connect-src 'self' https://*.supabase.co; " + "font-src 'self' data:; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self';" + ) + + async def dispatch(self, request, call_next): + response = await call_next(request) + h = response.headers + h["X-Frame-Options"] = "DENY" + h["X-Content-Type-Options"] = "nosniff" + h["X-XSS-Protection"] = "1; mode=block" + h["Referrer-Policy"] = "strict-origin-when-cross-origin" + h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + h["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + h["Content-Security-Policy"] = self._CSP + return response + + class StaticCacheMiddleware(BaseHTTPMiddleware): """Set long-lived Cache-Control headers on static assets.""" @@ -64,7 +95,11 @@ class StaticCacheMiddleware(BaseHTTPMiddleware): return response +# Middleware executes in reverse registration order (last added = outermost). +# Order: GZip compresses → StaticCache sets headers → SecurityHeaders sets headers. +app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(StaticCacheMiddleware) +app.add_middleware(GZipMiddleware, minimum_size=500) # Static files app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") diff --git a/src/web/templates/user/posts.html b/src/web/templates/user/posts.html index c4139b1..1d2e3c3 100644 --- a/src/web/templates/user/posts.html +++ b/src/web/templates/user/posts.html @@ -171,10 +171,19 @@
Ziehe Posts zwischen den Spalten um den Status zu ändern
- - - Neuer Post - +Keine aktiven Posts. {{ archived_count }} Post{{ 's' if archived_count != 1 else '' }} im Archiv.
+ + + Archiv ansehen + + {% else %}Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.
+ {% endif %} Post erstellen diff --git a/src/web/templates/user/posts_archive.html b/src/web/templates/user/posts_archive.html new file mode 100644 index 0000000..fd7569e --- /dev/null +++ b/src/web/templates/user/posts_archive.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Post-Archiv - LinkedIn Posts{% endblock %} + +{% block content %} +Veröffentlichte, geplante und abgelehnte Posts ({{ total }} gesamt)
+Noch keine veröffentlichten oder abgelehnten Posts.
+ + Zurück zu den Posts + +Seite {{ current_page }} von {{ total_pages }} ({{ total }} Posts)
+{% endif %} + +{% endif %} +{% endblock %} diff --git a/src/web/user/auth.py b/src/web/user/auth.py index 6653634..5fe69a5 100644 --- a/src/web/user/auth.py +++ b/src/web/user/auth.py @@ -229,7 +229,8 @@ def set_user_session(response: Response, session: UserSession, access_token: str value=session.to_cookie_value(), httponly=True, max_age=60 * 60 * 24 * 7, - samesite="lax" + samesite="lax", + secure=True, ) diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 8807fc5..b64c13e 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -1338,9 +1338,13 @@ async def dashboard(request: Request): }) +_ACTIVE_STATUSES = {"draft", "approved", "ready"} +_ARCHIVE_STATUSES = {"scheduled", "published", "rejected"} + + @user_router.get("/posts", response_class=HTMLResponse) async def posts_page(request: Request): - """View user's own posts.""" + """View user's own posts (active Kanban only).""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) @@ -1348,7 +1352,9 @@ async def posts_page(request: Request): try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) - posts = await db.get_generated_posts(user_id) + all_posts = await db.get_generated_posts(user_id) + active_posts = [p for p in all_posts if p.status in _ACTIVE_STATUSES] + archived_count = sum(1 for p in all_posts if p.status in _ARCHIVE_STATUSES) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("posts.html", { @@ -1356,8 +1362,9 @@ async def posts_page(request: Request): "page": "posts", "session": session, "profile": profile, - "posts": posts, - "total_posts": len(posts), + "posts": active_posts, + "total_posts": len(active_posts), + "archived_count": archived_count, "profile_picture": profile_picture }) except Exception as e: @@ -1370,6 +1377,61 @@ async def posts_page(request: Request): "session": session, "posts": [], "total_posts": 0, + "archived_count": 0, + "error": str(e) + }) + + +@user_router.get("/posts/archive", response_class=HTMLResponse) +async def posts_archive_page(request: Request, page: int = 1): + """View archived posts (published, scheduled, rejected).""" + session = require_user_session(request) + if not session: + return RedirectResponse(url="/login", status_code=302) + + try: + user_id = UUID(session.user_id) + all_posts = await db.get_generated_posts(user_id) + archived_posts = [p for p in all_posts if p.status in _ARCHIVE_STATUSES] + # Sort: scheduled first (upcoming), then by published_at/created_at desc + archived_posts.sort( + key=lambda p: ( + p.status != "scheduled", + -(p.published_at or p.created_at or datetime.min.replace(tzinfo=timezone.utc)).timestamp() + ) + ) + + per_page = 20 + total = len(archived_posts) + total_pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + start = (page - 1) * per_page + page_posts = archived_posts[start:start + per_page] + + profile_picture = await get_user_avatar(session, user_id) + + return templates.TemplateResponse("posts_archive.html", { + "request": request, + "page": "posts", + "session": session, + "posts": page_posts, + "total": total, + "current_page": page, + "total_pages": total_pages, + "per_page": per_page, + "profile_picture": profile_picture + }) + except Exception as e: + logger.error(f"Error loading posts archive: {e}") + return templates.TemplateResponse("posts_archive.html", { + "request": request, + "page": "posts", + "session": session, + "posts": [], + "total": 0, + "current_page": 1, + "total_pages": 1, + "per_page": 20, "error": str(e) }) @@ -1633,6 +1695,7 @@ async def post_detail_page(request: Request, post_id: str): "post_type_analysis": post_type_analysis, "final_feedback": final_feedback, "profile_picture_url": profile_picture_url, + "profile_picture": profile_picture_url, "media_items_dict": media_items_dict, "limit_reached": limit_reached, "limit_message": limit_message