From c578b91c2df6b5af7f382bd0b54f141d4b1b4e20 Mon Sep 17 00:00:00 2001 From: Mikhail Romanko Date: Thu, 15 Oct 2026 23:25:46 +0300 Subject: [PATCH] Initial commit --- .env.example | 10 + Dockerfile | 55 +++++ cmd/server/main.go | 254 +++++++++++++++++++++++ db.md | 36 ++++ docker-compose.yml | 23 +++ docs/jitsi-nginx-config.md | 248 ++++++++++++++++++++++ go.mod | 9 + go.sum | 6 + internal/auth/model.go | 31 +++ internal/auth/repository.go | 191 +++++++++++++++++ internal/auth/service.go | 113 ++++++++++ internal/env/loader.go | 83 ++++++++ internal/jitsi/jitsi.go | 98 +++++++++ internal/log/log.go | 80 ++++++++ internal/template/service.go | 90 ++++++++ internal/user/model.go | 16 ++ internal/user/repository.go | 200 ++++++++++++++++++ internal/user/service.go | 72 +++++++ internal/web/auth.go | 283 ++++++++++++++++++++++++++ internal/web/invite.go | 84 ++++++++ internal/web/jitsi.go | 113 ++++++++++ internal/web/profile.go | 164 +++++++++++++++ internal/web/service.go | 270 ++++++++++++++++++++++++ internal/web/session.go | 85 ++++++++ internal/web/user.go | 93 +++++++++ internal/web/webauthn.go | 57 ++++++ static/default.jpg | Bin 0 -> 28350 bytes static/style.css | 47 +++++ templates/layouts/alarm.html | 5 + templates/layouts/csrf.html | 3 + templates/layouts/footer.html | 4 + templates/layouts/header.html | 11 + templates/layouts/navbar.html | 12 ++ templates/layouts/redirect.html | 5 + templates/pages/auth/signin.html | 31 +++ templates/pages/auth/signout.html | 22 ++ templates/pages/auth/signup.html | 28 +++ templates/pages/invite/create.html | 25 +++ templates/pages/invite/view.html | 49 +++++ templates/pages/jitsi/token.html | 23 +++ templates/pages/profile/edit.html | 26 +++ templates/pages/profile/password.html | 26 +++ templates/pages/profile/view.html | 31 +++ templates/pages/redirect/warning.html | 24 +++ templates/pages/session/view.html | 52 +++++ templates/pages/user/view.html | 53 +++++ 46 files changed, 3241 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 db.md create mode 100644 docker-compose.yml create mode 100644 docs/jitsi-nginx-config.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/model.go create mode 100644 internal/auth/repository.go create mode 100644 internal/auth/service.go create mode 100644 internal/env/loader.go create mode 100644 internal/jitsi/jitsi.go create mode 100644 internal/log/log.go create mode 100644 internal/template/service.go create mode 100644 internal/user/model.go create mode 100644 internal/user/repository.go create mode 100644 internal/user/service.go create mode 100644 internal/web/auth.go create mode 100644 internal/web/invite.go create mode 100644 internal/web/jitsi.go create mode 100644 internal/web/profile.go create mode 100644 internal/web/service.go create mode 100644 internal/web/session.go create mode 100644 internal/web/user.go create mode 100644 internal/web/webauthn.go create mode 100644 static/default.jpg create mode 100644 static/style.css create mode 100644 templates/layouts/alarm.html create mode 100644 templates/layouts/csrf.html create mode 100644 templates/layouts/footer.html create mode 100644 templates/layouts/header.html create mode 100644 templates/layouts/navbar.html create mode 100644 templates/layouts/redirect.html create mode 100644 templates/pages/auth/signin.html create mode 100644 templates/pages/auth/signout.html create mode 100644 templates/pages/auth/signup.html create mode 100644 templates/pages/invite/create.html create mode 100644 templates/pages/invite/view.html create mode 100644 templates/pages/jitsi/token.html create mode 100644 templates/pages/profile/edit.html create mode 100644 templates/pages/profile/password.html create mode 100644 templates/pages/profile/view.html create mode 100644 templates/pages/redirect/warning.html create mode 100644 templates/pages/session/view.html create mode 100644 templates/pages/user/view.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd70bf1 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# uPortal Configuration +JITSI_SECRET=your-jitsi-secret-here +JITSI_APP_ID=your-jitsi-app-id-here +JITSI_DOMAIN=meet.jit.si +# Public domain of this portal (e.g. portal.example.com). When set, avatar URLs +# embedded in the Jitsi JWT are absolute (https://host/avatar/id) so Jitsi can +# load them from another domain. Leave empty for relative URLs. +SITE_DOMAIN=portal.example.com +TRUSTED_DOMAIINS=meet.jit.si,exampel.com +SESSION_CLEANUP_INTERVAL=5m \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1365b22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Builder stage +FROM golang:1.26-alpine AS builder + +# Install C compiler and musl headers required for CGO + go-sqlite3 +RUN apk add --no-cache gcc musl-dev + +# Enable CGO (required by github.com/mattn/go-sqlite3) +ENV CGO_ENABLED=1 + +# Cache build and module caches for faster rebuilds +ENV GOCACHE=/go-cache +ENV GOMODCACHE=/go/pkg/mod + +# Set working directory +WORKDIR /build + +# Copy dependency manifests first and download modules (cache layer) +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the rest of the source code +COPY . . + +# Build a static-ish binary (C code for SQLite is compiled in; musl libc stays dynamic) +RUN go build -o /app/server ./cmd/server/ + +# Runtime stage +FROM alpine:3.20 + +# ca-certificates for TLS, tzdata for timezone support +RUN apk add --no-cache ca-certificates tzdata + +# Create non-root user for better security +RUN addgroup -S app && adduser -S app -G app + +# Set working directory +WORKDIR /app + +# Copy the binary from the builder stage +COPY --from=builder /app/server /app/server + +# Copy static and template assets from the project source +COPY --from=builder /build/static /app/static +COPY --from=builder /build/templates /app/templates + +# Ensure the app user owns the working dir (DB file created here at runtime) +RUN chown -R app:app /app + +USER app + +# Expose the HTTP port +EXPOSE 8080 + +# Default command +CMD ["./server", "-addr", ":8080"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..2bb08ae --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "database/sql" + "flag" + "log" + "net/http" + "os" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" + + "uPortal/internal/auth" + "uPortal/internal/env" + "uPortal/internal/jitsi" + applog "uPortal/internal/log" + "uPortal/internal/template" + "uPortal/internal/user" + "uPortal/internal/web" +) + +var ( + DBPath = "uportal.db" + Addr = "127.0.0.1:8080" +) + +func main() { + // Load .env first (lowest priority; system env wins). + if err := env.LoadEnvDefault(); err != nil { + log.Printf("Warning: no .env file found: %v", err) + } + + flag.StringVar(&DBPath, "db", DBPath, "path to SQLite database file") + flag.StringVar(&Addr, "addr", Addr, "listen address") + flag.Parse() + + logLevelStr := os.Getenv("LOG_LEVEL") + if logLevelStr == "" { + logLevelStr = "info" + } + logLevel, err := applog.ParseLevel(logLevelStr) + if err != nil { + log.Fatalf("Invalid LOG_LEVEL %q: %v", logLevelStr, err) + } + var appLog = applog.New(logLevel, os.Stdout) + + db, err := sql.Open("sqlite3", DBPath) + if err != nil { + appLog.Error("Failed to open database: %v", err) + os.Exit(1) + } + defer db.Close() + + db.SetMaxOpenConns(1) // SQLite does not support concurrent writes + + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + appLog.Error("Failed to set PRAGMA foreign_keys: %v", err) + os.Exit(1) + } + + mustExec(db, ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + displayname TEXT, + superuser INTEGER NOT NULL DEFAULT 0 + ) + `) + mustExec(db, ` + CREATE TABLE IF NOT EXISTS metadatas ( + id INTEGER PRIMARY KEY, + data TEXT NOT NULL + ) + `) + mustExec(db, ` + CREATE TABLE IF NOT EXISTS avatars ( + id INTEGER PRIMARY KEY, + data BLOB + ) + `) + mustExec(db, ` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + userid INTEGER NOT NULL, + device TEXT, + address TEXT, + expiresat DATETIME NOT NULL, + createdat DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (userid) REFERENCES users(id) ON DELETE CASCADE + ) + `) + mustExec(db, ` + CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, + uses INTEGER NOT NULL DEFAULT 1, + expiresat DATETIME NOT NULL, + createdat DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + ctx := context.Background() + + userRepo, err := user.NewRepository(db) + if err != nil { + appLog.Error("Failed to create user repository: %v", err) + os.Exit(1) + } + authRepo, err := auth.NewRepository(db) + if err != nil { + appLog.Error("Failed to create auth repository: %v", err) + os.Exit(1) + } + + seed(ctx, userRepo, authRepo, db, appLog) + + views, err := template.NewService("templates") + if err != nil { + appLog.Error("Failed to initialize template engine: %v", err) + os.Exit(1) + } + + jitsiSecret := os.Getenv("JITSI_SECRET") + jitsiAppID := os.Getenv("JITSI_APP_ID") + jitsiDomain := os.Getenv("JITSI_DOMAIN") + siteDomain := os.Getenv("SITE_DOMAIN") + var jitsiCfg *jitsi.Config + if jitsiSecret != "" && jitsiAppID != "" { + if jitsiDomain == "" { + jitsiDomain = "meet.jit.si" + } + jitsiCfg = &jitsi.Config{ + Secret: jitsiSecret, + AppID: jitsiAppID, + AppName: "uPortal", + Domain: jitsiDomain, + SiteDomain: siteDomain, + } + } + + // Trusted domains bypass the external-redirect warning. The Jitsi domain + // is always trusted, even if not listed in the env var. + trustedDomains := []string{} + for _, d := range strings.Split(os.Getenv("TRUSTED_DOMAIINS"), ",") { + if d = strings.TrimSpace(d); d != "" { + trustedDomains = append(trustedDomains, d) + } + } + if jitsiDomain != "" { + trustedDomains = append(trustedDomains, jitsiDomain) + } + + webSvc := web.New(userRepo, authRepo, views, db, jitsiCfg, trustedDomains, appLog) + + // ── Start background session cleanup ── + const defaultCleanupInterval = 5 * time.Minute + go func() { + cleanupInterval := defaultCleanupInterval + if v := os.Getenv("SESSION_CLEANUP_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + cleanupInterval = d + } + } + + // Run once on startup + deleted, err := authRepo.DeleteExpiredSessions(ctx) + if err != nil { + appLog.Warn("session cleanup on startup failed: %v", err) + } else if deleted > 0 { + appLog.Info("cleaned up %d expired sessions on startup", deleted) + } + + // Then periodically + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + deleted, err := authRepo.DeleteExpiredSessions(ctx) + if err != nil { + appLog.Warn("session cleanup failed: %v", err) + } else if deleted > 0 { + appLog.Info("cleaned up %d expired sessions", deleted) + } + } + } + }() + + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + webSvc.Register(mux) + + appLog.Info("uPortal listening on %s", Addr) + appLog.Error("server stopped: %v", http.ListenAndServe(Addr, mux)) + os.Exit(1) +} + +// mustExec runs a DDL/DML statement and fatally logs on error. +func mustExec(db *sql.DB, ddl string) { + if _, err := db.Exec(ddl); err != nil { + log.Fatalf("Migration failed: %v\nSQL: %s", err, ddl) + } +} + +// seed creates the initial admin user and an invite code when the users +// table is empty (first run). +func seed(ctx context.Context, userRepo *user.Repository, authRepo *auth.Repository, db *sql.DB, appLog *applog.Logger) { + count, err := userRepo.GetUserCount(ctx) + if err != nil { + appLog.Error("Failed to check user count: %v", err) + os.Exit(1) + } + if count > 0 { + return + } + + appLog.Info("Seeding: no users found, creating admin account and first invite") + + hashed, err := auth.HashPassword("admin") + if err != nil { + appLog.Error("Failed to hash admin password: %v", err) + os.Exit(1) + } + + user, err := userRepo.CreateUser(ctx, "admin", hashed) + if err != nil { + appLog.Error("Failed to create admin user: %v", err) + os.Exit(1) + } + + appLog.Info("Seeded admin user — id=%d login=admin password=admin", user.Id) + + // userRepo.СreateUser does not set superuser, so update it directly. + if _, err := db.Exec("UPDATE users SET superuser = 1 WHERE id = ?", user.Id); err != nil { + appLog.Error("Failed to set admin superuser: %v", err) + os.Exit(1) + } + appLog.Info("Admin user promoted to superuser") + + token, err := auth.GenerateToken(32) + if err != nil { + appLog.Error("Failed to generate invite token: %v", err) + os.Exit(1) + } + if _, err := authRepo.CreateInvite(ctx, token, 1, time.Now().Add(24*time.Hour)); err != nil { + appLog.Error("Failed to create seed invite: %v", err) + os.Exit(1) + } + appLog.Info("Created seed invite — token=%s (expires in 24h)", token) +} diff --git a/db.md b/db.md new file mode 100644 index 0000000..2b259db --- /dev/null +++ b/db.md @@ -0,0 +1,36 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + fullname TEXT, + superuser INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE avatars ( + id INTEGER PRIMARY KEY, + data BLOB, + FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE metadatas ( + id INTEGER PRIMARY KEY, + data TEXT NOT NULL, + FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + device TEXT, + address TEXT, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE invites ( + token TEXT PRIMARY KEY, + uses INTEGER NOT NULL DEFAULT 1, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL CURRENT_TIMESTAMP, +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..36080de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + uportal: + build: + context: . + dockerfile: Dockerfile + container_name: uportal + ports: + - "8080:8080" + volumes: + # Bind mount to persist the SQLite database file locally in ./data/ + - ./data:/app + environment: + LOG_LEVEL: info + # Optional configuration (uncomment as needed): + # JITSI_SECRET: your-jitsi-secret + # JITSI_APP_ID: your-jitsi-app-id + # JITSI_DOMAIN: jitsi.example.com + # SITE_DOMAIN: example.com + # TRUSTED_DOMAINS: example.com + restart: unless-stopped + diff --git a/docs/jitsi-nginx-config.md b/docs/jitsi-nginx-config.md new file mode 100644 index 0000000..0b058ed --- /dev/null +++ b/docs/jitsi-nginx-config.md @@ -0,0 +1,248 @@ +# Jitsi & Nginx Deployment Configuration + +## Overview + +This document covers the configuration required to deploy **uPortal** alongside a **Docker-based Jitsi Meet** instance behind **Nginx**. + +--- + +## Environment Variables (.env) + +These are the variables used by uPortal for Jitsi integration. Copy them into your `.env` file: + +| Variable | Required | Default | Purpose | +|----------|----------|---------|---------| +| `JITSI_SECRET` | **Yes** | — | HMAC-SHA256 shared secret for signing JWTs. Must match the secret configured in Jitsi's prosody (`JWT_APP_SECRET`). Generate with: `openssl rand -hex 32` | +| `JITSI_APP_ID` | **Yes** | — | Jitsi App ID. Used as the `iss` (issuer) and `aud` (audience) in JWTs. Match this to `JWT_APP_ID` in Jitsi's prosody config. | +| `JITSI_DOMAIN` | No | `meet.jit.si` | Your Jitsi Meet instance FQDN (e.g. `meet.example.com`). | +| `SITE_DOMAIN` | No | — | The public domain of the portal (e.g. `portal.example.com`). Required for absolute avatar URLs in JWT context — otherwise Jitsi cannot fetch avatars. | +| `TRUSTED_DOMAIINS` | No | — | Comma-separated domains that bypass the external-redirect warning. Add your Jitsi domain here to skip the interstitial page. | + +> **Note on `TRUSTED_DOMAIINS`**: The variable name contains a typo (double "I") — this matches the codebase, so keep it as-is. + +### Minimal .env for Jitsi + +``` +JITSI_SECRET=your-64-char-hex-secret-here +JITSI_APP_ID=my_uportal_app +JITSI_DOMAIN=meet.example.com +SITE_DOMAIN=portal.example.com +TRUSTED_DOMAIINS=meet.example.com +``` + +--- + +## Docker-Based Jitsi Meet Setup + +### 1. Prerequisites + +- Docker and Docker Compose installed +- A server with a public IP and domain names pointing to it +- Ports 80 and 443 accessible (or behind Nginx) + +### 2. Clone Jitsi Meet Docker + +```bash +git clone https://github.com/jitsi/docker-jitsi-meet.git +cd docker-jitsi-meet +cp env.example .env +``` + +### 3. Configure Jitsi `.env` + +Edit the Jitsi `.env` file. The critical settings for JWT authentication: + +```bash +# --- Domain / TLS --- +CONFIG=~/.jitsi-meet-cfg +HTTP_PORT=8000 +HTTPS_PORT=8443 + +# --- JWT Authentication (REQUIRED for uPortal integration) --- +ENABLE_AUTH=1 +AUTH_TYPE=jwt +JWT_APP_ID=my_uportal_app # Must match uPortal's JITSI_APP_ID +JWT_APP_SECRET=your-64-char-hex-secret-here # Must match uPortal's JITSI_SECRET +JWT_ACCEPTED_ISSUERS=my_uportal_app +JWT_ACCEPTED_AUDIENCES=my_uportal_app +JWT_ASAP_KEYSERVER= +JWT_ALLOW_EMPTY=false + +# --- Optional: Disable guest access --- +# JWT_AUTH_MODULE=jwt +``` + +### 4. Start Jitsi Meet + +```bash +docker compose up -d +``` + +This starts the full Jitsi stack: prosody (XMPP), jicofo (focus), jvb (video bridge), and web (nginx frontend). + +--- + +## Nginx Reverse Proxy Configuration + +### Sample `/etc/nginx/sites-available/uportal` + +```nginx +server { + listen 80; + server_name portal.example.com meet.example.com; + return 301 https://$server_name$request_uri; +} + +# uPortal +server { + listen 443 ssl http2; + server_name portal.example.com; + + ssl_certificate /etc/letsencrypt/live/portal.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/portal.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $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; + } + + location /static/ { + proxy_pass http://127.0.0.1:8080; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} + +# Jitsi Meet +server { + listen 443 ssl http2; + server_name meet.example.com; + + ssl_certificate /etc/letsencrypt/live/meet.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/meet.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; # Jitsi HTTP port + proxy_set_header Host $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; + + # WebSocket support (required for Jitsi) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Large file uploads (recording, streaming) + client_max_body_size 10M; + client_body_buffer_size 128k; + } + + # Long-polling for Jitsi + location ~ ^/(xmpp|http-bind) { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $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_read_timeout 300s; + proxy_send_timeout 300s; + } +} +``` + +### Key Points + +- **uPortal** runs on `127.0.0.1:8080` (use `docker compose run -p 127.0.0.1:8080:8080` or systemd service) +- **Jitsi** HTTP port is `8000` (from Jitsi's `HTTP_PORT=8000`), proxied on a different subdomain +- **WebSocket** support is essential for Jitsi video/audio — the `Upgrade` and `Connection` headers must be forwarded +- Both services can share the same Nginx instance with different `server_name` blocks + +--- + +## Docker Compose for uPortal + +The project includes [`docker-compose.yml`](../docker-compose.yml) and [`Dockerfile`](../Dockerfile) for containerized deployment: + +```yaml +version: '3.8' + +services: + uportal: + build: + context: . + dockerfile: Dockerfile + container_name: uportal + ports: + - "127.0.0.1:8080:8080" # Bind to loopback behind Nginx + volumes: + - ./data:/app # Persist SQLite database locally + environment: + LOG_LEVEL: info + JITSI_SECRET: your-64-char-hex-secret-here + JITSI_APP_ID: my_uportal_app + JITSI_DOMAIN: meet.example.com + SITE_DOMAIN: portal.example.com + TRUSTED_DOMAIINS: meet.example.com + restart: unless-stopped +``` + +> Bind to `127.0.0.1:8080` (not `0.0.0.0:8080`) when running behind Nginx on the same host to avoid direct external access. + +--- + +## Architecture Diagram + +``` +Internet + │ + ├── portal.example.com:443 ──┐ + │ Nginx (reverse proxy) │ + │ ├── / → uPortal :8080 │ + │ └── /static/ → uPortal │ + │ │ + └── meet.example.com:443 ────┤ + Nginx (reverse proxy) │ + ├── / → Jitsi Web :8000 │ + ├── /xmpp → Jitsi │ + └── /http-bind → Jitsi │ + │ + Docker Network │ + ├── uPortal container │ + │ ├── uportal binary │ + │ ├── uportal.db (SQLite) │ + │ ├── templates/ │ + │ └── static/ │ + │ │ + └── jitsi-docker stack │ + ├── prosody (XMPP) │ + ├── jicofo (focus) │ + ├── jvb (video bridge) │ + └── web (nginx front) │ + │ + ┌──────────────────────────────┘ + │ + JWT Signing: uPortal signs JWT with JITSI_SECRET + → Redirects user to meet.example.com/room?jwt=... + → Jitsi verifies JWT with matching JWT_APP_SECRET +``` + +--- + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| "Jitsi secret is not configured" | `JITSI_SECRET` not set in uPortal `.env` | Set the variable | +| "Jitsi app id is not configured" | `JITSI_APP_ID` not set | Set the variable | +| "Authentication failed" in Jitsi | JWT secret mismatch between uPortal and Jitsi | Verify `JITSI_SECRET` = `JWT_APP_SECRET` | +| "Invalid issuer" in Jitsi logs | `JITSI_APP_ID` ≠ `JWT_APP_ID` | Make them match | +| Avatar not showing in Jitsi | `SITE_DOMAIN` not set, or avatar missing | Set `SITE_DOMAIN` to the portal's public URL | +| External redirect warning page appears | Jitsi domain not in `TRUSTED_DOMAIINS` | Add the domain | \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4345dee --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module uPortal + +go 1.26.0 + +require golang.org/x/crypto v0.58.0 + +require github.com/mattn/go-sqlite3 v1.14.52 + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5965d85 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= diff --git a/internal/auth/model.go b/internal/auth/model.go new file mode 100644 index 0000000..fc094c6 --- /dev/null +++ b/internal/auth/model.go @@ -0,0 +1,31 @@ +package auth + +import "time" + +type Session struct { + Token string + UserId int64 + Device string + Address string + CreatedAt time.Time + ExpiresAt time.Time +} + +type Invite struct { + Token string + Uses int64 + CreatedAt time.Time + ExpiresAt time.Time +} + +func (s *Session) CreatedAtLocal() string { + return s.CreatedAt.Local().Format("2006-01-02 15:04") +} + +func (s *Session) IsValid() bool { + return time.Now().Before(s.ExpiresAt) +} + +func (i *Invite) IsValid() bool { + return time.Now().Before(i.ExpiresAt) && i.Uses > 0 +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go new file mode 100644 index 0000000..da6e5b4 --- /dev/null +++ b/internal/auth/repository.go @@ -0,0 +1,191 @@ +package auth + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type Repository struct { + Db *sql.DB +} + +func NewRepository(db *sql.DB) (*Repository, error) { + return &Repository{Db: db}, nil +} + +func (r *Repository) CreateInvite(ctx context.Context, token string, uses int64, expiresAt time.Time) (*Invite, error) { + query := "INSERT INTO invites(token, uses, expiresat) VALUES(?, ?, ?) RETURNING createdat" + + invite := &Invite{Token: token, Uses: uses, ExpiresAt: expiresAt} + if err := r.Db.QueryRowContext(ctx, query, token, uses, expiresAt).Scan(&invite.CreatedAt); err != nil { + return nil, fmt.Errorf("db insert failed: %w", err) + } + + return invite, nil +} + +func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) { + query := "SELECT uses, expiresat, createdat FROM invites WHERE token = ?" + invite := &Invite{Token: token} + if err := r.Db.QueryRowContext(ctx, query, token).Scan(&invite.Uses, &invite.ExpiresAt, &invite.CreatedAt); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return invite, nil +} + +func (r *Repository) GetInvites(ctx context.Context, limit, offset int64) ([]Invite, error) { + query := "SELECT token, uses, expiresat, createdat FROM invites LIMIT ? OFFSET ?" + rows, err := r.Db.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + defer rows.Close() + + var invites []Invite + for rows.Next() { + var invite Invite + + if err := rows.Scan(&invite.Token, &invite.Uses, &invite.ExpiresAt, &invite.CreatedAt); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + invites = append(invites, invite) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return invites, nil +} + +func (r *Repository) GetInvitesCount(ctx context.Context) (int64, error) { + query := "SELECT COUNT(*) FROM invites" + var result int64 + if err := r.Db.QueryRowContext(ctx, query).Scan(&result); err != nil { + return 0, fmt.Errorf("db select failed: %w", err) + } + + return result, nil +} + +func (r *Repository) UpdateInvite(ctx context.Context, invite *Invite) error { + query := "UPDATE invites SET uses = ?, expiresat = ? WHERE token = ?" + + _, err := r.Db.ExecContext(ctx, query, invite.Uses, invite.ExpiresAt, invite.Token) + if err != nil { + return fmt.Errorf("db update failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteInvite(ctx context.Context, token string) error { + query := "DELETE FROM invites WHERE token = ?" + + _, err := r.Db.ExecContext(ctx, query, token) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +func (r *Repository) CreateSession(ctx context.Context, token string, userId int64, device, address string, expiresAt time.Time) (*Session, error) { + query := "INSERT INTO sessions(token, userid, device, address, expiresat) VALUES(?, ?, ?, ?, ?) RETURNING createdat" + + session := &Session{Token: token, UserId: userId, Device: device, Address: address, ExpiresAt: expiresAt} + if err := r.Db.QueryRowContext(ctx, query, token, userId, device, address, expiresAt).Scan(&session.CreatedAt); err != nil { + return nil, fmt.Errorf("db insert failed: %w", err) + } + + return session, nil +} + +func (r *Repository) GetSessionByToken(ctx context.Context, token string) (*Session, error) { + query := "SELECT userid, device, address, expiresat, createdat FROM sessions WHERE token = ?" + session := &Session{Token: token} + if err := r.Db.QueryRowContext(ctx, query, token).Scan(&session.UserId, &session.Device, &session.Address, &session.ExpiresAt, &session.CreatedAt); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return session, nil +} + +func (r *Repository) GetSessionByUserId(ctx context.Context, userId int64, limit, offset int64) ([]Session, error) { + query := "SELECT token, userid, device, address, expiresat, createdat FROM sessions WHERE userid = ? LIMIT ? OFFSET ?" + rows, err := r.Db.QueryContext(ctx, query, userId, limit, offset) + if err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + var session Session + + if err := rows.Scan(&session.Token, &session.UserId, &session.Device, &session.Address, &session.ExpiresAt, &session.CreatedAt); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + sessions = append(sessions, session) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return sessions, nil +} + +func (r *Repository) GetSessionByUserIdCount(ctx context.Context, userId int64) (int64, error) { + query := "SELECT COUNT(*) FROM sessions WHERE userid = ?" + var result int64 + if err := r.Db.QueryRowContext(ctx, query, userId).Scan(&result); err != nil { + return 0, fmt.Errorf("db select failed: %w", err) + } + + return result, nil +} + +func (r *Repository) DeleteSession(ctx context.Context, token string) error { + query := "DELETE FROM sessions WHERE token = ?" + + _, err := r.Db.ExecContext(ctx, query, token) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteSessionByTokenAndUserId(ctx context.Context, token string, userId int64) error { + query := "DELETE FROM sessions WHERE token = ? AND userid = ?" + _, err := r.Db.ExecContext(ctx, query, token, userId) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteSessionsByUserId(ctx context.Context, userId int64) error { + query := "DELETE FROM sessions WHERE userid = ?" + + _, err := r.Db.ExecContext(ctx, query, userId) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +// DeleteExpiredSessions deletes all sessions whose expiresat < now. +// Returns the number of rows deleted. +func (r *Repository) DeleteExpiredSessions(ctx context.Context) (int64, error) { + result, err := r.Db.ExecContext(ctx, + "DELETE FROM sessions WHERE expiresat < datetime('now')", + ) + if err != nil { + return 0, fmt.Errorf("delete expired sessions: %w", err) + } + return result.RowsAffected() +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..7cf0e04 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,113 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "net" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const ( + CsrfLength = 32 + SessionLength = 32 +) + +func GenerateToken(length int) (string, error) { + b := make([]byte, length) + + _, err := rand.Read(b) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func SetCsrfCookie(w http.ResponseWriter, r *http.Request, cookieName string) (string, error) { + token, err := GenerateToken(CsrfLength) + if err != nil { + return "", fmt.Errorf("error generating csrf token: %w", err) + } + + cookie := &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: false, + Secure: true, + SameSite: http.SameSiteLaxMode, + } + + http.SetCookie(w, cookie) + return token, nil +} + +func ValidateCsrf(r *http.Request, cookieName, fieldName string) error { + cookie, err := r.Cookie(cookieName) + if err != nil { + return fmt.Errorf("missing CSRF cookie") + } + cookieToken := cookie.Value + + if err := r.ParseForm(); err != nil { + return fmt.Errorf("failed to parse form data") + } + formToken := r.FormValue(fieldName) + + if cookieToken == "" || formToken == "" { + return fmt.Errorf("empty CSRF token") + } + + if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(formToken)) != 1 { + return fmt.Errorf("mismatch CSRF token") + } + + return nil +} + +func extractIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[0]) + } + + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +func CreateSession(r *http.Request, userID int64) (string, string, string, error) { + token, err := GenerateToken(SessionLength) + if err != nil { + return "", "", "", err + } + + userAgent := r.UserAgent() + if userAgent == "" { + userAgent = "Unknown" + } + + ipAddress := extractIP(r) + return token, userAgent, ipAddress, nil +} diff --git a/internal/env/loader.go b/internal/env/loader.go new file mode 100644 index 0000000..7149ad5 --- /dev/null +++ b/internal/env/loader.go @@ -0,0 +1,83 @@ +// Package env provides a minimal, dependency-free .env file loader. +// +// It reads KEY=VAL pairs from a file and sets them into the process +// environment using os.Setenv, but only for variables that are NOT already +// set. This gives the real environment (system env / shell) priority over +// values in the .env file. +package env + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// DefaultPath is the file LoadEnvDefault reads when no path is given. +const DefaultPath = ".env" + +// LoadEnvDefault loads environment variables from the default ".env" file +// in the current working directory. +func LoadEnvDefault() error { + return LoadEnv(DefaultPath) +} + +// LoadEnv reads the file at path line by line and sets each KEY=VALUE pair +// into the process environment. +// +// Rules: +// - Blank lines and lines starting with '#' (comments) are ignored. +// - Values may be wrapped in single or double quotes, which are stripped. +// - A variable already present in the environment is never overridden +// (system env wins; .env is the lowest priority). +func LoadEnv(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + // Not KEY=VAL format; skip silently. + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + value = strings.TrimSpace(value) + value = unquote(value) + + if _, exists := os.LookupEnv(key); exists { + // System environment wins over .env. + continue + } + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("failed to set env var %q: %w", key, err) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + return nil +} + +// unquote strips a single matching pair of surrounding single or double +// quotes from value, if present. Any other value is returned unchanged. +func unquote(value string) string { + if len(value) >= 2 { + first, last := value[0], value[len(value)-1] + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + return value[1 : len(value)-1] + } + } + return value +} diff --git a/internal/jitsi/jitsi.go b/internal/jitsi/jitsi.go new file mode 100644 index 0000000..67bf2fb --- /dev/null +++ b/internal/jitsi/jitsi.go @@ -0,0 +1,98 @@ +package jitsi + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Config struct { + // Secret is the HMAC-SHA256 shared secret (at least 32 bytes hex-encoded). + Secret string + // AppID is the Jitsi App ID (the "iss" and "aud" claim value). + AppID string + // AppName is the friendly name shown in the Jitsi client (optional). + AppName string + // Domain is the Jitsi Meet domain (e.g. "meet.jit.si"). + Domain string + // SiteDomain is the public domain of this portal (e.g. "portal.example.com"). + // When set, avatar URLs embedded in the JWT are absolute (https://...) so + // Jitsi, running on another domain, can load them. When empty, relative + // URLs are used. + SiteDomain string +} + +type JitsiClaims struct { + jwt.RegisteredClaims + Room string `json:"room,omitempty"` + Moderator bool `json:"moderator"` + Context *JitsiContext `json:"context,omitempty"` +} + +type JitsiContext struct { + User *JitsiUser `json:"user,omitempty"` +} + +type JitsiUser struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +// GenerateSecret creates a new random hex-encoded 32-byte secret. +func GenerateSecret() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// MeetURL returns the full Jitsi Meet URL for a room with a JWT token. +// If Domain is empty it defaults to "meet.jit.si". +func (c *Config) MeetURL(room, jwtToken string) string { + domain := c.Domain + if domain == "" { + domain = "meet.jit.si" + } + return fmt.Sprintf("https://%s/%s?jwt=%s", domain, room, jwtToken) +} + +// GenerateToken creates a JWT for the given room and user. +// moderator grants Jitsi moderator privileges (mute, kick, etc.) and avatar +// is the user's avatar URL (may be empty). +func (c *Config) GenerateToken(userID, userName, room string, moderator bool, avatar string) (string, error) { + if c.Secret == "" { + return "", fmt.Errorf("jitsi secret is not configured") + } + if c.AppID == "" { + return "", fmt.Errorf("jitsi app id is not configured") + } + + now := time.Now().UTC() + claims := JitsiClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: c.AppID, + Subject: userID, + Audience: jwt.ClaimStrings{c.AppID}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), + ID: fmt.Sprintf("%s-%d", userID, now.Unix()), + }, + Room: room, + Moderator: moderator, + Context: &JitsiContext{ + User: &JitsiUser{ + ID: userID, + Name: userName, + Avatar: avatar, + }, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(c.Secret)) +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..651b45a --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,80 @@ +package log + +import ( + "fmt" + "io" + stdlog "log" + "strings" +) + +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +var levelLabels = map[Level]string{ + LevelDebug: "[DEBUG]", + LevelInfo: "[INFO]", + LevelWarn: "[WARN]", + LevelError: "[ERROR]", +} + +type Logger struct { + level Level + logger *stdlog.Logger +} + +// New returns a Logger that writes to the given output. Messages below the +// configured level are silently dropped. +func New(level Level, output io.Writer) *Logger { + return &Logger{ + level: level, + logger: stdlog.New(output, "", stdlog.LstdFlags), + } +} + +func (l *Logger) Debug(format string, args ...any) { + l.log(LevelDebug, format, args...) +} + +func (l *Logger) Info(format string, args ...any) { + l.log(LevelInfo, format, args...) +} + +func (l *Logger) Warn(format string, args ...any) { + l.log(LevelWarn, format, args...) +} + +func (l *Logger) Error(format string, args ...any) { + l.log(LevelError, format, args...) +} + +func (l *Logger) log(level Level, format string, args ...any) { + if level < l.level { + return + } + label := levelLabels[level] + msg := fmt.Sprintf(format, args...) + l.logger.Printf("%s %s", label, msg) +} + +// ParseLevel converts a string to a Level. Input is case-insensitive. +// Valid values: "debug", "info", "warn", "error". +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return LevelDebug, nil + case "info": + return LevelInfo, nil + case "warn": + return LevelWarn, nil + case "error": + return LevelError, nil + default: + return LevelInfo, fmt.Errorf("unrecognized log level %q; valid values: debug, info, warn, error", s) + } +} diff --git a/internal/template/service.go b/internal/template/service.go new file mode 100644 index 0000000..fb31809 --- /dev/null +++ b/internal/template/service.go @@ -0,0 +1,90 @@ +package template + +import ( + "fmt" + "html/template" + "io" + "io/fs" + "os" + "path" + "strings" +) + +type Service struct { + baseDir string + cache map[string]*template.Template +} + +func createFuncMap() template.FuncMap { + funcMap := template.FuncMap{ + "dict": func(pairs ...any) (map[string]any, error) { + if len(pairs)%2 != 0 { + return nil, fmt.Errorf("dict requires an even number of arguments") + } + m := make(map[string]any) + for i := 0; i < len(pairs); i += 2 { + key, ok := pairs[i].(string) + if !ok { + return nil, fmt.Errorf("dict keys must be strings") + } + m[key] = pairs[i+1] + } + return m, nil + }, + "default": func(fallback string, value any) any { + if value == nil { + return fallback + } + return value + }, + } + + return funcMap +} + +func NewService(uiDir string) (*Service, error) { + cache := make(map[string]*template.Template) + + uiFS := os.DirFS(uiDir) + + funcMap := createFuncMap() + + err := fs.WalkDir(uiFS, "pages", func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(filePath, ".html") { + return nil + } + + // Key by the relative path under pages/, e.g. "auth/signin.html". + relPath := strings.TrimPrefix(filePath, "pages/") + + tmpl, err := template.New(path.Base(filePath)).Funcs(funcMap).ParseFS(uiFS, filePath, "layouts/*.html") + if err != nil { + return fmt.Errorf("can't parse filesystem: %w", err) + } + cache[relPath] = tmpl + + return nil + }) + if err != nil { + return nil, fmt.Errorf("can't walk 'pages' in '%s': %w", uiDir, err) + } + + return &Service{ + baseDir: uiDir, + cache: cache, + }, nil +} + +func (e *Service) Render(w io.Writer, pageName string, data any) error { + tmpl, ok := e.cache[pageName] + if !ok { + return fmt.Errorf("no such template: %s", pageName) + } + return tmpl.Execute(w, data) +} diff --git a/internal/user/model.go b/internal/user/model.go new file mode 100644 index 0000000..b046d7d --- /dev/null +++ b/internal/user/model.go @@ -0,0 +1,16 @@ +package user + +type User struct { + Id int64 + Login string + Password string + DisplayName *string + SuperUser bool +} + +func (u *User) EffectiveDisplayName() string { + if u.DisplayName == nil { + return u.Login + } + return *u.DisplayName +} diff --git a/internal/user/repository.go b/internal/user/repository.go new file mode 100644 index 0000000..162139d --- /dev/null +++ b/internal/user/repository.go @@ -0,0 +1,200 @@ +package user + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +type Repository struct { + Db *sql.DB +} + +func NewRepository(db *sql.DB) (*Repository, error) { + return &Repository{Db: db}, nil +} + +func (r *Repository) CreateUser(ctx context.Context, login, password string) (*User, error) { + query := "INSERT INTO users(login, password) VALUES(?, ?) RETURNING id, displayname, superuser" + + user := &User{Login: login, Password: password} + if err := r.Db.QueryRowContext(ctx, query, login, password).Scan(&user.Id, &user.DisplayName, &user.SuperUser); err != nil { + return nil, fmt.Errorf("db insert failed: %w", err) + } + + return user, nil +} + +func (r *Repository) GetUsers(ctx context.Context, limit, offset int64) ([]User, error) { + query := "SELECT id, login, password, displayname, superuser FROM users LIMIT ? OFFSET ?" + rows, err := r.Db.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var user User + + if err := rows.Scan(&user.Id, &user.Login, &user.Password, &user.DisplayName, &user.SuperUser); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + users = append(users, user) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return users, nil +} + +func (r *Repository) GetUserCount(ctx context.Context) (int64, error) { + query := "SELECT COUNT(*) FROM users" + var result int64 + if err := r.Db.QueryRowContext(ctx, query).Scan(&result); err != nil { + return 0, fmt.Errorf("db select failed: %w", err) + } + + return result, nil +} + +func (r *Repository) GetUserById(ctx context.Context, id int64) (*User, error) { + query := "SELECT login, password, displayname, superuser FROM users WHERE id = ?" + user := &User{Id: id} + if err := r.Db.QueryRowContext(ctx, query, id).Scan(&user.Login, &user.Password, &user.DisplayName, &user.SuperUser); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return user, nil +} + +func (r *Repository) GetUserByLogin(ctx context.Context, login string) (*User, error) { + query := "SELECT id, password, displayname, superuser FROM users WHERE login = ?" + user := &User{Login: login} + if err := r.Db.QueryRowContext(ctx, query, login).Scan(&user.Id, &user.Password, &user.DisplayName, &user.SuperUser); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return user, nil +} + +func (r *Repository) UpdateUser(ctx context.Context, user *User) error { + query := "UPDATE users SET password = ?, displayname = ?, superuser = ? WHERE id = ?" + + _, err := r.Db.ExecContext(ctx, query, user.Password, user.DisplayName, user.SuperUser, user.Id) + if err != nil { + return fmt.Errorf("db update failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteUser(ctx context.Context, id int64) error { + query := "DELETE FROM users WHERE id = ?" + + _, err := r.Db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +func (r *Repository) CreateMetadata(ctx context.Context, id int64, data map[string]any) error { + query := "INSERT INTO metadatas(id, data) VALUES(?, ?)" + + raw, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + _, err = r.Db.ExecContext(ctx, query, id, raw) + if err != nil { + return fmt.Errorf("db insert failed: %w", err) + } + return nil +} + +func (r *Repository) GetMetadata(ctx context.Context, id int64) (map[string]any, error) { + query := "SELECT data FROM metadatas WHERE id = ?" + + var rawMetadata []byte + if err := r.Db.QueryRowContext(ctx, query, id).Scan(&rawMetadata); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + var result map[string]any + if err := json.Unmarshal(rawMetadata, &result); err != nil { + return nil, fmt.Errorf("can't parse json: %w", err) + } + + return result, nil +} + +func (r *Repository) UpdateMetadata(ctx context.Context, id int64, data map[string]any) error { + query := "UPDATE metadatas SET data = ? WHERE id = ?" + + raw, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + _, err = r.Db.ExecContext(ctx, query, raw, id) + if err != nil { + return fmt.Errorf("db update failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteMetadata(ctx context.Context, id int64) error { + query := "DELETE FROM metadatas WHERE id = ?" + + _, err := r.Db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} + +func (r *Repository) CreateAvatar(ctx context.Context, id int64, data []byte) error { + query := "INSERT INTO avatars(id, data) VALUES(?, ?)" + + _, err := r.Db.ExecContext(ctx, query, id, data) + if err != nil { + return fmt.Errorf("db insert failed: %w", err) + } + return nil +} + +func (r *Repository) GetAvatar(ctx context.Context, id int64) ([]byte, error) { + query := "SELECT data FROM avatars WHERE id = ?" + + var result []byte + if err := r.Db.QueryRowContext(ctx, query, id).Scan(&result); err != nil { + return nil, fmt.Errorf("db select failed: %w", err) + } + + return result, nil +} + +func (r *Repository) UpdateAvatar(ctx context.Context, id int64, data []byte) error { + query := "UPDATE avatars SET data = ? WHERE id = ?" + + _, err := r.Db.ExecContext(ctx, query, data, id) + if err != nil { + return fmt.Errorf("db update failed: %w", err) + } + return nil +} + +func (r *Repository) DeleteAvatar(ctx context.Context, id int64) error { + query := "DELETE FROM avatars WHERE id = ?" + + _, err := r.Db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("db delete failed: %w", err) + } + return nil +} diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..f479b67 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,72 @@ +package user + +import ( + "bytes" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "path/filepath" + "strings" +) + +const ( + MaxWidth = 512 + MaxHeight = 512 + MaxUploadSize = 5 * 1024 * 1024 +) + +func VerifyImageLimit(r io.Reader) error { + cfg, format, err := image.DecodeConfig(r) + if err != nil { + return fmt.Errorf("failed to decode image: %w", err) + } + + switch format { + case "jpeg", "png": + // Allowed formats + default: + return fmt.Errorf("not supported format: %s", format) + } + + if cfg.Width > MaxWidth || cfg.Height > MaxHeight { + return fmt.Errorf("image dimensions exceed limits: %dx%d (max %dx%d)", cfg.Width, cfg.Height, MaxWidth, MaxHeight) + } + + return nil +} + +func ExtractUploadedImageBytes(r *http.Request, formKey string) ([]byte, string, error) { + // Read file with a size limit to prevent abuse. We read the raw multipart + // bytes, verify size and image format, then return them. + const maxRead = MaxUploadSize + 1024 // small extra for multipart framing + + file, header, err := r.FormFile(formKey) + if err != nil { + return nil, "", fmt.Errorf("form error: %w", err) + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".jpg" && ext != ".jpeg" && ext != ".png" { + return nil, "", fmt.Errorf("invalid file type: only JPG or PNG are allowed") + } + + // Read all bytes so we can verify image dimensions from a reusable reader. + lr := io.LimitReader(file, maxRead) + fileBytes, err := io.ReadAll(lr) + if err != nil { + return nil, "", err + } + if int64(len(fileBytes)) >= maxRead { + return nil, "", fmt.Errorf("file too large (max %d bytes)", MaxUploadSize) + } + + if err := VerifyImageLimit(bytes.NewReader(fileBytes)); err != nil { + return nil, "", fmt.Errorf("invalid file: %w", err) + } + + return fileBytes, header.Filename, nil +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..92f60c1 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,283 @@ +package web + +import ( + "net/http" + "net/url" + "strings" + "time" + + "uPortal/internal/auth" +) + +// HandleSignin renders the sign-in page on GET and authenticates on POST. +func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) { + // If already authenticated, redirect + if _, err := s.currentUser(r); err == nil { + redirectTo := "/" + if r.URL.Query().Get("redirect") != "" { + redirectTo = r.URL.Query().Get("redirect") + } + // For relative paths, redirect directly; for external URLs, route + // through the warning page. + if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther) + } + return + } + + switch r.Method { + case http.MethodGet: + redirect := r.URL.Query().Get("redirect") + s.render(w, r, "auth/signin.html", &ViewData{Redirect: redirect}) + + case http.MethodPost: + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid form, please try again"}) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + remember := r.FormValue("remember") + + user, err := s.users.GetUserByLogin(r.Context(), username) + if err != nil || !auth.CheckPasswordHash(password, user.Password) { + s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid username or password"}) + return + } + + token, device, address, err := auth.CreateSession(r, user.Id) + if err != nil { + s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not create session"}) + return + } + + // Session expires 24h from now; "remember" extends it to 30 days. + expiresAt := time.Now().UTC().Add(24 * time.Hour) + maxAge := 0 // zero => session cookie (until browser closes) + if remember == "on" { + expiresAt = time.Now().UTC().Add(30 * 24 * time.Hour) + maxAge = 30 * 24 * 3600 + } + + if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil { + s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not save session"}) + return + } + + cookie := &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: CookieSecure, + MaxAge: maxAge, + } + http.SetCookie(w, cookie) + + redirectTo := "/" + if r.FormValue("redirect") != "" { + redirectTo = r.FormValue("redirect") + } else if r.URL.Query().Get("redirect") != "" { + redirectTo = r.URL.Query().Get("redirect") + } + // For relative paths, redirect directly; for external URLs, route + // through the warning page unless the domain is trusted. + if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else if s.isTrustedTarget(redirectTo) { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther) + } + } +} + +// HandleSignup renders the sign-up page on GET and creates an account on POST. +// Invite codes are one-time-use unless they carry multiple uses. +func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) { + // If already authenticated, redirect + if _, err := s.currentUser(r); err == nil { + redirectTo := "/" + if r.URL.Query().Get("redirect") != "" { + redirectTo = r.URL.Query().Get("redirect") + } + // For relative paths, redirect directly; for external URLs, route + // through the warning page. + if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther) + } + return + } + + switch r.Method { + case http.MethodGet: + redirect := r.URL.Query().Get("redirect") + s.render(w, r, "auth/signup.html", &ViewData{Redirect: redirect}) + + case http.MethodPost: + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid form, please try again"}) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + confirm := r.FormValue("confirm") + inviteToken := r.FormValue("invite") + + if password != confirm { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Passwords do not match"}) + return + } + if len(password) < 6 { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Password must be at least 6 characters"}) + return + } + if inviteToken == "" { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code is required"}) + return + } + + invite, err := s.auths.GetInviteByToken(r.Context(), inviteToken) + if err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid invite code"}) + return + } + if !invite.IsValid() { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code has expired or been used up"}) + return + } + + // Decrement uses; delete the invite once exhausted. + invite.Uses-- + if invite.Uses <= 0 { + if err := s.auths.DeleteInvite(r.Context(), invite.Token); err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"}) + return + } + } else { + if err := s.auths.UpdateInvite(r.Context(), invite); err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"}) + return + } + } + + // CreateUser stores the password as-is, so hash it first. + hashed, err := auth.HashPassword(password) + if err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create account"}) + return + } + + user, err := s.users.CreateUser(r.Context(), username, hashed) + if err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Username already taken"}) + return + } + + // Auto-login. + token, device, address, err := auth.CreateSession(r, user.Id) + if err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create session"}) + return + } + + expiresAt := time.Now().UTC().Add(24 * time.Hour) + if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil { + s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not save session"}) + return + } + + cookie := &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: CookieSecure, + } + http.SetCookie(w, cookie) + redirectTo := "/" + if r.FormValue("redirect") != "" { + redirectTo = r.FormValue("redirect") + } else if r.URL.Query().Get("redirect") != "" { + redirectTo = r.URL.Query().Get("redirect") + } + // For relative paths, redirect directly; for external URLs, route + // through the warning page unless the domain is trusted. + if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else if s.isTrustedTarget(redirectTo) { + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther) + } + } +} + +// HandleSignout renders a confirmation page on GET and deletes the session on POST. +// It is wrapped in requireAuth for the POST route; the GET route may show the page +// even for anonymous visitors (the confirm form just redirects to /signin). +func (s *WebService) HandleSignout(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, ok := s.basicViewData(w, r, "Sign Out") + if !ok { + http.Redirect(w, r, "/signin?redirect="+url.PathEscape(r.URL.RequestURI()), http.StatusSeeOther) + return + } + s.render(w, r, "auth/signout.html", data) + + case http.MethodPost: + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + s.render(w, r, "auth/signout.html", &ViewData{Error: "Invalid form, please try again"}) + return + } + + if cookie, err := r.Cookie(SessionCookieName); err == nil { + _ = s.auths.DeleteSession(r.Context(), cookie.Value) + } + + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "/signin", http.StatusSeeOther) + } +} + +// HandleExternalRedirect shows a warning page when the user is about to +// leave the site via an external redirect. +func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Request) { + target := r.URL.Query().Get("to") + if target == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + // Trusted targets skip the warning and are redirected directly. + if s.isTrustedTarget(target) { + http.Redirect(w, r, target, http.StatusSeeOther) + return + } + + data, ok := s.basicViewData(w, r, "External Redirect") + if !ok { + // Not authenticated is fine — just show the warning without nav + data = &ViewData{Title: "External Redirect"} + token, _ := auth.SetCsrfCookie(w, r, CsrfCookieName) + data.FormToken = token + } + + data.Redirect = target + s.render(w, r, "redirect/warning.html", data) +} diff --git a/internal/web/invite.go b/internal/web/invite.go new file mode 100644 index 0000000..3bd4e9f --- /dev/null +++ b/internal/web/invite.go @@ -0,0 +1,84 @@ +package web + +import ( + "net/http" + "strconv" + "time" + + "uPortal/internal/auth" +) + +// HandleInvites lists all invite codes. Requires a super user. +func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Invites") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + invites, err := s.auths.GetInvites(r.Context(), 1000, 0) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data.Invites = invites + + s.render(w, r, "invite/view.html", data) +} + +// HandleInviteCreate renders the creation form on GET and mints an invite on POST. +func (s *WebService) HandleInviteCreate(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, ok := s.basicViewData(w, r, "Create Invite") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + s.render(w, r, "invite/create.html", data) + + case http.MethodPost: + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + s.render(w, r, "invite/create.html", &ViewData{Error: "Invalid form, please try again"}) + return + } + + uses := int64(1) + hours := int64(24) + if v, err := strconv.ParseInt(r.FormValue("uses"), 10, 64); err == nil && v > 0 { + uses = v + } + if v, err := strconv.ParseInt(r.FormValue("expires_hours"), 10, 64); err == nil && v > 0 { + hours = v + } + + expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour) + + token, err := auth.GenerateToken(32) + if err != nil { + s.render(w, r, "invite/create.html", &ViewData{Error: "Could not generate invite token"}) + return + } + + if _, err := s.auths.CreateInvite(r.Context(), token, uses, expiresAt); err != nil { + s.render(w, r, "invite/create.html", &ViewData{Error: "Could not create invite"}) + return + } + + http.Redirect(w, r, "/invites", http.StatusSeeOther) + } +} + +// HandleInviteDelete removes the invite identified by the form field "token". +func (s *WebService) HandleInviteDelete(w http.ResponseWriter, r *http.Request) { + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + http.Redirect(w, r, "/invites", http.StatusSeeOther) + return + } + + token := r.FormValue("token") + if token != "" { + _ = s.auths.DeleteInvite(r.Context(), token) + } + http.Redirect(w, r, "/invites", http.StatusSeeOther) +} diff --git a/internal/web/jitsi.go b/internal/web/jitsi.go new file mode 100644 index 0000000..3fb0940 --- /dev/null +++ b/internal/web/jitsi.go @@ -0,0 +1,113 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + + "uPortal/internal/auth" +) + +// HandleJitsi renders the Jitsi room form on GET and redirects to Jitsi Meet on POST. +func (s *WebService) HandleJitsi(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Jitsi") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + switch r.Method { + case http.MethodGet: + s.render(w, r, "jitsi/token.html", data) + + case http.MethodPost: + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + data.Error = "Invalid form, please try again" + s.render(w, r, "jitsi/token.html", data) + return + } + + if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" { + data.Error = "Jitsi is not configured" + s.render(w, r, "jitsi/token.html", data) + return + } + + room := r.FormValue("room") + if room == "" { + data.Error = "Room name is required" + s.render(w, r, "jitsi/token.html", data) + return + } + + avatar := avatarURL(r, s, data.User.Id) + + token, err := s.jitsiCfg.GenerateToken( + strconv.FormatInt(data.User.Id, 10), + data.User.EffectiveDisplayName(), + room, + data.User.SuperUser, + avatar, + ) + if err != nil { + data.Error = "Could not generate token" + s.render(w, r, "jitsi/token.html", data) + return + } + + http.Redirect(w, r, s.jitsiCfg.MeetURL(room, token), http.StatusSeeOther) + } +} + +// HandleJitsiGo is a GET endpoint that reads a "room" query parameter, +// generates a JWT token, and redirects to the Jitsi Meet URL. +func (s *WebService) HandleJitsiGo(w http.ResponseWriter, r *http.Request) { + if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" { + http.Error(w, "Jitsi is not configured", http.StatusInternalServerError) + return + } + + user, err := s.currentUser(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + room := r.URL.Query().Get("room") + if room == "" { + http.Redirect(w, r, "/jitsi", http.StatusSeeOther) + return + } + + avatar := avatarURL(r, s, user.Id) + + token, err := s.jitsiCfg.GenerateToken( + strconv.FormatInt(user.Id, 10), + user.EffectiveDisplayName(), + room, + user.SuperUser, + avatar, + ) + if err != nil { + http.Error(w, "Could not generate token", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, s.jitsiCfg.MeetURL(room, token), http.StatusSeeOther) +} + +// avatarURL returns the app's avatar URL for the given user id, or an empty +// string if the user has no stored avatar. If the Jitsi site domain is +// configured, an absolute https:// URL is returned so Jitsi (running on a +// different domain) can load the avatar; otherwise a relative URL is used. +func avatarURL(r *http.Request, s *WebService, id int64) string { + data, err := s.users.GetAvatar(r.Context(), id) + if err != nil || len(data) == 0 { + return "" + } + path := fmt.Sprintf("/avatar/%d", id) + if s.jitsiCfg != nil && s.jitsiCfg.SiteDomain != "" { + return fmt.Sprintf("https://%s%s", s.jitsiCfg.SiteDomain, path) + } + return path +} diff --git a/internal/web/profile.go b/internal/web/profile.go new file mode 100644 index 0000000..0f2a6fa --- /dev/null +++ b/internal/web/profile.go @@ -0,0 +1,164 @@ +package web + +import ( + "net/http" + "strconv" + + "uPortal/internal/auth" + "uPortal/internal/user" +) + +// HandleProfile shows the current user's own profile. +func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Profile") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + full, err := s.users.GetUserById(r.Context(), data.User.Id) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data.User = full + + s.render(w, r, "profile/view.html", data) +} + +// HandleProfileEdit renders the edit form on GET and applies changes on POST. +func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Edit Profile") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + switch r.Method { + case http.MethodGet: + s.render(w, r, "profile/edit.html", data) + + case http.MethodPost: + if err := r.ParseMultipartForm(32 << 20); err != nil { + data.Error = "Could not parse form" + s.render(w, r, "profile/edit.html", data) + return + } + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + data.Error = "Invalid form, please try again" + s.render(w, r, "profile/edit.html", data) + return + } + + // Display name: UpdateUser writes the displayname column via the user row. + displayName := r.FormValue("display_name") + if displayName != "" { + data.User.DisplayName = &displayName + if err := s.users.UpdateUser(r.Context(), data.User); err != nil { + data.Error = "Could not save display name" + s.render(w, r, "profile/edit.html", data) + return + } + } + + // Avatar: upload a JPG/PNG <= 256x256 (validated by the user package). + if _, _, err := r.FormFile("avatar"); err == nil { + bytes, _, err := user.ExtractUploadedImageBytes(r, "avatar") + if err != nil { + data.Error = "Invalid avatar image: " + err.Error() + s.render(w, r, "profile/edit.html", data) + return + } + if err := s.users.CreateAvatar(r.Context(), data.User.Id, bytes); err != nil { + data.Error = "Could not save avatar" + s.render(w, r, "profile/edit.html", data) + return + } + } + + http.Redirect(w, r, "/profile", http.StatusSeeOther) + } +} + +// HandleProfilePassword renders the password change form on GET and +// applies the new password on POST. +func (s *WebService) HandleProfilePassword(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Change Password") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + switch r.Method { + case http.MethodGet: + s.render(w, r, "profile/password.html", data) + + case http.MethodPost: + if err := r.ParseForm(); err != nil { + data.Error = "Could not parse form" + s.render(w, r, "profile/password.html", data) + return + } + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + data.Error = "Invalid form token" + s.render(w, r, "profile/password.html", data) + return + } + + oldPassword := r.FormValue("old_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + if !auth.CheckPasswordHash(oldPassword, data.User.Password) { + data.Error = "Current password is incorrect" + s.render(w, r, "profile/password.html", data) + return + } + + if len(newPassword) < 6 { + data.Error = "New password must be at least 6 characters" + s.render(w, r, "profile/password.html", data) + return + } + + if newPassword != confirmPassword { + data.Error = "New passwords do not match" + s.render(w, r, "profile/password.html", data) + return + } + + hashedPassword, err := auth.HashPassword(newPassword) + if err != nil { + data.Error = "Could not hash password" + s.render(w, r, "profile/password.html", data) + return + } + + data.User.Password = hashedPassword + if err := s.users.UpdateUser(r.Context(), data.User); err != nil { + data.Error = "Could not update password" + s.render(w, r, "profile/password.html", data) + return + } + + http.Redirect(w, r, "/profile", http.StatusSeeOther) + } +} + +// HandleAvatar serves a stored avatar image by user id. +func (s *WebService) HandleAvatar(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + + data, err := s.users.GetAvatar(r.Context(), id) + if err != nil || len(data) == 0 { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", http.DetectContentType(data)) + w.Write(data) +} diff --git a/internal/web/service.go b/internal/web/service.go new file mode 100644 index 0000000..4de15e0 --- /dev/null +++ b/internal/web/service.go @@ -0,0 +1,270 @@ +package web + +import ( + "database/sql" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "uPortal/internal/auth" + "uPortal/internal/jitsi" + "uPortal/internal/log" + "uPortal/internal/template" + "uPortal/internal/user" +) + +// Cookie names and the CSRF form field name. These match the layout +// template (templates/layouts/csrf.html) and the auth package helpers. +const ( + SessionCookieName = "sessiontoken" + CsrfCookieName = "csrftoken" + CsrfFieldName = "formtoken" +) + +// CookieSecure controls the Secure attribute of the session cookie. +// It is disabled by default so the app works over plain http://127.0.0.1 +// during development (browsers drop Secure cookies over plain http). +// Production deployments behind HTTPS must flip this to true. +var CookieSecure = false + +// ViewData is the data bag passed to every page template. It carries the +// CSRF token, the current user, and any collections the pages display. +type ViewData struct { + Title string + IsSuperUser bool + FormToken string + Error string + User *user.User + Users []user.User + Sessions []auth.Session + Invites []auth.Invite + Redirect string +} + +type WebService struct { + users *user.Repository + auths *auth.Repository + views *template.Service + db *sql.DB + jitsiCfg *jitsi.Config + trustedDomains []string + log *log.Logger +} + +// New returns a WebService ready to be registered onto an http.ServeMux. +func New(users *user.Repository, auths *auth.Repository, views *template.Service, db *sql.DB, jitsiCfg *jitsi.Config, trustedDomains []string, logger *log.Logger) *WebService { + return &WebService{ + users: users, + auths: auths, + views: views, + db: db, + jitsiCfg: jitsiCfg, + trustedDomains: trustedDomains, + log: logger, + } +} + +// isTrustedTarget reports whether target may skip the /redirect warning page. +// Relative paths, empty hostnames and unknown hosts are never trusted; +// localhost and 127.0.0.1 are always trusted. +func (s *WebService) isTrustedTarget(target string) bool { + // Handle URLs without a scheme (e.g. "meet.jit.si/room"). + if !strings.Contains(target, "://") { + target = "https://" + target + } + u, err := url.Parse(target) + if err != nil || u.Hostname() == "" { + return false + } + host := u.Hostname() + + // Loopback addresses are always trusted for full URL redirects. + if host == "localhost" || host == "127.0.0.1" { + return true + } + + for _, domain := range s.trustedDomains { + if strings.EqualFold(host, strings.TrimSpace(domain)) { + return true + } + } + return false +} + +// Register wires every route (method + path) onto the mux. +func (s *WebService) Register(mux *http.ServeMux) { + // Helper to wrap every handler with logging middleware. + handle := func(pattern string, handler http.HandlerFunc) { + mux.HandleFunc(pattern, s.loggingMiddleware(handler)) + } + + // Password + handle("GET /profile/password", s.requireAuth(s.HandleProfilePassword)) + handle("POST /profile/password", s.requireAuth(s.HandleProfilePassword)) + + // Profile + // "/{$}" matches only the root path exactly; any other unmatched path + // falls through to the mux default 404 instead of being served here. + handle("GET /{$}", s.requireAuth(s.HandleProfile)) + handle("GET /profile", s.requireAuth(s.HandleProfile)) + handle("GET /profile/edit", s.requireAuth(s.HandleProfileEdit)) + handle("POST /profile/edit", s.requireAuth(s.HandleProfileEdit)) + handle("GET /avatar/{id}", s.HandleAvatar) + + // Auth + handle("GET /signin", s.HandleSignin) + handle("POST /signin", s.HandleSignin) + handle("GET /signup", s.HandleSignup) + handle("POST /signup", s.HandleSignup) + handle("GET /signout", s.requireAuth(s.HandleSignout)) + handle("POST /signout", s.requireAuth(s.HandleSignout)) + + // External redirect warning + handle("GET /redirect", s.HandleExternalRedirect) + + // Sessions + handle("GET /sessions", s.requireAuth(s.HandleSessions)) + handle("POST /sessions/end", s.requireAuth(s.HandleSessionEnd)) + handle("POST /sessions/end-all", s.requireAuth(s.HandleSessionEndAll)) + + // Users (super user only) + handle("GET /users", s.requireSuperUser(s.HandleUsers)) + handle("POST /users/reset-password", s.requireSuperUser(s.HandleUserResetPassword)) + handle("POST /users/end-sessions", s.requireSuperUser(s.HandleUserEndSessions)) + + // Invites (super user only) + handle("GET /invites", s.requireSuperUser(s.HandleInvites)) + handle("GET /invites/create", s.requireSuperUser(s.HandleInviteCreate)) + handle("POST /invites/create", s.requireSuperUser(s.HandleInviteCreate)) + handle("POST /invites/delete", s.requireSuperUser(s.HandleInviteDelete)) + + // Jitsi + handle("GET /jitsi", s.requireAuth(s.HandleJitsi)) + handle("POST /jitsi", s.requireAuth(s.HandleJitsi)) + handle("GET /jitsi/go", s.requireAuth(s.HandleJitsiGo)) + + // nginx auth_request endpoint — validates session, returns identity headers. + handle("GET /auth", s.HandleAuthRequest) +} + +// loggingMiddleware logs every HTTP request. Static file endpoints are skipped. +func (s *WebService) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Skip logging for static files and health checks. + if strings.HasPrefix(r.URL.Path, "/static/") { + next(w, r) + return + } + + start := time.Now() + + // Wrap the ResponseWriter to capture the status code. + lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next(lrw, r) + + query := r.URL.RawQuery + path := r.URL.Path + if query != "" { + path = path + "?" + query + } + + s.log.Info("%s %s -> %d in %s", + r.Method, path, lrw.statusCode, time.Since(start).Round(time.Millisecond)) + } +} + +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +// render mints a CSRF token, fills ViewData defaults, and writes the response. +func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string, data *ViewData) { + token, err := auth.SetCsrfCookie(w, r, CsrfCookieName) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if data == nil { + data = &ViewData{} + } + data.FormToken = token + if data.Title == "" { + data.Title = page + } + if data.User != nil && data.User.SuperUser { + data.IsSuperUser = true + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.views.Render(w, page, data); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// getUserInfo reads the session cookie and looks up the associated user. +func (s *WebService) getUserInfo(r *http.Request) (*user.User, error) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + return nil, err + } + session, err := s.auths.GetSessionByToken(r.Context(), cookie.Value) + if err != nil { + return nil, err + } + if !session.IsValid() { + return nil, fmt.Errorf("session expired") + } + return s.users.GetUserById(r.Context(), session.UserId) +} + +func (s *WebService) currentUser(r *http.Request) (*user.User, error) { + return s.getUserInfo(r) +} + +// basicViewData builds a ViewData with the current user and a CSRF token. +func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title string) (*ViewData, bool) { + user, err := s.getUserInfo(r) + if err != nil { + return nil, false + } + token, err := auth.SetCsrfCookie(w, r, CsrfCookieName) + if err != nil { + return nil, false + } + return &ViewData{Title: title, User: user, IsSuperUser: user.SuperUser, FormToken: token}, true +} + +// requireAuth redirects unauthenticated visitors to /signin with a ?redirect= +// parameter so they return to their intended page after login. +func (s *WebService) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, err := s.currentUser(r); err != nil { + redirectTo := "/signin" + if r.URL.Path != "/" && r.URL.Path != "" { + redirectTo = "/signin?redirect=" + url.PathEscape(r.URL.RequestURI()) + } + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + return + } + next(w, r) + } +} + +// requireSuperUser wraps a handler, requiring an authenticated super user. +func (s *WebService) requireSuperUser(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + u, err := s.currentUser(r) + if err != nil || u == nil || !u.SuperUser { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next(w, r) + }) +} diff --git a/internal/web/session.go b/internal/web/session.go new file mode 100644 index 0000000..3a36817 --- /dev/null +++ b/internal/web/session.go @@ -0,0 +1,85 @@ +package web + +import ( + "net/http" + "net/url" + + "uPortal/internal/auth" +) + +// HandleSessions lists the current user's active sessions. +func (s *WebService) HandleSessions(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Sessions") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + // A generous limit; the page renders whatever comes back. + sessions, err := s.auths.GetSessionByUserId(r.Context(), data.User.Id, 100, 0) + if err != nil { + s.log.Error("failed to get sessions: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data.Sessions = sessions + + s.render(w, r, "session/view.html", data) +} + +// HandleSessionEnd deletes a single session identified by the form field "token". +// It enforces ownership: a user can only delete their own session unless they are a superuser. +func (s *WebService) HandleSessionEnd(w http.ResponseWriter, r *http.Request) { + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + http.Redirect(w, r, "/sessions", http.StatusSeeOther) + return + } + + currentUser, err := s.currentUser(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + token := r.FormValue("token") + if token == "" { + http.Redirect(w, r, "/sessions", http.StatusSeeOther) + return + } + + session, err := s.auths.GetSessionByToken(r.Context(), token) + if err != nil { + http.Redirect(w, r, "/sessions", http.StatusSeeOther) + return + } + + if session.UserId == currentUser.Id { + // User deleting their own session — use defense-in-depth method + _ = s.auths.DeleteSessionByTokenAndUserId(r.Context(), token, currentUser.Id) + } else if currentUser.SuperUser { + // Superuser overriding — can delete any session + _ = s.auths.DeleteSession(r.Context(), token) + } else { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + http.Redirect(w, r, "/sessions", http.StatusSeeOther) +} + +// HandleSessionEndAll ends every session belonging to the current user. +func (s *WebService) HandleSessionEndAll(w http.ResponseWriter, r *http.Request) { + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + http.Redirect(w, r, "/sessions", http.StatusSeeOther) + return + } + + u, err := s.currentUser(r) + if err != nil { + http.Redirect(w, r, "/signin?redirect="+url.PathEscape(r.URL.RequestURI()), http.StatusSeeOther) + return + } + + _ = s.auths.DeleteSessionsByUserId(r.Context(), u.Id) + http.Redirect(w, r, "/sessions", http.StatusSeeOther) +} diff --git a/internal/web/user.go b/internal/web/user.go new file mode 100644 index 0000000..1707675 --- /dev/null +++ b/internal/web/user.go @@ -0,0 +1,93 @@ +package web + +import ( + "net/http" + "strconv" + + "uPortal/internal/auth" +) + +// HandleUsers lists every user in the system. Requires a super user. +func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) { + data, ok := s.basicViewData(w, r, "Users") + if !ok { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + users, err := s.users.GetUsers(r.Context(), 1000, 0) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data.Users = users + + s.render(w, r, "user/view.html", data) +} + +// HandleUserResetPassword lets a super admin reset another user's password. +func (s *WebService) HandleUserResetPassword(w http.ResponseWriter, r *http.Request) { + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + userIdStr := r.FormValue("user_id") + newPassword := r.FormValue("new_password") + + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + if len(newPassword) < 6 { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + user, err := s.users.GetUserById(r.Context(), userId) + if err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + hashedPassword, err := auth.HashPassword(newPassword) + if err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + user.Password = hashedPassword + s.users.UpdateUser(r.Context(), user) + + http.Redirect(w, r, "/users", http.StatusSeeOther) +} + +// HandleUserEndSessions ends all sessions for a given user. Requires a super user. +func (s *WebService) HandleUserEndSessions(w http.ResponseWriter, r *http.Request) { + if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + userIdStr := r.FormValue("user_id") + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } + + s.auths.DeleteSessionsByUserId(r.Context(), userId) + http.Redirect(w, r, "/users", http.StatusSeeOther) +} diff --git a/internal/web/webauthn.go b/internal/web/webauthn.go new file mode 100644 index 0000000..07b9bfc --- /dev/null +++ b/internal/web/webauthn.go @@ -0,0 +1,57 @@ +package web + +import "net/http" + +// HandleAuthRequest is an nginx auth_request endpoint. It validates the +// session cookie and, on success, returns the authenticated user's identity +// as response headers that nginx forwards to the upstream application. +// +// Expected usage in nginx: +// +// location / { +// auth_request /auth; +// auth_request_set $webauth_user $upstream_http_x_webauth_user; +// auth_request_set $webauth_email $upstream_http_x_webauth_email; +// auth_request_set $webauth_display_name $upstream_http_x_webauth_display_name; +// proxy_set_header X-WEBAUTH-USER $webauth_user; +// proxy_set_header X-WEBAUTH-EMAIL $webauth_email; +// proxy_set_header X-WEBAUTH-DISPLAY-NAME $webauth_display_name; +// proxy_pass http://backend; +// } +// +// location = /auth { +// internal; +// proxy_pass http://backend/auth; +// proxy_pass_request_body off; +// proxy_set_header Content-Length ""; +// proxy_set_header X-Original-URI $request_uri; +// } +func (s *WebService) HandleAuthRequest(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + session, err := s.auths.GetSessionByToken(r.Context(), cookie.Value) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if !session.IsValid() { + w.WriteHeader(http.StatusUnauthorized) + return + } + + user, err := s.users.GetUserById(r.Context(), session.UserId) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.Header().Set("X-WEBAUTH-USER", user.Login) + w.Header().Set("X-WEBAUTH-EMAIL", user.Login) + w.Header().Set("X-WEBAUTH-DISPLAY-NAME", user.EffectiveDisplayName()) + w.WriteHeader(http.StatusOK) +} diff --git a/static/default.jpg b/static/default.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a8a84327d080b877818d9fb2f528a5e4784c790 GIT binary patch literal 28350 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 literal 0 HcmV?d00001 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..4d6b121 --- /dev/null +++ b/static/style.css @@ -0,0 +1,47 @@ +*{box-sizing:border-box} +body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;padding:10px} +img{max-width:100%} + +.container{max-width:960px;margin:0 auto} + +.row{display:flex;flex-wrap:wrap} +.col-1{width:16.66%}.col-2{width:33.33%}.col-3{width:50%}.col-4{width:66.66%}.col-5{width:83.33%}.col-6{width:100%} +.gap{display:flex;flex-wrap:wrap} +.gap>[class^=col-]{padding:0 4px} + +.text-center{text-align:center} + +.card{background:#fafafa;border:1px solid #999;margin:0 4px 10px 4px} +.card-header{background:#f0f0f0;border-bottom:1px solid #999;padding:6px 10px} +.card-b{padding:8px 10px} + +.w-100{display:block;width:100%} +.mx-auto{display:block;margin:0 auto} + +.alert{border:1px solid;padding:8px 10px;margin:0 4px 10px 4px} +.alert-info{border-color:#88c;background:#eef} +.alert-warn{border-color:#cc0;background:#ffc} +.alert-err{border-color:#c88;background:#fee} + +.navbar{border-bottom:1px solid #999} +.navbar a{text-decoration:none;padding:6px 12px;display:inline-block} +.navbar a+a{border-left:1px solid #ccc} + +.table{width:100%;border-collapse:collapse;border:1px solid #999} +.table td,.table th{border:1px solid #999;padding:5px 8px} +.table th{text-align:center;font-weight:bold} +.col-shrink{width:1%;white-space:nowrap} + +.pagination{display:flex;flex-wrap:wrap;gap:4px;justify-content:center} +.pagination a{display:inline-block;padding:3px 8px;border:1px solid #999;text-decoration:none;color:#222;font-size:12px} +.pagination .active{font-weight:bold} + +@media(max-width:640px){ +body{padding:6px} +.col-1,.col-2,.col-3,.col-4,.col-5,.col-6{width:100%} +.card,.alert{margin:0 0 8px 0} +.gap>[class^=col-]{padding:0} +.navbar a{display:block;padding:5px 10px;border-bottom:1px solid #ccc;text-align:center} +.navbar a+a{border-left:none} +.col-shrink{white-space:normal} +} \ No newline at end of file diff --git a/templates/layouts/alarm.html b/templates/layouts/alarm.html new file mode 100644 index 0000000..a8be57a --- /dev/null +++ b/templates/layouts/alarm.html @@ -0,0 +1,5 @@ +{{ define "alarm" }} +{{ if .Error }} +
{{.Error}}
+{{ end }} +{{ end }} \ No newline at end of file diff --git a/templates/layouts/csrf.html b/templates/layouts/csrf.html new file mode 100644 index 0000000..9472d4f --- /dev/null +++ b/templates/layouts/csrf.html @@ -0,0 +1,3 @@ +{{define "csrf"}} + +{{end}} \ No newline at end of file diff --git a/templates/layouts/footer.html b/templates/layouts/footer.html new file mode 100644 index 0000000..37bdaad --- /dev/null +++ b/templates/layouts/footer.html @@ -0,0 +1,4 @@ +{{define "footer"}} + + +{{end}} \ No newline at end of file diff --git a/templates/layouts/header.html b/templates/layouts/header.html new file mode 100644 index 0000000..115e36c --- /dev/null +++ b/templates/layouts/header.html @@ -0,0 +1,11 @@ +{{define "header"}} + + + + + {{.Title}} + + + + +{{end}} \ No newline at end of file diff --git a/templates/layouts/navbar.html b/templates/layouts/navbar.html new file mode 100644 index 0000000..78a67af --- /dev/null +++ b/templates/layouts/navbar.html @@ -0,0 +1,12 @@ +{{define "navbar"}} + +{{end}} \ No newline at end of file diff --git a/templates/layouts/redirect.html b/templates/layouts/redirect.html new file mode 100644 index 0000000..4cb1cfd --- /dev/null +++ b/templates/layouts/redirect.html @@ -0,0 +1,5 @@ +{{define "redirect"}} +{{if .Redirect}} + +{{end}} +{{end}} \ No newline at end of file diff --git a/templates/pages/auth/signin.html b/templates/pages/auth/signin.html new file mode 100644 index 0000000..25bd44f --- /dev/null +++ b/templates/pages/auth/signin.html @@ -0,0 +1,31 @@ +{{template "header" .}} + +
+

