first commit

This commit is contained in:
2026-05-27 14:37:46 +02:00
commit 1b9be90d29
24 changed files with 778 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
__pycache__/
*.pyc
.env
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+12
View File
@@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://pastebin:pastebin@db:5432/pastebin"
admin_password: str = "change-me"
max_paste_length: int = 100_000
model_config = {"env_file": ".env"}
settings = Settings()
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
+89
View File
@@ -0,0 +1,89 @@
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, Query, Header
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db, engine, Base
from app.models import Paste
from app.schemas import (
PasteCreate,
PasteResponse,
PasteCreatedResponse,
PasteListResponse,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(title="Pastebin API", lifespan=lifespan)
def verify_admin(password: str = Header(..., alias="X-Admin-Password")):
if password != settings.admin_password:
raise HTTPException(status_code=401, detail="Invalid admin password")
@app.post("/api/pastes", response_model=PasteCreatedResponse, status_code=201)
async def create_paste(data: PasteCreate, db: AsyncSession = Depends(get_db)):
paste = Paste(content=data.content)
db.add(paste)
await db.commit()
await db.refresh(paste)
return PasteCreatedResponse(
id=paste.id, delete_token=paste.delete_token, created_at=paste.created_at
)
@app.get("/api/pastes/{paste_id}", response_model=PasteResponse)
async def get_paste(paste_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
paste = await db.get(Paste, paste_id)
if not paste:
raise HTTPException(status_code=404, detail="Paste not found")
return PasteResponse(id=paste.id, content=paste.content, created_at=paste.created_at)
@app.delete("/api/pastes/{paste_id}")
async def delete_paste(
paste_id: uuid.UUID,
token: uuid.UUID = Query(..., alias="token"),
db: AsyncSession = Depends(get_db),
):
paste = await db.get(Paste, paste_id)
if not paste:
raise HTTPException(status_code=404, detail="Paste not found")
if paste.delete_token != token:
raise HTTPException(status_code=403, detail="Invalid delete token")
await db.delete(paste)
await db.commit()
return {"detail": "Paste deleted"}
@app.get("/api/admin/pastes", response_model=list[PasteListResponse])
async def list_pastes(
_=Depends(verify_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Paste).order_by(Paste.created_at.desc()))
return result.scalars().all()
@app.delete("/api/admin/pastes/{paste_id}")
async def admin_delete_paste(
paste_id: uuid.UUID,
_=Depends(verify_admin),
db: AsyncSession = Depends(get_db),
):
paste = await db.get(Paste, paste_id)
if not paste:
raise HTTPException(status_code=404, detail="Paste not found")
await db.delete(paste)
await db.commit()
return {"detail": "Paste deleted"}
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Paste(Base):
__tablename__ = "pastes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
content: Mapped[str] = mapped_column(Text, nullable=False)
delete_token: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False, default=uuid.uuid4
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from app.config import settings
class PasteCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=settings.max_paste_length)
class PasteResponse(BaseModel):
id: uuid.UUID
content: str
created_at: datetime
model_config = {"from_attributes": True}
class PasteCreatedResponse(BaseModel):
id: uuid.UUID
delete_token: uuid.UUID
created_at: datetime
class PasteListResponse(BaseModel):
id: uuid.UUID
created_at: datetime
model_config = {"from_attributes": True}
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
asyncpg==0.30.0
psycopg2-binary==2.9.10
pydantic==2.10.3
pydantic-settings==2.7.0
python-dotenv==1.0.1
alembic==1.14.0
+37
View File
@@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
depends_on:
- db
restart: unless-stopped
frontend:
build: ./frontend
restart: unless-stopped
proxy:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
restart: unless-stopped
volumes:
pgdata:
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pastebin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "pastebin-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Outlet, Link } from "react-router-dom";
export default function App() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 800, margin: "0 auto", padding: "1rem" }}>
<nav style={{ marginBottom: "2rem", borderBottom: "1px solid #ddd", paddingBottom: "1rem" }}>
<Link to="/" style={{ marginRight: "1rem", textDecoration: "none", color: "#0066cc" }}>
Neuer Paste
</Link>
<Link to="/admin" style={{ textDecoration: "none", color: "#0066cc" }}>
Admin
</Link>
</nav>
<Outlet />
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
const API_BASE = "/api";
export interface PasteCreated {
id: string;
delete_token: string;
created_at: string;
}
export interface Paste {
id: string;
content: string;
created_at: string;
}
export interface PasteListItem {
id: string;
created_at: string;
}
export async function createPaste(content: string): Promise<PasteCreated> {
const res = await fetch(`${API_BASE}/pastes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to create paste");
return res.json();
}
export async function getPaste(id: string): Promise<Paste> {
const res = await fetch(`${API_BASE}/pastes/${id}`);
if (!res.ok) throw new Error("Paste not found");
return res.json();
}
export async function deletePaste(id: string, token: string): Promise<void> {
const res = await fetch(`${API_BASE}/pastes/${id}?token=${token}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete paste");
}
export async function listPastes(adminPassword: string): Promise<PasteListItem[]> {
const res = await fetch(`${API_BASE}/admin/pastes`, {
headers: { "X-Admin-Password": adminPassword },
});
if (!res.ok) throw new Error("Unauthorized");
return res.json();
}
export async function adminDeletePaste(
id: string,
adminPassword: string
): Promise<void> {
const res = await fetch(`${API_BASE}/admin/pastes/${id}`, {
method: "DELETE",
headers: { "X-Admin-Password": adminPassword },
});
if (!res.ok) throw new Error("Failed to delete paste");
}
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import App from "./App";
import HomePage from "./pages/HomePage";
import PastePage from "./pages/PastePage";
import AdminPage from "./pages/AdminPage";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<HomePage />} />
<Route path="paste/:id" element={<PastePage />} />
<Route path="admin" element={<AdminPage />} />
</Route>
</Routes>
</BrowserRouter>
</React.StrictMode>
);
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react";
import { listPastes, adminDeletePaste, type PasteListItem } from "../api/client";
export default function AdminPage() {
const [password, setPassword] = useState("");
const [authenticated, setAuthenticated] = useState(false);
const [pastes, setPastes] = useState<PasteListItem[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await listPastes(password);
setPastes(data);
setAuthenticated(true);
} catch {
setError("Falsches Passwort.");
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Paste wirklich löschen?")) return;
try {
await adminDeletePaste(id, password);
setPastes((prev) => prev.filter((p) => p.id !== id));
} catch {
setError("Löschen fehlgeschlagen.");
}
};
if (!authenticated) {
return (
<div>
<h1>Admin-Bereich</h1>
<form onSubmit={handleLogin} style={{ maxWidth: 400 }}>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.25rem" }}>Admin-Passwort:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: "100%", padding: "0.4rem", boxSizing: "border-box" }}
/>
</div>
<button
type="submit"
disabled={loading || !password}
style={{
padding: "0.5rem 1.5rem",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: 4,
cursor: "pointer",
}}
>
{loading ? "Prüfe..." : "Anmelden"}
</button>
</form>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
return (
<div>
<h1>Alle Pastes ({pastes.length})</h1>
{pastes.length === 0 ? (
<p>Keine Pastes vorhanden.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "0.5rem" }}>ID</th>
<th style={{ textAlign: "left", padding: "0.5rem" }}>Erstellt am</th>
<th style={{ textAlign: "left", padding: "0.5rem" }}>Aktion</th>
</tr>
</thead>
<tbody>
{pastes.map((p) => (
<tr key={p.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "0.5rem" }}>
<a href={`/paste/${p.id}`} style={{ fontFamily: "monospace", fontSize: 13 }}>
{p.id}
</a>
</td>
<td style={{ padding: "0.5rem" }}>
{new Date(p.created_at).toLocaleString("de-DE")}
</td>
<td style={{ padding: "0.5rem" }}>
<button
onClick={() => handleDelete(p.id)}
style={{
padding: "0.25rem 0.75rem",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: 4,
cursor: "pointer",
}}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { createPaste } from "../api/client";
const MAX_LENGTH = 100_000;
export default function HomePage() {
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setLoading(true);
setError("");
try {
const result = await createPaste(content);
navigate(`/paste/${result.id}?token=${result.delete_token}`);
} catch {
setError("Fehler beim Erstellen des Pastes.");
} finally {
setLoading(false);
}
};
return (
<div>
<h1>Neuen Paste erstellen</h1>
<form onSubmit={handleSubmit}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={MAX_LENGTH}
placeholder="Dein Text hier..."
style={{
width: "100%",
minHeight: 300,
fontFamily: "monospace",
fontSize: 14,
padding: "0.5rem",
border: "1px solid #ccc",
borderRadius: 4,
resize: "vertical",
boxSizing: "border-box",
}}
/>
<div style={{ marginTop: "0.5rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ color: "#888", fontSize: 14 }}>
{content.length.toLocaleString()} / {MAX_LENGTH.toLocaleString()} Zeichen
</span>
<button
type="submit"
disabled={loading || !content.trim()}
style={{
padding: "0.5rem 1.5rem",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: 4,
cursor: loading ? "default" : "pointer",
opacity: loading || !content.trim() ? 0.6 : 1,
}}
>
{loading ? "Erstelle..." : "Paste erstellen"}
</button>
</div>
</form>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams, useNavigate } from "react-router-dom";
import { getPaste, deletePaste, type Paste } from "../api/client";
export default function PastePage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const deleteToken = searchParams.get("token");
const [paste, setPaste] = useState<Paste | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [deleted, setDeleted] = useState(false);
const [copied, setCopied] = useState<"view" | "delete" | null>(null);
useEffect(() => {
if (!id) return;
getPaste(id)
.then(setPaste)
.catch(() => setError("Paste nicht gefunden."))
.finally(() => setLoading(false));
}, [id]);
const handleDelete = async () => {
if (!id || !deleteToken) return;
if (!confirm("Paste wirklich löschen?")) return;
try {
await deletePaste(id, deleteToken);
setDeleted(true);
} catch {
setError("Löschen fehlgeschlagen.");
}
};
const copyToClipboard = async (text: string, type: "view" | "delete") => {
await navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(null), 2000);
};
if (loading) return <p>Lade...</p>;
if (deleted) {
return (
<div>
<h1>Paste gelöscht</h1>
<p>Der Paste wurde erfolgreich gelöscht.</p>
<button onClick={() => navigate("/")}>Neuen Paste erstellen</button>
</div>
);
}
if (error) return <p style={{ color: "red" }}>{error}</p>;
if (!paste) return null;
const viewUrl = `${window.location.origin}/paste/${paste.id}`;
const deleteUrl = deleteToken
? `${window.location.origin}/paste/${paste.id}?token=${deleteToken}`
: null;
return (
<div>
<h1>Paste anzeigen</h1>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.25rem", fontWeight: "bold" }}>
Link zum Teilen:
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
readOnly
value={viewUrl}
style={{ flex: 1, padding: "0.4rem", fontFamily: "monospace", fontSize: 13 }}
/>
<button onClick={() => copyToClipboard(viewUrl, "view")}>
{copied === "view" ? "Kopiert!" : "Kopieren"}
</button>
</div>
</div>
{deleteUrl && (
<div
style={{
marginBottom: "1rem",
padding: "1rem",
backgroundColor: "#fff3cd",
border: "1px solid #ffc107",
borderRadius: 4,
}}
>
<strong> Löschen-Link (wird nur einmal angezeigt!):</strong>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<input
readOnly
value={deleteUrl}
style={{ flex: 1, padding: "0.4rem", fontFamily: "monospace", fontSize: 13 }}
/>
<button onClick={() => copyToClipboard(deleteUrl, "delete")}>
{copied === "delete" ? "Kopiert!" : "Kopieren"}
</button>
</div>
<button
onClick={handleDelete}
style={{
marginTop: "0.5rem",
padding: "0.4rem 1rem",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: 4,
cursor: "pointer",
}}
>
Paste löschen
</button>
</div>
)}
<div style={{ marginBottom: "0.5rem", color: "#888", fontSize: 14 }}>
Erstellt am: {new Date(paste.created_at).toLocaleString("de-DE")}
</div>
<pre
style={{
padding: "1rem",
backgroundColor: "#f5f5f5",
border: "1px solid #ddd",
borderRadius: 4,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "monospace",
fontSize: 14,
}}
>
{paste.content}
</pre>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
client_max_body_size 100k;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}