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