Merge pull request #4 from Salastil/claude/source-management-content-1w2yno
Replace admin username/password with a per-launch API key, and disabl…
This commit is contained in:
@@ -15,11 +15,14 @@ doesn't know or care which one it's talking to. Switch between them by changing
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
cp .env.example .env # set ADMIN_PASSWORD at minimum
|
cp .env.example .env
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The console prints an admin API key on every startup (a fresh one each time) — copy
|
||||||
|
it into the admin login page. See `backend/README.md` for details.
|
||||||
|
|
||||||
See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter,
|
See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter,
|
||||||
image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama
|
image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama
|
||||||
isn't reachable.
|
isn't reachable.
|
||||||
@@ -49,7 +52,7 @@ Open http://localhost:5173.
|
|||||||
- **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer
|
- **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer
|
||||||
- **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier
|
- **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier
|
||||||
- **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`).
|
- **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`).
|
||||||
- **Admin panel** (`/admin/settings`) — six tabs, all wired to the mock backend's `/api/admin/*` routes:
|
- **Admin panel** (`/admin/settings`) — disabled by default; set `ADMIN_PANEL_ENABLED=true` in `frontend/.env` to turn on the cog icon and the `/admin/*` pages (see `frontend/.env.example`). Six tabs, all wired to the mock backend's `/api/admin/*` routes:
|
||||||
- **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry
|
- **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry
|
||||||
- **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds
|
- **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds
|
||||||
- **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog
|
- **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ FRONTEND_ORIGIN=http://localhost:5173
|
|||||||
DB_PATH=./data/homefeed.db
|
DB_PATH=./data/homefeed.db
|
||||||
MEDIA_DIR=./data/media
|
MEDIA_DIR=./data/media
|
||||||
|
|
||||||
# Seeded once on first run — change the password after logging in.
|
# There's no admin username/password to configure here — the backend generates a
|
||||||
ADMIN_USERNAME=admin
|
# random API key on every startup and prints it to the console. Copy that key into
|
||||||
ADMIN_PASSWORD=change-me-immediately
|
# the admin login page (every /api/admin/* request requires it as an X-Api-Key
|
||||||
|
# header). It changes on every restart, so check the console output each time.
|
||||||
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|||||||
+12
-3
@@ -10,7 +10,6 @@ contract the frontend already consumes from the mock backend — plus the full
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# edit .env — at minimum set ADMIN_PASSWORD to something real
|
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -18,6 +17,14 @@ npm run dev
|
|||||||
Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of
|
Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of
|
||||||
the mock backend and everything else keeps working unchanged — same API contract.
|
the mock backend and everything else keeps working unchanged — same API contract.
|
||||||
|
|
||||||
|
On startup, the console prints an admin API key — a fresh random value generated
|
||||||
|
every time the process starts (see `api/apiKey.ts`), not stored anywhere and not
|
||||||
|
configurable via `.env`. Every `/api/admin/*` request must send it as an
|
||||||
|
`X-Api-Key` header (enforced in `api/auth.ts`); the admin login page just asks for
|
||||||
|
this key and stashes it in the browser's `localStorage` rather than issuing its own
|
||||||
|
session. Restarting the backend invalidates the previous key — check the console
|
||||||
|
each time.
|
||||||
|
|
||||||
You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in
|
You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in
|
||||||
the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at
|
the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at
|
||||||
minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g.
|
minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g.
|
||||||
@@ -88,8 +95,10 @@ real RSS parsing, real HTTP calls to a stub Ollama server, real media download t
|
|||||||
disk, real tag dedup across separate synthesis calls):
|
disk, real tag dedup across separate synthesis calls):
|
||||||
|
|
||||||
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
|
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
|
||||||
- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the
|
- Per-launch API key auth (random key printed to the console on every startup,
|
||||||
configured frontend origin) protecting all `/api/admin/*` routes
|
checked via a timing-safe comparison against an `X-Api-Key` header on every
|
||||||
|
request, CORS locked to the configured frontend origin) protecting all
|
||||||
|
`/api/admin/*` routes
|
||||||
- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
|
- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
|
||||||
(configurable field mapping)
|
(configurable field mapping)
|
||||||
- Poller respecting per-source poll intervals
|
- Poller respecting per-source poll intervals
|
||||||
|
|||||||
Generated
-34
@@ -9,7 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.1",
|
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"fastify": "^5.10.0",
|
"fastify": "^5.10.0",
|
||||||
@@ -696,26 +695,6 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"fast-uri": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/cookie": {
|
|
||||||
"version": "11.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz",
|
|
||||||
"integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^2.0.0",
|
|
||||||
"fastify-plugin": "^6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "11.3.0",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
||||||
@@ -1295,19 +1274,6 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
},
|
},
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.1",
|
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"fastify": "^5.10.0",
|
"fastify": "^5.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Generated once per process start — not persisted, not configurable via env. Every
|
||||||
|
// restart invalidates the previous key, which is the whole point: the only way to
|
||||||
|
// learn the current key is to have console/log access to the running process (see
|
||||||
|
// index.ts's startup banner), which is a meaningfully different trust boundary than a
|
||||||
|
// password someone could guess or brute-force over the network.
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
export const ADMIN_API_KEY = randomBytes(24).toString('hex');
|
||||||
+22
-35
@@ -1,44 +1,31 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { verifyPassword } from './password.js';
|
import { ADMIN_API_KEY } from './apiKey.js';
|
||||||
|
|
||||||
const SESSION_COOKIE = 'homefeed_session';
|
function isValidKey(provided: string | undefined): boolean {
|
||||||
|
if (!provided) return false;
|
||||||
|
// Buffers of mismatched length would make timingSafeEqual throw rather than
|
||||||
|
// return false — checking length first keeps this a normal "wrong key" case for
|
||||||
|
// any header of a different length rather than a runtime error.
|
||||||
|
const providedBuf = Buffer.from(provided);
|
||||||
|
const expectedBuf = Buffer.from(ADMIN_API_KEY);
|
||||||
|
if (providedBuf.length !== expectedBuf.length) return false;
|
||||||
|
return timingSafeEqual(providedBuf, expectedBuf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards every /api/admin/* route with the process's current API key (see
|
||||||
|
* api/apiKey.ts) — there's no session or login endpoint anymore: the key itself is
|
||||||
|
* the credential, checked on every single request, exactly the way a bot or curl
|
||||||
|
* script hitting these routes unauthenticated is meant to be stopped cold.
|
||||||
|
*/
|
||||||
export async function registerAuth(app: FastifyInstance) {
|
export async function registerAuth(app: FastifyInstance) {
|
||||||
app.post('/api/admin/login', async (req, reply) => {
|
|
||||||
const { username, password } = req.body as { username?: string; password?: string };
|
|
||||||
if (!username || !password) return reply.code(400).send({ error: 'username and password required' });
|
|
||||||
|
|
||||||
const user = getAdminUserByUsername(username);
|
|
||||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
|
||||||
// Deliberately generic — doesn't reveal whether the username exists.
|
|
||||||
return reply.code(401).send({ error: 'invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createSession(req.ip ?? null);
|
|
||||||
reply.setCookie(SESSION_COOKIE, session.id, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
|
||||||
path: '/',
|
|
||||||
expires: new Date(session.expiresAt)
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/admin/logout', async (req, reply) => {
|
|
||||||
const sessionId = req.cookies[SESSION_COOKIE];
|
|
||||||
if (sessionId) deleteSession(sessionId);
|
|
||||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Guards every /api/admin/* route except login itself.
|
|
||||||
app.addHook('preHandler', async (req, reply) => {
|
app.addHook('preHandler', async (req, reply) => {
|
||||||
if (!req.url.startsWith('/api/admin/') || req.url === '/api/admin/login') return;
|
if (!req.url.startsWith('/api/admin/')) return;
|
||||||
|
|
||||||
const sessionId = req.cookies[SESSION_COOKIE];
|
const header = req.headers['x-api-key'];
|
||||||
if (!sessionId || !isSessionValid(sessionId)) {
|
const provided = Array.isArray(header) ? header[0] : header;
|
||||||
|
if (!isValidKey(provided)) {
|
||||||
return reply.code(401).send({ error: 'unauthorized' });
|
return reply.code(401).send({ error: 'unauthorized' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
const KEY_LEN = 64;
|
|
||||||
|
|
||||||
export function hashPassword(password: string): string {
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
const hash = scryptSync(password, salt, KEY_LEN);
|
|
||||||
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyPassword(password: string, stored: string): boolean {
|
|
||||||
const [saltHex, hashHex] = stored.split(':');
|
|
||||||
if (!saltHex || !hashHex) return false;
|
|
||||||
const salt = Buffer.from(saltHex, 'hex');
|
|
||||||
const expected = Buffer.from(hashHex, 'hex');
|
|
||||||
const actual = scryptSync(password, salt, KEY_LEN);
|
|
||||||
if (actual.length !== expected.length) return false;
|
|
||||||
return timingSafeEqual(actual, expected);
|
|
||||||
}
|
|
||||||
+14
-8
@@ -1,10 +1,9 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import cookie from '@fastify/cookie';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { migrate } from './storage/db/index.js';
|
import { migrate } from './storage/db/index.js';
|
||||||
import { ensureAdminUserSeeded } from './storage/db/auth.js';
|
import { ADMIN_API_KEY } from './api/apiKey.js';
|
||||||
import { registerAuth } from './api/auth.js';
|
import { registerAuth } from './api/auth.js';
|
||||||
import { registerPublicRoutes } from './api/public.js';
|
import { registerPublicRoutes } from './api/public.js';
|
||||||
import { registerAdminRoutes } from './api/admin.js';
|
import { registerAdminRoutes } from './api/admin.js';
|
||||||
@@ -15,12 +14,21 @@ const PORT = Number(process.env.PORT) || 4000;
|
|||||||
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
|
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
|
||||||
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
|
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
|
||||||
|
|
||||||
|
function printApiKeyBanner() {
|
||||||
|
const line = '='.repeat(64);
|
||||||
|
// Deliberately console.log, not the DB-backed logger — the Logs tab in the admin
|
||||||
|
// panel is itself behind this key, so printing there would be unreachable until
|
||||||
|
// you already have the key. This is the one and only place it's ever surfaced.
|
||||||
|
console.log(`\n${line}`);
|
||||||
|
console.log(' Homefeed admin API key (required for every /api/admin/* request)');
|
||||||
|
console.log(` ${ADMIN_API_KEY}`);
|
||||||
|
console.log(' This key is generated fresh on every restart — it will not be the same next time.');
|
||||||
|
console.log(`${line}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
migrate();
|
migrate();
|
||||||
ensureAdminUserSeeded(
|
printApiKeyBanner();
|
||||||
process.env.ADMIN_USERNAME || 'admin',
|
|
||||||
process.env.ADMIN_PASSWORD || 'change-me-immediately'
|
|
||||||
);
|
|
||||||
|
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
|
|
||||||
@@ -32,10 +40,8 @@ async function main() {
|
|||||||
// reaches a route handler.
|
// reaches a route handler.
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: FRONTEND_ORIGIN,
|
origin: FRONTEND_ORIGIN,
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
|
||||||
});
|
});
|
||||||
await app.register(cookie);
|
|
||||||
|
|
||||||
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
|
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
|
||||||
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
|
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { runEventRecaps } from './eventsRecap.js';
|
|||||||
import { runRetentionSweep } from './retention.js';
|
import { runRetentionSweep } from './retention.js';
|
||||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||||
import * as settingsDb from '../storage/db/settings.js';
|
import * as settingsDb from '../storage/db/settings.js';
|
||||||
import { pruneExpiredSessions } from '../storage/db/auth.js';
|
|
||||||
import { logger } from '../storage/db/logs.js';
|
import { logger } from '../storage/db/logs.js';
|
||||||
|
|
||||||
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
||||||
@@ -56,7 +55,6 @@ export function startScheduler() {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
try {
|
||||||
runRetentionSweep(settingsDb.getSettings());
|
runRetentionSweep(settingsDb.getSettings());
|
||||||
pruneExpiredSessions();
|
|
||||||
logger.info('retention', 'Retention sweep completed');
|
logger.info('retention', 'Retention sweep completed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('retention', `Retention tick failed: ${(err as Error).message}`);
|
logger.error('retention', `Retention tick failed: ${(err as Error).message}`);
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { db } from './index.js';
|
|
||||||
import { hashPassword } from '../../api/password.js';
|
|
||||||
import { logger } from './logs.js';
|
|
||||||
|
|
||||||
const SESSION_TTL_HOURS = 24;
|
|
||||||
|
|
||||||
export function ensureAdminUserSeeded(defaultUsername: string, defaultPassword: string) {
|
|
||||||
const existing = db.prepare('SELECT id FROM admin_users LIMIT 1').get();
|
|
||||||
if (existing) return;
|
|
||||||
db.prepare('INSERT INTO admin_users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)').run(
|
|
||||||
randomUUID(),
|
|
||||||
defaultUsername,
|
|
||||||
hashPassword(defaultPassword),
|
|
||||||
new Date().toISOString()
|
|
||||||
);
|
|
||||||
logger.warn('auth', `Seeded initial admin user "${defaultUsername}". Change this password after first login.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminUserByUsername(username: string) {
|
|
||||||
return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
|
|
||||||
| { id: string; username: string; password_hash: string }
|
|
||||||
| undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSession(ip: string | null): { id: string; expiresAt: string } {
|
|
||||||
const id = randomUUID();
|
|
||||||
const now = new Date();
|
|
||||||
const expiresAt = new Date(now.getTime() + SESSION_TTL_HOURS * 3600_000).toISOString();
|
|
||||||
db.prepare('INSERT INTO sessions (id, created_at, expires_at, ip) VALUES (?, ?, ?, ?)').run(
|
|
||||||
id,
|
|
||||||
now.toISOString(),
|
|
||||||
expiresAt,
|
|
||||||
ip
|
|
||||||
);
|
|
||||||
return { id, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSessionValid(id: string): boolean {
|
|
||||||
const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) as
|
|
||||||
| { expires_at: string }
|
|
||||||
| undefined;
|
|
||||||
if (!row) return false;
|
|
||||||
return new Date(row.expires_at).getTime() > Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSession(id: string) {
|
|
||||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pruneExpiredSessions() {
|
|
||||||
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(new Date().toISOString());
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,13 @@ db.exec('PRAGMA journal_mode = WAL;');
|
|||||||
db.exec('PRAGMA foreign_keys = ON;');
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
|
||||||
export function migrate() {
|
export function migrate() {
|
||||||
|
// Admin auth moved from username/password + sessions to a per-launch API key (see
|
||||||
|
// api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live
|
||||||
|
// session in them, have no further purpose and are dropped rather than left as
|
||||||
|
// orphaned schema/data.
|
||||||
|
db.exec('DROP TABLE IF EXISTS admin_users;');
|
||||||
|
db.exec('DROP TABLE IF EXISTS sessions;');
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sources (
|
CREATE TABLE IF NOT EXISTS sources (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -133,20 +140,6 @@ export function migrate() {
|
|||||||
is_default INTEGER NOT NULL DEFAULT 0
|
is_default INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
ip TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Where the frontend talks to the backend (real or mock). Also settable at runtime
|
||||||
|
# via the connection setup screen, which saves to this browser's localStorage and
|
||||||
|
# takes priority over this build-time value — see src/lib/config.ts.
|
||||||
|
VITE_BACKEND_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# The admin panel (cog icon in the masthead, and the /admin/* pages themselves) is
|
||||||
|
# disabled by default on every deployment. Set this to "true" to turn it on for a
|
||||||
|
# given deployment. This only controls whether the admin UI renders at all — the
|
||||||
|
# backend's per-launch API key (printed to its console on startup) is what actually
|
||||||
|
# protects every /api/admin/* request regardless of this setting.
|
||||||
|
ADMIN_PANEL_ENABLED=false
|
||||||
Generated
+18
@@ -11,6 +11,7 @@
|
|||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -525,6 +526,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||||
|
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -1281,6 +1292,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.4",
|
"version": "8.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getBackendUrl } from './config';
|
import { getBackendUrl } from './config';
|
||||||
|
import { getApiKey, setApiKey, clearApiKey } from './adminAuth';
|
||||||
import type {
|
import type {
|
||||||
AdminSettings,
|
AdminSettings,
|
||||||
AdminSource,
|
AdminSource,
|
||||||
@@ -13,10 +14,13 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
|
|||||||
// application/json ("Body cannot be empty when content-type is set to
|
// application/json ("Body cannot be empty when content-type is set to
|
||||||
// 'application/json'") — so this header is only attached when there's actually a
|
// 'application/json'") — so this header is only attached when there's actually a
|
||||||
// body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls.
|
// body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls.
|
||||||
const headers = options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers;
|
const headers: Record<string, string> = { ...(options.headers as Record<string, string> | undefined) };
|
||||||
|
if (options.body) headers['Content-Type'] = 'application/json';
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (apiKey) headers['X-Api-Key'] = apiKey;
|
||||||
|
|
||||||
const res = await fetchFn(`${getBackendUrl()}${path}`, {
|
const res = await fetchFn(`${getBackendUrl()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
credentials: 'include',
|
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@@ -29,22 +33,22 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth — there's no backend session to create; "logging in" means storing the
|
||||||
export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise<void> {
|
// entered key locally and confirming it actually works with one real authenticated
|
||||||
const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, {
|
// call (getSettings has no side effects), and "logging out" is just discarding it.
|
||||||
method: 'POST',
|
export async function login(apiKey: string, fetchFn: typeof fetch = fetch): Promise<void> {
|
||||||
credentials: 'include',
|
setApiKey(apiKey);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
body: JSON.stringify({ username, password })
|
await getSettings(fetchFn);
|
||||||
});
|
} catch (err) {
|
||||||
if (!res.ok) {
|
clearApiKey();
|
||||||
const body = await res.json().catch(() => ({}));
|
if ((err as { status?: number }).status === 401) throw new Error('Invalid API key');
|
||||||
throw new Error(body.error || `Login failed (${res.status})`);
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(fetchFn: typeof fetch = fetch): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' });
|
clearApiKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// The admin API key isn't a backend-issued session — it lives entirely in this
|
||||||
|
// browser's localStorage, attached as an X-Api-Key header on every /api/admin/*
|
||||||
|
// request (see adminApi.ts). There's nothing to invalidate server-side on "logout";
|
||||||
|
// clearing it here is the whole operation.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'homefeed:adminApiKey';
|
||||||
|
|
||||||
|
export function getApiKey(): string | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiKey(key: string) {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearApiKey() {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
// The admin panel is off by default on every deployment — it only appears (cog icon
|
||||||
|
// and the /admin/* pages themselves, see admin/+layout.svelte) once this is
|
||||||
|
// explicitly turned on. This is a UI-visibility gate only; the backend's API key
|
||||||
|
// check on every /api/admin/* request is what actually protects it either way.
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
return { adminPanelEnabled: process.env.ADMIN_PANEL_ENABLED === 'true' };
|
||||||
|
};
|
||||||
@@ -37,14 +37,16 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
|
{#if data.adminPanelEnabled}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
|
||||||
<circle cx="12" cy="12" r="3" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
<path
|
<circle cx="12" cy="12" r="3" />
|
||||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
<path
|
||||||
/>
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
</svg>
|
/>
|
||||||
</a>
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { getCategories } from '$lib/api';
|
import { getCategories } from '$lib/api';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch }) => {
|
export const load: LayoutLoad = async ({ fetch, data }) => {
|
||||||
const categories = await getCategories(fetch);
|
const categories = await getCategories(fetch);
|
||||||
return { categories };
|
return { ...data, categories };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { logout } from '$lib/adminApi';
|
import { logout } from '$lib/adminApi';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -10,17 +11,29 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="admin-shell">
|
{#if !data.adminPanelEnabled}
|
||||||
<div class="page admin-inner">
|
<div class="page disabled-notice">
|
||||||
<div class="top-row">
|
<p>The admin panel is disabled on this deployment.</p>
|
||||||
<a class="back" href="/">← Back to site</a>
|
|
||||||
<button class="logout" onclick={handleLogout}>Log out</button>
|
|
||||||
</div>
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<div class="admin-shell">
|
||||||
|
<div class="page admin-inner">
|
||||||
|
<div class="top-row">
|
||||||
|
<a class="back" href="/">← Back to site</a>
|
||||||
|
<button class="logout" onclick={handleLogout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.disabled-notice {
|
||||||
|
padding-top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// The admin section talks to a different origin (the backend) than the frontend
|
// The admin API key lives in this browser's localStorage (see adminAuth.ts), which
|
||||||
// itself. During SSR, the `load` function's fetch runs on the Node server, which has
|
// is only reachable from client-side code — a server-rendered `load` function
|
||||||
// no access to the browser's cookie jar — it can't attach the session cookie to a
|
// running on the Node server during SSR has no access to it and couldn't attach it
|
||||||
// cross-origin request. Disabling SSR here means all admin data fetching happens in
|
// to a cross-origin request. Disabling SSR here means all admin data fetching
|
||||||
// the actual browser instead, where credentials: 'include' works correctly against
|
// happens in the actual browser instead, where the stored key is available.
|
||||||
// whatever cookie the browser already holds from login.
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { login } from '$lib/adminApi';
|
import { login } from '$lib/adminApi';
|
||||||
|
|
||||||
let username = $state('admin');
|
let apiKey = $state('');
|
||||||
let password = $state('');
|
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
error = '';
|
error = '';
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(apiKey);
|
||||||
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
|
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
|
||||||
await goto(redirectTo);
|
await goto(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -27,12 +26,13 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<form onsubmit={handleSubmit}>
|
<form onsubmit={handleSubmit}>
|
||||||
<span class="title">Admin login</span>
|
<span class="title">Admin login</span>
|
||||||
|
<p class="hint">
|
||||||
|
Find the API key printed in your backend server's console output when it starts up. It's
|
||||||
|
generated fresh every restart, so check there again if this one stops working.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label class="field-label" for="username">Username</label>
|
<label class="field-label" for="apiKey">API Key</label>
|
||||||
<input id="username" type="text" bind:value={username} autocomplete="username" />
|
<input id="apiKey" type="password" bind:value={apiKey} autocomplete="off" />
|
||||||
|
|
||||||
<label class="field-label" for="password">Password</label>
|
|
||||||
<input id="password" type="password" bind:value={password} autocomplete="current-password" />
|
|
||||||
|
|
||||||
{#if error}<div class="error">{error}</div>{/if}
|
{#if error}<div class="error">{error}</div>{/if}
|
||||||
|
|
||||||
@@ -58,6 +58,12 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user