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,7 @@
|
|||||||
|
_commit: v0.3.0
|
||||||
|
_src_path: https://git.kotikot.com/templates/blank.git
|
||||||
|
layout: root
|
||||||
|
part: backend
|
||||||
|
project_name: usoscal
|
||||||
|
project_slug: usoscal
|
||||||
|
service: api
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
_commit: v0.6.0
|
||||||
|
_src_path: https://git.kotikot.com/templates/infra.git
|
||||||
|
init_git: true
|
||||||
|
layout: single
|
||||||
|
mcp_servers: []
|
||||||
|
project_description: USOS timetable as a calendar feed that keeps the past
|
||||||
|
project_name: usoscal
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin
|
||||||
|
storage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.git
|
||||||
|
docs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
COMPOSE_PROFILES=services
|
||||||
|
PUBLIC_URL=
|
||||||
|
USOS_CONSUMER_KEY=
|
||||||
|
USOS_CONSUMER_SECRET=
|
||||||
|
TOKEN_ENC_KEY=
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
docker-compose.override.yml
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
sessions/*.session*
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
/storage/
|
||||||
|
/bin/
|
||||||
|
|
||||||
|
# Local playground
|
||||||
|
/t/
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: standard
|
||||||
|
enable:
|
||||||
|
- bodyclose
|
||||||
|
- contextcheck
|
||||||
|
- copyloopvar
|
||||||
|
- errorlint
|
||||||
|
- gocritic
|
||||||
|
- gosec
|
||||||
|
- misspell
|
||||||
|
- perfsprint
|
||||||
|
- revive
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
- usestdlibvars
|
||||||
|
settings:
|
||||||
|
gosec:
|
||||||
|
excludes:
|
||||||
|
- G104
|
||||||
|
- G401
|
||||||
|
- G505
|
||||||
|
- G204
|
||||||
|
- G301
|
||||||
|
- G302
|
||||||
|
- G304
|
||||||
|
- G306
|
||||||
|
- G122
|
||||||
|
- G124
|
||||||
|
- G703
|
||||||
|
- G706
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
disabled: true
|
||||||
|
- name: package-comments
|
||||||
|
disabled: true
|
||||||
|
errcheck:
|
||||||
|
exclude-functions:
|
||||||
|
- (*os.File).Close
|
||||||
|
- (io.Closer).Close
|
||||||
|
- (*database/sql.Rows).Close
|
||||||
|
- (*database/sql.DB).Close
|
||||||
|
- (*database/sql.Tx).Rollback
|
||||||
|
- (*usoscal/internal/store.Store).Close
|
||||||
|
- os.Unsetenv
|
||||||
|
- os.Remove
|
||||||
|
- fmt.Fprint
|
||||||
|
- fmt.Fprintf
|
||||||
|
- fmt.Fprintln
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- path: internal/usostest/
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
settings:
|
||||||
|
gofumpt:
|
||||||
|
module-path: usoscal
|
||||||
|
goimports:
|
||||||
|
local-prefixes:
|
||||||
|
- usoscal
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
name: fmt
|
||||||
|
entry: make fmt
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: check
|
||||||
|
name: check
|
||||||
|
entry: make check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Code Principles
|
||||||
|
- **Simplicity**: Write simple, straightforward code
|
||||||
|
- **Readability**: Make code easy to understand
|
||||||
|
- **Performance**: Consider performance without sacrificing readability
|
||||||
|
- **Maintainability**: Write code that's easy to update
|
||||||
|
- **Reusability**: Create reusable components and functions
|
||||||
|
- **Less Code = Less Debt**: Minimize code footprint
|
||||||
|
- **NEVER write comments** - code should be self-documenting
|
||||||
|
- **Docstrings ONLY if explicitly asked**
|
||||||
|
|
||||||
|
## Checking commands
|
||||||
|
After writing code, always run:
|
||||||
|
```shell
|
||||||
|
make fmt
|
||||||
|
make check
|
||||||
|
```
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
RUN go build -trimpath -ldflags "-s -w" -o /out/usoscal ./cmd/usoscal
|
||||||
|
|
||||||
|
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /out/usoscal /usr/local/bin/usoscal
|
||||||
|
|
||||||
|
CMD ["usoscal"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
COMPOSE := docker compose
|
||||||
|
GO := $(if $(shell command -v go),,docker run --rm -v $(CURDIR):/src -v usoscal-gomod:/go/pkg/mod -v usoscal-gocache:/root/.cache -w /src golang:1.27) go
|
||||||
|
LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
|
||||||
|
|
||||||
|
.PHONY: env recreate rebuild restart logs down fmt check test build
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
$(GO) mod tidy
|
||||||
|
$(LINT) fmt
|
||||||
|
|
||||||
|
check:
|
||||||
|
$(LINT) run
|
||||||
|
$(MAKE) test
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test -race ./...
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(COMPOSE) build api
|
||||||
|
|
||||||
|
env:
|
||||||
|
@fresh=; test -f .env || { cp .env.example .env; fresh=1; }; \
|
||||||
|
if ! test -f docker-compose.override.yml; then \
|
||||||
|
cp docker-compose.override.yml.example docker-compose.override.yml; \
|
||||||
|
for port in $$(sed -n 's/.*127\.0\.0\.1:\([0-9]*\):.*/\1/p' docker-compose.override.yml | sort -u); do \
|
||||||
|
random=$$((20000 + $$(od -An -N2 -tu2 /dev/urandom) % 40000)); \
|
||||||
|
sed "s/127\.0\.0\.1:$$port:/127.0.0.1:$$random:/" docker-compose.override.yml > docker-compose.override.yml.tmp && mv docker-compose.override.yml.tmp docker-compose.override.yml; \
|
||||||
|
test -z "$$fresh" || { sed "s|localhost:$$port/|localhost:$$random/|" .env > .env.tmp && mv .env.tmp .env; }; \
|
||||||
|
done; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
recreate:
|
||||||
|
$(COMPOSE) up -d --force-recreate
|
||||||
|
|
||||||
|
rebuild: build
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(COMPOSE) restart
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f --tail=100
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(COMPOSE) down
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# usoscal
|
||||||
|
|
||||||
|
USOS timetable as a calendar feed that keeps the past.
|
||||||
|
|
||||||
|
The iCalendar export built into USOS only contains what is ahead of today, so classes vanish from the calendar as soon as they pass. usoscal signs a student in through USOS, gives them a private `.ics` address and, when they ask for it, keeps every class it has ever seen.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make env # .env and docker-compose.override.yml from the examples
|
||||||
|
make recreate # the services in COMPOSE_PROFILES
|
||||||
|
```
|
||||||
|
|
||||||
|
`make fmt` and `make check` format, lint and test; without a local Go they run in the `golang` image.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Everything comes from `.env`:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `PUBLIC_URL` | Address the service is opened at, used for the OAuth callback and for calendar addresses |
|
||||||
|
| `USOS_CONSUMER_KEY`, `USOS_CONSUMER_SECRET` | API key of the USOS installation, issued at `<usos>/developers/` |
|
||||||
|
| `TOKEN_ENC_KEY` | 32 random bytes in base64 (`openssl rand -base64 32`), encrypts stored tokens |
|
||||||
|
| `USOS_BASE_URL` | USOS API installation, `https://usosapps.put.poznan.pl` by default |
|
||||||
|
| `SYNC_INTERVAL` | How often each timetable is refreshed, `24h` by default |
|
||||||
|
| `STORAGE_ROOT` | Host directory of the SQLite file, `./storage` by default |
|
||||||
|
|
||||||
|
Changing `TOKEN_ENC_KEY` makes every stored token unreadable: people sign in again and get a new calendar address.
|
||||||
|
|
||||||
|
## What a user gets
|
||||||
|
|
||||||
|
One page. Signed out, it explains the service and has a single button. Signed in, it shows:
|
||||||
|
|
||||||
|
- the calendar address, with a button to issue a new one and a button to revoke it, both of which stop the current address at once;
|
||||||
|
- the archive checkbox: on, past classes stay and earlier terms are fetched once; off, the stored past is deleted;
|
||||||
|
- when the timetable was last refreshed;
|
||||||
|
- sign-out, which revokes the token in USOS, deletes the stored tokens, stops the calendar address and, if asked, deletes the timetable.
|
||||||
|
|
||||||
|
An event carries the course name with the class type as its title, the room with the building and its postal address as the location, and the group, the lecturers and the USOS link in the description.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
One Go binary and one SQLite file. `internal/usos` is the OAuth 1.0a client, `internal/syncer` refreshes timetables, `internal/ical` writes the feed, `internal/server` is the page and the feed endpoint, `internal/store` is the database, `internal/seal` is the encryption.
|
||||||
|
|
||||||
|
The feed is served from the database, never by calling USOS on a calendar request. Once per `SYNC_INTERVAL` the syncer asks USOS for every week from today to the end of the running semester and replaces those weeks with what it got, so a class that was moved or cancelled changes in the feed too. Days that have passed are never asked for again: with the archive on they stay as they were last seen, with it off they are deleted. Turning the archive on walks backwards week by week until USOS has returned nothing for half a year.
|
||||||
|
|
||||||
|
What USOS allows and where it misbehaves is written down in [docs/usos-api.md](docs/usos-api.md), what is stored and how it is protected in [docs/security.md](docs/security.md).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"usoscal/internal/seal"
|
||||||
|
"usoscal/internal/server"
|
||||||
|
"usoscal/internal/store"
|
||||||
|
"usoscal/internal/syncer"
|
||||||
|
"usoscal/internal/usos"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
database := cmp.Or(os.Getenv("DATABASE"), "/app/storage/usoscal.db")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(database), 0o700); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
box, err := seal.New(os.Getenv("TOKEN_ENC_KEY"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("TOKEN_ENC_KEY: %v", err)
|
||||||
|
}
|
||||||
|
consumerKey, consumerSecret := os.Getenv("USOS_CONSUMER_KEY"), os.Getenv("USOS_CONSUMER_SECRET")
|
||||||
|
if consumerKey == "" || consumerSecret == "" {
|
||||||
|
log.Fatal("USOS_CONSUMER_KEY and USOS_CONSUMER_SECRET are required")
|
||||||
|
}
|
||||||
|
interval, err := time.ParseDuration(cmp.Or(os.Getenv("SYNC_INTERVAL"), "24h"))
|
||||||
|
if err != nil || interval <= 0 {
|
||||||
|
log.Fatal("SYNC_INTERVAL must be a positive duration like 24h")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := usos.New(cmp.Or(os.Getenv("USOS_BASE_URL"), "https://usosapps.put.poznan.pl"), consumerKey, consumerSecret)
|
||||||
|
sync := syncer.New(st, client, box, interval)
|
||||||
|
handler, err := server.New(os.Getenv("PUBLIC_URL"), st, client, box, sync)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
go sync.Run(context.Background())
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: cmp.Or(os.Getenv("LISTEN"), ":8080"),
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
log.Printf("listening on %s", httpServer.Addr)
|
||||||
|
log.Fatal(httpServer.ListenAndServe())
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
caddy:
|
||||||
|
aliases:
|
||||||
|
- usoscal-api
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
image: usoscal/backend
|
||||||
|
profiles: [api, services]
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- ${STORAGE_ROOT:-./storage}:/app/storage
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# What is stored and how
|
||||||
|
|
||||||
|
| Data | Form |
|
||||||
|
|---|---|
|
||||||
|
| USOS access token and its secret | AES-256-GCM with `TOKEN_ENC_KEY`, bound to the user id and the field |
|
||||||
|
| Calendar address token | 32 random bytes; a SHA-256 hash to look the feed up, and an encrypted copy to show the address on the page |
|
||||||
|
| Browser session | 32 random bytes in an `HttpOnly`, `SameSite=Lax` cookie for seven days; only its SHA-256 hash is stored |
|
||||||
|
| Timetable | Plain: course, class type, group, room, building, address, lecturers, times |
|
||||||
|
| Profile | USOS user id and name |
|
||||||
|
|
||||||
|
Only the `studies` and `offline_access` scopes are requested. No e-mail, no personal data, no grades.
|
||||||
|
|
||||||
|
- Revoking or reissuing the calendar address replaces the stored hash in one statement; the next request to the old address is answered with 404.
|
||||||
|
- Sign-out revokes the token in USOS, then deletes the tokens, the calendar address and the sessions. With the checkbox, the user and the timetable are deleted as well.
|
||||||
|
- When USOS rejects a token, it is deleted; the timetable stays until the user signs out with deletion.
|
||||||
|
- Nothing is logged per request. Error logs carry the user id and the USOS error message, never a token, a signature or a calendar address. The reverse proxy in front must not log request paths of `/f/`.
|
||||||
|
- Forms are protected against cross-site requests by the `Sec-Fetch-Site` and `Origin` check of Go's `http.CrossOriginProtection`. A sign-in is bound to the browser that started it by a one-time cookie.
|
||||||
|
- The page sends `Content-Security-Policy: default-src 'none'` with inline styles only and has no scripts.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# USOS API notes
|
||||||
|
|
||||||
|
Checked against USOS API 7.3.1 at `usosapps.put.poznan.pl` on 2026-10-11, by reading `/developers/api/` and by calling the methods that need no key.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
OAuth 1.0a with HMAC-SHA1 signatures, three legs: `services/oauth/request_token`, the user's browser at `services/oauth/authorize`, then `services/oauth/access_token`. Scopes are a USOS extension, passed to `request_token` as `scopes=studies|offline_access`.
|
||||||
|
|
||||||
|
- `studies` is what the timetable methods require.
|
||||||
|
- Without `offline_access` an access token dies after two hours or when the user logs out of USOS. With it the token lives until it is revoked.
|
||||||
|
- `services/oauth/revoke_token` revokes the token it is signed with. usoscal calls it on sign-out.
|
||||||
|
- A user can also revoke the application in USOS itself. The next refresh then gets HTTP 401, and usoscal drops the stored token and keeps the timetable.
|
||||||
|
|
||||||
|
Requests are sent as POST with the OAuth parameters in the `Authorization` header and the method parameters in the body, so no token ever appears in a URL.
|
||||||
|
|
||||||
|
## Timetable
|
||||||
|
|
||||||
|
`services/tt/user` returns the activities of the signed-in user for `days` days starting at `start`.
|
||||||
|
|
||||||
|
- `days` is at most 7; 8 is answered with HTTP 400. A semester is therefore about twenty requests, a year about fifty.
|
||||||
|
- `start` may be in the past. Weeks of the two previous semesters were returned by the public `services/tt/course_edition`, which shares the same parameters. Dates with no data give an empty list, not an error.
|
||||||
|
- No request limits are documented. usoscal waits 200 ms between requests and refreshes each user once a day.
|
||||||
|
- `fields` must only name fields that exist for every activity type in the answer. Asking for `related_user_ids` or `slot_number` while the week contains a `classgroup2` activity is answered with HTTP 500. usoscal asks for the class fields and, on a 500, repeats the request with the fields common to all types.
|
||||||
|
- `sm_id` identifies a single meeting and is used as the event UID. Activities without it get a UID derived from their course, group, name and start.
|
||||||
|
|
||||||
|
`services/tt/upcoming_ical` is the export USOS offers itself. It has no date parameters and its documentation says that past events and events far in the future are not included, which is the reason this project exists.
|
||||||
|
|
||||||
|
## Other methods
|
||||||
|
|
||||||
|
- `services/users/users` gives lecturer names and titles by id, signed with the consumer key only.
|
||||||
|
- `services/geo/building2` gives the postal address of a building, anonymously.
|
||||||
|
- `services/terms/search` gives term dates, anonymously. The refresh horizon is the latest `finish_date` among terms shorter than 250 days that are running or start within 60 days, so the exam session after the last class is covered.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
module usoscal
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.59.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||||
|
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||||
|
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package ical
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"usoscal/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
stamp = "20060102T150405Z"
|
||||||
|
lineLimit = 75
|
||||||
|
)
|
||||||
|
|
||||||
|
func Summary(event store.Event) string {
|
||||||
|
if event.Kind == "" || event.Kind == event.Title {
|
||||||
|
return event.Title
|
||||||
|
}
|
||||||
|
return event.Title + " (" + event.Kind + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func Location(event store.Event) string {
|
||||||
|
place := event.Room
|
||||||
|
switch {
|
||||||
|
case place != "" && event.Building != "":
|
||||||
|
place += " (" + event.Building + ")"
|
||||||
|
case place == "":
|
||||||
|
place = event.Building
|
||||||
|
}
|
||||||
|
return joinNonEmpty(", ", place, event.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Description(event store.Event) string {
|
||||||
|
about := event.Kind
|
||||||
|
if event.Group != "" {
|
||||||
|
about = joinNonEmpty(", ", about, "group "+event.Group)
|
||||||
|
}
|
||||||
|
lecturers := ""
|
||||||
|
if event.Lecturers != "" {
|
||||||
|
lecturers = "Lecturer: " + event.Lecturers
|
||||||
|
}
|
||||||
|
return joinNonEmpty("\n", about, lecturers, event.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Write(w io.Writer, name, host string, events []store.Event) error {
|
||||||
|
var b strings.Builder
|
||||||
|
line(&b, "BEGIN:VCALENDAR")
|
||||||
|
line(&b, "VERSION:2.0")
|
||||||
|
line(&b, "PRODID:-//usoscal//EN")
|
||||||
|
line(&b, "CALSCALE:GREGORIAN")
|
||||||
|
line(&b, "METHOD:PUBLISH")
|
||||||
|
line(&b, "X-WR-CALNAME:"+escape(name))
|
||||||
|
line(&b, "X-WR-TIMEZONE:Europe/Warsaw")
|
||||||
|
line(&b, "REFRESH-INTERVAL;VALUE=DURATION:PT6H")
|
||||||
|
line(&b, "X-PUBLISHED-TTL:PT6H")
|
||||||
|
for _, event := range events {
|
||||||
|
line(&b, "BEGIN:VEVENT")
|
||||||
|
line(&b, "UID:"+event.UID+"@"+host)
|
||||||
|
line(&b, "DTSTAMP:"+utc(event.UpdatedAt))
|
||||||
|
line(&b, "LAST-MODIFIED:"+utc(event.UpdatedAt))
|
||||||
|
line(&b, "DTSTART:"+utc(event.Start))
|
||||||
|
line(&b, "DTEND:"+utc(event.End))
|
||||||
|
line(&b, "SUMMARY:"+escape(Summary(event)))
|
||||||
|
if location := Location(event); location != "" {
|
||||||
|
line(&b, "LOCATION:"+escape(location))
|
||||||
|
}
|
||||||
|
if description := Description(event); description != "" {
|
||||||
|
line(&b, "DESCRIPTION:"+escape(description))
|
||||||
|
}
|
||||||
|
if event.URL != "" {
|
||||||
|
line(&b, "URL:"+event.URL)
|
||||||
|
}
|
||||||
|
line(&b, "END:VEVENT")
|
||||||
|
}
|
||||||
|
line(&b, "END:VCALENDAR")
|
||||||
|
_, err := io.WriteString(w, b.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func utc(t time.Time) string {
|
||||||
|
return t.UTC().Format(stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNonEmpty(separator string, parts ...string) string {
|
||||||
|
kept := parts[:0:0]
|
||||||
|
for _, part := range parts {
|
||||||
|
if part != "" {
|
||||||
|
kept = append(kept, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
var escaper = strings.NewReplacer(`\`, `\\`, ";", `\;`, ",", `\,`, "\r\n", `\n`, "\n", `\n`, "\r", "")
|
||||||
|
|
||||||
|
func escape(text string) string {
|
||||||
|
return escaper.Replace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func line(b *strings.Builder, content string) {
|
||||||
|
for len(content) > lineLimit {
|
||||||
|
cut := lineLimit
|
||||||
|
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
b.WriteString(content[:cut])
|
||||||
|
b.WriteString("\r\n ")
|
||||||
|
content = content[cut:]
|
||||||
|
}
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ical
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"usoscal/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWrite(t *testing.T) {
|
||||||
|
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
event := store.Event{
|
||||||
|
UID: "sm-90917229",
|
||||||
|
Start: time.Date(2026, 10, 9, 11, 45, 0, 0, warsaw),
|
||||||
|
End: time.Date(2026, 10, 9, 13, 15, 0, 0, warsaw),
|
||||||
|
Title: "Algebra liniowa z elementami matematyki dyskretnej",
|
||||||
|
Kind: "Laboratorium",
|
||||||
|
Group: "2",
|
||||||
|
Room: "602",
|
||||||
|
Building: "A1 - BM - Budowa Maszyn, budynek z zegarem",
|
||||||
|
Address: "ul. Piotrowo 3, 61-138 Poznań, Polska",
|
||||||
|
Lecturers: "dr inż. Zażółć Gęślą-Jaźń",
|
||||||
|
URL: "https://usosweb.put.poznan.pl/kontroler.php?_action=x&gr_nr=2",
|
||||||
|
UpdatedAt: time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
if err := Write(&out, "USOS", "usos.example", []store.Event{event}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := out.String()
|
||||||
|
|
||||||
|
for _, rawLine := range strings.Split(strings.TrimSuffix(text, "\r\n"), "\r\n") {
|
||||||
|
if len(rawLine) > lineLimit+1 {
|
||||||
|
t.Errorf("line longer than the fold limit: %q", rawLine)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(rawLine) {
|
||||||
|
t.Errorf("fold split a character: %q", rawLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ReplaceAll(text, "\r\n", ""), "\n") {
|
||||||
|
t.Error("bare line feed in the output")
|
||||||
|
}
|
||||||
|
|
||||||
|
unfolded := strings.ReplaceAll(text, "\r\n ", "")
|
||||||
|
for _, want := range []string{
|
||||||
|
"UID:sm-90917229@usos.example\r\n",
|
||||||
|
"DTSTART:20261009T094500Z\r\n",
|
||||||
|
"DTEND:20261009T111500Z\r\n",
|
||||||
|
"SUMMARY:Algebra liniowa z elementami matematyki dyskretnej (Laboratorium)\r\n",
|
||||||
|
`LOCATION:602 (A1 - BM - Budowa Maszyn\, budynek z zegarem)\, ul. Piotrowo 3\, 61-138 Poznań\, Polska` + "\r\n",
|
||||||
|
`DESCRIPTION:Laboratorium\, group 2\nLecturer: dr inż. Zażółć Gęślą-Jaźń\nhttps://usosweb.put.poznan.pl/kontroler.php?_action=x&gr_nr=2` + "\r\n",
|
||||||
|
"END:VCALENDAR\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(unfolded, want) {
|
||||||
|
t.Errorf("missing %q in\n%s", want, unfolded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSparseEvent(t *testing.T) {
|
||||||
|
event := store.Event{UID: "x", Title: "Spotkanie", Kind: "Spotkanie", Start: time.Unix(0, 0), End: time.Unix(60, 0)}
|
||||||
|
if got := Summary(event); got != "Spotkanie" {
|
||||||
|
t.Errorf("Summary = %q", got)
|
||||||
|
}
|
||||||
|
if got := Location(event); got != "" {
|
||||||
|
t.Errorf("Location = %q", got)
|
||||||
|
}
|
||||||
|
if got := Description(event); got != "Spotkanie" {
|
||||||
|
t.Errorf("Description = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package seal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keySize = 32
|
||||||
|
|
||||||
|
var ErrKey = errors.New("seal: key must be 32 random bytes in base64")
|
||||||
|
|
||||||
|
type Box struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(encodedKey string) (*Box, error) {
|
||||||
|
key, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||||
|
if err != nil || len(key) != keySize {
|
||||||
|
return nil, ErrKey
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Box{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Box) Seal(plain, purpose string) []byte {
|
||||||
|
nonce := make([]byte, b.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return b.aead.Seal(nonce, nonce, []byte(plain), []byte(purpose))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Box) Open(sealed []byte, purpose string) (string, error) {
|
||||||
|
size := b.aead.NonceSize()
|
||||||
|
if len(sealed) < size {
|
||||||
|
return "", errors.New("seal: ciphertext too short")
|
||||||
|
}
|
||||||
|
plain, err := b.aead.Open(nil, sealed[:size], sealed[size:], []byte(purpose))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("seal: cannot decrypt")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package seal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoundTripAndBinding(t *testing.T) {
|
||||||
|
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, keySize))
|
||||||
|
box, err := New(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sealed := box.Seal("secret-value", "42/usos_token")
|
||||||
|
if bytes.Contains(sealed, []byte("secret-value")) {
|
||||||
|
t.Fatal("plaintext is visible in the sealed value")
|
||||||
|
}
|
||||||
|
if got, err := box.Open(sealed, "42/usos_token"); err != nil || got != "secret-value" {
|
||||||
|
t.Fatalf("Open = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := box.Open(sealed, "43/usos_token"); err == nil {
|
||||||
|
t.Fatal("a value sealed for one user opened for another")
|
||||||
|
}
|
||||||
|
if bytes.Equal(sealed, box.Seal("secret-value", "42/usos_token")) {
|
||||||
|
t.Fatal("sealing twice produced the same bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsWeakKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{"", "CHANGEME", base64.StdEncoding.EncodeToString([]byte("short"))} {
|
||||||
|
if _, err := New(key); err == nil {
|
||||||
|
t.Errorf("New(%q) accepted a bad key", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
create table if not exists users (
|
||||||
|
id text primary key,
|
||||||
|
name text not null,
|
||||||
|
usos_token blob,
|
||||||
|
usos_secret blob,
|
||||||
|
feed_hash text unique,
|
||||||
|
feed_token blob,
|
||||||
|
archive integer not null default 0,
|
||||||
|
backfilled integer not null default 0,
|
||||||
|
synced_at integer,
|
||||||
|
sync_error text not null default '',
|
||||||
|
created_at integer not null
|
||||||
|
);
|
||||||
|
create table if not exists sessions (
|
||||||
|
token_hash text primary key,
|
||||||
|
user_id text not null references users (id) on delete cascade,
|
||||||
|
expires_at integer not null
|
||||||
|
);
|
||||||
|
create table if not exists events (
|
||||||
|
user_id text not null references users (id) on delete cascade,
|
||||||
|
uid text not null,
|
||||||
|
starts_at integer not null,
|
||||||
|
ends_at integer not null,
|
||||||
|
title text not null,
|
||||||
|
kind text not null,
|
||||||
|
grp text not null,
|
||||||
|
room text not null,
|
||||||
|
building text not null,
|
||||||
|
address text not null,
|
||||||
|
lecturers text not null,
|
||||||
|
url text not null,
|
||||||
|
updated_at integer not null,
|
||||||
|
primary key (user_id, uid)
|
||||||
|
);
|
||||||
|
create index if not exists events_by_start on events (user_id, starts_at);
|
||||||
|
create table if not exists lecturers (
|
||||||
|
id text primary key,
|
||||||
|
name text not null
|
||||||
|
);
|
||||||
|
create table if not exists buildings (
|
||||||
|
id text primary key,
|
||||||
|
name text not null,
|
||||||
|
address text not null
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
USOSToken []byte
|
||||||
|
USOSSecret []byte
|
||||||
|
FeedToken []byte
|
||||||
|
Archive bool
|
||||||
|
Backfilled bool
|
||||||
|
SyncedAt time.Time
|
||||||
|
SyncError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) Connected() bool {
|
||||||
|
return len(u.USOSToken) > 0 && len(u.USOSSecret) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
UID string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
Title string
|
||||||
|
Kind string
|
||||||
|
Group string
|
||||||
|
Room string
|
||||||
|
Building string
|
||||||
|
Address string
|
||||||
|
Lecturers string
|
||||||
|
URL string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Building struct {
|
||||||
|
Name string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, name, usos_token, usos_secret, feed_token, archive, backfilled, coalesce(synced_at, 0), sync_error`
|
||||||
|
|
||||||
|
func scanUser(row *sql.Row) (User, error) {
|
||||||
|
var (
|
||||||
|
user User
|
||||||
|
synced int64
|
||||||
|
)
|
||||||
|
err := row.Scan(&user.ID, &user.Name, &user.USOSToken, &user.USOSSecret, &user.FeedToken, &user.Archive, &user.Backfilled, &synced, &user.SyncError)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return user, ErrNotFound
|
||||||
|
}
|
||||||
|
if synced > 0 {
|
||||||
|
user.SyncedAt = time.Unix(synced, 0)
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) User(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.db.QueryRowContext(ctx, `select `+userColumns+` from users where id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserByFeed(ctx context.Context, feedHash string) (User, error) {
|
||||||
|
return scanUser(s.db.QueryRowContext(ctx, `select `+userColumns+` from users where feed_hash = ?`, feedHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Connect(ctx context.Context, id, name string, token, secret []byte) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
insert into users (id, name, usos_token, usos_secret, created_at) values (?, ?, ?, ?, ?)
|
||||||
|
on conflict (id) do update set name = excluded.name, usos_token = excluded.usos_token,
|
||||||
|
usos_secret = excluded.usos_secret, sync_error = ''`,
|
||||||
|
id, name, token, secret, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetFeed(ctx context.Context, id, feedHash string, sealed []byte) error {
|
||||||
|
var hash any
|
||||||
|
if feedHash != "" {
|
||||||
|
hash = feedHash
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `update users set feed_hash = ?, feed_token = ? where id = ?`, hash, sealed, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetArchive(ctx context.Context, id string, on bool) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update users set archive = ?, backfilled = 0 where id = ?`, on, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetBackfilled(ctx context.Context, id string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update users set backfilled = 1 where id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetSynced(ctx context.Context, id string, at time.Time, failure string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update users set synced_at = ?, sync_error = ? where id = ?`, at.Unix(), failure, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DropTokens(ctx context.Context, id, failure string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update users set usos_token = null, usos_secret = null, sync_error = ? where id = ?`, failure, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Disconnect(ctx context.Context, id string) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
update users set usos_token = null, usos_secret = null, feed_hash = null, feed_token = null, sync_error = ''
|
||||||
|
where id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `delete from sessions where user_id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, id string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `delete from users where id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DueUsers(ctx context.Context, syncedBefore time.Time) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
select id from users
|
||||||
|
where usos_token is not null and (synced_at is null or synced_at < ?)
|
||||||
|
order by coalesce(synced_at, 0)`, syncedBefore.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, tokenHash, userID string, expires time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `delete from sessions where expires_at < ?`, time.Now().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expires.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (User, error) {
|
||||||
|
return scanUser(s.db.QueryRowContext(ctx, `
|
||||||
|
select `+userColumns+` from users
|
||||||
|
where id = (select user_id from sessions where token_hash = ? and expires_at > ?)`, tokenHash, time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReplaceWindow(ctx context.Context, userID string, from, to time.Time, events []Event) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
stale, err := windowUIDs(ctx, tx, userID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, event := range events {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
insert into events (user_id, uid, starts_at, ends_at, title, kind, grp, room, building, address, lecturers, url, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
on conflict (user_id, uid) do update set
|
||||||
|
starts_at = excluded.starts_at, ends_at = excluded.ends_at, title = excluded.title, kind = excluded.kind,
|
||||||
|
grp = excluded.grp, room = excluded.room, building = excluded.building, address = excluded.address,
|
||||||
|
lecturers = excluded.lecturers, url = excluded.url, updated_at = excluded.updated_at
|
||||||
|
where (starts_at, ends_at, title, kind, grp, room, building, address, lecturers, url) is not
|
||||||
|
(excluded.starts_at, excluded.ends_at, excluded.title, excluded.kind, excluded.grp, excluded.room,
|
||||||
|
excluded.building, excluded.address, excluded.lecturers, excluded.url)`,
|
||||||
|
userID, event.UID, event.Start.Unix(), event.End.Unix(), event.Title, event.Kind, event.Group, event.Room,
|
||||||
|
event.Building, event.Address, event.Lecturers, event.URL, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(stale, event.UID)
|
||||||
|
}
|
||||||
|
for uid := range stale {
|
||||||
|
if _, err := tx.ExecContext(ctx, `delete from events where user_id = ? and uid = ?`, userID, uid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowUIDs(ctx context.Context, tx *sql.Tx, userID string, from, to time.Time) (map[string]struct{}, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, `select uid from events where user_id = ? and starts_at >= ? and starts_at < ?`, userID, from.Unix(), to.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
uids := map[string]struct{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uids[uid] = struct{}{}
|
||||||
|
}
|
||||||
|
return uids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteEventsBefore(ctx context.Context, userID string, before time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `delete from events where user_id = ? and starts_at < ?`, userID, before.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Events(ctx context.Context, userID string) ([]Event, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
select uid, starts_at, ends_at, title, kind, grp, room, building, address, lecturers, url, updated_at
|
||||||
|
from events where user_id = ? order by starts_at, uid`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
event Event
|
||||||
|
start, end, updated int64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&event.UID, &start, &end, &event.Title, &event.Kind, &event.Group, &event.Room,
|
||||||
|
&event.Building, &event.Address, &event.Lecturers, &event.URL, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
event.Start, event.End, event.UpdatedAt = time.Unix(start, 0), time.Unix(end, 0), time.Unix(updated, 0)
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) EventSpan(ctx context.Context, userID string) (count int, first time.Time, err error) {
|
||||||
|
var earliest int64
|
||||||
|
err = s.db.QueryRowContext(ctx, `select count(*), coalesce(min(starts_at), 0) from events where user_id = ?`, userID).Scan(&count, &earliest)
|
||||||
|
if earliest > 0 {
|
||||||
|
first = time.Unix(earliest, 0)
|
||||||
|
}
|
||||||
|
return count, first, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LecturerNames(ctx context.Context, ids []string) (map[string]string, error) {
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, id := range ids {
|
||||||
|
var name string
|
||||||
|
err := s.db.QueryRowContext(ctx, `select name from lecturers where id = ?`, id).Scan(&name)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names[id] = name
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveLecturers(ctx context.Context, names map[string]string) error {
|
||||||
|
for id, name := range names {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `insert or replace into lecturers (id, name) values (?, ?)`, id, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Building(ctx context.Context, id string) (Building, bool) {
|
||||||
|
var building Building
|
||||||
|
err := s.db.QueryRowContext(ctx, `select name, address from buildings where id = ?`, id).Scan(&building.Name, &building.Address)
|
||||||
|
return building, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveBuilding(ctx context.Context, id string, building Building) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `insert or replace into buildings (id, name, address) values (?, ?, ?)`, id, building.Name, building.Address)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["local>templates/renovate"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user