feat: usos sign-in, private calendar feed and timetable archive

This commit is contained in:
hh committed 2026-10-11 00:47:38 +00:00
commit 7d4e21f977
33 files changed
+3270

No files matched your search

+400
View File
@@ -0,0 +1,400 @@
package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/hex"
"errors"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"usoscal/internal/ical"
"usoscal/internal/seal"
"usoscal/internal/store"
"usoscal/internal/syncer"
"usoscal/internal/usos"
)
const (
sessionCookie = "usoscal_session"
loginCookie = "usoscal_login"
sessionLifetime = 7 * 24 * time.Hour
loginLifetime = 30 * time.Minute
pendingLimit = 500
revokeTimeout = 10 * time.Second
feedPurpose = "/feed_token"
feedSuffix = ".ics"
dateFormat = "2 January 2006"
momentFormat = "2 January 2006 at 15:04"
calendarName = "USOS"
)
var (
//go:embed page.html
pageSource string
page = template.Must(template.New("page").Parse(pageSource))
)
type Server struct {
publicURL string
host string
secure bool
store *store.Store
usos *usos.Client
box *seal.Box
syncer *syncer.Syncer
mu sync.Mutex
pending map[string]pendingLogin
}
type pendingLogin struct {
secret string
state string
expires time.Time
}
type view struct {
SignedIn bool
Connected bool
Name string
FeedURL string
WebcalURL string
Archive bool
Backfilling bool
Events int
First string
Synced string
SyncError string
Error string
}
func New(publicURL string, st *store.Store, client *usos.Client, box *seal.Box, sync *syncer.Syncer) (http.Handler, error) {
parsed, err := url.Parse(publicURL)
if err != nil || parsed.Host == "" {
return nil, errors.New("PUBLIC_URL must be an absolute URL")
}
s := &Server{
publicURL: strings.TrimRight(publicURL, "/"),
host: parsed.Hostname(),
secure: parsed.Scheme == "https",
store: st,
usos: client,
box: box,
syncer: sync,
pending: map[string]pendingLogin{},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.home)
mux.HandleFunc("POST /login", s.login)
mux.HandleFunc("GET /callback", s.callback)
mux.HandleFunc("POST /link", s.signedIn(s.link))
mux.HandleFunc("POST /archive", s.signedIn(s.archive))
mux.HandleFunc("POST /logout", s.signedIn(s.logout))
mux.HandleFunc("GET /f/{file}", s.feed)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
return http.NewCrossOriginProtection().Handler(headers(mux)), nil
}
func headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Robots-Tag", "noindex, nofollow")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; frame-ancestors 'none'")
h.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func randomToken() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(buf)
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Server) render(w http.ResponseWriter, status int, data view) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := page.Execute(w, data); err != nil {
log.Printf("page render failed: %v", err)
}
}
func (s *Server) fail(w http.ResponseWriter, status int, message string) {
s.render(w, status, view{Error: message})
}
func (s *Server) cookie(name, value, path string, lifetime time.Duration) *http.Cookie {
maxAge := int(lifetime.Seconds())
if value == "" {
maxAge = -1
}
return &http.Cookie{
Name: name, Value: value, Path: path, MaxAge: maxAge,
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
}
}
func (s *Server) sessionUser(r *http.Request) (store.User, bool) {
cookie, err := r.Cookie(sessionCookie)
if err != nil || cookie.Value == "" {
return store.User{}, false
}
user, err := s.store.SessionUser(r.Context(), hashToken(cookie.Value))
return user, err == nil
}
func (s *Server) signedIn(next func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.sessionUser(r)
if !ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
next(w, r, user)
}
}
func (s *Server) home(w http.ResponseWriter, r *http.Request) {
user, ok := s.sessionUser(r)
if !ok {
s.render(w, http.StatusOK, view{})
return
}
data := view{
SignedIn: true,
Connected: user.Connected(),
Name: user.Name,
Archive: user.Archive,
Backfilling: user.Archive && !user.Backfilled && user.Connected(),
SyncError: user.SyncError,
}
if len(user.FeedToken) > 0 {
if token, err := s.box.Open(user.FeedToken, user.ID+feedPurpose); err == nil {
data.FeedURL = s.publicURL + "/f/" + token + feedSuffix
data.WebcalURL = "webcal://" + strings.SplitN(data.FeedURL, "://", 2)[1]
}
}
count, first, err := s.store.EventSpan(r.Context(), user.ID)
if err != nil {
log.Printf("event count failed: %v", err)
}
data.Events = count
if !first.IsZero() {
data.First = first.In(usos.Location).Format(dateFormat)
}
if !user.SyncedAt.IsZero() && user.SyncError == "" {
data.Synced = user.SyncedAt.In(usos.Location).Format(momentFormat)
}
s.render(w, http.StatusOK, data)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
now := time.Now()
for key, login := range s.pending {
if now.After(login.expires) {
delete(s.pending, key)
}
}
busy := len(s.pending) >= pendingLimit
s.mu.Unlock()
if busy {
s.fail(w, http.StatusServiceUnavailable, "Too many sign-ins are in progress. Try again in a few minutes.")
return
}
request, err := s.usos.RequestToken(r.Context(), s.publicURL+"/callback")
if err != nil {
log.Printf("request token failed: %v", err)
s.fail(w, http.StatusBadGateway, "USOS did not answer the sign-in request. Try again in a moment.")
return
}
state := randomToken()
s.mu.Lock()
s.pending[request.Key] = pendingLogin{secret: request.Secret, state: hashToken(state), expires: now.Add(loginLifetime)}
s.mu.Unlock()
http.SetCookie(w, s.cookie(loginCookie, state, "/callback", loginLifetime))
http.Redirect(w, r, s.usos.AuthorizeURL(request), http.StatusSeeOther)
}
func (s *Server) takePending(r *http.Request) (usos.Token, bool) {
key := r.URL.Query().Get("oauth_token")
s.mu.Lock()
login, found := s.pending[key]
delete(s.pending, key)
s.mu.Unlock()
cookie, err := r.Cookie(loginCookie)
if !found || err != nil || time.Now().After(login.expires) ||
subtle.ConstantTimeCompare([]byte(hashToken(cookie.Value)), []byte(login.state)) != 1 {
return usos.Token{}, false
}
return usos.Token{Key: key, Secret: login.secret}, true
}
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
const expired = "This sign-in has expired or was cancelled. Start again from the first page."
request, ok := s.takePending(r)
verifier := r.URL.Query().Get("oauth_verifier")
http.SetCookie(w, s.cookie(loginCookie, "", "/callback", 0))
if !ok || verifier == "" {
s.fail(w, http.StatusBadRequest, expired)
return
}
ctx := r.Context()
access, err := s.usos.AccessToken(ctx, request, verifier)
if err != nil {
log.Printf("access token exchange failed: %v", err)
s.fail(w, http.StatusBadGateway, expired)
return
}
me, err := s.usos.Me(ctx, access)
if err != nil {
log.Printf("reading the signed-in user failed: %v", err)
s.fail(w, http.StatusBadGateway, "USOS did not return your account. Try again in a moment.")
return
}
id := string(me.ID)
previous, err := s.store.User(ctx, id)
if err != nil && !errors.Is(err, store.ErrNotFound) {
s.internal(w, err)
return
}
if err := s.store.Connect(ctx, id, me.Name(),
s.box.Seal(access.Key, id+syncer.TokenPurpose), s.box.Seal(access.Secret, id+syncer.SecretPurpose)); err != nil {
s.internal(w, err)
return
}
if !previous.Connected() && len(previous.FeedToken) == 0 {
if err := s.issueFeed(ctx, id); err != nil {
s.internal(w, err)
return
}
}
session := randomToken()
if err := s.store.CreateSession(ctx, hashToken(session), id, time.Now().Add(sessionLifetime)); err != nil {
s.internal(w, err)
return
}
s.syncer.Kick(id)
http.SetCookie(w, s.cookie(sessionCookie, session, "/", sessionLifetime))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) internal(w http.ResponseWriter, err error) {
log.Printf("request failed: %v", err)
s.fail(w, http.StatusInternalServerError, "Something went wrong on our side. Try again in a moment.")
}
func (s *Server) issueFeed(ctx context.Context, userID string) error {
token := randomToken()
return s.store.SetFeed(ctx, userID, hashToken(token), s.box.Seal(token, userID+feedPurpose))
}
func (s *Server) link(w http.ResponseWriter, r *http.Request, user store.User) {
var err error
switch r.PostFormValue("action") {
case "rotate":
err = s.issueFeed(r.Context(), user.ID)
case "revoke":
err = s.store.SetFeed(r.Context(), user.ID, "", nil)
}
if err != nil {
s.internal(w, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) archive(w http.ResponseWriter, r *http.Request, user store.User) {
on := r.PostFormValue("archive") != ""
if on != user.Archive {
ctx := r.Context()
if err := s.store.SetArchive(ctx, user.ID, on); err != nil {
s.internal(w, err)
return
}
if !on {
if err := s.store.DeleteEventsBefore(ctx, user.ID, syncer.Today(time.Now())); err != nil {
s.internal(w, err)
return
}
}
s.syncer.Kick(user.ID)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, user store.User) {
if user.Connected() {
s.revoke(r.Context(), user)
}
var err error
if r.PostFormValue("delete") != "" {
err = s.store.DeleteUser(r.Context(), user.ID)
} else {
err = s.store.Disconnect(r.Context(), user.ID)
}
if err != nil {
s.internal(w, err)
return
}
http.SetCookie(w, s.cookie(sessionCookie, "", "/", 0))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) revoke(ctx context.Context, user store.User) {
key, keyErr := s.box.Open(user.USOSToken, user.ID+syncer.TokenPurpose)
secret, secretErr := s.box.Open(user.USOSSecret, user.ID+syncer.SecretPurpose)
if keyErr != nil || secretErr != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, revokeTimeout)
defer cancel()
if err := s.usos.Revoke(ctx, usos.Token{Key: key, Secret: secret}); err != nil && !errors.Is(err, usos.ErrUnauthorized) {
log.Printf("revoking the USOS token of user %s failed: %v", user.ID, err)
}
}
func (s *Server) feed(w http.ResponseWriter, r *http.Request) {
token, isCalendar := strings.CutSuffix(r.PathValue("file"), feedSuffix)
if !isCalendar || token == "" {
http.NotFound(w, r)
return
}
user, err := s.store.UserByFeed(r.Context(), hashToken(token))
if err != nil {
http.NotFound(w, r)
return
}
events, err := s.store.Events(r.Context(), user.ID)
if err != nil {
log.Printf("feed read failed: %v", err)
http.Error(w, "temporarily unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="usos.ics"`)
if err := ical.Write(w, calendarName, s.host, events); err != nil {
log.Printf("feed write failed: %v", err)
}
}