misaki/server/server.go
q 4da1cb6d84
All checks were successful
Build & push Docker image / build (push) Successful in 1m44s
wal database fix
2026-11-07 19:31:49 -05:00

1336 lines
36 KiB
Go

package server
import (
"bytes"
"context"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net"
"net/http"
"net/smtp"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"misaki/db"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "session"
sessionTTL = 7 * 24 * time.Hour
bcryptCost = 12
)
var (
validUsernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
reservedNames = map[string]bool{
"admin": true, "root": true, "www": true, "mail": true,
"postmaster": true, "webmaster": true, "misaki": true,
"abuse": true, "hostmaster": true, "noreply": true, "no-reply": true,
}
dummyHash []byte
)
func init() {
dummyHash, _ = bcrypt.GenerateFromPassword([]byte("timing-guard-dummy-hash-do-not-use"), bcryptCost)
}
type rateLimiter struct {
mu sync.Mutex
buckets map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
buckets: make(map[string][]time.Time),
limit: limit,
window: window,
}
go func() {
for range time.Tick(5 * time.Minute) {
rl.evict()
}
}()
return rl
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
ts := rl.buckets[key]
n := 0
for _, t := range ts {
if t.After(cutoff) {
ts[n] = t
n++
}
}
ts = ts[:n]
if len(ts) >= rl.limit {
rl.buckets[key] = ts
return false
}
rl.buckets[key] = append(ts, now)
return true
}
func (rl *rateLimiter) blocked(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
ts := rl.buckets[key]
n := 0
for _, t := range ts {
if t.After(cutoff) {
ts[n] = t
n++
}
}
rl.buckets[key] = ts[:n]
return n >= rl.limit
}
// record logs one (failed) attempt against key.
func (rl *rateLimiter) record(key string) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.buckets[key] = append(rl.buckets[key], time.Now())
}
func (rl *rateLimiter) evict() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for key, ts := range rl.buckets {
n := 0
for _, t := range ts {
if t.After(cutoff) {
ts[n] = t
n++
}
}
if n == 0 {
delete(rl.buckets, key)
} else {
rl.buckets[key] = ts[:n]
}
}
}
type SMTPConfig struct {
Host string
User string
Pass string
From string
To string
TLSName string // overrides the hostname used for TLS cert verification
}
func (c SMTPConfig) send(to, subject, body string) error {
if c.Host == "" {
return nil
}
host := strings.Split(c.Host, ":")[0]
tlsName := c.TLSName
if tlsName == "" {
tlsName = host
}
client, err := smtp.Dial(c.Host)
if err != nil {
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: tlsName}); err != nil {
return err
}
}
if err := client.Auth(smtp.PlainAuth("", c.User, c.Pass, host)); err != nil {
return err
}
if err := client.Mail(c.From); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s", c.From, to, subject, body)
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
func (c SMTPConfig) notify(subject, body string) error {
if c.To == "" {
return nil
}
return c.send(c.To, subject, body)
}
type ServiceURLs struct {
GancioURL string
AkkomaURL string
ProsodyURL string
ForgejoURL string
MumbleHost string
MumblePass string
TransferHost string
TransferUser string
TransferPass string
}
type Config struct {
SecureCookies bool
InfoURLs map[string]string
CookieURLs map[string]string
InternalSecret string
AuthSecret string
BaseDomain string
DefaultNote string
OIDC *OIDCConfig
TOTPKeyFile string
PublicURL string
}
var svcType = map[string]string{
"mail": "form",
"werc": "form",
"ergo": "request",
"gancio": "link",
"akkoma": "oidc",
"pixelfed": "oidc",
"prosody": "request",
"forgejo": "link",
"cerca": "request",
"streaming": "request",
"radio": "request",
"files": "request",
"mumble": "display",
"transfer": "cookie",
"notes": "cookie",
"paste": "cookie",
"rss": "cookie",
}
// adminDataHints marks services whose grant carries admin-set data, with the field's placeholder.
var adminDataHints = map[string]string{
"cerca": "registration link",
"streaming": "mediamtx stream key",
"radio": "stations, e.g. fmradio,live (* = all)",
"files": "mounts, e.g. share,fmradio (* = admin/all)",
}
type serviceCard struct {
Service string
Label string
Type string
Applied bool
Granted bool
Denied bool
DenyReason string
Data string
Desc template.HTML
InfoURL string
URL string
NeedsEmail bool
}
type Server struct {
db *db.DB
mux *http.ServeMux
tmpl map[string]*template.Template
smtp SMTPConfig
svcURLs ServiceURLs
svcDescs map[string]template.HTML
svcInfoURLs map[string]string
svcCookieURLs map[string]string
secureCookies bool
internalSecret string
authSecret []byte
baseDomain string
defaultNote string
loginLimiter *rateLimiter
applyLimiter *rateLimiter
streamLimiter *rateLimiter
authLimiter *rateLimiter
tokenLimiter *rateLimiter
codeLimiter *rateLimiter
resetLimiter *rateLimiter
oidc *oidcProvider
totp cipher.AEAD
publicURL string
pendingMu sync.Mutex
pending map[string]*pendingLogin
}
func New(database *db.DB, svcURLs ServiceURLs, svcDescs map[string]template.HTML, smtpCfg SMTPConfig, cfg Config) *Server {
defaultNote := cfg.DefaultNote
if defaultNote == "" {
defaultNote = "I love misaki!"
}
s := &Server{
db: database,
smtp: smtpCfg,
svcURLs: svcURLs,
svcDescs: svcDescs,
svcInfoURLs: cfg.InfoURLs,
svcCookieURLs: cfg.CookieURLs,
secureCookies: cfg.SecureCookies,
internalSecret: cfg.InternalSecret,
authSecret: []byte(cfg.AuthSecret),
baseDomain: cfg.BaseDomain,
defaultNote: defaultNote,
loginLimiter: newRateLimiter(10, 15*time.Minute),
applyLimiter: newRateLimiter(3, time.Hour),
streamLimiter: newRateLimiter(10, 15*time.Minute),
authLimiter: newRateLimiter(10, 15*time.Minute),
tokenLimiter: newRateLimiter(20, 15*time.Minute),
codeLimiter: newRateLimiter(5, time.Hour),
resetLimiter: newRateLimiter(5, time.Hour),
publicURL: strings.TrimSuffix(cfg.PublicURL, "/"),
pending: map[string]*pendingLogin{},
}
if cfg.TOTPKeyFile != "" {
aead, err := loadTOTPKey(cfg.TOTPKeyFile)
if err != nil {
log.Fatalf("totp: %v", err)
}
s.totp = aead
}
oidc, err := newOIDCProvider(cfg.OIDC)
if err != nil {
log.Fatalf("oidc: %v", err)
}
s.oidc = oidc
pages := []string{"index.html", "apply.html", "applied.html", "login.html", "logout.html", "dashboard.html", "admin.html", "help.html", "404.html", "500.html", "code.html", "reset.html", "security.html"}
s.tmpl = make(map[string]*template.Template, len(pages))
for _, p := range pages {
s.tmpl[p] = template.Must(template.ParseFiles("tmpl/base.html", "tmpl/"+p))
}
s.routes()
return s
}
func (s *Server) routes() {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("GET /", s.handleIndex)
mux.HandleFunc("GET /help", s.requireUser(s.handleHelp))
mux.HandleFunc("GET /apply", s.handleApplyGet)
mux.HandleFunc("POST /apply", s.handleApplyPost)
mux.HandleFunc("GET /applied", s.handleApplied)
mux.HandleFunc("GET /login", s.handleLoginGet)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /login/code", s.handleCodeGet)
mux.HandleFunc("POST /login/code", s.handleCodePost)
mux.HandleFunc("GET /reset", s.handleResetGet)
mux.HandleFunc("POST /reset", s.handleResetPost)
mux.HandleFunc("POST /reset/confirm", s.handleResetConfirm)
mux.HandleFunc("POST /dashboard/password", s.requireUser(s.handlePasswordChange))
mux.HandleFunc("POST /dashboard/2fa/setup", s.requireUser(s.handleTOTPSetup))
mux.HandleFunc("POST /dashboard/2fa/enable", s.requireUser(s.handleTOTPEnable))
mux.HandleFunc("POST /dashboard/2fa/disable", s.requireUser(s.handleTOTPDisable))
mux.HandleFunc("GET /logout", s.handleLogoutGet)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /dashboard", s.requireUser(s.handleDashboard))
mux.HandleFunc("POST /dashboard/apply", s.requireUser(s.handleApplyService))
mux.HandleFunc("POST /dashboard/request-reset", s.requireUser(s.handleRequestReset))
mux.HandleFunc("POST /dashboard/request-namechange", s.requireUser(s.handleRequestNameChange))
mux.HandleFunc("POST /dashboard/username", s.requireUser(s.handleRename))
mux.HandleFunc("POST /dashboard/note", s.requireUser(s.handleUpdateNote))
mux.HandleFunc("POST /dashboard/email", s.requireUser(s.handleUpdateEmail))
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdmin))
mux.HandleFunc("POST /admin/action", s.requireAdmin(s.handleAdminAction))
mux.HandleFunc("POST /admin/service", s.requireAdmin(s.handleAdminToggleService))
mux.HandleFunc("POST /admin/user-service", s.requireAdmin(s.handleAdminUserService))
if s.internalSecret != "" {
mux.HandleFunc("POST /internal/auth", s.handleInternalAuth)
mux.HandleFunc("POST /internal/stream-auth", s.handleInternalStreamAuth)
mux.HandleFunc("GET /internal/users", s.handleInternalUsers)
}
mux.HandleFunc("GET /internal/session-check", s.handleInternalSessionCheck)
s.oidcRoutes(mux)
s.mux = mux
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// ListenAndServe serves until ctx is cancelled, then drains in-flight requests.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
hs := &http.Server{Addr: addr, Handler: s.mux, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
errc := make(chan error, 1)
go func() { errc <- hs.ListenAndServe() }()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return hs.Shutdown(sctx)
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
t, ok := s.tmpl[name]
if !ok {
log.Printf("unknown template: %s", name)
s.internalError(w)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil {
log.Printf("template %s: %v", name, err)
s.internalError(w)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
t, ok := s.tmpl["404.html"]
if !ok {
http.NotFound(w, r)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base.html", nil); err != nil {
log.Printf("404 template: %v", err)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
buf.WriteTo(w)
}
func (s *Server) internalError(w http.ResponseWriter) {
t, ok := s.tmpl["500.html"]
if !ok {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base.html", nil); err != nil {
log.Printf("500 template: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
buf.WriteTo(w)
}
func (s *Server) isAllowedRedirect(u string) bool {
if u == "" || s.baseDomain == "" {
return false
}
parsed, err := url.Parse(u)
if err != nil || parsed.Scheme != "https" {
return false
}
host := parsed.Hostname()
return host == s.baseDomain || strings.HasSuffix(host, "."+s.baseDomain)
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func validEmail(e string) bool {
at := strings.LastIndex(e, "@")
if at < 1 || at == len(e)-1 {
return false
}
domain := e[at+1:]
dot := strings.LastIndex(domain, ".")
return dot > 0 && dot < len(domain)-1
}
func (s *Server) session(r *http.Request) (*db.User, *db.Session) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return nil, nil
}
sess, err := s.db.GetSession(c.Value)
if err != nil {
return nil, nil
}
u, err := s.db.GetUserByID(sess.UserID)
if err != nil {
return nil, nil
}
return u, sess
}
func (s *Server) sessionUser(r *http.Request) *db.User {
u, _ := s.session(r)
return u
}
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.sessionUser(r) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
if u == nil || !u.IsAdmin {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.notFound(w, r)
return
}
s.render(w, "index.html", nil)
}
func (s *Server) handleHelp(w http.ResponseWriter, r *http.Request) {
s.render(w, "help.html", map[string]any{"User": s.sessionUser(r)})
}
func (s *Server) handleApplyGet(w http.ResponseWriter, r *http.Request) {
s.render(w, "apply.html", map[string]any{"Error": r.URL.Query().Get("err")})
}
func (s *Server) handleApplyPost(w http.ResponseWriter, r *http.Request) {
if !s.applyLimiter.allow(clientIP(r)) {
http.Redirect(w, r, "/apply?err=too+many+registration+attempts%2C+please+try+again+later", http.StatusSeeOther)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
confirm := r.FormValue("password_confirm")
email := r.FormValue("email")
bio := r.FormValue("bio")
if username == "" || password == "" || email == "" {
http.Redirect(w, r, "/apply?err=username%2C+password+and+email+are+required", http.StatusSeeOther)
return
}
if msg := usernameProblem(username); msg != "" {
back(w, r, "/apply", "err", msg)
return
}
if taken, err := s.db.NameTaken(username, 0); err != nil || taken {
http.Redirect(w, r, "/apply?err=username+already+taken", http.StatusSeeOther)
return
}
if len(password) < 8 {
http.Redirect(w, r, "/apply?err=password+must+be+at+least+8+characters", http.StatusSeeOther)
return
}
if password != confirm {
http.Redirect(w, r, "/apply?err=passwords+do+not+match", http.StatusSeeOther)
return
}
if len(password) > 128 {
http.Redirect(w, r, "/apply?err=password+too+long", http.StatusSeeOther)
return
}
if !validEmail(email) {
http.Redirect(w, r, "/apply?err=invalid+email+address", http.StatusSeeOther)
return
}
if bio == "" {
http.Redirect(w, r, "/apply?err=biography+is+required", http.StatusSeeOther)
return
}
if len(email) > 254 || len(bio) > 2000 {
http.Redirect(w, r, "/apply?err=input+too+long", http.StatusSeeOther)
return
}
if r.FormValue("agree") != "on" {
http.Redirect(w, r, "/apply?err=you+must+agree+to+the+manifesto+and+privacy+policy", http.StatusSeeOther)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
s.internalError(w)
return
}
if _, err := s.db.CreateUser(username, string(hash), s.defaultNote); err != nil {
http.Redirect(w, r, "/apply?err=username+already+taken", http.StatusSeeOther)
return
}
go func() {
subject := "[misaki] New application: " + username
body := fmt.Sprintf("Username: %s\nEmail: %s\nBiography:\n%s\n\nApplied: %s",
username, email, bio, time.Now().Format("2006-01-02 15:04 UTC"))
if err := s.smtp.notify(subject, body); err != nil {
log.Printf("notify apply (admin): %v", err)
}
confirmBody := fmt.Sprintf("Hi %s <3,\n\nYour application has been received I will get started on creating your personal contract right away! I will reach you at this address once I completed my review.\n\nBe seeing you soon :3", username)
if err := s.smtp.send(email, "[misaki] Application received", confirmBody); err != nil {
log.Printf("notify apply (confirm): %v", err)
} else {
log.Printf("notify apply (confirm): sent to %s", email)
}
}()
http.Redirect(w, r, "/applied", http.StatusSeeOther)
}
func (s *Server) handleApplied(w http.ResponseWriter, r *http.Request) {
s.render(w, "applied.html", nil)
}
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
if s.sessionUser(r) != nil {
next := r.URL.Query().Get("next")
if s.isAllowedRedirect(next) {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
s.render(w, "login.html", map[string]any{
"Error": r.URL.Query().Get("err"),
"Msg": r.URL.Query().Get("msg"),
"Next": r.URL.Query().Get("next"),
})
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.allow(clientIP(r)) {
http.Redirect(w, r, "/login?err=too+many+attempts%2C+please+try+again+later", http.StatusSeeOther)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
u, dbErr := s.db.GetUserByUsername(username)
hash := dummyHash
if dbErr == nil {
hash = []byte(u.PassHash)
}
if bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil || dbErr != nil {
http.Redirect(w, r, "/login?err=invalid+credentials", http.StatusSeeOther)
return
}
if u.Status != "approved" {
msg := "application+pending+approval"
if u.Status == "rejected" {
msg = "application+rejected"
}
http.Redirect(w, r, "/login?err="+msg, http.StatusSeeOther)
return
}
if s.totpOn(u.ID) {
s.startPending(w, u.ID, r.FormValue("next"))
http.Redirect(w, r, "/login/code", http.StatusSeeOther)
return
}
if err := s.startSession(w, u.ID, "pwd"); err != nil {
s.internalError(w)
return
}
s.redirectNext(w, r, r.FormValue("next"))
}
func (s *Server) startSession(w http.ResponseWriter, uid int64, amr string) error {
token, err := randomToken()
if err != nil {
return err
}
expires := time.Now().Add(sessionTTL)
if err := s.db.CreateSession(token, uid, expires, amr); err != nil {
return err
}
if s.baseDomain != "" {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Path: "/",
MaxAge: -1,
})
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Domain: s.baseDomain,
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
Expires: expires,
})
return nil
}
func (s *Server) redirectNext(w http.ResponseWriter, r *http.Request, next string) {
if s.isAllowedRedirect(next) {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (s *Server) handleLogoutGet(w http.ResponseWriter, r *http.Request) {
if s.sessionUser(r) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "logout.html", nil)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
_ = s.db.DeleteSession(c.Value)
}
// Delete both host-only and domain cookie variants
http.SetCookie(w, &http.Cookie{Name: sessionCookie, MaxAge: -1, Path: "/"})
if s.baseDomain != "" {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, MaxAge: -1, Path: "/", Domain: s.baseDomain})
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
svcs, _ := s.db.GetServicesForUser(u.ID)
cards := make([]serviceCard, 0, len(svcs))
for _, sv := range svcs {
cards = append(cards, serviceCard{
Service: sv.Service,
Label: sv.Label,
Type: svcType[sv.Service],
Applied: sv.Applied,
Granted: sv.Granted,
Denied: sv.Denied,
DenyReason: sv.DenyReason,
Data: sv.Data,
Desc: s.svcDescs[sv.Service],
InfoURL: s.svcInfoURLs[sv.Service],
URL: s.svcCookieURLs[sv.Service],
NeedsEmail: s.needsEmail(sv.Service),
})
}
email, _ := s.db.GetUserEmail(u.ID)
codesLeft, _ := s.db.CountRecoveryCodes(u.ID)
s.render(w, "dashboard.html", map[string]any{
"User": u,
"Email": email,
"TOTPReady": s.totp != nil,
"TOTPOn": s.totpOn(u.ID),
"CodesLeft": codesLeft,
"Cards": cards,
"SvcDescs": s.svcDescs,
"InfoURLs": s.svcInfoURLs,
"URLs": s.svcURLs,
"Error": r.URL.Query().Get("err"),
"Success": r.URL.Query().Get("msg"),
})
}
func (s *Server) handleApplyService(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
service := r.FormValue("service")
typ, ok := svcType[service]
if !ok || typ == "display" || typ == "link" || typ == "cookie" {
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
return
}
var handle, password string
reason := r.FormValue("reason")
if len(reason) > 2000 {
http.Redirect(w, r, "/dashboard?err=input+too+long", http.StatusSeeOther)
return
}
if (service == "radio" || service == "streaming") && strings.TrimSpace(reason) == "" {
http.Redirect(w, r, "/dashboard?err=a+reason+is+required", http.StatusSeeOther)
return
}
if typ == "form" {
handle = r.FormValue("handle")
password = r.FormValue("password")
if handle == "" || password == "" {
http.Redirect(w, r, "/dashboard?err=handle+and+password+required", http.StatusSeeOther)
return
}
if len(handle) > 64 || len(password) > 128 {
http.Redirect(w, r, "/dashboard?err=input+too+long", http.StatusSeeOther)
return
}
}
email := strings.TrimSpace(r.FormValue("email"))
if typ == "oidc" {
if email == "" && s.needsEmail(service) {
email, _ = s.db.GetUserEmail(u.ID)
}
if email == "" && s.needsEmail(service) {
http.Redirect(w, r, "/dashboard?err=an+email+is+required+for+"+service, http.StatusSeeOther)
return
}
if email != "" {
if len(email) > 254 || !validEmail(email) {
http.Redirect(w, r, "/dashboard?err=invalid+email+address", http.StatusSeeOther)
return
}
if err := s.db.SetUserEmail(u.ID, email); err != nil {
s.internalError(w)
return
}
}
}
if err := s.db.ApplyForService(u.ID, service); err != nil {
if errors.Is(err, db.ErrAlreadyApplied) {
http.Redirect(w, r, "/dashboard?err=already+applied+for+"+service, http.StatusSeeOther)
return
}
s.internalError(w)
return
}
go func() {
subject := fmt.Sprintf("[misaki] Service request: %s / %s", u.Username, service)
reasonLine := reason
if reasonLine == "" {
reasonLine = "N/A"
}
var body string
if typ == "form" {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
hashStr := "(bcrypt failed)"
if err == nil {
hashStr = string(hash)
}
body = fmt.Sprintf(
"User: %s\nService: %s\nHandle: %s\nHash: %s\nReason: %s\n\nRequested: %s",
u.Username, service, handle, hashStr, reasonLine,
time.Now().Format("2006-01-02 15:04 UTC"))
} else {
body = fmt.Sprintf(
"User: %s\nService: %s\nEmail: %s\nReason: %s\n\nRequested: %s",
u.Username, service, email, reasonLine,
time.Now().Format("2006-01-02 15:04 UTC"))
}
if err := s.smtp.notify(subject, body); err != nil {
log.Printf("notify apply email: %v", err)
}
}()
http.Redirect(w, r, "/dashboard?msg=applied+for+"+service, http.StatusSeeOther)
}
func (s *Server) handleRequestReset(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
service := r.FormValue("service")
if _, ok := svcType[service]; !ok {
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
return
}
go func() {
subject := fmt.Sprintf("[misaki] Password reset request: %s / %s", u.Username, service)
body := fmt.Sprintf("User %s requests a password reset for service: %s\n\nSent: %s",
u.Username, service, time.Now().Format("2006-01-02 15:04 UTC"))
if err := s.smtp.notify(subject, body); err != nil {
log.Printf("notify reset email: %v", err)
}
}()
http.Redirect(w, r, "/dashboard?msg=reset+request+sent", http.StatusSeeOther)
}
func (s *Server) handleRequestNameChange(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
service := r.FormValue("service")
newName := r.FormValue("new_name")
if _, ok := svcType[service]; !ok {
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
return
}
if newName == "" {
http.Redirect(w, r, "/dashboard?err=new+name+is+required", http.StatusSeeOther)
return
}
if len(newName) > 64 {
http.Redirect(w, r, "/dashboard?err=new+name+too+long", http.StatusSeeOther)
return
}
go func() {
subject := fmt.Sprintf("[misaki] Name change request: %s / %s", u.Username, service)
body := fmt.Sprintf("User %s requests a name change for service: %s\nRequested name: %s\n\nSent: %s",
u.Username, service, newName, time.Now().Format("2006-01-02 15:04 UTC"))
if err := s.smtp.notify(subject, body); err != nil {
log.Printf("notify namechange email: %v", err)
}
}()
http.Redirect(w, r, "/dashboard?msg=name+change+request+sent", http.StatusSeeOther)
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
note := r.FormValue("note")
if len(note) > 2000 {
http.Redirect(w, r, "/dashboard?err=note+too+long+(max+2000+characters)", http.StatusSeeOther)
return
}
if err := s.db.UpdateUserNote(u.ID, note); err != nil {
s.internalError(w)
return
}
http.Redirect(w, r, "/dashboard?msg=note+updated", http.StatusSeeOther)
}
func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request) {
u := s.sessionUser(r)
email := strings.TrimSpace(r.FormValue("email"))
if email != "" && (len(email) > 254 || !validEmail(email)) {
http.Redirect(w, r, "/dashboard?err=invalid+email+address", http.StatusSeeOther)
return
}
if err := s.db.SetUserEmail(u.ID, email); err != nil {
s.internalError(w)
return
}
http.Redirect(w, r, "/dashboard?msg=email+updated", http.StatusSeeOther)
}
func (s *Server) needsEmail(service string) bool {
if s.oidc == nil {
return false
}
for _, c := range s.oidc.clients {
if c.Service == service && c.RequireEmail {
return true
}
}
return false
}
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
pending, _ := s.db.ListUsers("pending")
approved, _ := s.db.ListUsers("approved")
rejected, _ := s.db.ListUsers("rejected")
dbServices, _ := s.db.ListServices()
type request struct {
User *db.User
Svc *db.UserService
}
userServices := make(map[int64][]*db.UserService)
waiting := make(map[int64]int)
var requests []request
for _, u := range approved {
svcs, _ := s.db.ListUserServices(u.ID)
userServices[u.ID] = svcs
for _, sv := range svcs {
if sv.Applied && !sv.Granted {
requests = append(requests, request{u, sv})
waiting[u.ID]++
}
}
}
openID, _ := strconv.ParseInt(r.URL.Query().Get("open"), 10, 64)
s.render(w, "admin.html", map[string]any{
"Pending": pending,
"Approved": approved,
"Rejected": rejected,
"Services": dbServices,
"UserServices": userServices,
"Requests": requests,
"Waiting": waiting,
"DataHints": adminDataHints,
"OpenID": openID,
})
}
func (s *Server) handleAdminAction(w http.ResponseWriter, r *http.Request) {
idStr := r.FormValue("id")
action := r.FormValue("action")
id, err := strconv.ParseInt(idStr, 10, 64)
statuses := map[string]string{
"approve": "approved",
"reject": "rejected",
"reinstate": "approved",
}
status, ok := statuses[action]
if err != nil || !ok {
http.Error(w, "bad request", 400)
return
}
if dbErr := s.db.SetUserStatus(id, status); dbErr != nil && !errors.Is(dbErr, sql.ErrNoRows) {
s.internalError(w)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (s *Server) handleAdminToggleService(w http.ResponseWriter, r *http.Request) {
service := r.FormValue("service")
enabled := r.FormValue("enabled") == "1"
if service == "" {
http.Error(w, "bad request", 400)
return
}
if err := s.db.SetServiceEnabled(service, enabled); err != nil {
s.internalError(w)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (s *Server) handleAdminUserService(w http.ResponseWriter, r *http.Request) {
idStr := r.FormValue("user_id")
service := r.FormValue("service")
action := r.FormValue("action")
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || service == "" {
http.Error(w, "bad request", 400)
return
}
switch action {
case "grant":
err = s.db.SetUserServiceEnabled(userID, service, true)
case "revoke":
err = s.db.SetUserServiceEnabled(userID, service, false)
case "setlink":
data := r.FormValue("data")
err = s.db.SetUserServiceData(userID, service, data)
case "deny":
reason := strings.Join(strings.Fields(r.FormValue("reason")), " ")
if len(reason) > 280 {
http.Error(w, "reason too long", 400)
return
}
err = s.db.DenyService(userID, service, reason)
default:
http.Error(w, "bad request", 400)
return
}
if err != nil {
s.internalError(w)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin?open=%d#user-%d", userID, userID), http.StatusSeeOther)
}
func (s *Server) internalAuthorized(r *http.Request) bool {
auth := r.Header.Get("Authorization")
if s.internalSecret == "" || !strings.HasPrefix(auth, "Bearer ") {
return false
}
return subtle.ConstantTimeCompare([]byte(auth[len("Bearer "):]), []byte(s.internalSecret)) == 1
}
func (s *Server) handleInternalAuth(w http.ResponseWriter, r *http.Request) {
if !s.internalAuthorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Service string `json:"service"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.Password) > 128 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
limitKey := req.Service + ":" + strings.ToLower(req.Username)
if req.Service != "" && s.authLimiter.blocked(limitKey) {
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
fail := func(msg string, code int) {
if req.Service != "" {
s.authLimiter.record(limitKey)
}
http.Error(w, msg, code)
}
lookup := s.db.GetUserByUsername
if req.Service != "" {
lookup = s.db.GetUserByUsernameFold
}
u, dbErr := lookup(req.Username)
hash := dummyHash
if dbErr == nil {
hash = []byte(u.PassHash)
}
if bcrypt.CompareHashAndPassword(hash, []byte(req.Password)) != nil || dbErr != nil {
fail("unauthorized", http.StatusUnauthorized)
return
}
if u.Status != "approved" {
fail("forbidden", http.StatusForbidden)
return
}
if req.Service != "" {
enabled, _, err := s.db.GetUserService(u.ID, req.Service)
if err != nil {
s.internalError(w)
return
}
if !enabled {
fail("forbidden", http.StatusForbidden)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"username": u.Username})
}
func (s *Server) handleInternalUsers(w http.ResponseWriter, r *http.Request) {
if !s.internalAuthorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
service := r.URL.Query().Get("service")
if service == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
names, err := s.db.ListServiceUsernames(service)
if err != nil {
s.internalError(w)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string][]string{"users": names})
}
func stationAllowed(data, mount string) bool {
for _, st := range strings.Split(data, ",") {
st = strings.TrimSpace(st)
if st == mount || st == "*" {
return true
}
}
return false
}
func (s *Server) copyGroups(u *db.User) []string {
seen := map[string]bool{}
var groups []string
add := func(g string) {
if g != "" && !seen[g] {
seen[g] = true
groups = append(groups, g)
}
}
add("users")
if u.IsAdmin {
add("admin")
}
if _, data, err := s.db.GetUserService(u.ID, "files"); err == nil {
for _, g := range strings.Split(data, ",") {
g = strings.TrimSpace(g)
if g == "*" {
add("admin")
} else {
add(g)
}
}
}
return groups
}
func (s *Server) handleInternalStreamAuth(w http.ResponseWriter, r *http.Request) {
if !s.internalAuthorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Mount string `json:"mount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
req.Username == "" || req.Password == "" || req.Mount == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.Password) > 128 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if s.streamLimiter.blocked(req.Username) {
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
u, dbErr := s.db.GetUserByUsername(req.Username)
hash := dummyHash
if dbErr == nil {
hash = []byte(u.PassHash)
}
if bcrypt.CompareHashAndPassword(hash, []byte(req.Password)) != nil || dbErr != nil {
s.streamLimiter.record(req.Username)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if u.Status != "approved" {
s.streamLimiter.record(req.Username)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !u.IsAdmin {
enabled, data, err := s.db.GetUserService(u.ID, "radio")
if err != nil {
s.internalError(w)
return
}
if !enabled || !stationAllowed(data, req.Mount) {
s.streamLimiter.record(req.Username)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleInternalSessionCheck(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, err := s.db.GetSession(c.Value)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
u, err := s.db.GetUserByID(sess.UserID)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if u.Status != "approved" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if service := r.Header.Get("X-Auth-Service"); service != "" && !u.IsAdmin {
enabled, _, err := s.db.GetUserService(u.ID, service)
if err != nil {
s.internalError(w)
return
}
if !enabled {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
w.Header().Set("X-Auth-User", u.Username)
if groups := s.copyGroups(u); len(groups) > 0 {
w.Header().Set("X-Auth-Groups", strings.Join(groups, ","))
}
s.signAuth(w, u.Username)
w.WriteHeader(http.StatusOK)
}
func (s *Server) signAuth(w http.ResponseWriter, username string) {
if len(s.authSecret) == 0 {
return
}
exp := time.Now().Add(2 * time.Minute).Unix()
mac := hmac.New(sha256.New, s.authSecret)
fmt.Fprintf(mac, "v1:%s:%d", username, exp)
w.Header().Set("X-Auth-Exp", strconv.FormatInt(exp, 10))
w.Header().Set("X-Auth-Sig", base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))
}