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,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)
|
||||
}
|
||||
Reference in new issue
Block a user