diff --git a/.env.example b/.env.example index a7392c9..f8cc165 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ PORT=3000 # Data directory inside the container (default: /data) DATA_DIR=/data + +# Admin credentials for upload/edit/delete access (REQUIRED — change before deploying) +ADMIN_USER=admin +ADMIN_PASS=changeme diff --git a/UNRAID.md b/UNRAID.md index 34ecc7c..386eef1 100644 --- a/UNRAID.md +++ b/UNRAID.md @@ -71,15 +71,18 @@ Add two path mappings (click **Add another Path** → select **Path** for each): ### Environment Variables -Add three variables (click **Add another Path** → select **Variable** for each): +Add five variables (click **Add another Path** → select **Variable** for each): | Config Type | Name | Key | Value | |---|---|---|---| | Variable | Public URL | `PUBLIC_URL` | `https://meme.alwisp.com` | | Variable | Port | `PORT` | `3000` | | Variable | Data Dir | `DATA_DIR` | `/data` | +| Variable | Admin Username | `ADMIN_USER` | `admin` | +| Variable | Admin Password | `ADMIN_PASS` | *(your password)* | -> `PUBLIC_URL` is what gets embedded in share links (copy link, Telegram, SMS). Set it to your actual external URL. +> `PUBLIC_URL` is what gets embedded in share links. Set it to your actual external URL. +> `ADMIN_PASS` is **required** — the gallery is publicly viewable but upload/edit/delete require this password. Change it before exposing the container to the internet. 3. Click **Apply**. Unraid will pull/start the container. 4. Check the container log (click the container name → **Log**) — you should see: @@ -115,6 +118,8 @@ docker run -d \ -e PUBLIC_URL="https://meme.alwisp.com" \ -e PORT="3000" \ -e DATA_DIR="/data" \ + -e ADMIN_USER="admin" \ + -e ADMIN_PASS="yourpassword" \ memer:latest ``` @@ -174,6 +179,8 @@ docker run -d \ -e PUBLIC_URL="https://meme.alwisp.com" \ -e PORT="3000" \ -e DATA_DIR="/data" \ + -e ADMIN_USER="admin" \ + -e ADMIN_PASS="yourpassword" \ memer:latest ``` @@ -206,3 +213,5 @@ The SQLite database file is `/mnt/user/appdata/memer/db/memer.db`. Image files a | `PUBLIC_URL` | `http://localhost:3000` | External URL embedded in share links — must match your domain | | `PORT` | `3000` | Port the Node server listens on inside the container | | `DATA_DIR` | `/data` | Root path for images and DB inside the container — do not change unless remapping volumes | +| `ADMIN_USER` | `admin` | Username for admin login | +| `ADMIN_PASS` | *(none)* | Password for admin login — **required**, set before exposing to the internet | diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..962231c --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,73 @@ +import crypto from 'crypto'; +import type { FastifyRequest, FastifyReply } from 'fastify'; + +const COOKIE_NAME = 'memer_session'; +const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function secret(): string { + const pass = process.env.ADMIN_PASS; + if (!pass) throw new Error('ADMIN_PASS environment variable is not set'); + return pass; +} + +function b64url(str: string): string { + return Buffer.from(str).toString('base64url'); +} + +function fromB64url(str: string): string { + return Buffer.from(str, 'base64url').toString('utf8'); +} + +export function createToken(user: string): string { + const payload = b64url(JSON.stringify({ user, exp: Date.now() + TOKEN_TTL_MS })); + const sig = crypto.createHmac('sha256', secret()).update(payload).digest('hex'); + return `${payload}.${sig}`; +} + +export function verifyToken(token: string): { user: string } | null { + try { + const dot = token.lastIndexOf('.'); + if (dot === -1) return null; + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = crypto.createHmac('sha256', secret()).update(payload).digest('hex'); + if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null; + const data = JSON.parse(fromB64url(payload)) as { user: string; exp: number }; + if (Date.now() > data.exp) return null; + return { user: data.user }; + } catch { + return null; + } +} + +export function getTokenFromRequest(req: FastifyRequest): string | null { + const raw = req.headers.cookie ?? ''; + for (const part of raw.split(';')) { + const [key, ...rest] = part.trim().split('='); + if (key === COOKIE_NAME) return rest.join('='); + } + return null; +} + +export function setSessionCookie(reply: FastifyReply, token: string): void { + const secure = (process.env.PUBLIC_URL ?? '').startsWith('https'); + const maxAge = Math.floor(TOKEN_TTL_MS / 1000); + reply.header( + 'Set-Cookie', + `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}` + ); +} + +export function clearSessionCookie(reply: FastifyReply): void { + reply.header( + 'Set-Cookie', + `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0` + ); +} + +export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise { + const token = getTokenFromRequest(req); + if (!token || !verifyToken(token)) { + await reply.status(401).send({ error: 'Unauthorized' }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d9db72e..4301aa3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { ensureImagesDir, IMAGES_DIR } from './services/storage.js'; import { memesRoutes } from './routes/memes.js'; import { tagsRoutes } from './routes/tags.js'; +import { authRoutes } from './routes/auth.js'; // Ensure data dirs exist ensureImagesDir(); @@ -35,6 +36,7 @@ await app.register(fastifyStatic, { }); // API routes +await app.register(authRoutes); await app.register(memesRoutes); await app.register(tagsRoutes); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..369bc53 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,35 @@ +import type { FastifyInstance } from 'fastify'; +import { createToken, verifyToken, getTokenFromRequest, setSessionCookie, clearSessionCookie } from '../auth.js'; + +export async function authRoutes(app: FastifyInstance) { + app.post<{ Body: { username: string; password: string } }>('/api/auth/login', async (req, reply) => { + const { username, password } = req.body ?? {}; + + const adminUser = process.env.ADMIN_USER ?? 'admin'; + const adminPass = process.env.ADMIN_PASS; + + if (!adminPass) { + return reply.status(500).send({ error: 'Server auth is not configured (ADMIN_PASS missing)' }); + } + + if (username !== adminUser || password !== adminPass) { + return reply.status(401).send({ error: 'Invalid credentials' }); + } + + const token = createToken(username); + setSessionCookie(reply, token); + return { ok: true, user: username }; + }); + + app.post('/api/auth/logout', async (_req, reply) => { + clearSessionCookie(reply); + return { ok: true }; + }); + + app.get('/api/auth/me', async (req) => { + const token = getTokenFromRequest(req); + const payload = token ? verifyToken(token) : null; + if (!payload) return { authenticated: false }; + return { authenticated: true, user: payload.user }; + }); +} diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts index 25f93f3..c25da87 100644 --- a/backend/src/routes/memes.ts +++ b/backend/src/routes/memes.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import db from '../db.js'; import { buildFilePath, deleteFile, getExtension } from '../services/storage.js'; import { extractMeta, resizeImage, saveBuffer } from '../services/image.js'; +import { requireAuth } from '../auth.js'; import type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js'; const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); @@ -117,7 +118,7 @@ export async function memesRoutes(app: FastifyInstance) { }); // Upload meme - app.post('/api/memes', async (req, reply) => { + app.post('/api/memes', { preHandler: requireAuth }, async (req, reply) => { const data = await req.file(); if (!data) return reply.status(400).send({ error: 'No file uploaded' }); @@ -155,7 +156,7 @@ export async function memesRoutes(app: FastifyInstance) { }); // Update meme metadata - app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', async (req, reply) => { + app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => { const meme = getMemeById(req.params.id); if (!meme) return reply.status(404).send({ error: 'Not found' }); @@ -173,7 +174,7 @@ export async function memesRoutes(app: FastifyInstance) { }); // Delete meme (children cascade) - app.delete<{ Params: { id: string } }>('/api/memes/:id', async (req, reply) => { + app.delete<{ Params: { id: string } }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => { const meme = getMemeById(req.params.id); if (!meme) return reply.status(404).send({ error: 'Not found' }); @@ -194,6 +195,7 @@ export async function memesRoutes(app: FastifyInstance) { // Non-destructive rescale app.post<{ Params: { id: string }; Body: RescaleBody }>( '/api/memes/:id/rescale', + { preHandler: requireAuth }, async (req, reply) => { const parent = getMemeById(req.params.id); if (!parent) return reply.status(404).send({ error: 'Not found' }); diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts index dc0c74b..ba2dc63 100644 --- a/backend/src/routes/tags.ts +++ b/backend/src/routes/tags.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; import db from '../db.js'; +import { requireAuth } from '../auth.js'; export async function tagsRoutes(app: FastifyInstance) { app.get('/api/tags', async () => { @@ -34,7 +35,7 @@ export async function tagsRoutes(app: FastifyInstance) { } }); - app.delete<{ Params: { id: string } }>('/api/tags/:id', async (req, reply) => { + app.delete<{ Params: { id: string } }>('/api/tags/:id', { preHandler: requireAuth }, async (req, reply) => { const id = Number(req.params.id); if (!id) return reply.status(400).send({ error: 'Invalid tag id' }); db.prepare('DELETE FROM tags WHERE id = ?').run(id); diff --git a/docker-compose.yml b/docker-compose.yml index 48abf54..aead197 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: PORT: "3000" DATA_DIR: /data PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASS: ${ADMIN_PASS:-changeme} healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/tags"] interval: 30s diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx new file mode 100644 index 0000000..fd5bc07 --- /dev/null +++ b/frontend/src/components/LoginModal.tsx @@ -0,0 +1,87 @@ +import { useState, useRef, useEffect } from 'react'; +import { X, Lock, LogIn } from 'lucide-react'; +import { useLogin } from '../hooks/useAuth'; + +interface Props { + onClose: () => void; + onSuccess?: () => void; +} + +export function LoginModal({ onClose, onSuccess }: Props) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const login = useLogin(); + const userRef = useRef(null); + + useEffect(() => { + userRef.current?.focus(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + await login.mutateAsync({ username, password }); + onSuccess?.(); + onClose(); + } + + return ( +
+
+ {/* Header */} +
+
+ +

