feat: usos sign-in, private calendar feed and timetable archive
This commit is contained in:
commit
7d4e21f977
33 files changed
+3270
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user