commit 1b9be90d29444b6272b40a9b39163998fb5cf755 Author: Michael Date: Wed May 27 14:37:46 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80fb931 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +__pycache__/ +*.pyc +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0b9158f --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..2fc7349 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..ee4e66a --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3dbee5d --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..0eb54f4 --- /dev/null +++ b/backend/app/models.py @@ -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) + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..ff81175 --- /dev/null +++ b/backend/app/schemas.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..567581c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fef315d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..71be351 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a96b738 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Pastebin + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..150a627 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6003496 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f3c8332 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,17 @@ +import { Outlet, Link } from "react-router-dom"; + +export default function App() { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..e7c6e44 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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"); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..361bc3e --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + }> + } /> + } /> + } /> + + + + +); diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..4e1ee53 --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -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([]); + 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 ( +
+

Admin-Bereich

+
+
+ + setPassword(e.target.value)} + style={{ width: "100%", padding: "0.4rem", boxSizing: "border-box" }} + /> +
+ +
+ {error &&

{error}

} +
+ ); + } + + return ( +
+

Alle Pastes ({pastes.length})

+ {pastes.length === 0 ? ( +

Keine Pastes vorhanden.

+ ) : ( + + + + + + + + + + {pastes.map((p) => ( + + + + + + ))} + +
IDErstellt amAktion
+ + {p.id} + + + {new Date(p.created_at).toLocaleString("de-DE")} + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..0107650 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -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 ( +
+

Neuen Paste erstellen

+
+