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,309 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"usoscal/internal/seal"
|
||||
"usoscal/internal/store"
|
||||
"usoscal/internal/usos"
|
||||
)
|
||||
|
||||
const (
|
||||
week = 7 * 24 * time.Hour
|
||||
windowDays = 7
|
||||
fallbackHorizon = 26 * week
|
||||
maxHorizon = 40 * week
|
||||
termLookahead = 60 * 24 * time.Hour
|
||||
longestSemester = 250 * 24 * time.Hour
|
||||
backfillLimit = 6 * 52 * week
|
||||
backfillQuietRun = 26
|
||||
queueSize = 256
|
||||
retryAfter = time.Hour
|
||||
TokenPurpose = "/usos_token"
|
||||
SecretPurpose = "/usos_secret"
|
||||
revokedMessage = "USOS no longer accepts this authorization. Sign in again to resume updates."
|
||||
failedMessage = "The last update from USOS failed. It will be retried."
|
||||
)
|
||||
|
||||
type Syncer struct {
|
||||
Store *store.Store
|
||||
USOS *usos.Client
|
||||
Box *seal.Box
|
||||
Interval time.Duration
|
||||
Pause time.Duration
|
||||
Now func() time.Time
|
||||
|
||||
queue chan string
|
||||
}
|
||||
|
||||
func New(st *store.Store, client *usos.Client, box *seal.Box, interval time.Duration) *Syncer {
|
||||
return &Syncer{
|
||||
Store: st,
|
||||
USOS: client,
|
||||
Box: box,
|
||||
Interval: interval,
|
||||
Pause: 200 * time.Millisecond,
|
||||
Now: time.Now,
|
||||
queue: make(chan string, queueSize),
|
||||
}
|
||||
}
|
||||
|
||||
func Today(now time.Time) time.Time {
|
||||
local := now.In(usos.Location)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, usos.Location)
|
||||
}
|
||||
|
||||
func minTime(a, b time.Time) time.Time {
|
||||
if a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *Syncer) Kick(userID string) {
|
||||
select {
|
||||
case s.queue <- userID:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
s.enqueueDue(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.enqueueDue(ctx)
|
||||
case userID := <-s.queue:
|
||||
if err := s.Sync(ctx, userID); err != nil {
|
||||
log.Printf("sync of user %s failed: %v", userID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) enqueueDue(ctx context.Context) {
|
||||
ids, err := s.Store.DueUsers(ctx, s.Now().Add(-s.Interval))
|
||||
if err != nil {
|
||||
log.Printf("listing users due for sync failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
s.Kick(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) Sync(ctx context.Context, userID string) error {
|
||||
user, err := s.Store.User(ctx, userID)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !user.Connected()) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := s.token(user)
|
||||
if err != nil {
|
||||
return s.Store.DropTokens(ctx, userID, revokedMessage)
|
||||
}
|
||||
|
||||
err = s.pull(ctx, user, token)
|
||||
switch {
|
||||
case errors.Is(err, usos.ErrUnauthorized):
|
||||
return s.Store.DropTokens(ctx, userID, revokedMessage)
|
||||
case errors.Is(err, context.Canceled):
|
||||
return err
|
||||
case err != nil:
|
||||
retryAt := s.Now().Add(min(retryAfter, s.Interval) - s.Interval)
|
||||
return errors.Join(err, s.Store.SetSynced(ctx, userID, retryAt, failedMessage))
|
||||
}
|
||||
return s.Store.SetSynced(ctx, userID, s.Now(), "")
|
||||
}
|
||||
|
||||
func (s *Syncer) token(user store.User) (usos.Token, error) {
|
||||
key, err := s.Box.Open(user.USOSToken, user.ID+TokenPurpose)
|
||||
if err != nil {
|
||||
return usos.Token{}, err
|
||||
}
|
||||
secret, err := s.Box.Open(user.USOSSecret, user.ID+SecretPurpose)
|
||||
return usos.Token{Key: key, Secret: secret}, err
|
||||
}
|
||||
|
||||
func (s *Syncer) pull(ctx context.Context, user store.User, token usos.Token) error {
|
||||
today := Today(s.Now())
|
||||
|
||||
horizon := s.horizon(ctx, today)
|
||||
for from := today; from.Before(horizon); from = from.AddDate(0, 0, windowDays) {
|
||||
if _, err := s.window(ctx, user.ID, token, from); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !user.Archive {
|
||||
return s.Store.DeleteEventsBefore(ctx, user.ID, today)
|
||||
}
|
||||
if user.Backfilled {
|
||||
return nil
|
||||
}
|
||||
quiet := 0
|
||||
for from := today.AddDate(0, 0, -windowDays); quiet < backfillQuietRun && today.Sub(from) <= backfillLimit; from = from.AddDate(0, 0, -windowDays) {
|
||||
found, err := s.window(ctx, user.ID, token, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found == 0 {
|
||||
quiet++
|
||||
} else {
|
||||
quiet = 0
|
||||
}
|
||||
}
|
||||
return s.Store.SetBackfilled(ctx, user.ID)
|
||||
}
|
||||
|
||||
func (s *Syncer) horizon(ctx context.Context, today time.Time) time.Time {
|
||||
terms, err := s.USOS.Terms(ctx, today.Add(termLookahead), today)
|
||||
if err != nil {
|
||||
log.Printf("term dates unavailable, using the default horizon: %v", err)
|
||||
}
|
||||
var finish time.Time
|
||||
for _, term := range terms {
|
||||
if term.Finish.Sub(term.Start) <= longestSemester && term.Finish.After(finish) {
|
||||
finish = term.Finish
|
||||
}
|
||||
}
|
||||
if finish.IsZero() {
|
||||
return today.Add(fallbackHorizon)
|
||||
}
|
||||
return minTime(finish.AddDate(0, 0, 1), today.Add(maxHorizon))
|
||||
}
|
||||
|
||||
func (s *Syncer) window(ctx context.Context, userID string, token usos.Token, from time.Time) (int, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(s.Pause):
|
||||
}
|
||||
activities, err := s.USOS.Timetable(ctx, token, from, windowDays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
to := from.AddDate(0, 0, windowDays)
|
||||
events := make([]store.Event, 0, len(activities))
|
||||
seen := map[string]bool{}
|
||||
lecturers := s.lecturers(ctx, activities)
|
||||
for _, activity := range activities {
|
||||
if activity.Start.Before(from) || !activity.Start.Before(to) {
|
||||
continue
|
||||
}
|
||||
event := s.event(ctx, activity, lecturers)
|
||||
if seen[event.UID] {
|
||||
continue
|
||||
}
|
||||
seen[event.UID] = true
|
||||
events = append(events, event)
|
||||
}
|
||||
return len(events), s.Store.ReplaceWindow(ctx, userID, from, to, events)
|
||||
}
|
||||
|
||||
func (s *Syncer) lecturers(ctx context.Context, activities []usos.Activity) map[string]string {
|
||||
var ids []string
|
||||
for _, activity := range activities {
|
||||
for _, id := range activity.LecturerIDs {
|
||||
if !slices.Contains(ids, id) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
names, err := s.Store.LecturerNames(ctx, ids)
|
||||
if err != nil {
|
||||
log.Printf("lecturer cache read failed: %v", err)
|
||||
names = map[string]string{}
|
||||
}
|
||||
missing := slices.DeleteFunc(slices.Clone(ids), func(id string) bool { _, known := names[id]; return known })
|
||||
if len(missing) == 0 {
|
||||
return names
|
||||
}
|
||||
fetched, err := s.USOS.Lecturers(ctx, missing)
|
||||
if err != nil {
|
||||
log.Printf("lecturer names unavailable: %v", err)
|
||||
}
|
||||
if err := s.Store.SaveLecturers(ctx, fetched); err != nil {
|
||||
log.Printf("lecturer cache write failed: %v", err)
|
||||
}
|
||||
for id, name := range fetched {
|
||||
names[id] = name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Syncer) building(ctx context.Context, id, fallbackName string) store.Building {
|
||||
if id == "" {
|
||||
return store.Building{Name: fallbackName}
|
||||
}
|
||||
if cached, ok := s.Store.Building(ctx, id); ok {
|
||||
return cached
|
||||
}
|
||||
fetched, err := s.USOS.Building(ctx, id)
|
||||
if err != nil {
|
||||
log.Printf("building %s unavailable: %v", id, err)
|
||||
return store.Building{Name: fallbackName}
|
||||
}
|
||||
building := store.Building{Name: fetched.Name.String(), Address: fetched.Address}
|
||||
if building.Name == "" {
|
||||
building.Name = fallbackName
|
||||
}
|
||||
if err := s.Store.SaveBuilding(ctx, id, building); err != nil {
|
||||
log.Printf("building cache write failed: %v", err)
|
||||
}
|
||||
return building
|
||||
}
|
||||
|
||||
func (s *Syncer) event(ctx context.Context, activity usos.Activity, lecturers map[string]string) store.Event {
|
||||
title, kind := activity.CourseName, activity.ClasstypeName
|
||||
if title == "" {
|
||||
title = activity.Name
|
||||
}
|
||||
if kind == "" && activity.Name != title {
|
||||
kind = activity.Name
|
||||
}
|
||||
names := make([]string, 0, len(activity.LecturerIDs))
|
||||
for _, id := range activity.LecturerIDs {
|
||||
if name := lecturers[id]; name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
building := s.building(ctx, activity.BuildingID, activity.BuildingName)
|
||||
return store.Event{
|
||||
UID: uid(activity),
|
||||
Start: activity.Start,
|
||||
End: activity.End,
|
||||
Title: title,
|
||||
Kind: kind,
|
||||
Group: activity.GroupNumber,
|
||||
Room: activity.RoomNumber,
|
||||
Building: building.Name,
|
||||
Address: building.Address,
|
||||
Lecturers: strings.Join(names, ", "),
|
||||
URL: activity.URL,
|
||||
}
|
||||
}
|
||||
|
||||
func uid(activity usos.Activity) string {
|
||||
if activity.MeetingID != "" {
|
||||
return "sm-" + activity.MeetingID
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||
activity.Type, activity.CourseID, activity.UnitID, activity.GroupNumber, activity.Name,
|
||||
activity.Start.UTC().Format(time.RFC3339),
|
||||
}, "\x00")))
|
||||
return "a-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"usoscal/internal/seal"
|
||||
"usoscal/internal/store"
|
||||
"usoscal/internal/usos"
|
||||
"usoscal/internal/usostest"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
fake *usostest.Fake
|
||||
store *store.Store
|
||||
syncer *Syncer
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
fake := usostest.New()
|
||||
t.Cleanup(fake.Close)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
box, err := seal.New(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := usos.New(fake.URL, usostest.ConsumerKey, usostest.ConsumerSecret)
|
||||
f := &fixture{t: t, fake: fake, store: st, now: time.Date(2026, 10, 11, 2, 30, 0, 0, usos.Location)}
|
||||
f.syncer = New(st, client, box, 24*time.Hour)
|
||||
f.syncer.Pause = 0
|
||||
f.syncer.Now = func() time.Time { return f.now }
|
||||
|
||||
ctx := context.Background()
|
||||
request, err := client.RequestToken(ctx, "http://app.example/callback")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := client.AccessToken(ctx, request, "verifier-of-"+request.Key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := usostest.UserID
|
||||
if err := st.Connect(ctx, id, "Ada Nowak", box.Seal(access.Key, id+TokenPurpose), box.Seal(access.Secret, id+SecretPurpose)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) sync() {
|
||||
f.t.Helper()
|
||||
if err := f.syncer.Sync(context.Background(), usostest.UserID); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) events() []store.Event {
|
||||
f.t.Helper()
|
||||
events, err := f.store.Events(context.Background(), usostest.UserID)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (f *fixture) user() store.User {
|
||||
f.t.Helper()
|
||||
user, err := f.store.User(context.Background(), usostest.UserID)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func day(year int, month time.Month, date int) time.Time {
|
||||
return time.Date(year, month, date, 0, 0, 0, 0, usos.Location)
|
||||
}
|
||||
|
||||
func TestLiveSyncFetchesTheSemesterAhead(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.sync()
|
||||
|
||||
events := f.events()
|
||||
if len(events) == 0 {
|
||||
t.Fatal("no events after sync")
|
||||
}
|
||||
first, last := events[0], events[len(events)-1]
|
||||
if want := time.Date(2026, 10, 14, 13, 30, 0, 0, usos.Location); !first.Start.Equal(want) {
|
||||
t.Errorf("first event at %v, want %v", first.Start, want)
|
||||
}
|
||||
if want := time.Date(2027, 1, 29, 11, 45, 0, 0, usos.Location); !last.Start.Equal(want) {
|
||||
t.Errorf("last event at %v, want %v", last.Start, want)
|
||||
}
|
||||
if first.Title != "Algebra liniowa z elementami matematyki dyskretnej" || first.Kind != "Ćwiczenia" || first.Room != "303" ||
|
||||
first.Building != "A1 - BM - Budowa Maszyn, budynek z zegarem" || first.Address != "ul. Piotrowo 3, 61-138 Poznań, Polska" ||
|
||||
first.Lecturers != "dr inż. Jan Kowalski" || first.Group != "1" || first.UID != "sm-20261014" {
|
||||
t.Errorf("unexpected event fields: %+v", first)
|
||||
}
|
||||
if windows := f.fake.WindowCount(); windows != 21 {
|
||||
t.Errorf("%d timetable requests, want 21 weekly windows up to the end of the term", windows)
|
||||
}
|
||||
if user := f.user(); user.SyncedAt.IsZero() || user.SyncError != "" {
|
||||
t.Errorf("sync state not recorded: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutArchiveThePastIsDropped(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.sync()
|
||||
f.now = f.now.AddDate(0, 0, 7)
|
||||
f.sync()
|
||||
if first := f.events()[0]; first.Start.Before(day(2026, 10, 18)) {
|
||||
t.Errorf("event from %v survived without the archive", first.Start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveBackfillsAndFreezesThePast(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
if err := f.store.SetArchive(ctx, usostest.UserID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.sync()
|
||||
|
||||
events := f.events()
|
||||
if want := time.Date(2025, 10, 1, 13, 30, 0, 0, usos.Location); !events[0].Start.Equal(want) {
|
||||
t.Fatalf("archive starts at %v, want %v", events[0].Start, want)
|
||||
}
|
||||
if !f.user().Backfilled {
|
||||
t.Fatal("backfill not marked as done")
|
||||
}
|
||||
backfillWindows := f.fake.WindowCount() - 21
|
||||
if backfillWindows < 54 || backfillWindows > 54+backfillQuietRun {
|
||||
t.Errorf("%d backfill requests, want the weeks back to October 2025 plus the quiet run", backfillWindows)
|
||||
}
|
||||
total := len(events)
|
||||
|
||||
f.fake.SetSchedule(func(day time.Time) []usostest.Class {
|
||||
classes := usostest.Weekly(day)
|
||||
switch day.Format("2006-01-02") {
|
||||
case "2026-10-23":
|
||||
return nil
|
||||
case "2026-10-30":
|
||||
classes[0].Room = "105"
|
||||
}
|
||||
if day.Before(f.now) {
|
||||
return nil
|
||||
}
|
||||
return classes
|
||||
})
|
||||
before := f.fake.WindowCount()
|
||||
f.now = f.now.AddDate(0, 0, 7)
|
||||
f.sync()
|
||||
|
||||
if again := f.fake.WindowCount() - before; again > 21 {
|
||||
t.Errorf("%d requests on the second run, the backfill must not repeat", again)
|
||||
}
|
||||
events = f.events()
|
||||
if len(events) != total-1 {
|
||||
t.Errorf("%d events, want %d: only the cancelled future class goes away", len(events), total-1)
|
||||
}
|
||||
byUID := map[string]store.Event{}
|
||||
for _, event := range events {
|
||||
byUID[event.UID] = event
|
||||
}
|
||||
if _, kept := byUID["sm-20261014"]; !kept {
|
||||
t.Error("a class that passed since the last run left the archive")
|
||||
}
|
||||
if _, kept := byUID["sm-20261023"]; kept {
|
||||
t.Error("a future class removed from the plan is still in the feed")
|
||||
}
|
||||
if got := byUID["sm-20261030"].Room; got != "105" {
|
||||
t.Errorf("room of the changed class = %q, want 105", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurningTheArchiveOffAllowsANewBackfill(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := context.Background()
|
||||
for _, on := range []bool{true, false, true} {
|
||||
if err := f.store.SetArchive(ctx, usostest.UserID, on); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.sync()
|
||||
first := f.events()[0].Start
|
||||
if on && !first.Before(day(2025, 10, 2)) {
|
||||
t.Errorf("archive on: earliest event %v", first)
|
||||
}
|
||||
if !on && first.Before(day(2026, 10, 11)) {
|
||||
t.Errorf("archive off: earliest event %v", first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedAccessStopsSyncAndKeepsTheData(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.sync()
|
||||
kept := len(f.events())
|
||||
f.fake.Revoke()
|
||||
f.now = f.now.AddDate(0, 0, 1)
|
||||
f.sync()
|
||||
|
||||
user := f.user()
|
||||
if user.Connected() {
|
||||
t.Error("tokens kept after USOS rejected them")
|
||||
}
|
||||
if user.SyncError == "" {
|
||||
t.Error("no message for the user about the lost access")
|
||||
}
|
||||
if len(f.events()) != kept {
|
||||
t.Error("stored events were lost with the access")
|
||||
}
|
||||
due, err := f.store.DueUsers(context.Background(), f.now.Add(48*time.Hour))
|
||||
if err != nil || len(due) != 0 {
|
||||
t.Errorf("disconnected user still scheduled: %v %v", due, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user