feat: usos sign-in, private calendar feed and timetable archive

This commit is contained in:
hh committed 2026-10-11 00:47:38 +00:00
commit 7d4e21f977
33 files changed
+3270

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
package usostest
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
ConsumerKey = "fake-consumer-key"
ConsumerSecret = "fake-consumer-secret"
UserID = "1001"
)
var (
warsaw = mustLocation()
headerPair = regexp.MustCompile(`(oauth_[a-z_]+)="([^"]*)"`)
)
func mustLocation() *time.Location {
location, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
panic(err)
}
return location
}
type Class struct {
MeetingID int
Start time.Time
Minutes int
Course string
Kind string
Room string
}
type Fake struct {
*httptest.Server
mu sync.Mutex
Schedule func(day time.Time) []Class
Windows []string
Revoked bool
Deauthorize int
secrets map[string]string
callbacks map[string]string
counter int
}
func New() *Fake {
f := &Fake{secrets: map[string]string{}, callbacks: map[string]string{}, Schedule: Weekly}
mux := http.NewServeMux()
mux.HandleFunc("POST /services/oauth/request_token", f.requestToken)
mux.HandleFunc("GET /services/oauth/authorize", f.authorize)
mux.HandleFunc("POST /services/oauth/access_token", f.accessToken)
mux.HandleFunc("POST /services/oauth/revoke_token", f.revokeToken)
mux.HandleFunc("POST /services/users/user", f.user)
mux.HandleFunc("POST /services/users/users", f.users)
mux.HandleFunc("POST /services/geo/building2", f.building)
mux.HandleFunc("POST /services/terms/search", f.terms)
mux.HandleFunc("POST /services/tt/user", f.timetable)
f.Server = httptest.NewServer(mux)
return f
}
func Weekly(day time.Time) []Class {
inTerm := func(from, to string) bool {
date := day.Format("2006-01-02")
return date >= from && date <= to
}
if !inTerm("2025-10-01", "2026-01-28") && !inTerm("2026-03-02", "2026-06-20") && !inTerm("2026-10-01", "2027-01-29") {
return nil
}
id, _ := strconv.Atoi(day.Format("20060102"))
at := func(hour, minute int) time.Time {
return time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, warsaw)
}
switch day.Weekday() {
case time.Wednesday:
return []Class{{MeetingID: id, Start: at(13, 30), Minutes: 90, Course: "Algebra liniowa z elementami matematyki dyskretnej", Kind: "Ćwiczenia", Room: "303"}}
case time.Friday:
return []Class{{MeetingID: id, Start: at(11, 45), Minutes: 90, Course: "Algebra liniowa z elementami matematyki dyskretnej", Kind: "Laboratorium", Room: "602"}}
}
return nil
}
func (f *Fake) SetSchedule(schedule func(day time.Time) []Class) {
f.mu.Lock()
defer f.mu.Unlock()
f.Schedule = schedule
}
func (f *Fake) WindowCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.Windows)
}
func (f *Fake) Revoke() {
f.mu.Lock()
defer f.mu.Unlock()
f.Revoked = true
}
func (f *Fake) WasRevoked() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.Revoked
}
func percentEncode(s string) string {
return strings.NewReplacer("+", "%20", "*", "%2A", "%7E", "~").Replace(url.QueryEscape(s))
}
func (f *Fake) verify(w http.ResponseWriter, r *http.Request, needToken bool) (url.Values, bool) {
deny := func(status int, message string) (url.Values, bool) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
return nil, false
}
if err := r.ParseForm(); err != nil {
return deny(http.StatusBadRequest, "bad form")
}
oauth := url.Values{}
for _, match := range headerPair.FindAllStringSubmatch(r.Header.Get("Authorization"), -1) {
value, _ := url.QueryUnescape(match[2])
oauth.Set(match[1], value)
}
if oauth.Get("oauth_consumer_key") != ConsumerKey {
return deny(http.StatusUnauthorized, "Invalid consumer.")
}
token := oauth.Get("oauth_token")
f.mu.Lock()
tokenSecret, known := f.secrets[token]
revoked := f.Revoked
f.mu.Unlock()
if needToken && (!known || revoked) {
return deny(http.StatusUnauthorized, "Invalid access token.")
}
var pairs []string
for key, values := range r.PostForm {
for _, value := range values {
pairs = append(pairs, percentEncode(key)+"="+percentEncode(value))
}
}
for key := range oauth {
if key != "oauth_signature" {
pairs = append(pairs, percentEncode(key)+"="+percentEncode(oauth.Get(key)))
}
}
sort.Strings(pairs)
base := "POST&" + percentEncode(f.URL+r.URL.Path) + "&" + percentEncode(strings.Join(pairs, "&"))
mac := hmac.New(sha1.New, []byte(percentEncode(ConsumerSecret)+"&"+percentEncode(tokenSecret)))
mac.Write([]byte(base))
if base64.StdEncoding.EncodeToString(mac.Sum(nil)) != oauth.Get("oauth_signature") {
return deny(http.StatusUnauthorized, "Invalid signature.")
}
for key, values := range oauth {
r.PostForm[key] = values
}
return r.PostForm, true
}
func (f *Fake) issue(prefix string) (key, secret string) {
f.mu.Lock()
defer f.mu.Unlock()
f.counter++
key, secret = fmt.Sprintf("%s-token-%d", prefix, f.counter), fmt.Sprintf("%s-secret-%d", prefix, f.counter)
f.secrets[key] = secret
return key, secret
}
func (f *Fake) requestToken(w http.ResponseWriter, r *http.Request) {
form, ok := f.verify(w, r, false)
if !ok {
return
}
if form.Get("scopes") != "studies|offline_access" {
w.WriteHeader(http.StatusBadRequest)
return
}
key, secret := f.issue("request")
f.mu.Lock()
f.callbacks[key] = form.Get("oauth_callback")
f.mu.Unlock()
fmt.Fprintf(w, "oauth_token=%s&oauth_token_secret=%s&oauth_callback_confirmed=true", key, secret)
}
func (f *Fake) authorize(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("oauth_token")
f.mu.Lock()
callback := f.callbacks[token]
f.mu.Unlock()
if callback == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, callback+"?oauth_token="+token+"&oauth_verifier=verifier-of-"+token, http.StatusFound)
}
func (f *Fake) accessToken(w http.ResponseWriter, r *http.Request) {
form, ok := f.verify(w, r, true)
if !ok {
return
}
if form.Get("oauth_verifier") != "verifier-of-"+form.Get("oauth_token") {
w.WriteHeader(http.StatusUnauthorized)
return
}
key, secret := f.issue("access")
fmt.Fprintf(w, "oauth_token=%s&oauth_token_secret=%s", key, secret)
}
func (f *Fake) revokeToken(w http.ResponseWriter, r *http.Request) {
if _, ok := f.verify(w, r, true); !ok {
return
}
f.Revoke()
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func (f *Fake) user(w http.ResponseWriter, r *http.Request) {
if _, ok := f.verify(w, r, true); !ok {
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": UserID, "first_name": "Ada", "last_name": "Nowak"})
}
func (f *Fake) users(w http.ResponseWriter, r *http.Request) {
form, ok := f.verify(w, r, false)
if !ok {
return
}
people := map[string]any{}
for _, id := range strings.Split(form.Get("user_ids"), "|") {
people[id] = map[string]any{
"id": id, "first_name": "Jan", "last_name": "Kowalski",
"titles": map[string]any{"before": "dr inż.", "after": nil},
}
}
_ = json.NewEncoder(w).Encode(people)
}
func (f *Fake) building(w http.ResponseWriter, r *http.Request) {
if _, ok := f.verify(w, r, false); !ok {
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "A1",
"name": map[string]string{"pl": "A1 - BM - Budowa Maszyn, budynek z zegarem", "en": ""},
"postal_address": "ul. Piotrowo 3, 61-138 Poznań, Polska",
})
}
func (f *Fake) terms(w http.ResponseWriter, r *http.Request) {
if _, ok := f.verify(w, r, false); !ok {
return
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "2026", "start_date": "2026-10-01", "end_date": "2027-09-30", "finish_date": "2027-09-30"},
{"id": "2026Z", "start_date": "2026-10-01", "end_date": "2027-01-29", "finish_date": "2027-02-28"},
})
}
func (f *Fake) timetable(w http.ResponseWriter, r *http.Request) {
form, ok := f.verify(w, r, true)
if !ok {
return
}
days, _ := strconv.Atoi(form.Get("days"))
start, err := time.ParseInLocation("2006-01-02", form.Get("start"), warsaw)
if err != nil || days < 1 || days > 7 {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Parameter 'days' has invalid value."})
return
}
if strings.Contains(form.Get("fields"), "related_user_ids") || strings.Contains(form.Get("fields"), "slot_number") {
w.WriteHeader(http.StatusInternalServerError)
return
}
f.mu.Lock()
f.Windows = append(f.Windows, form.Get("start"))
schedule := f.Schedule
f.mu.Unlock()
activities := []map[string]any{}
for offset := range days {
for _, class := range schedule(start.AddDate(0, 0, offset)) {
activities = append(activities, map[string]any{
"type": "classgroup2",
"start_time": class.Start.Format("2006-01-02 15:04:05"),
"end_time": class.Start.Add(time.Duration(class.Minutes) * time.Minute).Format("2006-01-02 15:04:05"),
"name": map[string]string{"pl": class.Kind + " (gr.1)", "en": ""},
"url": "https://usosweb.example/zajecia?gr_nr=1&zaj_cyk_id=94092",
"course_id": "S1TK1E>ALzEMD",
"course_name": map[string]string{"pl": class.Course, "en": "Linear algebra"},
"classtype_name": map[string]string{"pl": class.Kind, "en": ""},
"lecturer_ids": []int{141055},
"group_number": 1,
"building_name": map[string]string{"pl": "A1 - BM", "en": ""},
"building_id": "A1",
"room_number": class.Room,
"unit_id": 94092,
"sm_id": class.MeetingID,
})
}
}
_ = json.NewEncoder(w).Encode(activities)
}