uPortal

+{{template "alarm" .}} +
+
+
+
Sign in
+
+
+{{template "csrf" .}} +{{template "redirect" .}} +
+
+
+
+
+{{if .Redirect}} +

Don't have an account? Sign up

+{{else}} +

Don't have an account? Sign up

+{{end}} +
+
+
+
+
+ + +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/auth/signout.html b/templates/pages/auth/signout.html new file mode 100644 index 0000000..568a0bb --- /dev/null +++ b/templates/pages/auth/signout.html @@ -0,0 +1,22 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

uPortal

+
+
+
+
Sign out
+
+
+{{template "csrf" .}} +
+
+
+
+
+
+
+ + +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/auth/signup.html b/templates/pages/auth/signup.html new file mode 100644 index 0000000..4ce31e0 --- /dev/null +++ b/templates/pages/auth/signup.html @@ -0,0 +1,28 @@ +{{template "header" .}} + +
+

uPortal

+{{template "alarm" .}} +
+
+
+
Sign up
+
+
+{{template "csrf" .}} +{{template "redirect" .}} +
+
+
+
+
+
+

Already have an account? Sign in

+
+
+
+
+
+ + +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/invite/create.html b/templates/pages/invite/create.html new file mode 100644 index 0000000..8435836 --- /dev/null +++ b/templates/pages/invite/create.html @@ -0,0 +1,25 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Create invite

