Initial commit
This commit is contained in:
commit
c578b91c2d
46 files changed
+3241
No files matched your search
@@ -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
|
||||
+55
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+83
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{ define "alarm" }}
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-err">{{.Error}}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{define "csrf"}}
|
||||
<input type="hidden" name="formtoken" value="{{.FormToken}}"/>
|
||||
{{end}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{define "footer"}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "header"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{.Title}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{end}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{define "navbar"}}
|
||||
<nav class="navbar">
|
||||
<a href="/">Profile</a>
|
||||
<a href="/sessions">Sessions</a>
|
||||
<a href="/jitsi">Jitsi</a>
|
||||
{{ if .IsSuperUser }}
|
||||
<a href="/users">Users</a>
|
||||
<a href="/invites">Invites</a>
|
||||
{{ end }}
|
||||
<a href="/signout">Logout</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{define "redirect"}}
|
||||
{{if .Redirect}}
|
||||
<input type="hidden" name="redirect" value="{{.Redirect}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2 class="text-center">uPortal</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Sign in</div>
|
||||
<div class="card-b">
|
||||
<form method="post">
|
||||
{{template "csrf" .}}
|
||||
{{template "redirect" .}}
|
||||
<div class="form-group"><label for="login_username">Username</label><input type="text" class="w-100" id="login_username" name="username"></div>
|
||||
<div class="form-group"><label for="login_password">Password</label><input type="password" class="w-100" id="login_password" name="password"></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="remember" name="remember" checked> Remember me</label></div>
|
||||
<div class="text-center"><button type="submit">Sign In</button></div>
|
||||
</form>
|
||||
{{if .Redirect}}
|
||||
<p class="text-center"><a href="/signup?redirect={{.Redirect}}">Don't have an account? Sign up</a></p>
|
||||
{{else}}
|
||||
<p class="text-center"><a href="/signup">Don't have an account? Sign up</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2 class="text-center">uPortal</h2>
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Sign out</div>
|
||||
<div class="card-b">
|
||||
<form method="post">
|
||||
{{template "csrf" .}}
|
||||
<div class="text-center"><button type="submit">Confirm</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2 class="text-center">uPortal</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Sign up</div>
|
||||
<div class="card-b">
|
||||
<form method="post">
|
||||
{{template "csrf" .}}
|
||||
{{template "redirect" .}}
|
||||
<div class="form-group"><label for="reg_username">Username</label><input type="text" class="w-100" id="reg_username" name="username"/></div>
|
||||
<div class="form-group"><label for="reg_password">Password</label><input type="password" class="w-100" id="reg_password" name="password"/></div>
|
||||
<div class="form-group"><label for="reg_confirm">Confirm Password</label><input type="password" class="w-100" id="reg_confirm" name="confirm"/></div>
|
||||
<div class="form-group"><label for="reg_invite">Invite code</label><input type="text" class="w-100" id="reg_invite" name="invite"/></div>
|
||||
<div class="text-center"><button type="submit">Create Account</button></div>
|
||||
</form>
|
||||
<p class="text-center"><a href="/signin">Already have an account? Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Create invite</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Create Invite</div>
|
||||
<div class="card-b">
|
||||
<form method="post" action="/invites/create">
|
||||
{{template "csrf" .}}
|
||||
<div class="form-group"><label for="uses">Uses</label><input type="number" class="w-100" id="uses" name="uses" value="1" min="1"></div>
|
||||
<div class="form-group"><label for="exp_hours">Expires in (hours)</label><input type="number" class="w-100" id="exp_hours" name="expires_hours" value="24" min="1"></div>
|
||||
<div class="text-center"><button type="submit">Create</button></div>
|
||||
</form>
|
||||
<p class="text-center"><a href="/invites">Back to invites</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Invites</h2>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Invites</div>
|
||||
<div class="card-b">
|
||||
{{template "alarm" .}}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Token</th><th>Uses</th><th>Expires At</th><th>Created At</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Invites}}
|
||||
<tr>
|
||||
<td>{{.Token}}</td>
|
||||
<td>{{.Uses}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="post" action="/invites/delete">
|
||||
{{template "csrf" $}}
|
||||
<input type="hidden" name="token" value="{{.Token}}">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Actions</div>
|
||||
<div class="card-b">
|
||||
<p><a href="/invites/create">Create Invite</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Jitsi</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Join Jitsi</div>
|
||||
<div class="card-b">
|
||||
<form method="post">
|
||||
{{template "csrf" .}}
|
||||
<div class="form-group"><label for="jitsi_room">Room name</label><input type="text" class="w-100" id="jitsi_room" name="room" placeholder="e.g. my-meeting-room" required></div>
|
||||
<div class="text-center"><button type="submit">Open in Jitsi</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Profile</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Edit profile</div>
|
||||
<div class="card-b">
|
||||
<p><img src="/avatar/{{.User.Id}}" onerror="this.src='/static/default.jpg'; this.onerror=null;" width="128" height="128" alt="Avatar" class="mx-auto"></p>
|
||||
<form method="post" action="/profile/edit" enctype="multipart/form-data">
|
||||
{{template "csrf" .}}
|
||||
<div class="form-group"><label for="disp_name">Display name</label><input type="text" class="w-100" id="disp_name" name="display_name" value="{{.User.EffectiveDisplayName}}"></div>
|
||||
<div class="form-group"><label for="avatar">Avatar</label><input type="file" class="w-100" id="avatar" name="avatar" accept="image/png,image/jpeg"></div>
|
||||
<div class="text-center"><button type="submit">Save</button></div>
|
||||
</form>
|
||||
<p class="text-center"><a href="/profile">Back to profile</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Profile</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Change Password</div>
|
||||
<div class="card-b">
|
||||
<form method="post" action="/profile/password">
|
||||
{{template "csrf" .}}
|
||||
<div class="form-group"><label for="old_password">Current password</label><input type="password" class="w-100" id="old_password" name="old_password" required></div>
|
||||
<div class="form-group"><label for="new_password">New password</label><input type="password" class="w-100" id="new_password" name="new_password" required minlength="6"></div>
|
||||
<div class="form-group"><label for="confirm_password">Confirm new password</label><input type="password" class="w-100" id="confirm_password" name="confirm_password" required minlength="6"></div>
|
||||
<div class="text-center"><button type="submit">Change Password</button></div>
|
||||
</form>
|
||||
<p class="text-center"><a href="/profile">Back to profile</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Profile</h2>
|
||||
{{template "alarm" .}}
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<div class="card">
|
||||
<div class="card-header">Info</div>
|
||||
<div class="card-b">
|
||||
<p><img src="/avatar/{{.User.Id}}" onerror="this.src='/static/default.jpg'; this.onerror=null;" width="128" height="128" alt="Avatar" class="mx-auto"></p>
|
||||
<p><strong>Login:</strong> {{.User.Login}}</p>
|
||||
<p><strong>Display name:</strong> {{.User.EffectiveDisplayName}}</p>
|
||||
{{if .User.SuperUser}}<p><strong>Super User:</strong> Yes</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="card">
|
||||
<div class="card-header">Actions</div>
|
||||
<div class="card-b">
|
||||
<p><a href="/profile/edit">Edit Profile</a></p>
|
||||
<p><a href="/profile/password">Change Password</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>External redirect</h2>
|
||||
<div class="row">
|
||||
<div class="col-3 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">Warning</div>
|
||||
<div class="card-b">
|
||||
<p>You are about to leave this site and go to:</p>
|
||||
<p><strong>{{.Redirect}}</strong></p>
|
||||
<p>Do you want to continue?</p>
|
||||
<form method="get" action="{{.Redirect}}">
|
||||
<button type="submit">Yes, go there</button>
|
||||
</form>
|
||||
<p><a href="/">No, stay here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
<h2>Sessions</h2>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Your Sessions ({{len .Sessions}})</div>
|
||||
<div class="card-b">
|
||||
{{template "alarm" .}}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Token</th><th>Device</th><th>Address</th><th>Created At</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Sessions}}
|
||||
<tr>
|
||||
<td>{{printf "%.8s" .Token}}...</td>
|
||||
<td>{{.Device}}</td>
|
||||
<td>{{.Address}}</td>
|
||||
<td>{{.CreatedAtLocal}}</td>
|
||||
<td>
|
||||
<form method="post" action="/sessions/end">
|
||||
{{template "csrf" $}}
|
||||
<input type="hidden" name="token" value="{{.Token}}">
|
||||
<button type="submit">End</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Actions</div>
|
||||
<div class="card-b">
|
||||
<form method="post" action="/sessions/end-all">
|
||||
{{template "csrf" .}}
|
||||
<div class="text-center"><button type="submit">End All Sessions</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{{template "header" .}}
|
||||
{{template "navbar" .}}
|
||||
|
||||
<div class="container">
|
||||
{{if not .IsSuperUser}}
|
||||
<div class="alert alert-err">403 Forbidden</div>
|
||||
{{else}}
|
||||
<h2>Users</h2>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Users</div>
|
||||
<div class="card-b">
|
||||
{{template "alarm" .}}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Login</th><th>Display Name</th><th>Super User</th><th>Reset Password</th><th>Sessions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.Id}}</td>
|
||||
<td>{{.Login}}</td>
|
||||
<td>{{.EffectiveDisplayName}}</td>
|
||||
<td>{{if .SuperUser}}Yes{{else}}No{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/users/reset-password" style="display:inline;">
|
||||
{{template "csrf" $}}
|
||||
<input type="hidden" name="user_id" value="{{.Id}}">
|
||||
<input type="password" name="new_password" placeholder="New password" required minlength="6">
|
||||
<button type="submit">Reset</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/users/end-sessions" style="display:inline;">
|
||||
{{template "csrf" $}}
|
||||
<input type="hidden" name="user_id" value="{{.Id}}">
|
||||
<button type="submit">End Sessions</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
Reference in new issue
Block a user