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,92 @@
|
||||
package usos
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
|
||||
func percentEncode(s string) string {
|
||||
var b strings.Builder
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if strings.IndexByte(unreserved, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{c})))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func signature(method, endpoint string, params url.Values, consumerSecret, tokenSecret string) string {
|
||||
pairs := make([]string, 0, len(params))
|
||||
for key, values := range params {
|
||||
for _, value := range values {
|
||||
pairs = append(pairs, percentEncode(key)+"="+percentEncode(value))
|
||||
}
|
||||
}
|
||||
sort.Strings(pairs)
|
||||
base := method + "&" + percentEncode(endpoint) + "&" + percentEncode(strings.Join(pairs, "&"))
|
||||
mac := hmac.New(sha1.New, []byte(percentEncode(consumerSecret)+"&"+percentEncode(tokenSecret)))
|
||||
mac.Write([]byte(base))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func nonce() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func (c *Client) authorization(endpoint string, token *Token, params url.Values) (header string, body url.Values) {
|
||||
oauth := url.Values{
|
||||
"oauth_consumer_key": {c.Key},
|
||||
"oauth_nonce": {nonce()},
|
||||
"oauth_signature_method": {"HMAC-SHA1"},
|
||||
"oauth_timestamp": {strconv.FormatInt(time.Now().Unix(), 10)},
|
||||
"oauth_version": {"1.0"},
|
||||
}
|
||||
tokenSecret := ""
|
||||
if token != nil {
|
||||
oauth.Set("oauth_token", token.Key)
|
||||
tokenSecret = token.Secret
|
||||
}
|
||||
body = url.Values{}
|
||||
all := url.Values{}
|
||||
for key, values := range params {
|
||||
if strings.HasPrefix(key, "oauth_") {
|
||||
oauth[key] = values
|
||||
} else {
|
||||
body[key] = values
|
||||
}
|
||||
all[key] = values
|
||||
}
|
||||
for key, values := range oauth {
|
||||
all[key] = values
|
||||
}
|
||||
oauth.Set("oauth_signature", signature("POST", endpoint, all, c.Secret, tokenSecret))
|
||||
|
||||
keys := make([]string, 0, len(oauth))
|
||||
for key := range oauth {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+`="`+percentEncode(oauth.Get(key))+`"`)
|
||||
}
|
||||
return "OAuth " + strings.Join(parts, ", "), body
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package usos
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignatureMatchesPublishedVector(t *testing.T) {
|
||||
params := url.Values{
|
||||
"include_entities": {"true"},
|
||||
"status": {"Hello Ladies + Gentlemen, a signed OAuth request!"},
|
||||
"oauth_consumer_key": {"xvz1evFS4wEEPTGEFPHBog"},
|
||||
"oauth_nonce": {"kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg"},
|
||||
"oauth_signature_method": {"HMAC-SHA1"},
|
||||
"oauth_timestamp": {"1318622958"},
|
||||
"oauth_token": {"370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"},
|
||||
"oauth_version": {"1.0"},
|
||||
}
|
||||
got := signature(
|
||||
"POST", "https://api.twitter.com/1.1/statuses/update.json", params,
|
||||
"kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw", "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE",
|
||||
)
|
||||
if want := "hCtSmYh+iHYCEqBWrE7C7hYmtUk="; got != want {
|
||||
t.Fatalf("signature = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentEncode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"studies|offline_access": "studies%7Coffline_access",
|
||||
"a b+c~d": "a%20b%2Bc~d",
|
||||
"Poznań": "Pozna%C5%84",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := percentEncode(in); got != want {
|
||||
t.Errorf("percentEncode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package usos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
const (
|
||||
Scopes = "studies|offline_access"
|
||||
activityFields = "type|start_time|end_time|name|url|course_id|course_name|classtype_name|lecturer_ids|group_number|building_name|building_id|room_number|unit_id|sm_id"
|
||||
commonFields = "type|start_time|end_time|name|url"
|
||||
timeLayout = "2006-01-02 15:04:05"
|
||||
dateLayout = "2006-01-02"
|
||||
responseLimit = 8 << 20
|
||||
lecturerBatch = 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("usos: access token rejected")
|
||||
|
||||
Location = mustLocation("Europe/Warsaw")
|
||||
)
|
||||
|
||||
func mustLocation(name string) *time.Location {
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("usos: http %d: %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Key string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Key string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, key, secret string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Key: key,
|
||||
Secret: secret,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, method string, token *Token, params url.Values) ([]byte, error) {
|
||||
endpoint := c.BaseURL + "/services/" + method
|
||||
header, body := c.authorization(endpoint, token, params)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", header)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usos: %s: %w", method, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, responseLimit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usos: %s: %w", method, err)
|
||||
}
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
var failure struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &failure)
|
||||
return nil, &Error{Status: res.StatusCode, Message: method + ": " + failure.Message}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) tokenCall(ctx context.Context, method string, token *Token, params url.Values) (Token, error) {
|
||||
data, err := c.call(ctx, method, token, params)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
values, err := url.ParseQuery(strings.TrimSpace(string(data)))
|
||||
if err != nil || values.Get("oauth_token") == "" || values.Get("oauth_token_secret") == "" {
|
||||
return Token{}, &Error{Status: http.StatusOK, Message: method + ": malformed token response"}
|
||||
}
|
||||
return Token{Key: values.Get("oauth_token"), Secret: values.Get("oauth_token_secret")}, nil
|
||||
}
|
||||
|
||||
func (c *Client) RequestToken(ctx context.Context, callback string) (Token, error) {
|
||||
return c.tokenCall(ctx, "oauth/request_token", nil, url.Values{"oauth_callback": {callback}, "scopes": {Scopes}})
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizeURL(request Token) string {
|
||||
return c.BaseURL + "/services/oauth/authorize?oauth_token=" + url.QueryEscape(request.Key)
|
||||
}
|
||||
|
||||
func (c *Client) AccessToken(ctx context.Context, request Token, verifier string) (Token, error) {
|
||||
return c.tokenCall(ctx, "oauth/access_token", &request, url.Values{"oauth_verifier": {verifier}})
|
||||
}
|
||||
|
||||
func (c *Client) Revoke(ctx context.Context, token Token) error {
|
||||
_, err := c.call(ctx, "oauth/revoke_token", &token, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
ID Scalar `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Titles struct {
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
} `json:"titles"`
|
||||
}
|
||||
|
||||
func (p Person) Name() string {
|
||||
return strings.Join(strings.Fields(p.Titles.Before+" "+p.FirstName+" "+p.LastName+" "+p.Titles.After), " ")
|
||||
}
|
||||
|
||||
func (c *Client) Me(ctx context.Context, token Token) (Person, error) {
|
||||
var person Person
|
||||
data, err := c.call(ctx, "users/user", &token, url.Values{"fields": {"id|first_name|last_name"}})
|
||||
if err != nil {
|
||||
return person, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &person); err != nil || person.ID == "" {
|
||||
return person, &Error{Status: http.StatusOK, Message: "users/user: malformed response"}
|
||||
}
|
||||
return person, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lecturers(ctx context.Context, ids []string) (map[string]string, error) {
|
||||
names := map[string]string{}
|
||||
for start := 0; start < len(ids); start += lecturerBatch {
|
||||
batch := ids[start:min(start+lecturerBatch, len(ids))]
|
||||
data, err := c.call(ctx, "users/users", nil, url.Values{
|
||||
"user_ids": {strings.Join(batch, "|")},
|
||||
"fields": {"id|first_name|last_name|titles"},
|
||||
})
|
||||
if err != nil {
|
||||
return names, err
|
||||
}
|
||||
var people map[string]*Person
|
||||
if err := json.Unmarshal(data, &people); err != nil {
|
||||
return names, &Error{Status: http.StatusOK, Message: "users/users: malformed response"}
|
||||
}
|
||||
for id, person := range people {
|
||||
if person != nil {
|
||||
names[id] = person.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type Building struct {
|
||||
Name LangDict `json:"name"`
|
||||
Address string `json:"postal_address"`
|
||||
}
|
||||
|
||||
func (c *Client) Building(ctx context.Context, id string) (Building, error) {
|
||||
var building Building
|
||||
data, err := c.call(ctx, "geo/building2", nil, url.Values{"building_id": {id}, "fields": {"id|name|postal_address"}})
|
||||
if err != nil {
|
||||
return building, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &building); err != nil {
|
||||
return building, &Error{Status: http.StatusOK, Message: "geo/building2: malformed response"}
|
||||
}
|
||||
return building, nil
|
||||
}
|
||||
|
||||
type Term struct {
|
||||
Start time.Time
|
||||
Finish time.Time
|
||||
}
|
||||
|
||||
func (c *Client) Terms(ctx context.Context, startsBy, finishesFrom time.Time) ([]Term, error) {
|
||||
data, err := c.call(ctx, "terms/search", nil, url.Values{
|
||||
"max_start_date": {startsBy.Format(dateLayout)},
|
||||
"min_finish_date": {finishesFrom.Format(dateLayout)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
Start string `json:"start_date"`
|
||||
Finish string `json:"finish_date"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, &Error{Status: http.StatusOK, Message: "terms/search: malformed response"}
|
||||
}
|
||||
terms := make([]Term, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
start, startErr := time.ParseInLocation(dateLayout, item.Start, Location)
|
||||
finish, finishErr := time.ParseInLocation(dateLayout, item.Finish, Location)
|
||||
if startErr == nil && finishErr == nil {
|
||||
terms = append(terms, Term{Start: start, Finish: finish})
|
||||
}
|
||||
}
|
||||
return terms, nil
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
Type string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Name string
|
||||
URL string
|
||||
CourseID string
|
||||
CourseName string
|
||||
ClasstypeName string
|
||||
LecturerIDs []string
|
||||
GroupNumber string
|
||||
BuildingName string
|
||||
BuildingID string
|
||||
RoomNumber string
|
||||
UnitID string
|
||||
MeetingID string
|
||||
}
|
||||
|
||||
type rawActivity struct {
|
||||
Type string `json:"type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Name LangDict `json:"name"`
|
||||
URL Scalar `json:"url"`
|
||||
CourseID Scalar `json:"course_id"`
|
||||
CourseName LangDict `json:"course_name"`
|
||||
ClasstypeName LangDict `json:"classtype_name"`
|
||||
LecturerIDs []Scalar `json:"lecturer_ids"`
|
||||
GroupNumber Scalar `json:"group_number"`
|
||||
BuildingName LangDict `json:"building_name"`
|
||||
BuildingID Scalar `json:"building_id"`
|
||||
RoomNumber Scalar `json:"room_number"`
|
||||
UnitID Scalar `json:"unit_id"`
|
||||
MeetingID Scalar `json:"sm_id"`
|
||||
}
|
||||
|
||||
func (c *Client) Timetable(ctx context.Context, token Token, start time.Time, days int) ([]Activity, error) {
|
||||
params := url.Values{
|
||||
"start": {start.Format(dateLayout)},
|
||||
"days": {strconv.Itoa(days)},
|
||||
"fields": {activityFields},
|
||||
}
|
||||
data, err := c.call(ctx, "tt/user", &token, params)
|
||||
var failure *Error
|
||||
if errors.As(err, &failure) && failure.Status >= http.StatusInternalServerError {
|
||||
params.Set("fields", commonFields)
|
||||
data, err = c.call(ctx, "tt/user", &token, params)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []rawActivity
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, &Error{Status: http.StatusOK, Message: "tt/user: malformed response"}
|
||||
}
|
||||
activities := make([]Activity, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
begin, beginErr := time.ParseInLocation(timeLayout, item.StartTime, Location)
|
||||
end, endErr := time.ParseInLocation(timeLayout, item.EndTime, Location)
|
||||
if beginErr != nil || endErr != nil {
|
||||
continue
|
||||
}
|
||||
lecturers := make([]string, 0, len(item.LecturerIDs))
|
||||
for _, id := range item.LecturerIDs {
|
||||
lecturers = append(lecturers, string(id))
|
||||
}
|
||||
activities = append(activities, Activity{
|
||||
Type: item.Type,
|
||||
Start: begin,
|
||||
End: end,
|
||||
Name: item.Name.String(),
|
||||
URL: string(item.URL),
|
||||
CourseID: string(item.CourseID),
|
||||
CourseName: item.CourseName.String(),
|
||||
ClasstypeName: item.ClasstypeName.String(),
|
||||
LecturerIDs: lecturers,
|
||||
GroupNumber: string(item.GroupNumber),
|
||||
BuildingName: item.BuildingName.String(),
|
||||
BuildingID: string(item.BuildingID),
|
||||
RoomNumber: string(item.RoomNumber),
|
||||
UnitID: string(item.UnitID),
|
||||
MeetingID: string(item.MeetingID),
|
||||
})
|
||||
}
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
type Scalar string
|
||||
|
||||
func (s *Scalar) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
*s = ""
|
||||
return nil
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = Scalar(text)
|
||||
return nil
|
||||
}
|
||||
if data[0] == '{' || data[0] == '[' {
|
||||
*s = ""
|
||||
return nil
|
||||
}
|
||||
*s = Scalar(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type LangDict struct {
|
||||
PL string
|
||||
EN string
|
||||
}
|
||||
|
||||
func (l *LangDict) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
PL Scalar `json:"pl"`
|
||||
EN Scalar `json:"en"`
|
||||
}
|
||||
if json.Unmarshal(data, &raw) == nil {
|
||||
l.PL, l.EN = strings.TrimSpace(string(raw.PL)), strings.TrimSpace(string(raw.EN))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l LangDict) String() string {
|
||||
if l.PL != "" {
|
||||
return l.PL
|
||||
}
|
||||
return l.EN
|
||||
}
|
||||
Reference in new issue
Block a user