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,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>usoscal</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%231f6f54'/%3E%3Cpath d='M4 6h8M4 9h8M4 12h5' stroke='%23fff' stroke-width='1.4' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f5f1; --ink: #1c1d1a; --soft: #5d6058; --line: #d9d7ce; --field: #fff;
|
||||
--accent: #1f6f54; --on-accent: #fff; --warn: #9a3b1e;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #151714; --ink: #e9e8e1; --soft: #a2a59b; --line: #33362f; --field: #1d201c; --accent: #6cc3a0; --on-accent: #0d1a14; --warn: #f0997a; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
main { max-width: 38rem; margin: 0 auto; padding: 3.5rem 1.25rem 4rem; }
|
||||
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 .35rem; letter-spacing: -.01em; }
|
||||
h2 { font-size: 1rem; margin: 0 0 .5rem; }
|
||||
p { margin: 0 0 .75rem; text-wrap: pretty; }
|
||||
.date { white-space: nowrap; }
|
||||
.lede { color: var(--soft); margin-bottom: 2rem; }
|
||||
.soft { color: var(--soft); font-size: .9rem; }
|
||||
.warn { color: var(--warn); }
|
||||
section { border-top: 1px solid var(--line); padding: 1.5rem 0 .75rem; }
|
||||
form { margin: 0; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: .75rem; }
|
||||
input[type=text] { flex: 1 1 16rem; min-width: 0; font: .85rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; padding: .6rem .7rem; color: var(--ink); background: var(--field); border: 1px solid var(--line); border-radius: 6px; }
|
||||
button, .button { font: inherit; font-weight: 550; padding: .55rem .95rem; border-radius: 6px; border: 1px solid var(--line); background: var(--field); color: var(--ink); cursor: pointer; text-decoration: none; display: inline-block; }
|
||||
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
button.danger { color: var(--warn); }
|
||||
button:focus-visible, .button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
label { display: flex; gap: .55rem; align-items: baseline; padding: .5rem 0; margin-bottom: .1rem; cursor: pointer; }
|
||||
input[type=checkbox] { accent-color: var(--accent); transform: translateY(1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>usoscal</h1>
|
||||
<p class="lede">Your USOS timetable as a calendar subscription that can keep the classes that have already passed.</p>
|
||||
|
||||
{{if .Error}}
|
||||
<section>
|
||||
<p class="warn">{{.Error}}</p>
|
||||
<p><a class="button" href="/">Back</a></p>
|
||||
</section>
|
||||
{{else if not .SignedIn}}
|
||||
<section>
|
||||
<p>Sign in with your university account and you get a private calendar address for Google Calendar, Apple Calendar, Outlook or anything else that reads iCalendar. Every class carries the course, the class type, the room with the building address, and the lecturer.</p>
|
||||
<p>USOS only exports what is ahead of today. Turn on the archive and past classes stay in your calendar.</p>
|
||||
<form class="row" method="post" action="/login"><button class="primary">Sign in with USOS</button></form>
|
||||
<p class="soft">USOS will ask you to allow reading your studies and to keep that access while you are offline, which the daily update needs. The access tokens are stored encrypted, and signing out revokes them.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<p>Signed in as <strong>{{.Name}}</strong>.</p>
|
||||
|
||||
{{if not .Connected}}
|
||||
<section>
|
||||
<p class="warn">{{if .SyncError}}{{.SyncError}}{{else}}USOS access is missing. Sign in again to resume updates.{{end}}</p>
|
||||
<form class="row" method="post" action="/login"><button class="primary">Sign in with USOS again</button></form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Calendar address</h2>
|
||||
{{if .FeedURL}}
|
||||
<div class="row">
|
||||
<input type="text" readonly value="{{.FeedURL}}" aria-label="Calendar address">
|
||||
<a class="button primary" href="{{.WebcalURL}}">Subscribe</a>
|
||||
</div>
|
||||
<p class="soft">Add it as a subscription by URL, not as a file import, so it keeps updating. Anyone who has this address can read your timetable. Issuing a new one or revoking it stops the current address at once.</p>
|
||||
<div class="row">
|
||||
<form method="post" action="/link"><button name="action" value="rotate">Issue a new address</button></form>
|
||||
<form method="post" action="/link"><button class="danger" name="action" value="revoke">Revoke</button></form>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>There is no active address.</p>
|
||||
<form class="row" method="post" action="/link"><button class="primary" name="action" value="rotate">Create an address</button></form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Archive</h2>
|
||||
<form method="post" action="/archive">
|
||||
<label><input type="checkbox" name="archive" {{if .Archive}}checked{{end}}> <span>Keep classes after they have passed</span></label>
|
||||
<p class="soft">The first run also fetches earlier terms, as far back as USOS still has them. Turning the archive off deletes the stored past.</p>
|
||||
<div class="row"><button>Save</button></div>
|
||||
</form>
|
||||
{{if .Backfilling}}<p class="soft">Earlier terms are being fetched, reload in a minute.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Status</h2>
|
||||
<p class="soft">
|
||||
{{if .Events}}{{.Events}} classes in the calendar, the earliest on <span class="date">{{.First}}</span>.{{else}}No classes in the calendar yet.{{end}}
|
||||
{{if .Synced}}Last updated from USOS on <span class="date">{{.Synced}}</span>.{{else if .Connected}}The first update is running, reload in a minute.{{end}}
|
||||
</p>
|
||||
{{if and .Connected .SyncError}}<p class="warn">{{.SyncError}}</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Sign out</h2>
|
||||
<form method="post" action="/logout">
|
||||
<p class="soft">Signing out revokes this app's access in USOS, deletes the stored tokens and stops the calendar address.</p>
|
||||
<label><input type="checkbox" name="delete"> <span>Also delete my stored timetable</span></label>
|
||||
<div class="row"><button class="danger">Sign out and disconnect</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"usoscal/internal/seal"
|
||||
"usoscal/internal/store"
|
||||
"usoscal/internal/syncer"
|
||||
"usoscal/internal/usos"
|
||||
"usoscal/internal/usostest"
|
||||
)
|
||||
|
||||
var feedAddress = regexp.MustCompile(`value="(http[^"]+/f/[^"]+\.ics)"`)
|
||||
|
||||
type app struct {
|
||||
t *testing.T
|
||||
fake *usostest.Fake
|
||||
store *store.Store
|
||||
syncer *syncer.Syncer
|
||||
site *httptest.Server
|
||||
browser *http.Client
|
||||
dbPath string
|
||||
}
|
||||
|
||||
func newApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
fake := usostest.New()
|
||||
t.Cleanup(fake.Close)
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
box, err := seal.New(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{5}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := usos.New(fake.URL, usostest.ConsumerKey, usostest.ConsumerSecret)
|
||||
sync := syncer.New(st, client, box, 24*time.Hour)
|
||||
sync.Pause = 0
|
||||
sync.Now = func() time.Time { return time.Date(2026, 10, 11, 2, 30, 0, 0, usos.Location) }
|
||||
|
||||
site := httptest.NewUnstartedServer(nil)
|
||||
site.Start()
|
||||
t.Cleanup(site.Close)
|
||||
handler, err := New(site.URL, st, client, box, sync)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site.Config.Handler = handler
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &app{t: t, fake: fake, store: st, syncer: sync, site: site, browser: &http.Client{Jar: jar}, dbPath: dbPath}
|
||||
}
|
||||
|
||||
func (a *app) do(client *http.Client, method, path string, form url.Values, header http.Header) (int, string) {
|
||||
a.t.Helper()
|
||||
target := path
|
||||
if strings.HasPrefix(path, "/") {
|
||||
target = a.site.URL + path
|
||||
}
|
||||
var body io.Reader
|
||||
if form != nil {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, target, body)
|
||||
if err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
for key, values := range header {
|
||||
req.Header[key] = values
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
return res.StatusCode, string(data)
|
||||
}
|
||||
|
||||
func (a *app) get(path string) (int, string) {
|
||||
a.t.Helper()
|
||||
return a.do(a.browser, http.MethodGet, path, nil, nil)
|
||||
}
|
||||
|
||||
func (a *app) post(path string, form url.Values) (int, string) {
|
||||
a.t.Helper()
|
||||
return a.do(a.browser, http.MethodPost, path, form, nil)
|
||||
}
|
||||
|
||||
func (a *app) anonymous(path string) (int, string) {
|
||||
a.t.Helper()
|
||||
return a.do(http.DefaultClient, http.MethodGet, path, nil, nil)
|
||||
}
|
||||
|
||||
func (a *app) signIn() string {
|
||||
a.t.Helper()
|
||||
status, body := a.post("/login", url.Values{})
|
||||
if status != http.StatusOK || !strings.Contains(body, "Signed in as <strong>Ada Nowak</strong>") {
|
||||
a.t.Fatalf("sign-in ended with %d:\n%s", status, body)
|
||||
}
|
||||
return a.address(body)
|
||||
}
|
||||
|
||||
func (a *app) address(body string) string {
|
||||
a.t.Helper()
|
||||
match := feedAddress.FindStringSubmatch(body)
|
||||
if match == nil {
|
||||
a.t.Fatalf("no calendar address on the page:\n%s", body)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func (a *app) sync() {
|
||||
a.t.Helper()
|
||||
if err := a.syncer.Sync(context.Background(), usostest.UserID); err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInGivesAWorkingCalendar(t *testing.T) {
|
||||
a := newApp(t)
|
||||
if status, body := a.get("/"); status != http.StatusOK || !strings.Contains(body, "Sign in with USOS") {
|
||||
t.Fatalf("front page: %d", status)
|
||||
}
|
||||
address := a.signIn()
|
||||
a.sync()
|
||||
|
||||
status, calendar := a.anonymous(address)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("feed status %d", status)
|
||||
}
|
||||
unfolded := strings.ReplaceAll(calendar, "\r\n ", "")
|
||||
for _, want := range []string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"DTSTART:20261014T113000Z",
|
||||
"SUMMARY:Algebra liniowa z elementami matematyki dyskretnej (Ćwiczenia)",
|
||||
`LOCATION:303 (A1 - BM - Budowa Maszyn\, budynek z zegarem)\, ul. Piotrowo 3\, 61-138 Poznań\, Polska`,
|
||||
`Lecturer: dr inż. Jan Kowalski`,
|
||||
} {
|
||||
if !strings.Contains(unfolded, want) {
|
||||
t.Errorf("feed lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(unfolded, "DTSTART:20261007") {
|
||||
t.Error("past class in the feed while the archive is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveAddsThePast(t *testing.T) {
|
||||
a := newApp(t)
|
||||
address := a.signIn()
|
||||
a.sync()
|
||||
if status, body := a.post("/archive", url.Values{"archive": {"on"}}); status != http.StatusOK || !strings.Contains(body, `name="archive" checked`) {
|
||||
t.Fatalf("archive not switched on: %d", status)
|
||||
}
|
||||
a.sync()
|
||||
if _, calendar := a.anonymous(address); !strings.Contains(calendar, "DTSTART:20261007T113000Z") || !strings.Contains(calendar, "DTSTART:20251001T113000Z") {
|
||||
t.Error("archived classes are missing from the feed")
|
||||
}
|
||||
|
||||
a.post("/archive", url.Values{})
|
||||
if _, calendar := a.anonymous(address); strings.Contains(calendar, "DTSTART:20261007") {
|
||||
t.Error("past classes stayed in the feed after the archive was turned off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressCanBeReissuedAndRevoked(t *testing.T) {
|
||||
a := newApp(t)
|
||||
first := a.signIn()
|
||||
a.sync()
|
||||
|
||||
_, body := a.post("/link", url.Values{"action": {"rotate"}})
|
||||
second := a.address(body)
|
||||
if second == first {
|
||||
t.Fatal("address did not change")
|
||||
}
|
||||
if status, _ := a.anonymous(first); status != http.StatusNotFound {
|
||||
t.Errorf("old address answers %d after a new one was issued", status)
|
||||
}
|
||||
if status, _ := a.anonymous(second); status != http.StatusOK {
|
||||
t.Errorf("new address answers %d", status)
|
||||
}
|
||||
|
||||
_, body = a.post("/link", url.Values{"action": {"revoke"}})
|
||||
if !strings.Contains(body, "There is no active address") {
|
||||
t.Error("page still shows an address after revocation")
|
||||
}
|
||||
if status, _ := a.anonymous(second); status != http.StatusNotFound {
|
||||
t.Errorf("revoked address answers %d", status)
|
||||
}
|
||||
if status, _ := a.anonymous("/f/.ics"); status != http.StatusNotFound {
|
||||
t.Errorf("empty token answers %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOutDisconnectsAndCanDeleteEverything(t *testing.T) {
|
||||
a := newApp(t)
|
||||
address := a.signIn()
|
||||
a.sync()
|
||||
|
||||
status, body := a.post("/logout", url.Values{})
|
||||
if status != http.StatusOK || !strings.Contains(body, "Sign in with USOS") {
|
||||
t.Fatalf("after sign-out: %d", status)
|
||||
}
|
||||
if !a.fake.WasRevoked() {
|
||||
t.Error("the USOS token was not revoked")
|
||||
}
|
||||
if status, _ := a.anonymous(address); status != http.StatusNotFound {
|
||||
t.Errorf("address answers %d after sign-out", status)
|
||||
}
|
||||
ctx := context.Background()
|
||||
user, err := a.store.User(ctx, usostest.UserID)
|
||||
if err != nil || user.Connected() || len(user.FeedToken) != 0 {
|
||||
t.Errorf("tokens left after sign-out: %+v %v", user, err)
|
||||
}
|
||||
if events, _ := a.store.Events(ctx, usostest.UserID); len(events) == 0 {
|
||||
t.Error("timetable deleted although deletion was not requested")
|
||||
}
|
||||
|
||||
b := newApp(t)
|
||||
b.signIn()
|
||||
b.sync()
|
||||
b.post("/logout", url.Values{"delete": {"on"}})
|
||||
if _, err := b.store.User(ctx, usostest.UserID); err == nil {
|
||||
t.Error("user row left after sign-out with deletion")
|
||||
}
|
||||
if events, _ := b.store.Events(ctx, usostest.UserID); len(events) != 0 {
|
||||
t.Error("events left after sign-out with deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsAreNotStoredInTheClear(t *testing.T) {
|
||||
a := newApp(t)
|
||||
address := a.signIn()
|
||||
feedToken := strings.TrimSuffix(address[strings.LastIndex(address, "/")+1:], ".ics")
|
||||
if len(feedToken) < 43 {
|
||||
t.Errorf("feed token is only %d characters", len(feedToken))
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+a.dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var token, secret, feed []byte
|
||||
var feedHash string
|
||||
if err := db.QueryRow(`select usos_token, usos_secret, feed_token, feed_hash from users`).Scan(&token, &secret, &feed, &feedHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := string(token) + string(secret) + string(feed) + feedHash
|
||||
for _, plain := range []string{"access-token", "access-secret", feedToken} {
|
||||
if strings.Contains(stored, plain) {
|
||||
t.Errorf("%q is readable in the database", plain)
|
||||
}
|
||||
}
|
||||
var sessionHash string
|
||||
if err := db.QueryRow(`select token_hash from sessions`).Scan(&sessionHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site, _ := url.Parse(a.site.URL)
|
||||
for _, cookie := range a.browser.Jar.Cookies(site) {
|
||||
if cookie.Name == sessionCookie && cookie.Value == sessionHash {
|
||||
t.Error("session token stored unhashed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignRequestsAreRejected(t *testing.T) {
|
||||
a := newApp(t)
|
||||
a.signIn()
|
||||
|
||||
status, _ := a.do(a.browser, http.MethodPost, "/link", url.Values{"action": {"revoke"}}, http.Header{"Sec-Fetch-Site": {"cross-site"}})
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("cross-site form post answered %d", status)
|
||||
}
|
||||
if _, body := a.get("/"); !feedAddress.MatchString(body) {
|
||||
t.Error("a cross-site request revoked the address")
|
||||
}
|
||||
|
||||
stranger := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
if status, _ := a.do(stranger, http.MethodPost, "/link", url.Values{"action": {"revoke"}}, nil); status != http.StatusSeeOther {
|
||||
t.Errorf("anonymous post answered %d", status)
|
||||
}
|
||||
if status, _ := a.do(stranger, http.MethodGet, "/callback?oauth_token=request-token-1&oauth_verifier=x", nil, nil); status != http.StatusBadRequest {
|
||||
t.Errorf("callback without a started sign-in answered %d", status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user