chore: smaller docker image, simplification pass on environment variables, fix direct navigation without vite
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
This commit is contained in:
54
.env.example
54
.env.example
@@ -1,32 +1,36 @@
|
|||||||
# ── API server ────────────────────────────────────────────────────────────────
|
# ── API server ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Protocol the API server listens on (http or https)
|
# Public-facing URL of the server. Used for CORS/WebSocket origin checks,
|
||||||
GERBEUR_PROTOCOL=http
|
# email links, and OG image URLs. Behind a reverse proxy, set this to the
|
||||||
|
# externally-visible URL with no trailing slash (e.g. https://example.com).
|
||||||
|
# Defaults to http://localhost:GERBEUR_PORT for local development.
|
||||||
|
# GERBEUR_PUBLIC_URL=https://example.com
|
||||||
|
|
||||||
# Public hostname for the API (used in generated URLs, e.g. OG image tags)
|
# Site name used in OG meta tags
|
||||||
GERBEUR_HOSTNAME=localhost
|
GERBEUR_SITE_NAME=gerbeur
|
||||||
|
|
||||||
|
# Port the API server listens on (the container's internal port)
|
||||||
|
GERBEUR_PORT=8000
|
||||||
|
|
||||||
# Network interface Oak binds to. Default: 0.0.0.0 (all interfaces, required for Docker).
|
# Network interface Oak binds to. Default: 0.0.0.0 (all interfaces, required for Docker).
|
||||||
# Set to 127.0.0.1 to restrict to loopback only.
|
# Set to 127.0.0.1 to restrict to loopback only.
|
||||||
GERBEUR_LISTEN_HOST=0.0.0.0
|
GERBEUR_LISTEN_HOST=0.0.0.0
|
||||||
|
|
||||||
# Port the API server listens on
|
# Base URL of the frontend, used in email links (e.g. password reset) and
|
||||||
GERBEUR_PORT=8000
|
# automatically added to the CORS/WebSocket allowed-origins list.
|
||||||
|
# Defaults to GERBEUR_PUBLIC_URL — correct for single-container deployments.
|
||||||
|
# In prod with a separate frontend host, set this to the frontend origin; that
|
||||||
|
# is the only variable you need for that case (no GERBEUR_ALLOWED_ORIGINS entry required).
|
||||||
|
# Example: https://app.example.com
|
||||||
|
# GERBEUR_FRONTEND_URL=
|
||||||
|
|
||||||
# Comma-separated list of extra origins allowed to reach the API/WS cross-origin.
|
# Comma-separated list of *extra* origins allowed to reach the API/WS.
|
||||||
# The server's own BASE_URL is always allowed automatically.
|
# GERBEUR_PUBLIC_URL and GERBEUR_FRONTEND_URL are always included automatically.
|
||||||
# In dev: add Vite's dev server URL (check actual host/port in Vite output).
|
# Typically only needed in dev to whitelist the Vite dev server.
|
||||||
# In prod with a separate frontend host: add that public frontend origin here.
|
# Defaults to empty (no extra origins).
|
||||||
# Example: http://localhost:3000,http://127.0.0.1:3000
|
# Example: http://localhost:3000,http://127.0.0.1:3000
|
||||||
GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
# Base URL of the frontend, used in email links (e.g. password reset).
|
|
||||||
# Defaults to the API's own BASE_URL — correct when the API serves the frontend
|
|
||||||
# (standard single-container deployment). Override when running the frontend on
|
|
||||||
# a separate host or port (e.g. Vite dev server or a CDN origin).
|
|
||||||
# Example: http://localhost:3000
|
|
||||||
# GERBEUR_FRONTEND_URL=
|
|
||||||
|
|
||||||
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
||||||
GERBEUR_JWT_SECRET=
|
GERBEUR_JWT_SECRET=
|
||||||
|
|
||||||
@@ -44,13 +48,13 @@ GERBEUR_FROM_EMAIL=
|
|||||||
# Use \n for line breaks in single-line .env values, or use a quoted multiline block.
|
# Use \n for line breaks in single-line .env values, or use a quoted multiline block.
|
||||||
GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!"
|
GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!"
|
||||||
|
|
||||||
# Site name used in OG meta tags
|
|
||||||
GERBEUR_SITE_NAME=gerbeur
|
|
||||||
|
|
||||||
# ── Frontend (Vite) ───────────────────────────────────────────────────────────
|
# ── Frontend (Vite) ───────────────────────────────────────────────────────────
|
||||||
# These must be prefixed with VITE_ to be exposed to the client bundle.
|
# These must be prefixed with VITE_ to be exposed to the client bundle.
|
||||||
# They tell the frontend where the API server is reachable from the browser.
|
# Only needed when the API runs on a different host/port than the frontend.
|
||||||
|
# In standard dev (API on :8000, Vite on :3000) these are not required —
|
||||||
VITE_API_PROTOCOL=http
|
# the frontend automatically uses http://localhost:8000 in dev mode.
|
||||||
VITE_API_HOSTNAME=localhost
|
# In prod (single container) they are not used — the frontend uses relative URLs.
|
||||||
VITE_API_PORT=8000
|
# Only set these when deploying the API and frontend on separate hosts.
|
||||||
|
# VITE_API_PROTOCOL=https
|
||||||
|
# VITE_API_HOSTNAME=api.example.com
|
||||||
|
# VITE_API_PORT=443
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \
|
|||||||
RUN deno task build
|
RUN deno task build
|
||||||
|
|
||||||
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
|
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
|
||||||
FROM denoland/deno:2.7.11
|
FROM denoland/deno:alpine-2.7.11
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ RUN deno install
|
|||||||
|
|
||||||
COPY api/ ./api/
|
COPY api/ ./api/
|
||||||
COPY --from=builder /app/dist/ ./dist/
|
COPY --from=builder /app/dist/ ./dist/
|
||||||
COPY public/ ./public/
|
|
||||||
|
|
||||||
# Persistent data: database and uploaded/generated files must be mounted as volumes.
|
# Persistent data: database and uploaded/generated files must be mounted as volumes.
|
||||||
VOLUME ["/app/api/sql", "/app/api/uploads"]
|
VOLUME ["/app/api/sql", "/app/api/uploads"]
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -39,14 +39,13 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default `adm
|
|||||||
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
|
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||||
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
||||||
| `GERBEUR_PROTOCOL` | Protocol the API server listens on (`http` or `https`) | `http` |
|
| `GERBEUR_PUBLIC_URL` | Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | `http://localhost:GERBEUR_PORT` |
|
||||||
| `GERBEUR_HOSTNAME` | Public hostname used in generated URLs (e.g. OG image tags) | `localhost` |
|
| `GERBEUR_PORT` | Internal port Oak listens on | `8000` |
|
||||||
| `GERBEUR_PORT` | API server port | `8000` |
|
|
||||||
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
|
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
|
||||||
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` |
|
| `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` |
|
||||||
| `GERBEUR_FRONTEND_URL` | Frontend base URL used in email links (e.g. password reset); defaults to the API's own `BASE_URL` | `BASE_URL` |
|
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) |
|
||||||
| `GERBEUR_SITE_NAME` | Site name used in OG meta tags and emails | `gerbeur` |
|
| `GERBEUR_SITE_NAME` | Site name used in OG meta tags and emails | `gerbeur` |
|
||||||
| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`) | unset |
|
| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`) | unset |
|
||||||
| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset |
|
| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset |
|
||||||
@@ -59,7 +58,7 @@ See [`.env.example`](.env.example) for the full list with descriptions. Key vari
|
|||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
|
|
||||||
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. The server's own `BASE_URL` is always allowed for HTTP/WebSocket requests automatically.
|
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t gerbeur .
|
docker build -t gerbeur .
|
||||||
@@ -69,9 +68,7 @@ docker run -d \
|
|||||||
-v gerbeur-db:/app/api/sql \
|
-v gerbeur-db:/app/api/sql \
|
||||||
-v gerbeur-uploads:/app/api/uploads \
|
-v gerbeur-uploads:/app/api/uploads \
|
||||||
-e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
|
-e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
|
||||||
-e GERBEUR_PROTOCOL=https \
|
-e GERBEUR_PUBLIC_URL=https://example.com \
|
||||||
-e GERBEUR_HOSTNAME=example.com \
|
|
||||||
-e GERBEUR_PORT=8000 \
|
|
||||||
--name gerbeur \
|
--name gerbeur \
|
||||||
gerbeur
|
gerbeur
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
|
|
||||||
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
|
|
||||||
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
||||||
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
|
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
|
||||||
export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || "";
|
export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || "";
|
||||||
@@ -18,12 +16,18 @@ export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
|
|||||||
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
|
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
|
||||||
// Set to 127.0.0.1 to restrict to loopback only.
|
// Set to 127.0.0.1 to restrict to loopback only.
|
||||||
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
|
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
|
||||||
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
|
// Public-facing URL of the server — used for CORS, WebSocket origin checks,
|
||||||
|
// email links, and OG image URLs. Behind a reverse proxy, set this to the
|
||||||
|
// externally-visible URL (e.g. https://example.com). No trailing slash.
|
||||||
|
// Defaults to http://localhost:PORT for local development.
|
||||||
|
export const PUBLIC_URL =
|
||||||
|
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
|
||||||
|
`http://localhost:${PORT}`;
|
||||||
// In single-container deployments the API serves the frontend, so FRONTEND_URL
|
// In single-container deployments the API serves the frontend, so FRONTEND_URL
|
||||||
// equals BASE_URL. Override with GERBEUR_FRONTEND_URL when running the frontend
|
// equals PUBLIC_URL. Override with GERBEUR_FRONTEND_URL when running the
|
||||||
// on a separate host/port (e.g. Vite dev server or a dedicated CDN origin).
|
// frontend on a separate host/port (e.g. Vite dev server or a CDN origin).
|
||||||
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
|
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
|
||||||
BASE_URL;
|
PUBLIC_URL;
|
||||||
export const DB_PATH = "api/sql/gerbeur.db";
|
export const DB_PATH = "api/sql/gerbeur.db";
|
||||||
|
|
||||||
// Upload/files
|
// Upload/files
|
||||||
@@ -87,15 +91,13 @@ export const VALIDATION = {
|
|||||||
// SEO/OG
|
// SEO/OG
|
||||||
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
||||||
|
|
||||||
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
|
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "";
|
||||||
"http://localhost:3000";
|
|
||||||
export const ALLOWED_ORIGINS: string[] = Array.from(
|
export const ALLOWED_ORIGINS: string[] = Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
BASE_URL,
|
PUBLIC_URL,
|
||||||
...(
|
// FRONTEND_URL is auto-included so the separate-frontend case only needs
|
||||||
rawOrigins
|
// GERBEUR_FRONTEND_URL — no need to repeat it in GERBEUR_ALLOWED_ORIGINS.
|
||||||
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
FRONTEND_URL,
|
||||||
: []
|
...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean),
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export function routeStaticFilesFrom(staticPaths: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html so client-side routes work on direct navigation
|
||||||
|
await send(context, "/index.html", { root: staticPaths[0] });
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import notificationsRouter from "./routes/notifications.ts";
|
|||||||
import invitesRouter from "./routes/invites.ts";
|
import invitesRouter from "./routes/invites.ts";
|
||||||
import searchRouter from "./routes/search.ts";
|
import searchRouter from "./routes/search.ts";
|
||||||
|
|
||||||
import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts";
|
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
|
||||||
import { errorMiddleware } from "./middleware/error.ts";
|
import { errorMiddleware } from "./middleware/error.ts";
|
||||||
import { ogMiddleware } from "./middleware/og.ts";
|
import { ogMiddleware } from "./middleware/og.ts";
|
||||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||||
@@ -100,7 +100,7 @@ app.use(routeStaticFilesFrom([
|
|||||||
|
|
||||||
app.addEventListener(
|
app.addEventListener(
|
||||||
"listen",
|
"listen",
|
||||||
() => console.log(`Server listening on ${BASE_URL}`),
|
() => console.log(`Server listening on ${PUBLIC_URL}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.addEventListener(
|
app.addEventListener(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RichContentProvider } from "../rich-content-service.ts";
|
|||||||
import { getDump } from "../dump-service.ts";
|
import { getDump } from "../dump-service.ts";
|
||||||
import { getUserByUsername } from "../user-service.ts";
|
import { getUserByUsername } from "../user-service.ts";
|
||||||
import { getPlaylistById } from "../playlist-service.ts";
|
import { getPlaylistById } from "../playlist-service.ts";
|
||||||
import { BASE_URL } from "../../config.ts";
|
import { PUBLIC_URL } from "../../config.ts";
|
||||||
|
|
||||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||||
const USER_RE = /^\/users\/([^/]+)$/;
|
const USER_RE = /^\/users\/([^/]+)$/;
|
||||||
@@ -31,7 +31,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
const dump = getDump(dumpMatch[1]);
|
const dump = getDump(dumpMatch[1]);
|
||||||
let thumbnailUrl: string | undefined;
|
let thumbnailUrl: string | undefined;
|
||||||
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) {
|
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) {
|
||||||
thumbnailUrl = `${BASE_URL}/api/files/${dump.id}`;
|
thumbnailUrl = `${PUBLIC_URL}/api/files/${dump.id}`;
|
||||||
} else if (dump.richContent?.thumbnailUrl) {
|
} else if (dump.richContent?.thumbnailUrl) {
|
||||||
thumbnailUrl = dump.richContent.thumbnailUrl;
|
thumbnailUrl = dump.richContent.thumbnailUrl;
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
if (userMatch) {
|
if (userMatch) {
|
||||||
const user = getUserByUsername(userMatch[1]);
|
const user = getUserByUsername(userMatch[1]);
|
||||||
const thumbnailUrl = user.avatarMime
|
const thumbnailUrl = user.avatarMime
|
||||||
? `${BASE_URL}/api/avatars/${user.id}`
|
? `${PUBLIC_URL}/api/avatars/${user.id}`
|
||||||
: undefined;
|
: undefined;
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
@@ -65,7 +65,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
if (playlistMatch) {
|
if (playlistMatch) {
|
||||||
const playlist = getPlaylistById(playlistMatch[1]);
|
const playlist = getPlaylistById(playlistMatch[1]);
|
||||||
const thumbnailUrl = playlist.imageMime
|
const thumbnailUrl = playlist.imageMime
|
||||||
? `${BASE_URL}/api/playlists/${playlist.id}/image`
|
? `${PUBLIC_URL}/api/playlists/${playlist.id}/image`
|
||||||
: undefined;
|
: undefined;
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
|
|||||||
@@ -2,4 +2,10 @@ import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-comp
|
|||||||
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
||||||
|
|
||||||
const config = getConfig({ cwd: Deno.cwd() });
|
const config = getConfig({ cwd: Deno.cwd() });
|
||||||
await compile(config, { watch: false, namespace: undefined, typescript: false, allowEmpty: true, workersOptions: { poolSize: 0 } });
|
await compile(config, {
|
||||||
|
watch: false,
|
||||||
|
namespace: undefined,
|
||||||
|
typescript: false,
|
||||||
|
allowEmpty: true,
|
||||||
|
workersOptions: { poolSize: 0 },
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,4 +2,9 @@ import extract from "../node_modules/@lingui/cli/dist/lingui-extract.js";
|
|||||||
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
||||||
|
|
||||||
const config = getConfig({ cwd: Deno.cwd() });
|
const config = getConfig({ cwd: Deno.cwd() });
|
||||||
await extract(config, { verbose: false, watch: false, files: [], workersOptions: { poolSize: 0 } });
|
await extract(config, {
|
||||||
|
verbose: false,
|
||||||
|
watch: false,
|
||||||
|
files: [],
|
||||||
|
workersOptions: { poolSize: 0 },
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user