Initial commit

This commit is contained in:
blankhex committed 2026-10-15 23:25:46 +03:00
commit c578b91c2d
46 files changed
+3241

No files matched your search

+10
View File
@@ -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
View File
@@ -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"]
+254
View File
@@ -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)
}
+36
View File
@@ -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,
);
+23
View File
@@ -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
+248
View File
@@ -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 |
+9
View File
@@ -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
+6
View File
@@ -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=
+31
View File
@@ -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
}
+191
View File
@@ -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()
}
+113
View File
@@ -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
}
+83
View File
@@ -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
}
+98
View File
@@ -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))
}
+80
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
+16
View File
@@ -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
}
+200
View File
@@ -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
}
+72
View File
@@ -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
}
+283
View File
@@ -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)
}
+84
View File
@@ -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)
}
+113
View File
@@ -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
}
+164
View File
@@ -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)
}
+270
View File
@@ -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)
})
}
+85
View File
@@ -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)
}
+93
View File
@@ -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)
}
+57
View File
@@ -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

+47
View File
@@ -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}
}
+5
View File
@@ -0,0 +1,5 @@
{{ define "alarm" }}
{{ if .Error }}
<div class="alert alert-err">{{.Error}}</div>
{{ end }}
{{ end }}
+3
View File
@@ -0,0 +1,3 @@
{{define "csrf"}}
<input type="hidden" name="formtoken" value="{{.FormToken}}"/>
{{end}}
+4
View File
@@ -0,0 +1,4 @@
{{define "footer"}}
</body>
</html>
{{end}}
+11
View File
@@ -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}}
+12
View File
@@ -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}}
+5
View File
@@ -0,0 +1,5 @@
{{define "redirect"}}
{{if .Redirect}}
<input type="hidden" name="redirect" value="{{.Redirect}}">
{{end}}
{{end}}
+31
View File
@@ -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" .}}
+22
View File
@@ -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" .}}
+28
View File
@@ -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" .}}
+25
View File
@@ -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" .}}
+49
View File
@@ -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" .}}
+23
View File
@@ -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" .}}
+26
View File
@@ -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" .}}
+26
View File
@@ -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" .}}
+31
View File
@@ -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" .}}
+24
View File
@@ -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" .}}
+52
View File
@@ -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" .}}
+53
View File
@@ -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" .}}