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