mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: swap Redis for Dragonfly, fix remote installs, self-hosted email-only auth
- Replace redis:7-alpine with Dragonfly (Redis-protocol compatible; go-redis client unchanged) in root, infra, and backend-dev compose files. Bounded defaults --proactor_threads/--maxmemory so it boots on small VPSs. - Frontend now defaults to same-origin API calls; nginx proxies /api/* to the backend (WebSocket-aware, docker DNS resolver for upstream re-resolution). Removes the baked localhost:8082 that broke installs reached by IP/domain. - /auth/bootstrap returns configured OAuth providers; the UI hides GitHub/Google buttons unless real credentials exist. Self-hosted stays email/password only - OAuth reserved for a future managed offering. - Sidecar ignores PLACEHOLDER_* OAuth credentials and trusts same-host origins dynamically (Origin host == request Host) so remote installs pass origin checks without config; cross-origin requests still rejected. - install.sh writes ENVIRONMENT=production; COOKIE_SECURE=false in production is now a startup warning instead of fatal (plain-HTTP installs). - Bootstrap failure surfaces an error notice instead of silently rendering the sign-in page. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9ba2bd06c5
commit
060038cb6f
21 files changed
+207
-83
No files matched your search
+16
-5
@@ -1,4 +1,5 @@
|
|||||||
# Local development env file. Use .env.prod for production.
|
# Local development env file. install.sh writes ENVIRONMENT=production for
|
||||||
|
# self-hosted installs - keep development here only for the checkout workflow.
|
||||||
|
|
||||||
# Domain Configuration
|
# Domain Configuration
|
||||||
DOMAIN=localhost
|
DOMAIN=localhost
|
||||||
@@ -28,9 +29,12 @@ AUTO_MIGRATE=true
|
|||||||
MIGRATION_LOCK_TIMEOUT=2m
|
MIGRATION_LOCK_TIMEOUT=2m
|
||||||
SEED_DATA_ON_START=false
|
SEED_DATA_ON_START=false
|
||||||
|
|
||||||
# Redis Configuration
|
# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
|
||||||
REDIS_PASSWORD=your_secure_redis_password
|
REDIS_PASSWORD=your_secure_redis_password
|
||||||
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
||||||
|
# DRAGONFLY_VERSION=latest
|
||||||
|
# DRAGONFLY_THREADS=1
|
||||||
|
# DRAGONFLY_MAXMEMORY=256mb
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
# In production this must be a strong value with at least 32 characters.
|
# In production this must be a strong value with at least 32 characters.
|
||||||
@@ -40,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
|||||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
VITE_API_URL=http://localhost:8082
|
# Optional build-time API URL overrides. Leave empty for the default
|
||||||
VITE_AUTH_URL=http://localhost:8082/api/auth
|
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
||||||
|
# works from any host or IP without rebuilding the image.
|
||||||
|
# VITE_API_URL=https://api.example.com
|
||||||
|
# VITE_AUTH_URL=https://api.example.com/api/auth
|
||||||
AUTH_PORT=3001
|
AUTH_PORT=3001
|
||||||
# In production this must be true.
|
# In production this must be true.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
@@ -54,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true
|
|||||||
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
||||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
||||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
||||||
|
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
|
||||||
|
# automatically, so this only needs entries for cross-origin callers.
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
|
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
|
||||||
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
@@ -64,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:8082
|
BACKEND_URL=http://localhost:8082
|
||||||
|
|
||||||
# OAuth (user auth)
|
# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
|
||||||
|
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
|
||||||
|
# values are treated as unset and the buttons stay hidden.
|
||||||
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
||||||
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
||||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||||
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
||||||
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik
|
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
|
||||||
|
|
||||||
- name: Wait for backend health
|
- name: Wait for backend health
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ click away.
|
|||||||
- **Managed databases** — one-click provisioning with backup/restore
|
- **Managed databases** — one-click provisioning with backup/restore
|
||||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
|
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
|
||||||
- **Auth & security** — Better Auth sessions (email + OAuth), audit logs, vuln scans
|
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans
|
||||||
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -60,6 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh |
|
|||||||
Installs into `./containr`, generates secrets, pulls the published `:latest`
|
Installs into `./containr`, generates secrets, pulls the published `:latest`
|
||||||
images, and starts the stack — UI at `http://localhost:3000`, API at
|
images, and starts the stack — UI at `http://localhost:3000`, API at
|
||||||
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
||||||
|
On first visit you create the owner account (email/password); registration
|
||||||
|
then closes automatically.
|
||||||
|
|
||||||
Override with env vars:
|
Override with env vars:
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
||||||
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
|
app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
|
||||||
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||||
docs/ Guides, OpenAPI spec, design documents
|
docs/ Guides, OpenAPI spec, design documents
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- PostgreSQL 12+
|
- PostgreSQL 12+
|
||||||
- Redis (optional)
|
- Dragonfly (Redis-compatible cache)
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
|
|||||||
pool,
|
pool,
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
const realCredential = (value) => {
|
||||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
const trimmed = (value || '').trim();
|
||||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
|
||||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
};
|
||||||
|
|
||||||
|
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
|
||||||
|
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
|
||||||
|
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
|
||||||
|
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
const socialProviders = {};
|
const socialProviders = {};
|
||||||
if (githubClientId && githubClientSecret) {
|
if (githubClientId && githubClientSecret) {
|
||||||
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustedOrigins = [
|
const staticTrustedOrigins = [
|
||||||
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
||||||
env.FRONTEND_URL,
|
env.FRONTEND_URL,
|
||||||
env.BACKEND_URL,
|
env.BACKEND_URL,
|
||||||
@@ -86,12 +91,33 @@ const trustedOrigins = [
|
|||||||
.map((origin) => (origin || '').trim())
|
.map((origin) => (origin || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
|
||||||
|
// list above cannot cover them. Trust an Origin only when it matches the
|
||||||
|
// request's own Host header - that is genuine same-origin traffic (browsers
|
||||||
|
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
|
||||||
|
// here: clients can set it freely.
|
||||||
|
const resolveTrustedOrigins = (request) => {
|
||||||
|
const origins = new Set(staticTrustedOrigins);
|
||||||
|
const host = request?.headers?.get('host');
|
||||||
|
const origin = request?.headers?.get('origin');
|
||||||
|
if (host && origin) {
|
||||||
|
try {
|
||||||
|
if (new URL(origin).host === host) {
|
||||||
|
origins.add(origin);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed Origin header - leave untrusted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...origins];
|
||||||
|
};
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
||||||
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
||||||
trustedOrigins,
|
trustedOrigins: resolveTrustedOrigins,
|
||||||
database,
|
database,
|
||||||
user: {
|
user: {
|
||||||
modelName: 'auth_users',
|
modelName: 'auth_users',
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ func main() {
|
|||||||
// Setup Gin router
|
// Setup Gin router
|
||||||
if cfg.IsProduction() {
|
if cfg.IsProduction() {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
if !cfg.CookieSecure {
|
||||||
|
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ../../.env.prod
|
- ../../.env.prod
|
||||||
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
|
command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_redis_data:/data
|
- backend_dragonfly_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -52,4 +52,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_postgres_data:
|
backend_postgres_data:
|
||||||
backend_redis_data:
|
backend_dragonfly_data:
|
||||||
@@ -120,13 +120,31 @@ func handleAuthBootstrap(c *gin.Context) {
|
|||||||
mode = "register"
|
mode = "register"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
providers := []string{}
|
||||||
|
for _, provider := range []string{"GITHUB", "GOOGLE"} {
|
||||||
|
if oauthProviderConfigured(provider) {
|
||||||
|
providers = append(providers, strings.ToLower(provider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"has_users": count > 0,
|
"has_users": count > 0,
|
||||||
"user_count": count,
|
"user_count": count,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
|
"providers": providers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauthProviderConfigured reports whether an OAuth provider has real
|
||||||
|
// credentials. Placeholder values from .env.example do not count, so
|
||||||
|
// self-hosted installs default to email/password only.
|
||||||
|
func oauthProviderConfigured(provider string) bool {
|
||||||
|
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
|
||||||
|
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
|
||||||
|
return id != "" && secret != "" &&
|
||||||
|
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
|
||||||
|
}
|
||||||
|
|
||||||
type betterAuthLoginUser struct {
|
type betterAuthLoginUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -215,9 +215,8 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.CookieSecure {
|
// COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
|
||||||
return fmt.Errorf("COOKIE_SECURE must be true in production")
|
// reported as a startup warning in main.go rather than a fatal error.
|
||||||
}
|
|
||||||
|
|
||||||
if c.BetterAuthEnabled {
|
if c.BetterAuthEnabled {
|
||||||
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
func TestValidateAllowsInsecureCookieInProduction(t *testing.T) {
|
||||||
t.Setenv("ENVIRONMENT", "production")
|
t.Setenv("ENVIRONMENT", "production")
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
||||||
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
|||||||
CORSCredentials: true,
|
CORSCredentials: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.Validate()
|
// Plain-HTTP self-hosted installs are a supported production deployment;
|
||||||
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") {
|
// insecure cookies are allowed and surfaced as a startup warning.
|
||||||
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err)
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected production config with insecure cookies to validate, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build-time API URL injection for Vite
|
# Build-time API URL injection for Vite. Empty = same-origin via the nginx
|
||||||
ARG VITE_API_URL=http://localhost:8082
|
# /api/ proxy, which keeps the published image usable from any host.
|
||||||
ARG VITE_AUTH_URL=http://localhost:8082/api/auth
|
ARG VITE_API_URL=
|
||||||
|
ARG VITE_AUTH_URL=
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# WebSocket upgrade support
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -51,6 +57,22 @@ http {
|
|||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
|
||||||
|
# Same-origin API proxy - keeps the published image host-agnostic
|
||||||
|
location /api/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $backend_upstream http://backend:8080;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_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;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function SignInPage() {
|
|||||||
queryKey: ['auth-bootstrap'],
|
queryKey: ['auth-bootstrap'],
|
||||||
queryFn: getAuthBootstrap,
|
queryFn: getAuthBootstrap,
|
||||||
});
|
});
|
||||||
|
const providers = bootstrapQuery.data?.providers ?? [];
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -168,10 +169,14 @@ export function SignInPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
<AuthCard
|
||||||
|
title="Sign In"
|
||||||
|
subtitle={providers.length > 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'}
|
||||||
|
>
|
||||||
{error ? <AuthErrorNotice message={error} /> : null}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{info ? <AuthInfoNotice message={info} /> : null}
|
{info ? <AuthInfoNotice message={info} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||||
|
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
@@ -211,30 +216,38 @@ export function SignInPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="my-5 flex items-center gap-3">
|
{providers.length > 0 ? (
|
||||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
<>
|
||||||
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
<div className="my-5 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
</div>
|
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<button
|
{providers.includes('github') ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void signInWithGitHubProvider()}
|
type="button"
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
onClick={() => void signInWithGitHubProvider()}
|
||||||
>
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||||
<Github size={15} />
|
>
|
||||||
GitHub
|
<Github size={15} />
|
||||||
</button>
|
GitHub
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : null}
|
||||||
onClick={() => void signInWithGoogleProvider()}
|
{providers.includes('google') ? (
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
<button
|
||||||
>
|
type="button"
|
||||||
<GoogleMark />
|
onClick={() => void signInWithGoogleProvider()}
|
||||||
Google
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||||
</button>
|
>
|
||||||
</div>
|
<GoogleMark />
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||||
<span>Need access?</span>
|
<span>Need access?</span>
|
||||||
@@ -292,6 +305,7 @@ export function SignUpPage() {
|
|||||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||||
{error ? <AuthErrorNotice message={error} /> : null}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||||
|
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
|
|||||||
@@ -348,7 +348,8 @@ type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content'
|
|||||||
|
|
||||||
type JsonLike = Record<string, unknown>;
|
type JsonLike = Record<string, unknown>;
|
||||||
|
|
||||||
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
|
const configuredBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
|
||||||
|
const rawBase = configuredBase || window.location.origin;
|
||||||
const normalizedBase = rawBase.replace(/\/$/, '');
|
const normalizedBase = rawBase.replace(/\/$/, '');
|
||||||
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
||||||
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type AuthBootstrap = {
|
|||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
mode: 'register' | 'login';
|
mode: 'register' | 'login';
|
||||||
|
providers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
@@ -32,15 +33,16 @@ export class AuthError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth';
|
const configuredAuthBase = ((import.meta.env.VITE_AUTH_URL as string | undefined) ?? '').trim();
|
||||||
const AUTH_BASE = rawAuthBase.replace(/\/$/, '');
|
const AUTH_BASE = (configuredAuthBase || `${window.location.origin}/api/auth`).replace(/\/$/, '');
|
||||||
|
|
||||||
export function getAuthBaseUrl(): string {
|
export function getAuthBaseUrl(): string {
|
||||||
return AUTH_BASE;
|
return AUTH_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
||||||
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
|
const configuredApiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
|
||||||
|
const apiBase = (configuredApiBase || window.location.origin).replace(/\/$/, '');
|
||||||
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
||||||
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -56,10 +58,15 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
|||||||
throw new AuthError(message, response.status);
|
throw new AuthError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providers = Array.isArray(payload?.providers)
|
||||||
|
? payload.providers.filter((provider): provider is string => typeof provider === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUsers: Boolean(payload?.has_users),
|
hasUsers: Boolean(payload?.has_users),
|
||||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||||
|
providers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import path from 'path'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// Same-origin API path for dev - matches the nginx proxy in the shipped image
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8082',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
|
|||||||
+13
-9
@@ -16,13 +16,17 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123}
|
command:
|
||||||
|
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
|
||||||
|
- --proactor_threads=${DRAGONFLY_THREADS:-1}
|
||||||
|
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
|
||||||
|
- --dir=/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- dragonfly_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
|
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -40,7 +44,7 @@ services:
|
|||||||
- "${API_PORT:-8082}:8080"
|
- "${API_PORT:-8082}:8080"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||||
@@ -83,7 +87,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -100,8 +104,8 @@ services:
|
|||||||
context: ./app/frontend
|
context: ./app/frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth}
|
VITE_AUTH_URL: ${VITE_AUTH_URL:-}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-3000}:80"
|
- "${HTTP_PORT:-3000}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -111,4 +115,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
dragonfly_data:
|
||||||
@@ -68,7 +68,7 @@ Internet → Traefik (Port 80/443)
|
|||||||
├── Frontend (React/Nginx)
|
├── Frontend (React/Nginx)
|
||||||
├── Backend (Go API)
|
├── Backend (Go API)
|
||||||
├── PostgreSQL (Database)
|
├── PostgreSQL (Database)
|
||||||
└── Redis (Cache)
|
└── Dragonfly (Cache)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
@@ -138,8 +138,8 @@ docker-compose up -d --force-recreate
|
|||||||
# Backup PostgreSQL
|
# Backup PostgreSQL
|
||||||
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
|
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
|
||||||
|
|
||||||
# Backup Redis
|
# Backup Dragonfly (snapshot)
|
||||||
docker-compose exec redis redis-cli --rdb /data/dump.rdb
|
docker-compose exec dragonfly redis-cli -a "$REDIS_PASSWORD" save
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSL Certificates
|
### SSL Certificates
|
||||||
@@ -211,7 +211,7 @@ docker-compose up -d
|
|||||||
### Performance Tuning
|
### Performance Tuning
|
||||||
|
|
||||||
1. **Nginx Caching** - Already configured in `nginx.conf`
|
1. **Nginx Caching** - Already configured in `nginx.conf`
|
||||||
2. **Redis Caching** - Configure in your application
|
2. **Dragonfly Caching** - Configure in your application
|
||||||
3. **Database Pooling** - Adjust connection limits in Go app
|
3. **Database Pooling** - Adjust connection limits in Go app
|
||||||
|
|
||||||
## Production Tips
|
## Production Tips
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- PostgreSQL 12+
|
- PostgreSQL 12+
|
||||||
- Redis (optional)
|
- Dragonfly or Redis-compatible cache (optional)
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -65,12 +65,16 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
|
||||||
container_name: containr-redis
|
container_name: containr-dragonfly
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123}
|
command:
|
||||||
|
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
|
||||||
|
- --proactor_threads=${DRAGONFLY_THREADS:-1}
|
||||||
|
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
|
||||||
|
- --dir=/data
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- dragonfly_data:/data
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -89,7 +93,7 @@ services:
|
|||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0
|
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
@@ -135,7 +139,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -160,8 +164,8 @@ services:
|
|||||||
context: ../app/frontend
|
context: ../app/frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth}
|
VITE_AUTH_URL: ${VITE_AUTH_URL:-}
|
||||||
container_name: containr-frontend
|
container_name: containr-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
@@ -203,7 +207,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
redis_data:
|
dragonfly_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
|
PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
|
||||||
|
setvar ENVIRONMENT "production"
|
||||||
setvar JWT_SECRET "$(gen)"
|
setvar JWT_SECRET "$(gen)"
|
||||||
setvar BETTER_AUTH_SECRET "$(gen)"
|
setvar BETTER_AUTH_SECRET "$(gen)"
|
||||||
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
|
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
|
||||||
|
|||||||
Reference in new issue
Block a user