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