small performance and security changes

This commit is contained in:
2026-02-19 18:18:15 +01:00
parent 2885a23544
commit 91d9fa3a21
5 changed files with 244 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from loguru import logger from loguru import logger
from src.config import settings from src.config import settings
@@ -49,6 +50,36 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan) 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): class StaticCacheMiddleware(BaseHTTPMiddleware):
"""Set long-lived Cache-Control headers on static assets.""" """Set long-lived Cache-Control headers on static assets."""
@@ -64,7 +95,11 @@ class StaticCacheMiddleware(BaseHTTPMiddleware):
return response 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(StaticCacheMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
# Static files # Static files
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")

View File

@@ -171,11 +171,20 @@
<h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1> <h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1>
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p> <p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
</div> </div>
<div class="flex items-center gap-3">
{% if archived_count and archived_count > 0 %}
<a href="/posts/archive" class="px-4 py-2.5 rounded-lg font-medium flex items-center gap-2 text-sm border border-gray-600 text-gray-300 hover:border-gray-400 hover:text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
Archiv
<span class="bg-gray-700 text-gray-300 text-xs px-1.5 py-0.5 rounded-full">{{ archived_count }}</span>
</a>
{% endif %}
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2"> <a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Neuer Post Neuer Post
</a> </a>
</div> </div>
</div>
{% if error %} {% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6"> <div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
@@ -253,8 +262,17 @@
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6"> <div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div> </div>
{% if archived_count and archived_count > 0 %}
<h3 class="text-xl font-semibold text-white mb-2">Alle Posts veröffentlicht</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Keine aktiven Posts. {{ archived_count }} Post{{ 's' if archived_count != 1 else '' }} im Archiv.</p>
<a href="/posts/archive" class="inline-flex items-center gap-2 px-6 py-3 border border-gray-600 text-gray-300 hover:text-white font-medium rounded-lg transition-colors mr-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
Archiv ansehen
</a>
{% else %}
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3> <h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p> <p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
{% endif %}
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors"> <a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Post erstellen Post erstellen

View File

@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Post-Archiv - LinkedIn Posts{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-3 mb-1">
<a href="/posts" class="text-gray-400 hover:text-white transition-colors flex items-center gap-1 text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Zurück
</a>
<h1 class="text-2xl font-bold text-white">Post-Archiv</h1>
</div>
<p class="text-gray-400 text-sm">Veröffentlichte, geplante und abgelehnte Posts ({{ total }} gesamt)</p>
</div>
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Neuer Post
</a>
</div>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
{% if not posts and not error %}
<div class="card-bg rounded-xl border p-12 text-center">
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
</div>
<h3 class="text-xl font-semibold text-white mb-2">Kein Archiv vorhanden</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Noch keine veröffentlichten oder abgelehnten Posts.</p>
<a href="/posts" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
Zurück zu den Posts
</a>
</div>
{% else %}
<div class="space-y-3">
{% for post in posts %}
<a href="/posts/{{ post.id }}" class="block card-bg rounded-xl border hover:border-gray-500 transition-colors p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
{% if post.status == 'published' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-900/50 text-green-300 border border-green-800">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Veröffentlicht
</span>
{% elif post.status == 'scheduled' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-900/50 text-blue-300 border border-blue-800">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Geplant
</span>
{% elif post.status == 'rejected' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-900/50 text-red-300 border border-red-800">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
Abgelehnt
</span>
{% endif %}
<h4 class="font-medium text-white truncate">{{ post.topic_title or 'Untitled' }}</h4>
</div>
{% if post.post_content %}
<p class="text-gray-400 text-sm line-clamp-2">{{ post.post_content[:200] }}{% if post.post_content | length > 200 %}...{% endif %}</p>
{% endif %}
</div>
<div class="flex-shrink-0 text-right text-xs text-gray-500 space-y-1">
{% if post.status == 'published' and post.published_at %}
<div>veröffentlicht {{ post.published_at.strftime('%d.%m.%Y') }}</div>
{% elif post.status == 'scheduled' and post.scheduled_at %}
<div>geplant für {{ post.scheduled_at.strftime('%d.%m.%Y %H:%M') }}</div>
{% else %}
<div>erstellt {{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}</div>
{% endif %}
{% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %}
{% set score = post.critic_feedback[-1].get('overall_score', 0) %}
<div class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-semibold
{{ 'bg-green-900/50 text-green-300' if score >= 85 else 'bg-yellow-900/50 text-yellow-300' if score >= 70 else 'bg-red-900/50 text-red-300' }}">
{{ score }}
</div>
{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="flex items-center justify-center gap-2 mt-8">
{% if current_page > 1 %}
<a href="/posts/archive?page={{ current_page - 1 }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">
&larr; Zurück
</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == current_page %}
<span class="px-3 py-2 rounded-lg bg-brand-bg text-white text-sm font-medium border border-yellow-500">{{ p }}</span>
{% elif p <= 2 or p >= total_pages - 1 or (p >= current_page - 2 and p <= current_page + 2) %}
<a href="/posts/archive?page={{ p }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">{{ p }}</a>
{% elif p == 3 or p == total_pages - 2 %}
<span class="text-gray-500 px-1"></span>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/posts/archive?page={{ current_page + 1 }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">
Weiter &rarr;
</a>
{% endif %}
</div>
<p class="text-center text-xs text-gray-500 mt-2">Seite {{ current_page }} von {{ total_pages }} ({{ total }} Posts)</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -229,7 +229,8 @@ def set_user_session(response: Response, session: UserSession, access_token: str
value=session.to_cookie_value(), value=session.to_cookie_value(),
httponly=True, httponly=True,
max_age=60 * 60 * 24 * 7, max_age=60 * 60 * 24 * 7,
samesite="lax" samesite="lax",
secure=True,
) )

View File

@@ -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) @user_router.get("/posts", response_class=HTMLResponse)
async def posts_page(request: Request): async def posts_page(request: Request):
"""View user's own posts.""" """View user's own posts (active Kanban only)."""
session = require_user_session(request) session = require_user_session(request)
if not session: if not session:
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
@@ -1348,7 +1352,9 @@ async def posts_page(request: Request):
try: try:
user_id = UUID(session.user_id) user_id = UUID(session.user_id)
profile = await db.get_profile(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) profile_picture = await get_user_avatar(session, user_id)
return templates.TemplateResponse("posts.html", { return templates.TemplateResponse("posts.html", {
@@ -1356,8 +1362,9 @@ async def posts_page(request: Request):
"page": "posts", "page": "posts",
"session": session, "session": session,
"profile": profile, "profile": profile,
"posts": posts, "posts": active_posts,
"total_posts": len(posts), "total_posts": len(active_posts),
"archived_count": archived_count,
"profile_picture": profile_picture "profile_picture": profile_picture
}) })
except Exception as e: except Exception as e:
@@ -1370,6 +1377,61 @@ async def posts_page(request: Request):
"session": session, "session": session,
"posts": [], "posts": [],
"total_posts": 0, "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) "error": str(e)
}) })
@@ -1633,6 +1695,7 @@ async def post_detail_page(request: Request, post_id: str):
"post_type_analysis": post_type_analysis, "post_type_analysis": post_type_analysis,
"final_feedback": final_feedback, "final_feedback": final_feedback,
"profile_picture_url": profile_picture_url, "profile_picture_url": profile_picture_url,
"profile_picture": profile_picture_url,
"media_items_dict": media_items_dict, "media_items_dict": media_items_dict,
"limit_reached": limit_reached, "limit_reached": limit_reached,
"limit_message": limit_message "limit_message": limit_message