Admin Login

+
+ +
+ +
+

+ Sign in to upload, edit, and manage memes. +

+ +
+ + setUsername(e.target.value)} + autoComplete="username" + required + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" + /> +
+ + {login.error && ( +

{(login.error as Error).message}

+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx index 7961c1a..29f7d13 100644 --- a/frontend/src/components/MemeDetail.tsx +++ b/frontend/src/components/MemeDetail.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { X, Minimize2, Trash2, Edit2, Check, Layers } from 'lucide-react'; import { useMeme, useDeleteMeme, useUpdateMeme } from '../hooks/useMemes'; +import { useAuth } from '../hooks/useAuth'; import { SharePanel } from './SharePanel'; import { RescaleModal } from './RescaleModal'; import { api, type Meme } from '../api/client'; @@ -26,6 +27,8 @@ export function MemeDetail({ memeId, onClose }: Props) { const { data, isLoading, refetch } = useMeme(memeId); const deleteMeme = useDeleteMeme(); const updateMeme = useUpdateMeme(); + const { data: auth } = useAuth(); + const isAdmin = auth?.authenticated === true; const [editing, setEditing] = useState(false); const [editTitle, setEditTitle] = useState(''); @@ -94,24 +97,26 @@ export function MemeDetail({ memeId, onClose }: Props) {

{meme.title}

)}
- {editing ? ( - - ) : ( - + {isAdmin && ( + editing ? ( + + ) : ( + + ) )} - {!meme.parent_id && ( + {isAdmin && !meme.parent_id && ( )} - + {isAdmin && ( + + )} @@ -164,7 +171,7 @@ export function MemeDetail({ memeId, onClose }: Props) { {/* Description */}

Description

- {editing ? ( + {isAdmin && editing ? (