401 lines
11 KiB
Go
401 lines
11 KiB
Go
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)
|
|
}
|
|
}
|