+{{template "alarm" .}} +
+
+
+
Create Invite
+
+
+{{template "csrf" .}} +
+
+
+
+

Back to invites

+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/invite/view.html b/templates/pages/invite/view.html new file mode 100644 index 0000000..89c48bd --- /dev/null +++ b/templates/pages/invite/view.html @@ -0,0 +1,49 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Invites

+
+
+
+
Invites
+
+{{template "alarm" .}} + + + + + + +{{range .Invites}} + + + + + + + +{{end}} + +
TokenUsesExpires AtCreated At
{{.Token}}{{.Uses}}{{.ExpiresAt.Format "2006-01-02 15:04"}}{{.CreatedAt.Format "2006-01-02 15:04"}} +
+{{template "csrf" $}} + + +
+
+
+
+
+
+
+
Actions
+ +
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/jitsi/token.html b/templates/pages/jitsi/token.html new file mode 100644 index 0000000..cac4346 --- /dev/null +++ b/templates/pages/jitsi/token.html @@ -0,0 +1,23 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Jitsi

+{{template "alarm" .}} +
+
+
+
Join Jitsi
+
+
+{{template "csrf" .}} +
+
+
+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/profile/edit.html b/templates/pages/profile/edit.html new file mode 100644 index 0000000..37ae4c8 --- /dev/null +++ b/templates/pages/profile/edit.html @@ -0,0 +1,26 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Profile

