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

+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)
}