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.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")

View File

@@ -171,11 +171,20 @@
<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>
</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">
<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>
</div>
{% if error %}
<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">
<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>
{% 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>
<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">
<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

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(),
httponly=True,
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)
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