+{{template "alarm" .}} +
+
+
+
Edit profile
+
+

Avatar

+
+{{template "csrf" .}} +
+
+
+
+

Back to profile

+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/profile/password.html b/templates/pages/profile/password.html new file mode 100644 index 0000000..1d8ef99 --- /dev/null +++ b/templates/pages/profile/password.html @@ -0,0 +1,26 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Profile

+{{template "alarm" .}} +
+
+
+
Change Password
+
+
+{{template "csrf" .}} +
+
+
+
+
+

Back to profile

+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/profile/view.html b/templates/pages/profile/view.html new file mode 100644 index 0000000..3a33d22 --- /dev/null +++ b/templates/pages/profile/view.html @@ -0,0 +1,31 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Profile

+{{template "alarm" .}} +
+
+
+
Info
+
+

Avatar

+

Login: {{.User.Login}}

+

Display name: {{.User.EffectiveDisplayName}}

+{{if .User.SuperUser}}

Super User: Yes

{{end}} +
+
+
+
+
+
Actions
+ +
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/redirect/warning.html b/templates/pages/redirect/warning.html new file mode 100644 index 0000000..35432b8 --- /dev/null +++ b/templates/pages/redirect/warning.html @@ -0,0 +1,24 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

External redirect

+
+
+
+
Warning
+
+

You are about to leave this site and go to:

+

{{.Redirect}}

+

Do you want to continue?

+
+ +
+

No, stay here

+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/session/view.html b/templates/pages/session/view.html new file mode 100644 index 0000000..35d76c4 --- /dev/null +++ b/templates/pages/session/view.html @@ -0,0 +1,52 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+

Sessions

+
+
+
+
Your Sessions ({{len .Sessions}})
+
+{{template "alarm" .}} + + + + + + +{{range .Sessions}} + + + + + + + +{{end}} + +
TokenDeviceAddressCreated At
{{printf "%.8s" .Token}}...{{.Device}}{{.Address}}{{.CreatedAtLocal}} +
+{{template "csrf" $}} + + +
+
+
+
+
+
+
+
Actions
+
+
+{{template "csrf" .}} +
+
+
+
+
+
+
+ +{{template "footer" .}} \ No newline at end of file diff --git a/templates/pages/user/view.html b/templates/pages/user/view.html new file mode 100644 index 0000000..be9526d --- /dev/null +++ b/templates/pages/user/view.html @@ -0,0 +1,53 @@ +{{template "header" .}} +{{template "navbar" .}} + +
+{{if not .IsSuperUser}} +
403 Forbidden
+{{else}} +

Users

+
+
+
+
Users
+
+{{template "alarm" .}} + + + + + + +{{range .Users}} + + + + + + + + +{{end}} + +
IDLoginDisplay NameSuper UserReset PasswordSessions
{{.Id}}{{.Login}}{{.EffectiveDisplayName}}{{if .SuperUser}}Yes{{else}}No{{end}} +
+{{template "csrf" $}} + + + +
+
+
+{{template "csrf" $}} + + +
+
+
+
+
+
+{{end}} +
+ +{{template "footer" .}} \ No newline at end of file