Compare commits

...

93 commits

Author SHA1 Message Date
Joe Ardent
2e09d0b8e9 better render error text for the Wender body 2025-08-30 15:38:01 -07:00
Joe Ardent
1aa2d48869 have wender return a response instead of a bare body 2025-08-29 15:52:56 -07:00
Joe Ardent
59f2acf8a7 update axum-test dep 2025-08-29 11:24:47 -07:00
Joe Ardent
3dd25b2063 make sure everything is wendered 2025-08-29 11:10:42 -07:00
Joe Ardent
fc26533460 update deps, fuck with errors 2025-08-28 18:20:01 -07:00
Joe Ardent
72ca947cf6 rename routes to namespace them. 2024-04-19 16:53:08 -07:00
Joe Ardent
47d3fbc339 Add handler for editing quests. 2024-04-19 14:32:11 -07:00
Joe Ardent
d90011b619 Add html items for updating quest status.
Still need add a handler to edit quest status (remove, change publicity).
2024-04-18 18:03:54 -07:00
Joe Ardent
67a69ca0d0 unify watchlist item use. 2024-04-15 22:45:47 -07:00
Joe Ardent
7b41e0f91b use migrate macro 2024-04-15 22:13:08 -07:00
Joe Ardent
79da5a02a6 make server listen on localhost 2024-04-15 20:45:36 -07:00
Joe Ardent
a1379615a0 cargo update 2024-04-14 21:51:16 -07:00
Joe Ardent
062a3e26bc split title and preson search 2024-04-14 21:22:16 -07:00
Joe Ardent
5ab8fa8444 fix watch search from user page 2024-04-14 20:00:53 -07:00
Joe Ardent
97623afd8d fix date handling. 2024-04-14 15:22:26 -07:00
Joe Ardent
4d8706d6bf simplify search more and add more convenience views. 2024-04-14 11:00:12 -07:00
Joe Ardent
0e170c0428 hey buddy. 2024-04-11 22:15:34 -07:00
Joe Ardent
a45ca8d3a5 simplify search, sketch out search-branches. 2024-04-11 22:15:09 -07:00
Joe Ardent
4e2e8e585f simplify search 2024-04-11 18:17:31 -07:00
Joe Ardent
ba1f9119e8 make date-related columns "text". 2024-04-09 17:44:37 -07:00
Joe Ardent
9179395aee update deps 2024-04-09 17:14:47 -07:00
Joe Ardent
def08b69b8 try to make imdb import more robust, add convenience views 2024-04-09 17:14:32 -07:00
Joe Ardent
a7104e54aa Update the code to use new datetime in the DB. 2024-04-07 14:01:31 -07:00
Joe Ardent
51427ecdb5 update db schema to use real datetime 2024-04-07 12:11:55 -07:00
Joe Ardent
714274659e pull tower_sessions use out of axum_login 2024-03-16 16:36:31 -07:00
Joe Ardent
9a94efd29b add graceful shutdown 2024-03-12 19:17:59 -07:00
Joe Ardent
30b75cc936 migrate to latest tower_sessions 2024-03-12 19:05:12 -07:00
Joe Ardent
94e26f8080 use a transaction for committing invites to db 2024-02-10 12:21:28 -08:00
Joe Ardent
8ee362b991 break search out into own module 2024-02-09 17:16:44 -08:00
Joe Ardent
61ad0a17e8 tweaks 2024-02-05 19:47:45 -08:00
Joe Ardent
c1bea8284c make import more robust and with fewer duplicates for stars. 2024-02-04 13:09:26 -08:00
Joe Ardent
a46a2e8847 add configfile stuff 2024-02-03 17:09:18 -08:00
Joe Ardent
efec2e670f fix fts table defs 2024-02-03 15:04:05 -08:00
Joe Ardent
24c67bc529 add full text search to db 2024-02-02 21:47:50 -08:00
Joe Ardent
a680d01d7f add config for base url 2024-02-02 21:18:27 -08:00
Joe Ardent
8f9b7b413c update deps 2024-01-29 22:14:42 -08:00
Joe Ardent
14925684ee do more insertion batching for db in importers 2024-01-28 20:50:29 -08:00
Joe Ardent
4343abfb7b batch import inserts in a transaction. 2024-01-27 17:20:47 -08:00
Joe Ardent
d1e2e95248 reorder table drops to respect fk constraints 2024-01-27 17:20:47 -08:00
Joe Ardent
fab52203e5 redo migrations 2024-01-27 17:20:47 -08:00
Joe Ardent
8ca6751a87 add imdb url to search result items 2024-01-15 16:37:38 -08:00
Joe Ardent
785913fdba add the imdb url for the movie 2024-01-15 16:00:24 -08:00
Joe Ardent
3caefad767 update follows table, rename importer 2024-01-15 15:11:39 -08:00
Joe Ardent
88b870031e fix tests 2024-01-15 14:11:30 -08:00
Joe Ardent
ba3e8625f6 add more invitation tests 2024-01-15 13:24:22 -08:00
Joe Ardent
c63786a3fc Add tests for bad invite signups. 2024-01-14 22:16:50 -08:00
Joe Ardent
c2bad4bb94 update julid version 2024-01-14 17:02:25 -08:00
Joe Ardent
df01960be5 Working invites. 2024-01-14 16:56:52 -08:00
Joe Ardent
a8efdac3dd add invitation support to the db 2024-01-14 16:56:52 -08:00
Joe Ardent
e05d74d143 remove unused deps from cargo 2024-01-06 16:53:49 -08:00
Joe Ardent
d18de3e084 make searches better 2024-01-04 22:23:19 -08:00
Joe Ardent
1e0a71f275 tidy watches handler, fix quest trigger. 2024-01-02 22:31:38 -08:00
Joe Ardent
dab2dc4081 have drop-down for public/private in add-watch from search. 2024-01-01 22:44:29 -08:00
Joe Ardent
5e998dfe86 move add watch button to template 2024-01-01 17:27:17 -08:00
Joe Ardent
8708271d9d adds reactive status to search results 2024-01-01 12:19:04 -08:00
Joe Ardent
c4976a3efc clickable htmx button for adding search results to your list 2023-12-31 13:55:16 -08:00
Joe Ardent
1c304e1184 better-looking search result table 2023-12-30 21:54:15 -08:00
Joe Ardent
beec047caf make search results a table 2023-12-30 16:59:27 -08:00
Joe Ardent
64474c8673 update tower_sessions, allow insecure cookies 2023-12-30 13:33:14 -08:00
Joe Ardent
c30cf86986 minor tweaks 2023-12-30 12:18:08 -08:00
Joe Ardent
521bbe9fa4 fix login button label 2023-12-26 16:04:37 -08:00
Joe Ardent
e317097a6a fix inner join for watch quests 2023-12-26 15:22:10 -08:00
Joe Ardent
cced08f653 fix html 2023-12-26 12:26:14 -08:00
Joe Ardent
dd5ae09ab8 simplify auth and login 2023-12-24 16:21:55 -08:00
Joe Ardent
c133031123 update lockfile 2023-12-24 15:53:06 -08:00
Joe Ardent
e9e5436e02 Compute user digest on deserialization. 2023-12-23 10:01:51 -08:00
Joe Ardent
91a0ba05c4 add pw digest to user 2023-12-20 21:50:55 -08:00
Joe Ardent
dfbf605257 minor re-org and tidy 2023-12-18 16:48:54 -08:00
Joe Ardent
eda946fa4c everything works 2023-12-17 17:38:22 -08:00
Joe Ardent
57eb4001ee almost works, still need to impl auth 2023-12-17 16:41:04 -08:00
Joe Ardent
d4a684de29 non-working update to latest deps 2023-12-10 13:52:27 -08:00
Joe Ardent
404d0d6159 add submodule and makefile for building julid 2023-10-22 13:57:00 -07:00
Joe Ardent
5831d974ec random styling and shit 2023-10-22 13:35:41 -07:00
Joe Ardent
a09ec898f9 spiff the proc macro for user reflection in templates 2023-09-24 12:29:26 -07:00
Joe Ardent
cc7afb3533 Get CSS working by adding a route to serve it. 2023-09-24 11:47:09 -07:00
Joe Ardent
fd03e4a4db fix the logout button 2023-09-23 15:22:45 -07:00
Joe Ardent
1852c9b6ad ignore style playground 2023-09-23 14:25:49 -07:00
Joe Ardent
bdd826b2f1 fuck css frameworks 2023-09-22 16:51:48 -07:00
Joe Ardent
bc9f5c29cb add axum-htmx to deps because yolo 2023-09-21 21:47:39 -07:00
Joe Ardent
826c21b4df vendor in picnic scss lib 2023-09-21 17:43:18 -07:00
Joe Ardent
14c36397d1 fuckin' with shit 2023-09-21 16:58:41 -07:00
Joe Ardent
cc14c30fcf delete watchquest benchmark bin 2023-07-29 12:29:40 -07:00
Joe Ardent
71fae193be minor tidy 2023-07-29 12:27:12 -07:00
Joe Ardent
b8481dc106 finish the migration to julids 2023-07-28 16:15:27 -07:00
Joe Ardent
7aefedf993 Use Julids for DbId.
First phase just does a typedef of "DbId = Julid", and confirms the plugin will load into
sqlite. All tests pass. Next phase removes client-side generation of IDs.
2023-07-26 17:10:52 -07:00
Joe Ardent
8a23272cfe update deps 2023-07-26 12:49:32 -07:00
Joe Ardent
c824a2b07a update to sqlx 0.7, using personal fork of axum-login 2023-07-26 12:46:53 -07:00
Joe Ardent
359a732a84 Fix the tests.
The test server had to be created in an async context or Hyper wouldn't run.
2023-07-21 15:15:47 -07:00
Joe Ardent
48308aa169 make db acquire sync; tests fail but app runs correctly 2023-07-18 17:37:24 -07:00
Joe Ardent
c685dc1a6b add unique constraint for quests 2023-07-18 10:26:16 -07:00
Joe Ardent
5aa7a64354 add triggers for follows table, lowercase username index 2023-07-15 12:36:06 -07:00
Joe Ardent
2c7990ff09 use implicit rowid primary key for quests 2023-07-14 17:17:50 -07:00
Joe Ardent
f6b5b181d8 update logo 2023-07-14 16:24:42 -07:00
69 changed files with 4710 additions and 3284 deletions

2
.env
View file

@ -1 +1 @@
DATABASE_URL=sqlite://${HOME}/.what2watch.db
DATABASE_URL=sqlite://${HOME}/.local/share/what2watch/what2watch.db

3
.gitignore vendored
View file

@ -1 +1,4 @@
/target
/libjulid.so
ww-style
*.db

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "julid"]
path = julid
url = https://gitlab.com/nebkor/julid.git

2665
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package]
name = "what2watch"
version = "0.0.1"
edition = "2021"
edition = "2024"
default-run = "what2watch"
[dependencies]
@ -10,32 +10,33 @@ optional_optional_user = {path = "optional_optional_user"}
# regular external deps
argon2 = "0.5"
askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.3"
async-session = "3"
axum = { version = "0.6", features = ["macros", "headers"] }
axum-login = { version = "0.5", features = ["sqlite", "sqlx"] }
axum-macros = "0.3"
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
clap = { version = "4.3.10", features = ["derive", "env", "unicode", "suggestions", "usage"] }
askama = { version = "0.14" }
axum = { version = "0.8", features = ["macros"] }
axum-login = "0.18"
axum-macros = "0.5"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
clap = { version = "4", features = ["derive", "env", "unicode", "suggestions", "usage"] }
confy = "1"
dirs = "6"
http = "1"
julid-rs = "1"
justerror = "1"
parse_duration = "2"
password-auth = "1"
password-hash = { version = "0.5", features = ["std", "getrandom"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.6", default-features = false, features = ["runtime-tokio-rustls", "any", "sqlite", "chrono", "time"] }
sha256 = { version = "1", default-features = false }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "tls-none", "migrate", "chrono"] }
thiserror = "1"
tokio = { version = "1", features = ["full", "tracing"], default-features = false }
tokio-retry = "0.3.0"
tokio-stream = "0.1.14"
tower = { version = "0.4", features = ["util", "timeout"], default-features = false }
tower-http = { version = "0.4", features = ["add-extension", "trace"] }
tokio = { version = "1", features = ["rt-multi-thread", "signal", "tracing"], default-features = false }
tower = { version = "0.5", features = ["util", "timeout"], default-features = false }
tower-http = { version = "0.6", features = ["add-extension", "trace", "tracing", "fs"], default-features = false }
tower-sessions = { version = "0.14", default-features = false }
tower-sessions-sqlx-store = { version = "0.15.0", default-features = false, features = ["sqlite"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ulid = { version = "1", features = ["rand"] }
unicode-segmentation = "1"
rand_distr = "0.4.3"
[dev-dependencies]
axum-test = "9.0.0"
serde_test = "1.0.164"
axum-test = "18"

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
.PHONY: run
run: libjulid.so
cargo run --release
.PHONY: init
init:
git submodule init && git submodule update
julid: init
cd julid && \
cargo build --release --no-default-features -F plugin && \
cp target/release/libjulid.so ../
libjulid.so: julid
.PHONY: build
build: libjulid.so
cargo build --release
.PHONY: test
test: libjulid.so
cargo test

1
assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

40
assets/ww.css Normal file
View file

@ -0,0 +1,40 @@
body {
background-color: darkgrey
}
table {
border-collapse: collapse;
width: 100%;
}
th {
background-color: ghostwhite;
}
th, td {
text-align: left;
padding: 8px;
}
tr:nth-child(even) {background-color: ghostwhite;}
#header {
text-align: end;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.warning {
background-color: goldenrod;
}
.watchtitle {
font-style: italic;
}
.header_logged_in {
display: flex;
flex-direction: column;
}

1
julid Submodule

@ -0,0 +1 @@
Subproject commit 1e93d0b1e4bc76ff19e1ce8e638c60204f458604

View file

@ -1,16 +0,0 @@
-- indices
drop index if exists user_username_dex;
drop index if exists user_email_dex;
drop index if exists watch_title_dex;
drop index if exists witch_added_by_dex;
drop index if exists quests_user_dex;
drop index if exists quests_watch_dex;
drop index if exists note_user_dex;
drop index if exists note_watch_dex;
-- tables
drop table if exists watch_quests;
drop table if exists watch_notes;
drop table if exists follows;
drop table if exists users;
drop table if exists watches;

View file

@ -1,73 +0,0 @@
-- note: sqlite-specific migration due to the types of the columns
-- When used for an ID, a blob is a UUID in byte form, or a vector of those like for friends list.
-- Otherwise, for content, a blob is just binary data, possibly representing UTF-8 text.
-- Dates are ints, unix epoch style
-- users
create table if not exists users (
id blob not null primary key,
username text not null unique,
displayname text,
email text,
last_seen int,
pwhash blob not null,
last_updated int not null default (unixepoch())
);
-- table of things to watch
create table if not exists watches (
id blob not null primary key,
kind int not null, -- enum for movie or tv show or whatev
title text not null,
metadata_url text, -- possible url for imdb or other metadata-esque site to show the user
length int,
release_date int,
added_by blob not null, -- ID of the user that added it
last_updated int not null default (unixepoch()),
foreign key (added_by) references users (id)
);
-- table of what people want to watch
create table if not exists watch_quests (
user blob not null,
watch blob not null,
priority int, -- 1-5 how much do you want to watch it
public boolean not null default true,
watched boolean not null default false,
when_added int not null default (unixepoch()),
when_watched int,
last_updated int not null default (unixepoch()),
foreign key (user) references users (id) on delete cascade on update no action,
foreign key (watch) references watches (id) on delete cascade on update no action,
primary key (user, watch)
) without rowid;
-- friend lists; this should really be a graph db, maybe the whole thing should be
-- TODO: look into replacing sqlite with https://www.cozodb.org/
create table if not exists follows (
user blob not null primary key,
follows blob, -- possibly empty friends list in some app-specific format
last_updated int not null default (unixepoch()),
foreign key (user) references users (id) on delete cascade on update no action
);
create table if not exists watch_notes (
id blob not null primary key,
user blob not null,
watch blob not null,
note blob,
public boolean not null,
last_updated int not null default (unixepoch()),
foreign key (user) references users (id) on delete cascade on update no action,
foreign key (watch) references watches (id) on delete cascade on update no action
);
-- indices, not needed for follows
create index if not exists user_username_dex on users (username);
create index if not exists user_email_dex on users (lower(email));
create index if not exists watch_title_dex on watches (lower(title));
create index if not exists watch_added_by_dex on watches (added_by);
create index if not exists quests_user_dex on watch_quests (user);
create index if not exists quests_watch_dex on watch_quests (watch);
create index if not exists note_user_dex on watch_notes (user);
create index if not exists note_watch_dex on watch_notes (watch);

View file

@ -1,5 +0,0 @@
drop trigger if exists update_last_updated_users;
drop trigger if exists update_last_updated_watches;
drop trigger if exists update_last_updated_watch_quests;
drop trigger if exists update_last_updated_follows;
drop trigger if exists update_last_updated_watch_notes;

View file

@ -1,34 +0,0 @@
create trigger if not exists update_last_updated_users
after update on users
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update users set last_updated = (select unixepoch()) where id=NEW.id;
END;
create trigger if not exists update_last_updated_watches
after update on watches
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watches set last_updated = (select unixepoch()) where id=NEW.id;
END;
create trigger if not exists update_last_updated_watch_quests
after update on watch_quests
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watch_quests set last_updated = (select unixepoch()) where id=NEW.id;
END;
create trigger if not exists update_last_updated_follows
after update on follows
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update follows set last_updated = (select unixepoch()) where id=NEW.id;
END;
create trigger if not exists update_last_updated_watch_notes
after update on watch_notes
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watch_notes set last_updated = (select unixepoch()) where id=NEW.id;
END;

View file

@ -0,0 +1,2 @@
drop table if exists invites;
drop table if exists users; -- must be last

View file

@ -0,0 +1,41 @@
create table if not exists users (
id blob not null primary key default (julid_new()),
username text not null unique,
displayname text,
email text,
last_seen text,
pwhash blob not null,
invited_by blob not null,
is_active boolean not null default true,
last_updated text not null default CURRENT_TIMESTAMP,
foreign key (invited_by) references users (id)
);
create index if not exists users_username_dex on users (lower(username));
create index if not exists users_email_dex on users (lower(email));
create index if not exists users_invited_by_dex on users (invited_by);
create trigger if not exists update_last_updated_users
after update on users
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update users set last_updated = CURRENT_TIMESTAMP where id=NEW.id;
END;
-- invitations
create table if not exists invites (
id blob not null primary key default (julid_new()),
owner blob not null,
expires_at text,
remaining int not null default 1,
last_updated text not null default CURRENT_TIMESTAMP,
foreign key (owner) references users (id) on delete cascade on update no action
);
create index if not exists invites_owner_dex on invites (owner);
create trigger if not exists update_last_updated_invites
after update on invites
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update invites set last_updated = CURRENT_TIMESTAMP where id=NEW.id;
END;

View file

@ -0,0 +1,3 @@
drop table if exists watch_quests;
drop table if exists watch_notes;
drop table if exists watches; -- must be last

View file

@ -0,0 +1,65 @@
-- table of things to watch
create table if not exists watches (
id blob not null primary key default (julid_new()),
kind int not null, -- enum for movie or tv show or whatev
title text not null,
metadata_url text, -- possible url for imdb or other metadata-esque site to show the user
length int,
release_date text,
added_by blob not null, -- ID of the user that added it
last_updated text not null default CURRENT_TIMESTAMP,
foreign key (added_by) references users (id)
);
create index if not exists watches_title_dex on watches (lower(title));
create trigger if not exists update_last_updated_watches
after update on watches
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watches set last_updated = CURRENT_TIMESTAMP where id=NEW.id;
END;
-- table of what people want to watch
create table if not exists watch_quests (
user blob not null,
watch blob not null,
priority int, -- 1-5 how much do you want to watch it
public boolean not null default true,
watched boolean not null default false,
when_watched text,
created_at text not null default CURRENT_TIMESTAMP,
last_updated text not null default CURRENT_TIMESTAMP,
foreign key (user) references users (id) on delete cascade on update no action,
foreign key (watch) references watches (id) on delete cascade on update no action,
primary key (user, watch)
);
create index if not exists quests_user_dex on watch_quests (user);
create index if not exists quests_watch_dex on watch_quests (watch);
create trigger if not exists update_last_updated_watch_quests
after update on watch_quests
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watch_quests set last_updated = CURRENT_TIMESTAMP where watch=NEW.watch and user=NEW.user;
END;
-- notes on stuff to watch
create table if not exists watch_notes (
id blob not null primary key default (julid_new()), -- a user can have multiple notes about the same thing
user blob not null,
watch blob not null,
note blob,
public boolean not null,
last_updated text not null default CURRENT_TIMESTAMP,
foreign key (user) references users (id) on delete cascade on update no action,
foreign key (watch) references watches (id) on delete cascade on update no action
);
create index if not exists notes_user_dex on watch_notes (user);
create index if not exists notes_watch_dex on watch_notes (watch);
create trigger if not exists update_last_updated_watch_notes
after update on watch_notes
when OLD.last_updated = NEW.last_updated or OLD.last_updated is null
BEGIN
update watch_notes set last_updated = CURRENT_TIMESTAMP where id=NEW.id;
END;

View file

@ -0,0 +1,2 @@
drop table if exists credits; -- must be first
drop table if exists stars;

View file

@ -0,0 +1,20 @@
create table if not exists stars (
id blob not null primary key default (julid_new()),
name text not null,
metadata_url text,
born text,
died text
);
create index if not exists stars_name_dex on stars (lower(name));
-- as in screen credits for a movie
create table if not exists credits (
star blob not null,
watch blob not null,
credit text, -- "actor", "director", whatevs
unique (star, watch, credit),
foreign key (star) references stars (id),
foreign key (watch) references watches (id)
);
create index if not exists credits_star_dex on credits (star);
create index if not exists credits_watch_dex on credits (watch);

View file

@ -0,0 +1 @@
drop table if exists follows;

View file

@ -0,0 +1,11 @@
create table if not exists follows (
follower blob not null,
followee blob not null,
created_at text not null default CURRENT_TIMESTAMP,
foreign key (follower) references users (id) on delete cascade on update no action,
foreign key (followee) references users (id) on delete cascade on update no action,
unique (follower, followee)
);
create index if not exists follows_follower_dex on follows (follower);
create index if not exists follows_followee_dex on follows (followee);

View file

@ -0,0 +1,2 @@
drop table if exists star_search;
drop table if exists watch_search;

View file

@ -0,0 +1,16 @@
create virtual table if not exists star_search using fts5 (name, id UNINDEXED, tokenize = 'trigram', content = 'stars', content_rowid=rowid);
create trigger if not exists stars_update_search after insert on stars begin
insert into star_search (rowid, name, id) values (new.rowid, new.name, new.id);
end;
create trigger if not exists stars_delete_search after delete on stars begin
insert into star_search (star_search, rowid, name, id) values ('delete', old.rowid, old.name, old.id);
end;
create virtual table if not exists watch_search using fts5 (title, id UNINDEXED, tokenize = 'trigram', content = 'watches', content_rowid=rowid);
create trigger if not exists watches_update_search after insert on watches begin
insert into watch_search (rowid, title, id) values (new.rowid, new.title, new.id);
end;
create trigger if not exists watches_delete_search after delete on watches begin
insert into watch_search (watch_search, rowid, title, id) values ('delete', old.rowid, old.title, old.id);
end;

View file

@ -0,0 +1,5 @@
drop view if exists q;
drop view if exists i;
drop view if exists u;
drop view if exists s;
drop view if exists w;

View file

@ -0,0 +1,6 @@
-- human-friendly views with joined fields and string julids
create view if not exists w as select julid_string(id) id, kind, title, metadata_url, length, release_date, last_updated from watches;
create view if not exists s as select julid_string(id) id, name, born, died from stars;
create view if not exists u as select julid_string(id) id, username, displayname, email, (select username from users where id = invited_by) invited_by, last_seen, last_updated from users;
create view if not exists i as select julid_string(invites.id) id, users.username, expires_at, remaining, invites.last_updated from invites inner join users on users.id = owner;
create view if not exists q as select users.username, watches.title, julid_string(watch) from watch_quests inner join users on users.id = user inner join watches on watch = watches.id;

View file

@ -19,7 +19,7 @@ pub fn derive_optional_optional_user(input: TokenStream) -> TokenStream {
})
.is_some();
let (use_any, user_is_option_user) = if has_user {
let (use_any, user_is_option_user, user_is_mandatory) = if has_user {
(
quote!(
use ::std::any::Any;
@ -28,9 +28,10 @@ pub fn derive_optional_optional_user(input: TokenStream) -> TokenStream {
::std::any::TypeId::of::<::std::option::Option<crate::User>>()
== self.user.type_id()
),
quote!(::std::any::TypeId::of::<crate::User>() == self.user.type_id()),
)
} else {
(quote!(), quote!(false))
(quote!(), quote!(false), quote!(false))
};
let output = quote!(
@ -39,6 +40,15 @@ pub fn derive_optional_optional_user(input: TokenStream) -> TokenStream {
#use_any
#user_is_option_user
}
pub fn has_mandatory_user(&self) -> bool {
#use_any
#user_is_mandatory
}
pub fn has_user(&self) -> bool {
self.has_optional_user() || self.has_mandatory_user()
}
}
);

105
src/auth.rs Normal file
View file

@ -0,0 +1,105 @@
use axum::response::{IntoResponse, Response};
use axum_login::{AuthUser, AuthnBackend, UserId};
use http::StatusCode;
use julid::Julid;
use password_auth::verify_password;
use sqlx::SqlitePool;
use tower_sessions::{cookie::time::Duration, Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::SqliteStore;
use crate::User;
const SESSION_TTL: Duration = Duration::new((365.2422 * 24. * 3600.0) as i64, 0);
#[derive(Debug, Clone)]
pub struct AuthStore(SqlitePool);
pub type AuthSession = axum_login::AuthSession<AuthStore>;
impl AuthStore {
pub fn new(pool: SqlitePool) -> Self {
AuthStore(pool)
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[Error]
pub struct AuthError(#[from] pub AuthErrorKind);
#[Error]
#[non_exhaustive]
pub enum AuthErrorKind {
Internal,
Unknown,
}
impl AuthnBackend for AuthStore {
type User = User;
type Credentials = Credentials;
type Error = AuthError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let username = creds.username.trim();
let password = creds.password.trim();
let user = User::try_get(username, &self.0).await.map_err(|e| {
tracing::debug!("Got error getting {username}: {e:?}");
AuthErrorKind::Internal
})?;
Ok(user.filter(|user| verify_password(password, &user.pwhash).is_ok()))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
sqlx::query_as("select * from users where id = ?")
.bind(user_id)
.fetch_optional(&self.0)
.await
.map_err(|_| AuthErrorKind::Unknown.into())
}
}
impl AuthUser for User {
type Id = Julid;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.digest.as_bytes()
}
}
pub async fn session_layer(pool: SqlitePool) -> SessionManagerLayer<SqliteStore> {
let store = SqliteStore::new(pool);
store
.migrate()
.await
.expect("Calling `migrate()` should be reliable, is the DB gone?");
SessionManagerLayer::new(store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(SESSION_TTL))
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self.0 {
AuthErrorKind::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"An unknown error occurred; you cursed, brah?",
)
.into_response(),
AuthErrorKind::Unknown => (StatusCode::OK, "Not successful.").into_response(),
}
}
}

107
src/bin/import_imdb.rs Normal file
View file

@ -0,0 +1,107 @@
use std::{
ffi::{OsStr, OsString},
path::Path,
time::Duration,
};
use clap::Parser;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
Connection, SqliteConnection, SqlitePool,
};
use what2watch::{get_db_pool, imdb_utils::*};
#[derive(Debug, Parser)]
struct Cli {
#[clap(long, short)]
pub db_path: OsString,
#[clap(
long,
short,
help = "Number of movies to add.",
default_value_t = 10_000
)]
pub number: u32,
}
fn main() {
let w2w_db = get_db_pool();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let cli = Cli::parse();
let now = std::time::Instant::now();
let ids = rt.block_on(import_watches(&w2w_db, &cli));
let dur = std::time::Instant::now() - now;
println!(
"Imported {} watches in {} seconds",
ids.len(),
dur.as_secs()
);
let now = std::time::Instant::now();
rt.block_on(save_ids(&cli.db_path, ids));
let dur = std::time::Instant::now() - now;
println!("Saved the IDs in {} seconds", dur.as_secs());
}
async fn import_watches(w2w_db: &SqlitePool, cli: &Cli) -> IdMap {
let path = &cli.db_path;
let num = cli.number;
let opts = SqliteConnectOptions::new().filename(path).read_only(true);
let movie_db = SqlitePoolOptions::new()
.idle_timeout(Duration::from_secs(90))
.connect_with(opts)
.await
.unwrap();
let _ = what2watch::import_utils::ensure_omega(w2w_db).await;
let mut map = IdMap::new();
import_imdb_data(w2w_db, &movie_db, &mut map, num).await;
w2w_db.close().await;
map
}
async fn save_ids(path: &OsStr, ids: IdMap) {
let path = Path::new(path);
let file = path.file_name().unwrap();
let file = file.to_str().unwrap();
let path = format!("{}/w2w-{file}", path.parent().unwrap().to_str().unwrap());
println!("Writing IDs to {path}");
let conn_opts = SqliteConnectOptions::new()
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.filename(path)
.create_if_missing(true)
.pragma("mmap_size", "3000000000");
let mut conn = SqliteConnection::connect_with(&conn_opts).await.unwrap();
let create =
"create table if not exists id_map (imdb text not null primary key, w2w blob not null)";
let _ = sqlx::query(create).execute(&mut conn).await.unwrap();
let limit = 5000;
let num_ids = ids.len();
let mut num_rows = 0;
let ids = &mut ids.into_iter();
while num_rows < num_ids {
let num_rows = &mut num_rows;
let mut q = sqlx::QueryBuilder::new("insert into id_map (imdb, w2w) ");
q.push_values(ids.take(limit), |mut qb, row| {
qb.push_bind(row.0.clone());
qb.push_bind(row.1);
*num_rows += 1;
});
q.build().execute(&mut conn).await.unwrap();
}
conn.close().await.unwrap();
}

View file

@ -1,41 +0,0 @@
use std::{ffi::OsString, time::Duration};
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use what2watch::{get_db_pool, import_utils::add_omega_watches};
#[derive(Debug, Parser)]
struct Cli {
#[clap(long, short)]
pub db_path: OsString,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let path = cli.db_path;
let opts = SqliteConnectOptions::new().filename(&path).read_only(true);
let movie_db = SqlitePoolOptions::new()
.idle_timeout(Duration::from_secs(90))
.connect_with(opts)
.await
.expect("could not open movies db");
let w2w_db = get_db_pool().await;
let start = std::time::Instant::now();
add_omega_watches(&w2w_db, &movie_db).await.unwrap();
let end = std::time::Instant::now();
let dur = (end - start).as_secs_f32();
let rows: i32 = sqlx::query_scalar("select count(*) from watches")
.fetch_one(&w2w_db)
.await
.unwrap();
println!("Added {rows} movies in {dur} seconds");
w2w_db.close().await;
}

View file

@ -1,159 +0,0 @@
use std::{ffi::OsString, time::Duration};
use clap::Parser;
use rand::{seq::SliceRandom, thread_rng, Rng};
use rand_distr::Normal;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
SqlitePool,
};
use tokio::task::JoinSet;
use tokio_retry::Retry;
use what2watch::{
get_db_pool,
import_utils::{add_omega_watches, add_users, add_watch_quests},
DbId, User, WatchQuest,
};
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let path = cli.db_path;
let num_users = cli.users;
let mpu = cli.movies_per_user as f32;
let dict = if let Some(dict) = cli.words {
dict
} else {
"/usr/share/dict/words".into()
};
let words = std::fs::read_to_string(dict).expect("tried to open {dict:?}");
let words: Vec<&str> = words.split('\n').collect();
let opts = SqliteConnectOptions::new().filename(&path).read_only(true);
let movie_db = SqlitePoolOptions::new()
.idle_timeout(Duration::from_secs(3))
.connect_with(opts)
.await
.expect("could not open movies db");
let w2w_db = get_db_pool().await;
let users = &gen_users(num_users, &words, &w2w_db).await;
let movies = &add_omega_watches(&w2w_db, &movie_db).await.unwrap();
let rng = &mut thread_rng();
let normal = Normal::new(mpu, mpu / 10.0).unwrap();
let start = std::time::Instant::now();
for &user in users {
add_quests(user, movies, &w2w_db, rng, normal).await;
}
let end = std::time::Instant::now();
let rows: i32 = sqlx::query_scalar("select count(*) from watch_quests")
.fetch_one(&w2w_db)
.await
.unwrap();
w2w_db.close().await;
let dur = (end - start).as_secs_f32();
println!("Added {rows} quests in {dur} seconds");
}
//-************************************************************************
// add the users
//-************************************************************************
async fn gen_users(num: usize, words: &[&str], pool: &SqlitePool) -> Vec<DbId> {
let mut rng = thread_rng();
let rng = &mut rng;
let range = 0usize..(words.len());
let mut users = Vec::with_capacity(num);
for _ in 0..num {
let n1 = rng.gen_range(range.clone());
let n2 = rng.gen_range(range.clone());
let n3 = rng.gen_range(range.clone());
let nn = rng.gen_range(0..200);
let n1 = words[n1].replace('\'', "");
let n2 = words[n2].replace('\'', "");
let email_domain = words[n3].replace('\'', "");
let username = format!("{n1}_{n2}{nn}");
let displayname = Some(format!("{n1} {n2}"));
let email = Some(format!("{username}@{email_domain}"));
let id = DbId::new();
let user = User {
id,
username,
displayname,
email,
last_seen: None,
pwhash: "can't password this".to_string(),
};
users.push(user);
}
add_users(pool, &users).await.unwrap();
users.into_iter().map(|u| u.id).collect()
}
//-************************************************************************
// batch add quests
//-************************************************************************
async fn add_quests<R: Rng>(
user: DbId,
movies: &[DbId],
w2w_db: &SqlitePool,
rng: &mut R,
normal: Normal<f32>,
) {
let mut tasks = JoinSet::new();
let num_movies = rng.sample(normal) as usize;
let quests: Vec<WatchQuest> = movies
.choose_multiple(rng, num_movies)
.cloned()
.map(|watch| WatchQuest {
user,
watch,
is_public: true,
already_watched: false,
})
.collect();
let retry_strategy = tokio_retry::strategy::ExponentialBackoff::from_millis(10)
.map(tokio_retry::strategy::jitter)
.take(3);
let db = w2w_db.clone();
tasks.spawn(async move {
let movies = quests;
(
user,
Retry::spawn(retry_strategy, || async {
add_watch_quests(&db, &movies).await
})
.await,
)
});
// get the stragglers
while (tasks.join_next().await).is_some() {}
}
#[derive(Debug, Parser)]
pub struct Cli {
/// path to the movie database
#[clap(long = "database", short)]
pub db_path: OsString,
/// number of users to create
#[clap(long, short, default_value_t = 1000)]
pub users: usize,
/// expected gaussian value for number of movies per use
#[clap(long = "movies", short, default_value_t = 100)]
pub movies_per_user: u32,
/// path to the dictionary to be used for usernames [default:
/// /usr/share/dict/words]
#[clap(long, short)]
pub words: Option<OsString>,
}

89
src/bin/mkinvites.rs Normal file
View file

@ -0,0 +1,89 @@
use std::time::Duration;
use clap::Parser;
use julid::Julid;
use parse_duration::parse;
use sqlx::SqlitePool;
use what2watch::{conf::Config, get_db_pool, Invitation, User};
#[derive(Debug, Parser)]
struct Cli {
#[clap(long, short, help = "Expire after period (eg, '5h', '1y', etc.)")]
pub expires_in: Option<String>,
#[clap(long, short, help = "Number of times the invitation can be used")]
pub uses: Option<u8>,
#[clap(long, short, help = "ID of the user creating the invite", default_value_t = Julid::omega().as_string())]
pub owner: String,
#[clap(long, short, help = "Number of invites to create", default_value_t = 1)]
pub number: u8,
}
struct InvitationQuest {
owner: Julid,
expires: Option<Duration>,
uses: Option<u8>,
}
fn main() {
let cli = Cli::parse();
let num = cli.number;
let owner = Julid::from_str(&cli.owner).expect("Malformed ID given for owner");
let expires = cli
.expires_in
.map(|e| parse(&e).expect("Could not parse {e} as a duration"));
let uses = cli.uses;
let quest = InvitationQuest {
owner,
expires,
uses,
};
let pool = get_db_pool();
let conf = Config::get();
let base_url = &conf.base_url;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let invites = rt.block_on(async {
ensure_omega(&pool).await;
let invites = generate_invites(quest, num, &pool).await;
pool.close().await;
invites
});
for invite in invites {
println!("{base_url}/signup/{invite}");
}
}
async fn ensure_omega(pool: &SqlitePool) {
let omega = User::try_get("omega", pool).await.unwrap();
if omega.is_none() {
User::omega()
.try_insert(pool)
.await
.expect("Could not ensure Omega");
}
}
async fn generate_invites(quest: InvitationQuest, number: u8, pool: &SqlitePool) -> Vec<Julid> {
let mut invites = Vec::with_capacity(number as usize);
for _ in 0..number {
let mut invite = Invitation::new(quest.owner);
if let Some(uses) = quest.uses {
invite = invite.with_uses(uses);
}
if let Some(expires) = quest.expires {
invite = invite.with_expires_in(expires);
}
let invite = invite
.commit(pool)
.await
.expect("Error inserting invite into DB");
invites.push(invite);
}
invites
}

48
src/conf.rs Normal file
View file

@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
pub const APP_NAME: &str = "what2watch";
const CONFIG_NAME: Option<&str> = Some("config");
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Config {
pub base_url: String,
pub db_file: String,
pub julid_plugin: String,
}
impl Default for Config {
fn default() -> Self {
let mut datadir = dirs::data_dir().unwrap();
datadir.push(APP_NAME);
datadir.push("what2watch.db");
let db_file = datadir.as_os_str().to_string_lossy().to_string();
datadir.pop();
datadir.push("libjulid"); // don't have the '.so' extension here
let julid_plugin = datadir.as_os_str().to_string_lossy().to_string();
Self {
base_url: "http://localhost:3000".into(),
db_file,
julid_plugin,
}
}
}
impl Config {
pub fn get() -> Self {
let config: Config = confy::load(APP_NAME, CONFIG_NAME).unwrap_or_else(|e| {
tracing::debug!("Could not load {APP_NAME} config, got error {e}");
Default::default()
});
confy::store(APP_NAME, CONFIG_NAME, config.clone()).unwrap_or_else(|e| {
tracing::debug!("Could not store {APP_NAME} config, got error {e}");
});
tracing::info!("Loading config from {}", cpath());
config
}
}
fn cpath() -> String {
confy::get_configuration_file_path(APP_NAME, CONFIG_NAME)
.map(|p| p.as_os_str().to_str().unwrap().to_string())
.expect("couldn't get the path to the configuration file")
}

522
src/db.rs
View file

@ -1,32 +1,25 @@
use std::time::Duration;
use async_session::SessionStore;
use axum_login::{
axum_sessions::{PersistencePolicy, SessionLayer},
AuthLayer, SqliteStore, SqlxStore,
};
use session_store::SqliteSessionStore;
use sqlx::{
migrate::Migrator,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
SqlitePool,
};
use crate::{db_id::DbId, User};
const MAX_CONNS: u32 = 200;
const MIN_CONNS: u32 = 5;
const TIMEOUT: u64 = 11;
const SESSION_TTL: Duration = Duration::from_secs((365.2422 * 24. * 3600.0) as u64);
const TIMEOUT: u64 = 2000; // in milliseconds
pub async fn get_db_pool() -> SqlitePool {
pub fn get_db_pool() -> SqlitePool {
let conf = crate::conf::Config::get();
let db_filename = {
std::env::var("DATABASE_FILE").unwrap_or_else(|_| {
#[cfg(not(test))]
{
let home =
std::env::var("HOME").expect("Could not determine $HOME for finding db file");
format!("{home}/.what2watch.db")
let f = conf.db_file;
let p = std::path::Path::new(&f);
let p = p.parent().unwrap();
std::fs::create_dir_all(p).expect("couldn't create data dir");
f
}
#[cfg(test)]
{
@ -45,497 +38,62 @@ pub async fn get_db_pool() -> SqlitePool {
let conn_opts = SqliteConnectOptions::new()
.foreign_keys(true)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.filename(&db_filename)
// be sure to have run `make julid` so that the libjulid extension is built
.extension(conf.julid_plugin)
.busy_timeout(Duration::from_secs(TIMEOUT))
.create_if_missing(true);
.pragma("temp_store", "memory")
.create_if_missing(true)
.optimize_on_close(true, None)
.pragma("mmap_size", "3000000000");
let pool = SqlitePoolOptions::new()
.max_connections(MAX_CONNS)
.min_connections(MIN_CONNS)
.idle_timeout(Some(Duration::from_secs(30)))
.idle_timeout(Some(Duration::from_secs(3)))
.max_lifetime(Some(Duration::from_secs(3600)))
.connect_with(conn_opts)
.await
.expect("can't connect to database");
.connect_with(conn_opts);
// let the filesystem settle before trying anything
// possibly not effective?
tokio::time::sleep(Duration::from_millis(500)).await;
let pool = {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(pool).unwrap()
};
{
let mut m = Migrator::new(std::path::Path::new("./migrations"))
.await
.expect("Should be able to read the migration directory.");
let m = m.set_locking(true);
m.run(&pool)
.await
.expect("Should be able to run the migration.");
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(sqlx::migrate!().run(&pool)).unwrap();
tracing::info!("Ran migrations");
}
pool
}
pub async fn session_layer(pool: SqlitePool, secret: &[u8]) -> SessionLayer<SqliteSessionStore> {
let store = session_store::SqliteSessionStore::from_client(pool);
store
.migrate()
.await
.expect("Calling `migrate()` should be reliable, is the DB gone?");
// since the secret is new every time the server starts, old sessions won't be
// valid anymore; if there were ever more than one service host or there were
// managed secrets, this would need to go away.
store
.clear_store()
.await
.unwrap_or_else(|e| tracing::error!("Could not delete old sessions; got error: {e}"));
SessionLayer::new(store, secret)
.with_secure(true)
.with_session_ttl(Some(SESSION_TTL))
.with_persistence_policy(PersistencePolicy::ExistingOnly)
}
pub async fn auth_layer(
pool: SqlitePool,
secret: &[u8],
) -> AuthLayer<SqlxStore<SqlitePool, User>, DbId, User> {
const QUERY: &str = "select * from users where id = $1";
let store = SqliteStore::<User>::new(pool).with_query(QUERY);
AuthLayer::new(store, secret)
}
//-************************************************************************
// Tests for `db` module.
//-************************************************************************
#[cfg(test)]
mod tests {
use tokio::runtime::Runtime;
#[tokio::test]
async fn it_migrates_the_db() {
let db = super::get_db_pool().await;
let r = sqlx::query("select count(*) from users")
.fetch_one(&db)
.await;
assert!(r.is_ok());
}
}
//-************************************************************************
// End public interface.
//-************************************************************************
//-************************************************************************
// Session store sub-module, not a public lib.
//-************************************************************************
#[allow(dead_code)]
mod session_store {
use async_session::{async_trait, chrono::Utc, log, serde_json, Result, Session};
use sqlx::{pool::PoolConnection, Sqlite};
use super::*;
// NOTE! This code was straight stolen from
// https://github.com/jbr/async-sqlx-session/blob/30d00bed44ab2034082698f098eba48b21600f36/src/sqlite.rs
// and used under the terms of the MIT license:
/*
Copyright 2022 Jacob Rothstein
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the Software), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/// sqlx sqlite session store for async-sessions
#[derive(Clone, Debug)]
pub struct SqliteSessionStore {
client: SqlitePool,
table_name: String,
}
impl SqliteSessionStore {
/// constructs a new SqliteSessionStore from an existing
/// sqlx::SqlitePool. the default table name for this session
/// store will be "async_sessions". To override this, chain this
/// with [`with_table_name`](crate::SqliteSessionStore::with_table_name).
pub fn from_client(client: SqlitePool) -> Self {
Self {
client,
table_name: "async_sessions".into(),
}
}
/// Constructs a new SqliteSessionStore from a sqlite: database url.
/// note that this documentation uses the special `:memory:`
/// sqlite database for convenient testing, but a real
/// application would use a path like
/// `sqlite:///path/to/database.db`. The default table name for
/// this session store will be "async_sessions". To
/// override this, either chain with
/// [`with_table_name`](crate::SqliteSessionStore::with_table_name) or
/// use
/// [`new_with_table_name`](crate::SqliteSessionStore::new_with_table_name)
pub async fn new(database_url: &str) -> sqlx::Result<Self> {
Ok(Self::from_client(SqlitePool::connect(database_url).await?))
}
/// constructs a new SqliteSessionStore from a sqlite: database url. the
/// default table name for this session store will be
/// "async_sessions". To override this, either chain with
/// [`with_table_name`](crate::SqliteSessionStore::with_table_name) or
/// use
/// [`new_with_table_name`](crate::SqliteSessionStore::new_with_table_name)
pub async fn new_with_table_name(
database_url: &str,
table_name: &str,
) -> sqlx::Result<Self> {
Ok(Self::new(database_url).await?.with_table_name(table_name))
}
/// Chainable method to add a custom table name. This will panic
/// if the table name is not `[a-zA-Z0-9_-]+`.
pub fn with_table_name(mut self, table_name: impl AsRef<str>) -> Self {
let table_name = table_name.as_ref();
if table_name.is_empty()
|| !table_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
panic!(
"table name must be [a-zA-Z0-9_-]+, but {} was not",
table_name
);
}
self.table_name = table_name.to_owned();
self
}
/// Creates a session table if it does not already exist. If it
/// does, this will noop, making it safe to call repeatedly on
/// store initialization. In the future, this may make
/// exactly-once modifications to the schema of the session table
/// on breaking releases.
pub async fn migrate(&self) -> sqlx::Result<()> {
log::info!("migrating sessions on `{}`", self.table_name);
let mut conn = self.client.acquire().await?;
sqlx::query(&self.substitute_table_name(
r#"
CREATE TABLE IF NOT EXISTS %%TABLE_NAME%% (
id TEXT PRIMARY KEY NOT NULL,
expires INTEGER NULL,
session TEXT NOT NULL
)
"#,
))
.execute(&mut conn)
.await?;
Ok(())
}
// private utility function because sqlite does not support
// parametrized table names
fn substitute_table_name(&self, query: &str) -> String {
query.replace("%%TABLE_NAME%%", &self.table_name)
}
/// retrieve a connection from the pool
async fn connection(&self) -> sqlx::Result<PoolConnection<Sqlite>> {
self.client.acquire().await
}
/// Performs a one-time cleanup task that clears out stale
/// (expired) sessions. You may want to call this from cron.
pub async fn cleanup(&self) -> sqlx::Result<()> {
let mut connection = self.connection().await?;
sqlx::query(&self.substitute_table_name(
r#"
DELETE FROM %%TABLE_NAME%%
WHERE expires < ?
"#,
))
.bind(Utc::now().timestamp())
.execute(&mut connection)
.await?;
Ok(())
}
/// retrieves the number of sessions currently stored, including
/// expired sessions
pub async fn count(&self) -> sqlx::Result<i32> {
let (count,) =
sqlx::query_as(&self.substitute_table_name("SELECT COUNT(*) FROM %%TABLE_NAME%%"))
.fetch_one(&mut self.connection().await?)
.await?;
Ok(count)
}
}
#[async_trait]
impl SessionStore for SqliteSessionStore {
async fn load_session(&self, cookie_value: String) -> Result<Option<Session>> {
let id = Session::id_from_cookie_value(&cookie_value)?;
let mut connection = self.connection().await?;
let result: Option<(String,)> = sqlx::query_as(&self.substitute_table_name(
r#"
SELECT session FROM %%TABLE_NAME%%
WHERE id = ? AND (expires IS NULL OR expires > ?)
"#,
))
.bind(&id)
.bind(Utc::now().timestamp())
.fetch_optional(&mut connection)
.await?;
Ok(result
.map(|(session,)| serde_json::from_str(&session))
.transpose()?)
}
async fn store_session(&self, session: Session) -> Result<Option<String>> {
let id = session.id();
let string = serde_json::to_string(&session)?;
let mut connection = self.connection().await?;
sqlx::query(&self.substitute_table_name(
r#"
INSERT INTO %%TABLE_NAME%%
(id, session, expires) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
expires = excluded.expires,
session = excluded.session
"#,
))
.bind(id)
.bind(&string)
.bind(session.expiry().map(|expiry| expiry.timestamp()))
.execute(&mut connection)
.await?;
Ok(session.into_cookie_value())
}
async fn destroy_session(&self, session: Session) -> Result {
let id = session.id();
let mut connection = self.connection().await?;
sqlx::query(&self.substitute_table_name(
r#"
DELETE FROM %%TABLE_NAME%% WHERE id = ?
"#,
))
.bind(id)
.execute(&mut connection)
.await?;
Ok(())
}
async fn clear_store(&self) -> Result {
let mut connection = self.connection().await?;
sqlx::query(&self.substitute_table_name(
r#"
DELETE FROM %%TABLE_NAME%%
"#,
))
.execute(&mut connection)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
async fn test_store() -> SqliteSessionStore {
let store = SqliteSessionStore::new("sqlite::memory:")
.await
.expect("building a sqlite :memory: SqliteSessionStore");
store
.migrate()
.await
.expect("migrating a brand new :memory: SqliteSessionStore");
store
}
#[tokio::test]
async fn creating_a_new_session_with_no_expiry() -> Result {
let store = test_store().await;
let mut session = Session::new();
session.insert("key", "value")?;
let cloned = session.clone();
let cookie_value = store.store_session(session).await?.unwrap();
let (id, expires, serialized, count): (String, Option<i64>, String, i64) =
sqlx::query_as("select id, expires, session, count(*) from async_sessions")
.fetch_one(&mut store.connection().await?)
.await?;
assert_eq!(1, count);
assert_eq!(id, cloned.id());
assert_eq!(expires, None);
let deserialized_session: Session = serde_json::from_str(&serialized)?;
assert_eq!(cloned.id(), deserialized_session.id());
assert_eq!("value", &deserialized_session.get::<String>("key").unwrap());
let loaded_session = store.load_session(cookie_value).await?.unwrap();
assert_eq!(cloned.id(), loaded_session.id());
assert_eq!("value", &loaded_session.get::<String>("key").unwrap());
assert!(!loaded_session.is_expired());
Ok(())
}
#[tokio::test]
async fn updating_a_session() -> Result {
let store = test_store().await;
let mut session = Session::new();
let original_id = session.id().to_owned();
session.insert("key", "value")?;
let cookie_value = store.store_session(session).await?.unwrap();
let mut session = store.load_session(cookie_value.clone()).await?.unwrap();
session.insert("key", "other value")?;
assert_eq!(None, store.store_session(session).await?);
let session = store.load_session(cookie_value.clone()).await?.unwrap();
assert_eq!(session.get::<String>("key").unwrap(), "other value");
let (id, count): (String, i64) =
sqlx::query_as("select id, count(*) from async_sessions")
.fetch_one(&mut store.connection().await?)
.await?;
assert_eq!(1, count);
assert_eq!(original_id, id);
Ok(())
}
#[tokio::test]
async fn updating_a_session_extending_expiry() -> Result {
let store = test_store().await;
let mut session = Session::new();
session.expire_in(Duration::from_secs(10));
let original_id = session.id().to_owned();
let original_expires = session.expiry().unwrap().clone();
let cookie_value = store.store_session(session).await?.unwrap();
let mut session = store.load_session(cookie_value.clone()).await?.unwrap();
assert_eq!(session.expiry().unwrap(), &original_expires);
session.expire_in(Duration::from_secs(20));
let new_expires = session.expiry().unwrap().clone();
store.store_session(session).await?;
let session = store.load_session(cookie_value.clone()).await?.unwrap();
assert_eq!(session.expiry().unwrap(), &new_expires);
let (id, expires, count): (String, i64, i64) =
sqlx::query_as("select id, expires, count(*) from async_sessions")
.fetch_one(&mut store.connection().await?)
.await?;
assert_eq!(1, count);
assert_eq!(expires, new_expires.timestamp());
assert_eq!(original_id, id);
Ok(())
}
#[tokio::test]
async fn creating_a_new_session_with_expiry() -> Result {
let store = test_store().await;
let mut session = Session::new();
session.expire_in(Duration::from_secs(1));
session.insert("key", "value")?;
let cloned = session.clone();
let cookie_value = store.store_session(session).await?.unwrap();
let (id, expires, serialized, count): (String, Option<i64>, String, i64) =
sqlx::query_as("select id, expires, session, count(*) from async_sessions")
.fetch_one(&mut store.connection().await?)
.await?;
assert_eq!(1, count);
assert_eq!(id, cloned.id());
assert!(expires.unwrap() > Utc::now().timestamp());
let deserialized_session: Session = serde_json::from_str(&serialized)?;
assert_eq!(cloned.id(), deserialized_session.id());
assert_eq!("value", &deserialized_session.get::<String>("key").unwrap());
let loaded_session = store.load_session(cookie_value.clone()).await?.unwrap();
assert_eq!(cloned.id(), loaded_session.id());
assert_eq!("value", &loaded_session.get::<String>("key").unwrap());
assert!(!loaded_session.is_expired());
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(None, store.load_session(cookie_value).await?);
Ok(())
}
#[tokio::test]
async fn destroying_a_single_session() -> Result {
let store = test_store().await;
for _ in 0..3i8 {
store.store_session(Session::new()).await?;
}
let cookie = store.store_session(Session::new()).await?.unwrap();
assert_eq!(4, store.count().await?);
let session = store.load_session(cookie.clone()).await?.unwrap();
store.destroy_session(session.clone()).await.unwrap();
assert_eq!(None, store.load_session(cookie).await?);
assert_eq!(3, store.count().await?);
// // attempting to destroy the session again is not an error
// assert!(store.destroy_session(session).await.is_ok());
Ok(())
}
#[tokio::test]
async fn clearing_the_whole_store() -> Result {
let store = test_store().await;
for _ in 0..3i8 {
store.store_session(Session::new()).await?;
}
assert_eq!(3, store.count().await?);
store.clear_store().await.unwrap();
assert_eq!(0, store.count().await?);
Ok(())
}
#[test]
fn it_migrates_the_db() {
let rt = Runtime::new().unwrap();
let db = super::get_db_pool();
rt.block_on(async {
let r = sqlx::query("select count(*) from users")
.fetch_one(&db)
.await;
assert!(r.is_ok());
});
}
}

View file

@ -1,214 +0,0 @@
use std::{
borrow::Cow,
fmt::{Debug, Display},
};
use chrono::Utc;
use serde::{de::Visitor, Deserialize, Serialize};
use sqlx::{
encode::IsNull,
sqlite::{SqliteArgumentValue, SqliteValueRef},
Decode, Encode, Sqlite,
};
use ulid::Ulid;
#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DbId(pub Ulid);
impl DbId {
pub fn bytes(&self) -> [u8; 16] {
self.to_be_bytes()
}
pub fn to_be_bytes(self) -> [u8; 16] {
self.0 .0.to_be_bytes()
}
pub fn is_nil(&self) -> bool {
self.0.is_nil()
}
pub fn new() -> Self {
Self(Ulid::new())
}
pub fn from_string(s: &str) -> Result<Self, ulid::DecodeError> {
let id = Ulid::from_string(s)?;
Ok(id.into())
}
pub fn as_string(&self) -> String {
self.0.to_string()
}
pub fn created_at(&self) -> chrono::DateTime<Utc> {
self.0.datetime().into()
}
}
//-************************************************************************
// standard trait impls
//-************************************************************************
impl Display for DbId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.to_string())
}
}
impl Debug for DbId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("DbId").field(&self.as_string()).finish()
}
}
impl From<Ulid> for DbId {
fn from(value: Ulid) -> Self {
DbId(value)
}
}
impl From<u128> for DbId {
fn from(value: u128) -> Self {
DbId(value.into())
}
}
//-************************************************************************
// sqlx traits for going in and out of the db
//-************************************************************************
impl sqlx::Type<sqlx::Sqlite> for DbId {
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
<&[u8] as sqlx::Type<sqlx::Sqlite>>::type_info()
}
}
impl<'q> Encode<'q, Sqlite> for DbId {
fn encode_by_ref(&self, args: &mut Vec<SqliteArgumentValue<'q>>) -> IsNull {
args.push(SqliteArgumentValue::Blob(Cow::Owned(self.bytes().to_vec())));
IsNull::No
}
}
impl Decode<'_, Sqlite> for DbId {
fn decode(value: SqliteValueRef<'_>) -> Result<Self, sqlx::error::BoxDynError> {
let bytes = <&[u8] as Decode<Sqlite>>::decode(value)?;
let bytes: [u8; 16] = bytes.try_into().unwrap_or_default();
Ok(u128::from_be_bytes(bytes).into())
}
}
//-************************************************************************
// serde traits
//-************************************************************************
impl Serialize for DbId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(&self.bytes())
}
}
struct DbIdVisitor;
impl<'de> Visitor<'de> for DbIdVisitor {
type Value = DbId;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("16 bytes")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match std::convert::TryInto::<[u8; 16]>::try_into(v) {
Ok(v) => Ok(u128::from_be_bytes(v).into()),
Err(_) => Err(serde::de::Error::invalid_length(v.len(), &self)),
}
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
DbId::from_string(&v)
.map_err(|_| serde::de::Error::invalid_value(serde::de::Unexpected::Str(&v), &self))
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
DbId::from_string(v)
.map_err(|_| serde::de::Error::invalid_value(serde::de::Unexpected::Str(v), &self))
}
fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let len = v.len();
match std::convert::TryInto::<[u8; 16]>::try_into(v) {
Ok(v) => Ok(u128::from_be_bytes(v).into()),
Err(_) => Err(serde::de::Error::invalid_length(len, &self)),
}
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut raw_bytes_from_db = [0u8; 16];
let size = seq.size_hint().unwrap_or(0);
let mut count = 0;
while let Some(val) = seq.next_element()? {
if count > 15 {
break;
}
raw_bytes_from_db[count] = val;
count += 1;
}
if count != 16 || size > 16 {
let sz = if count < 16 { count } else { size };
Err(serde::de::Error::invalid_length(sz, &self))
} else {
Ok(u128::from_be_bytes(raw_bytes_from_db).into())
}
}
}
impl<'de> Deserialize<'de> for DbId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_bytes(DbIdVisitor)
}
}
//-************************************************************************
// serialization tests
//-************************************************************************
#[cfg(test)]
mod test {
use serde_test::{assert_tokens, Token};
use super::*;
#[test]
fn test_ser_de() {
let bytes: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
let id: DbId = u128::from_be_bytes(bytes).into();
assert_tokens(
&id,
&[Token::Bytes(&[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
])],
);
}
}

View file

@ -1,53 +1,53 @@
use askama::Template;
use axum::response::{IntoResponse, Redirect};
use crate::{AuthContext, MainPage};
use crate::{AuthSession, MainPage, Wender};
pub async fn handle_slash_redir() -> impl IntoResponse {
Redirect::to("/")
}
pub async fn handle_slash(auth: AuthContext) -> impl IntoResponse {
if let Some(ref user) = auth.current_user {
#[axum::debug_handler]
pub async fn handle_slash(auth: AuthSession) -> impl IntoResponse {
if let Some(ref user) = auth.user {
let name = &user.username;
tracing::debug!("Logged in as: {name}");
} else {
tracing::debug!("Not logged in.");
}
MainPage {
user: auth.current_user,
}
MainPage { user: auth.user }.render().wender()
}
#[cfg(test)]
mod test {
use axum_test::TestServer;
use crate::db;
use tokio::runtime::Runtime;
#[tokio::test]
async fn slash_is_ok() {
let pool = db::get_db_pool().await;
let secret = [0u8; 64];
let app = crate::app(pool.clone(), &secret).await.into_make_service();
use crate::{get_db_pool, test_utils::server_with_pool};
let server = TestServer::new(app).unwrap();
#[test]
fn slash_is_ok() {
let db = get_db_pool();
server.get("/").await.assert_status_ok();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&db).await;
server.get("/").await
})
.assert_status_ok();
}
#[tokio::test]
async fn not_found_is_303() {
let pool = db::get_db_pool().await;
let secret = [0u8; 64];
let app = crate::app(pool, &secret).await.into_make_service();
#[test]
fn not_found_is_303() {
let rt = Runtime::new().unwrap();
let server = TestServer::new(app).unwrap();
let db = get_db_pool();
assert_eq!(
server
.get("/no-actual-route")
.expect_failure()
.await
.status_code(),
rt.block_on(async {
let server = server_with_pool(&db).await;
server.get("/no-actual-route").expect_failure().await
})
.status_code(),
303
);
}

160
src/imdb_utils.rs Normal file
View file

@ -0,0 +1,160 @@
use julid::Julid;
use sqlx::{Sqlite, SqlitePool};
use crate::{
import_utils::{insert_credit, insert_star, insert_watch},
Credit, ShowKind, Star, Watch,
};
pub type IdMap = std::collections::BTreeMap<String, Julid>;
const OMEGA_ID: Julid = Julid::omega();
#[derive(Debug, sqlx::FromRow, Clone)]
pub struct ImportImdbMovie {
pub title: String,
pub year: Option<String>,
pub length: Option<String>,
pub id: String,
pub kind: Option<String>,
}
impl From<ImportImdbMovie> for Watch {
fn from(value: ImportImdbMovie) -> Self {
Watch {
id: OMEGA_ID, // this is ignored by the inserter
title: value.title,
release_date: value.year,
length: value.length.and_then(|v| v.parse::<i64>().ok()),
kind: ShowKind::Movie,
metadata_url: Some(format!("https://imdb.com/title/{}/", &value.id)),
added_by: OMEGA_ID,
}
}
}
impl From<&ImportImdbMovie> for Watch {
fn from(value: &ImportImdbMovie) -> Self {
Watch {
id: OMEGA_ID,
title: value.title.to_string(),
release_date: value.year.clone(),
length: value.length.as_ref().and_then(|v| v.parse::<i64>().ok()),
kind: ShowKind::Movie,
metadata_url: Some(format!("https://imdb.com/title/{}/", value.id)),
added_by: OMEGA_ID,
}
}
}
#[derive(Debug, sqlx::FromRow, Clone)]
pub struct ImdbStar {
pub nconst: String,
#[sqlx(rename = "primaryName")]
pub name: String,
#[sqlx(rename = "birthYear")]
pub born: Option<String>,
#[sqlx(rename = "deathYear")]
pub died: Option<String>,
}
impl From<&ImdbStar> for Star {
fn from(value: &ImdbStar) -> Self {
let id = &value.nconst;
let metadata_url = Some(format!("https://imdb.com/name/{id}/"));
Self {
name: value.name.clone(),
metadata_url,
born: value.born.clone(),
died: value.died.clone(),
..Default::default()
}
}
}
pub async fn import_imdb_data(w2w_db: &SqlitePool, imdb: &SqlitePool, ids: &mut IdMap, num: u32) {
const IMDB_QUERY: &str = "select * from watches order by year, title asc limit ?";
let iwatches: Vec<ImportImdbMovie> = sqlx::query_as(IMDB_QUERY)
.bind(num)
.fetch_all(imdb)
.await
.unwrap();
for batch in iwatches.chunks(5_000) {
let mut tx = w2w_db.begin().await.unwrap();
for iwatch in batch {
let aid = iwatch.id.clone();
let kind = show_kind(iwatch.kind.as_ref().unwrap());
let mut watch: Watch = iwatch.into();
watch.kind = kind;
let watch_id: Julid = insert_watch(watch, &mut tx).await;
add_imdb_stars(&mut tx, imdb, &aid, watch_id, ids).await;
ids.insert(aid, watch_id);
}
tx.commit().await.unwrap();
}
}
async fn add_imdb_stars(
w2w_db: &mut sqlx::Transaction<'_, Sqlite>,
imdb: &SqlitePool,
iwatch: &str,
watch: Julid,
ids: &mut IdMap,
) {
let principals_query = "select nconst, category from principals where tconst = ?";
let principals = sqlx::query_as::<Sqlite, (String, String)>(principals_query)
.bind(iwatch)
.fetch_all(imdb)
.await
.unwrap();
for row in principals {
let (name_id, cat) = row;
let star = if let Some(id) = ids.get(&name_id) {
*id
} else {
let name_query =
"select nconst, primaryName, birthYear, deathYear from names where nconst = ?";
let istar: Option<ImdbStar> = sqlx::query_as(name_query)
.bind(&name_id)
.fetch_optional(imdb)
.await
.unwrap();
if let Some(star) = istar {
let star = (&star).into();
let star_id = insert_star(&star, w2w_db).await;
ids.insert(name_id, star_id);
star_id
} else {
continue;
}
};
let credit = Credit {
star,
watch,
credit: Some(cat.to_string()),
};
insert_credit(&credit, w2w_db).await;
}
}
fn show_kind(kind: &str) -> ShowKind {
/*
tvSeries
tvMiniSeries
tvMovie
tvShort
tvSpecial
*/
match &kind[0..4] {
"tvSe" => ShowKind::Series,
"tvSh" => ShowKind::Short,
"tvMi" => ShowKind::LimitedSeries,
"tvSp" => ShowKind::Other,
"tvMo" | "movi" => ShowKind::Movie,
_ => ShowKind::Unknown,
}
}

View file

@ -1,147 +1,79 @@
use sqlx::{query_scalar, SqlitePool};
use julid::Julid;
use sqlx::{query_scalar, SqliteConnection, SqlitePool};
use crate::{db_id::DbId, util::year_to_epoch, ShowKind, User, Watch, WatchQuest};
const USER_EXISTS_QUERY: &str = "select count(*) from users where id = $1";
const MOVIE_QUERY: &str = "select * from movies order by random() limit 10000";
use crate::{Credit, Star, User, Watch};
//-************************************************************************
// the omega user is the system ID, but has no actual power in the app
//-************************************************************************
const OMEGA_ID: u128 = u128::MAX;
const BULK_INSERT: usize = 2_000;
#[derive(Debug, sqlx::FromRow, Clone)]
pub struct ImportMovieOmega {
pub title: String,
pub year: Option<String>,
pub length: Option<String>,
}
impl From<ImportMovieOmega> for Watch {
fn from(value: ImportMovieOmega) -> Self {
Watch {
title: value.title,
release_date: year_to_epoch(value.year.as_deref()),
length: value.length.and_then(|v| v.parse::<i64>().ok()),
id: DbId::new(),
kind: ShowKind::Movie,
metadata_url: None,
added_by: OMEGA_ID.into(),
}
}
}
impl From<&ImportMovieOmega> for Watch {
fn from(value: &ImportMovieOmega) -> Self {
Watch {
title: value.title.to_string(),
release_date: year_to_epoch(value.year.as_deref()),
length: value.length.as_ref().and_then(|v| v.parse::<i64>().ok()),
id: DbId::new(),
kind: ShowKind::Movie,
metadata_url: None,
added_by: OMEGA_ID.into(),
}
}
}
const OMEGA_ID: Julid = Julid::omega();
//-************************************************************************
// utility functions for building CLI tools, currently just for benchmarking
//-************************************************************************
pub async fn add_watch_quests(pool: &SqlitePool, quests: &[WatchQuest]) -> Result<(), ()> {
let mut builder = sqlx::QueryBuilder::new("insert into watch_quests (user, watch) ");
builder.push_values(quests, |mut b, quest| {
let user = quest.user;
let watch = quest.watch;
//eprintln!("{user}, {watch}");
b.push_bind(user).push_bind(watch);
});
let q = builder.build();
q.execute(pool).await.map_err(|e| {
dbg!(e);
})?;
Ok(())
}
pub async fn add_users(db_pool: &SqlitePool, users: &[User]) -> Result<(), ()> {
let mut builder =
sqlx::QueryBuilder::new("insert into users (id, username, displayname, email, pwhash) ");
builder.push_values(users.iter(), |mut b, user| {
b.push_bind(user.id)
.push_bind(&user.username)
.push_bind(&user.displayname)
.push_bind(&user.email)
.push_bind(&user.pwhash);
});
let q = builder.build();
q.execute(db_pool).await.map_err(|_| ())?;
Ok(())
}
pub async fn add_omega_watches(
w2w_db: &SqlitePool,
movie_db: &SqlitePool,
) -> Result<Vec<DbId>, ()> {
ensure_omega(w2w_db).await;
let movies: Vec<ImportMovieOmega> = sqlx::query_as(MOVIE_QUERY)
.fetch_all(movie_db)
pub async fn insert_watch(watch: Watch, db: &mut SqliteConnection) -> Julid {
let q = "insert into watches (kind, title, length, release_date, added_by, metadata_url) values (?, ?, ?, ?, ?, ?) returning id";
sqlx::query_scalar(q)
.bind(watch.kind)
.bind(watch.title)
.bind(watch.length)
.bind(watch.release_date)
.bind(watch.added_by)
.bind(watch.metadata_url)
.fetch_one(db)
.await
.unwrap();
let mut ids = Vec::with_capacity(10_000);
let omega: DbId = OMEGA_ID.into();
for movies in movies.as_slice().chunks(BULK_INSERT) {
let mut builder = sqlx::QueryBuilder::new(
"insert into watches (id, kind, title, length, release_date, added_by) ",
);
builder.push_values(movies, |mut b, movie| {
let id = DbId::new();
ids.push(id);
let title = &movie.title;
b.push_bind(id)
.push_bind(ShowKind::Movie)
.push_bind(title)
.push_bind(movie.length.as_ref().and_then(|l| l.parse::<i64>().ok()))
.push_bind(year_to_epoch(movie.year.as_deref()))
.push_bind(omega);
});
let q = builder.build();
q.execute(w2w_db).await.map_err(|_| ())?;
}
Ok(ids)
.unwrap()
}
pub async fn ensure_omega(db_pool: &SqlitePool) -> DbId {
pub async fn insert_star(star: &Star, db: &mut SqliteConnection) -> Julid {
let q = "insert into stars (name, metadata_url, born, died) values (?, ?, ?, ?) returning id";
sqlx::query_scalar(q)
.bind(&star.name)
.bind(&star.metadata_url)
.bind(&star.born)
.bind(&star.died)
.fetch_one(db)
.await
.unwrap()
}
pub async fn insert_credit(credit: &Credit, db: &mut SqliteConnection) {
let q = "insert into credits (star, watch, credit) values (?, ?, ?)";
sqlx::query(q)
.bind(credit.star)
.bind(credit.watch)
.bind(credit.credit.as_deref())
.execute(db)
.await
.map(|_| ())
.or_else(|e| match e {
sqlx::Error::Database(ref db) => {
let exit = db.code().unwrap_or_default().parse().unwrap_or(0u32);
// https://www.sqlite.org/rescode.html codes for unique constraint violations:
if exit == 2067 || exit == 1555 {
Ok(())
} else {
Err(e)
}
}
_ => Err(e),
})
.unwrap();
}
pub async fn ensure_omega(db_pool: &SqlitePool) -> Julid {
if !check_omega_exists(db_pool).await {
let omega = User {
id: OMEGA_ID.into(),
username: "The Omega User".to_string(),
displayname: Some("I am the end of all watches".to_string()),
email: None,
last_seen: None,
pwhash: "you shall not password".to_string(),
};
add_users(db_pool, &[omega]).await.unwrap();
User::omega().try_insert(db_pool).await.unwrap();
}
OMEGA_ID.into()
OMEGA_ID
}
async fn check_omega_exists(db_pool: &SqlitePool) -> bool {
let id: DbId = OMEGA_ID.into();
const USER_EXISTS_QUERY: &str = "select count(*) from users where id = $1";
let count = query_scalar(USER_EXISTS_QUERY)
.bind(id)
.bind(OMEGA_ID)
.fetch_one(db_pool)
.await
.unwrap_or(0);
@ -154,13 +86,21 @@ async fn check_omega_exists(db_pool: &SqlitePool) -> bool {
#[cfg(test)]
mod test {
use tokio::runtime::Runtime;
use super::*;
#[tokio::test]
async fn ensure_omega_user() {
let p = crate::db::get_db_pool().await;
assert!(!check_omega_exists(&p).await);
ensure_omega(&p).await;
assert!(check_omega_exists(&p).await);
#[test]
fn ensure_omega_user() {
let p = crate::db::get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
dbg!("checking omega");
assert!(!check_omega_exists(&p).await);
dbg!("no omega");
ensure_omega(&p).await;
});
dbg!("maybe omega");
assert!(rt.block_on(check_omega_exists(&p)));
}
}

View file

@ -1,86 +1,179 @@
use axum::{
body::Body,
middleware,
response::{IntoResponse, Response},
routing::{get, post, IntoMakeService},
};
use axum_login::AuthManagerLayerBuilder;
use http::StatusCode;
use sqlx::SqlitePool;
#[macro_use]
extern crate justerror;
#[cfg(test)]
pub mod test_utils;
/// Some public interfaces for interacting with the database outside of the web
/// app
pub use db::get_db_pool;
pub use db_id::DbId;
pub mod conf;
pub mod imdb_utils;
pub mod import_utils;
pub mod misc_util;
pub use conf::*;
pub use signup::Invitation;
pub use stars::*;
pub use users::User;
pub use watches::{ShowKind, Watch, WatchQuest};
pub type WWRouter = axum::Router<SqlitePool>;
pub type WatchDate = chrono::DateTime<chrono::Utc>;
// everything else is private to the crate
mod auth;
mod db;
mod db_id;
mod generic_handlers;
mod login;
mod search;
mod signup;
mod stars;
mod templates;
mod users;
mod util;
mod watches;
// things we want in the crate namespace
use auth::AuthSession;
use optional_optional_user::OptionalOptionalUser;
use templates::*;
use watches::templates::*;
type AuthContext = axum_login::extractors::AuthContext<DbId, User, axum_login::SqliteStore<User>>;
#[Error]
pub enum WatchError {
Auth(auth::AuthError),
Signup(signup::SignupError),
Watches(watches::WatchesError),
Render,
}
/// Returns the router to be used as a service or test object, you do you.
pub async fn app(db_pool: sqlx::SqlitePool, session_secret: &[u8]) -> axum::Router {
use axum::{middleware, routing::get};
let session_layer = db::session_layer(db_pool.clone(), session_secret).await;
let auth_layer = db::auth_layer(db_pool.clone(), session_secret).await;
pub async fn app(db_pool: sqlx::SqlitePool) -> IntoMakeService<axum::Router> {
// don't bother bringing handlers into the whole crate namespace
use auth::*;
use generic_handlers::{handle_slash, handle_slash_redir};
use login::{get_login, get_logout, post_login, post_logout};
use signup::{get_create_user, get_signup_success, post_create_user};
use search::get_search_watch;
use signup::handlers::{get_create_user, get_signup_success, post_create_user};
use tower_http::services::ServeDir;
use watches::handlers::{
get_add_new_watch, get_search_watch, get_watch, get_watches, post_add_new_watch,
post_add_watch_quest,
edit_watch_quest, get_add_new_watch, get_watch, get_watch_status, get_watches,
post_add_new_watch, post_add_watch_quest,
};
let conf = crate::conf::Config::get();
tracing::info!("Using config: {conf:#?}");
let auth_layer = {
let session_layer = session_layer(db_pool.clone()).await;
let store = AuthStore::new(db_pool.clone());
AuthManagerLayerBuilder::new(store, session_layer).build()
};
let assets_dir = std::env::current_dir().unwrap().join("assets");
let assets_svc = ServeDir::new(assets_dir.as_path());
axum::Router::new()
.route("/", get(handle_slash).post(handle_slash))
.nest_service("/assets", assets_svc)
.route("/signup", get(get_create_user).post(post_create_user))
.route("/signup_success/:id", get(get_signup_success))
.route("/signup/{invitation}", get(get_create_user))
.route("/signup_success/{user}", get(get_signup_success))
.route("/login", get(get_login).post(post_login))
.route("/logout", get(get_logout).post(post_logout))
.route("/watches", get(get_watches))
.route("/watch", get(get_watch))
.route("/watch/:id", get(get_watch))
.route("/search", get(get_search_watch))
.route("/add", get(get_add_new_watch).post(post_add_new_watch))
.route("/watch/{watch}", get(get_watch))
.route(
"/add/watch",
"/watch/add",
get(get_add_new_watch).post(post_add_new_watch),
)
.route("/watch/status/{watch}", get(get_watch_status))
.route(
"/quest/add",
get(get_search_watch).post(post_add_watch_quest),
)
.route("/quest/edit", post(edit_watch_quest))
.route("/title-search", get(get_search_watch))
.fallback(handle_slash_redir)
.layer(middleware::from_fn_with_state(
db_pool.clone(),
users::handle_update_last_seen,
))
.layer(auth_layer)
.layer(session_layer)
.with_state(db_pool)
.into_make_service()
}
//-************************************************************************
// internal stuff
//-************************************************************************
pub(crate) trait Wender {
fn wender(self) -> Response;
}
impl Wender for askama::Result<String> {
fn wender(self) -> Response {
let b = self.unwrap_or_else(|e| {
tracing::error!("got error rendering template: {e}");
"could not render template".to_string()
});
Body::new(b).into_response()
}
}
impl IntoResponse for WatchError {
fn into_response(self) -> axum::response::Response {
match self {
Self::Auth(ae) => ae.into_response(),
Self::Signup(se) => se.into_response(),
Self::Watches(we) => we.into_response(),
Self::Render => {
(StatusCode::INTERNAL_SERVER_ERROR, "could not render page").into_response()
}
}
}
}
impl From<auth::AuthError> for WatchError {
fn from(value: auth::AuthError) -> Self {
Self::Auth(value)
}
}
impl From<signup::SignupError> for WatchError {
fn from(value: signup::SignupError) -> Self {
Self::Signup(value)
}
}
impl From<watches::WatchesError> for WatchError {
fn from(value: watches::WatchesError) -> Self {
Self::Watches(value)
}
}
#[cfg(test)]
pub mod test_utils;
//-************************************************************************
// tests for the proc macro for optional user
//-************************************************************************
#[cfg(test)]
mod test {
use super::{MainPage, OptionalOptionalUser, SignupSuccessPage, User};
use super::{signup::templates::SignupSuccessPage, MainPage, OptionalOptionalUser, User};
#[test]
fn main_page_has_optional_user() {
assert!(MainPage::default().has_optional_user());
assert!(MainPage::default().has_user());
}
#[test]
@ -95,6 +188,8 @@ mod test {
user: User,
}
assert!(!TestThing::default().has_optional_user());
assert!(TestThing::default().has_mandatory_user());
assert!(TestThing::default().has_user());
}
#[test]
@ -104,5 +199,6 @@ mod test {
user: Option<bool>,
}
assert!(!TestThing::default().has_optional_user());
assert!(!TestThing::default().has_user());
}
}

View file

@ -1,56 +1,34 @@
use argon2::{
password_hash::{PasswordHash, PasswordVerifier},
Argon2,
};
use askama::Template;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Redirect, Response},
response::{IntoResponse, Redirect},
Form,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::{AuthContext, LoginPage, LogoutPage, LogoutSuccessPage, User};
//-************************************************************************
// Constants
//-************************************************************************
use crate::{
auth::{AuthError, AuthErrorKind, Credentials},
AuthSession, LoginPage, LogoutPage, LogoutSuccessPage, WatchError, Wender,
};
//-************************************************************************
// Login error and success types
//-************************************************************************
#[Error]
pub struct LoginError(#[from] LoginErrorKind);
#[Error]
#[non_exhaustive]
pub enum LoginErrorKind {
Internal,
BadPassword,
Unknown,
}
impl IntoResponse for LoginError {
fn into_response(self) -> Response {
match self.0 {
LoginErrorKind::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"An unknown error occurred; you cursed, brah?",
)
.into_response(),
LoginErrorKind::Unknown => (StatusCode::OK, "Not successful.").into_response(),
_ => (StatusCode::OK, format!("{self}")).into_response(),
}
}
}
// for receiving form submissions
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct LoginPostForm {
pub username: String,
pub password: String,
pub destination: Option<String>,
}
impl From<LoginPostForm> for Credentials {
fn from(form: LoginPostForm) -> Self {
Self {
username: form.username,
password: form.password,
}
}
}
//-************************************************************************
@ -60,139 +38,194 @@ pub struct LoginPostForm {
/// Handle login queries
#[axum::debug_handler]
pub async fn post_login(
mut auth: AuthContext,
State(pool): State<SqlitePool>,
Form(login): Form<LoginPostForm>,
) -> Result<impl IntoResponse, LoginError> {
let username = &login.username;
let username = username.trim();
let pw = &login.password;
let pw = pw.trim();
let user = User::try_get(username, &pool).await.map_err(|e| {
tracing::debug!("{e}");
LoginErrorKind::Unknown
})?;
let verifier = Argon2::default();
let hash = PasswordHash::new(&user.pwhash).map_err(|_| LoginErrorKind::Internal)?;
match verifier.verify_password(pw.as_bytes(), &hash) {
Ok(_) => {
// log them in and set a session cookie
auth.login(&user)
.await
.map_err(|_| LoginErrorKind::Internal)?;
Ok(Redirect::to("/"))
mut auth: AuthSession,
Form(mut login_form): Form<LoginPostForm>,
) -> Result<impl IntoResponse, WatchError> {
let dest = login_form.destination.take();
let user = match auth.authenticate(login_form.clone().into()).await {
Ok(Some(user)) => user,
Ok(None) => return Ok(LoginPage::default().render().wender()),
Err(_) => {
let err: AuthError = AuthErrorKind::Internal.into();
return Err(err.into());
}
_ => Err(LoginErrorKind::BadPassword.into()),
};
if auth.login(&user).await.is_err() {
let err: AuthError = AuthErrorKind::Internal.into();
return Err(err.into());
}
if let Some(ref next) = dest {
Ok(Redirect::to(next).into_response())
} else {
Ok(Redirect::to("/").into_response())
}
}
pub async fn get_login() -> impl IntoResponse {
LoginPage::default()
LoginPage::default().render().wender()
}
pub async fn get_logout() -> impl IntoResponse {
LogoutPage
LogoutPage.render().wender()
}
pub async fn post_logout(mut auth: AuthContext) -> impl IntoResponse {
if auth.current_user.is_some() {
auth.logout().await;
pub async fn post_logout(mut auth: AuthSession) -> impl IntoResponse {
match auth.logout().await {
Ok(_) => LogoutSuccessPage.render().wender(),
Err(e) => {
tracing::debug!("{e}");
let e: AuthError = AuthErrorKind::Internal.into();
e.into_response()
}
}
LogoutSuccessPage
}
//-************************************************************************
// tests
//-************************************************************************
#[cfg(test)]
mod test {
use crate::{
get_db_pool,
templates::{LoginPage, LogoutPage, LogoutSuccessPage, MainPage},
test_utils::{get_test_user, massage, server, FORM_CONTENT_TYPE},
test_utils::{massage, server_with_pool, FORM_CONTENT_TYPE},
User,
};
const LOGIN_FORM: &str = "username=test_user&password=a";
#[tokio::test]
async fn get_login() {
let s = server().await;
let resp = s.get("/login").await;
let body = std::str::from_utf8(resp.bytes()).unwrap().to_string();
assert_eq!(body, LoginPage::default().to_string());
#[test]
fn get_login() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s.get("/login").await;
let body = std::str::from_utf8(resp.as_bytes()).unwrap().to_string();
assert_eq!(body, LoginPage::default().to_string());
})
}
#[tokio::test]
async fn post_login_success() {
let s = server().await;
#[test]
fn post_login_success() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let body = massage(LOGIN_FORM);
let resp = s
.post("/login")
.expect_failure()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 303);
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s
.post("/login")
.expect_failure()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 303);
})
}
#[tokio::test]
async fn post_login_bad_user() {
let s = server().await;
#[test]
fn post_login_bad_user() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let form = "username=test_LOSER&password=aaaa";
let body = massage(form);
let resp = s
.post("/login")
.expect_success()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 200);
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s
.post("/login")
.expect_success()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 200);
})
}
#[tokio::test]
async fn post_login_bad_password() {
let s = server().await;
#[test]
fn post_login_bad_password() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let form = "username=test_user&password=bbbb";
let body = massage(form);
let resp = s
.post("/login")
.expect_success()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 200);
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s
.post("/login")
.expect_success()
.content_type(FORM_CONTENT_TYPE)
.bytes(body)
.await;
assert_eq!(resp.status_code(), 200);
})
}
#[tokio::test]
async fn get_logout() {
let s = server().await;
let resp = s.get("/logout").await;
let body = std::str::from_utf8(resp.bytes()).unwrap().to_string();
assert_eq!(body, LogoutPage.to_string());
#[test]
fn get_logout() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s.get("/logout").await;
let body = std::str::from_utf8(resp.as_bytes()).unwrap().to_string();
assert_eq!(body, LogoutPage.to_string());
})
}
#[tokio::test]
async fn post_logout_not_logged_in() {
let s = server().await;
let resp = s.post("/logout").await;
resp.assert_status_ok();
let body = std::str::from_utf8(resp.bytes()).unwrap();
let default = LogoutSuccessPage.to_string();
assert_eq!(body, &default);
#[test]
fn post_logout_not_logged_in() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let resp = s.post("/logout").await;
resp.assert_status_ok();
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let default = LogoutSuccessPage.to_string();
assert_eq!(body, &default);
})
}
#[tokio::test]
async fn post_logout_logged_in() {
let s = server().await;
#[test]
fn post_logout_logged_in() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
// log in and prove it
{
let db = get_db_pool();
rt.block_on(async {
let s = server_with_pool(&db).await;
let body = massage(LOGIN_FORM);
let resp = s
.post("/login")
@ -202,19 +235,18 @@ mod test {
.await;
assert_eq!(resp.status_code(), 303);
let logged_in = MainPage {
user: Some(get_test_user()),
}
.to_string();
let user = User::try_get("test_user", &db).await.unwrap();
let logged_in = MainPage { user }.to_string();
let main_page = s.get("/").await;
let body = std::str::from_utf8(main_page.bytes()).unwrap();
let body = std::str::from_utf8(main_page.as_bytes()).unwrap();
assert_eq!(&logged_in, body);
}
let resp = s.post("/logout").await;
let body = std::str::from_utf8(resp.bytes()).unwrap();
let default = LogoutSuccessPage.to_string();
assert_eq!(body, &default);
let resp = s.post("/logout").await;
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let default = LogoutSuccessPage.to_string();
assert_eq!(body, &default);
})
}
}

View file

@ -1,44 +1,47 @@
use std::net::SocketAddr;
use rand::{thread_rng, RngCore};
use tokio::signal;
use clap::Parser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use what2watch::get_db_pool;
#[tokio::main]
async fn main() {
#[derive(Debug, Parser)]
struct Cli {}
fn main() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "what2watch=debug,axum::routing=info".into()),
.unwrap_or_else(|_| "what2watch=debug,axum=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let pool = get_db_pool().await;
//tracing_subscriber::fmt().with_target(false).pretty().init();
let secret = {
let mut bytes = [0u8; 64];
let mut rng = thread_rng();
rng.fill_bytes(&mut bytes);
bytes
};
let pool = get_db_pool();
let app = what2watch::app(pool.clone(), &secret).await;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
tracing::debug!("binding to {addr:?}");
let app = rt.block_on(what2watch::app(pool.clone()));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap_or_default();
rt.block_on(async {
let addr: SocketAddr = ([127, 0, 0, 1], 3000).into();
tracing::debug!("binding to {addr:?}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(graceful_shutdown())
.await
.unwrap()
});
pool.close().await;
rt.block_on(pool.close());
}
async fn shutdown_signal() {
async fn graceful_shutdown() {
use tokio::signal;
let ctrl_c = async {
signal::ctrl_c()
.await
@ -53,10 +56,11 @@ async fn shutdown_signal() {
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = ctrl_c => {tracing::info!("shutting down")},
_ = terminate => {},
}
println!("signal received, starting graceful shutdown");
}

View file

@ -2,7 +2,7 @@ use std::{error::Error, ops::Range};
use unicode_segmentation::UnicodeSegmentation;
pub fn validate_optional_length<E: Error>(
pub(crate) fn validate_optional_length<E: Error>(
opt: &Option<String>,
len_range: Range<usize>,
err: E,
@ -21,7 +21,7 @@ pub fn validate_optional_length<E: Error>(
}
/// Serde deserialization decorator to map empty Strings to None
pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
pub(crate) fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
@ -35,17 +35,3 @@ where
.map(Some),
}
}
/// Convert a stringy number like "1999" to a 64-bit signed unix epoch-based
/// timestamp
pub fn year_to_epoch(year: Option<&str>) -> Option<i64> {
year?
.trim()
.parse::<i32>()
.map(|year| {
let years = (year - 1970) as f32;
let days = (years * 365.2425) as i64;
days * 24 * 60 * 60
})
.ok()
}

90
src/search.rs Normal file
View file

@ -0,0 +1,90 @@
use askama::Template;
use axum::{
extract::{Query, State},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::{
misc_util::empty_string_as_none, AuthSession, OptionalOptionalUser, Star, User, Watch, Wender,
};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "search_watches_page.html")]
pub struct SearchPage {
pub results: Vec<Watch>,
pub user: Option<User>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub enum SearchResult {
Star(Star),
Watch(Watch),
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
pub struct SearchWatchQuery {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub title: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
pub struct SearchStarQuery {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub name: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<String>,
}
pub async fn get_search_watch(
auth: AuthSession,
State(pool): State<SqlitePool>,
Query(search): Query<SearchWatchQuery>,
) -> impl IntoResponse {
let user = auth.user;
let SearchWatchQuery { title, year } = &search;
let watches: Vec<Watch> = match (title, year) {
(Some(title), None) => sqlx::query_as(
"select * from watches where id in (select id from watch_search where title match ? order by rank)").bind(
title.trim()).fetch_all(&pool).await.unwrap_or_default(),
(Some(title), Some(year)) => sqlx::query_as("select * from watches where id in (select id from watch_search where title match ? order by rank) and release_date = ?").bind(title.trim()).bind(year.trim()).fetch_all(&pool).await.unwrap_or_default(),
(None, Some(year)) => sqlx::query_as("select * from watches where release_date = ? order by title").bind(year.trim()).fetch_all(&pool).await.unwrap_or_default(),
_ => sqlx::query_as("select * from (select * from watches order by random() limit 50) order by release_date asc").fetch_all(&pool).await.unwrap_or_default()
};
SearchPage {
results: watches,
user,
}
.render()
.wender()
}
pub async fn get_search_star(
auth: AuthSession,
State(pool): State<SqlitePool>,
Query(search): Query<SearchStarQuery>,
) -> impl IntoResponse {
let user = auth.user;
let SearchStarQuery { name, year } = &search;
let watches: Vec<Watch> = match (name, year) {
(Some(title), None) => sqlx::query_as(
"select * from watches where id in (select id from watch_search where title match ? order by rank)").bind(
title.trim()).fetch_all(&pool).await.unwrap_or_default(),
(Some(title), Some(year)) => sqlx::query_as("select * from watches where id in (select id from watch_search where title match ? order by rank) and release_date = ?").bind(title.trim()).bind(year.trim()).fetch_all(&pool).await.unwrap_or_default(),
(None, Some(year)) => sqlx::query_as("select * from watches where release_date = ? order by title").bind(year.trim()).fetch_all(&pool).await.unwrap_or_default(),
_ => sqlx::query_as("select * from (select * from watches order by random() limit 50) order by release_date asc").fetch_all(&pool).await.unwrap_or_default()
};
SearchPage {
results: watches,
user,
}
.render()
.wender()
}

View file

@ -1,500 +0,0 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use axum::{
extract::{Form, Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use sqlx::{query_as, SqlitePool};
use unicode_segmentation::UnicodeSegmentation;
use crate::{util::empty_string_as_none, DbId, SignupPage, SignupSuccessPage, User};
pub(crate) const CREATE_QUERY: &str =
"insert into users (id, username, displayname, email, pwhash) values ($1, $2, $3, $4, $5)";
const ID_QUERY: &str = "select * from users where id = $1";
//-************************************************************************
// Error types for user creation
//-************************************************************************
#[Error(desc = "Could not create user.")]
#[non_exhaustive]
pub struct CreateUserError(#[from] CreateUserErrorKind);
impl IntoResponse for CreateUserError {
fn into_response(self) -> Response {
match self.0 {
CreateUserErrorKind::UnknownDBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
_ => (StatusCode::OK, format!("{self}")).into_response(),
}
}
}
#[Error]
#[non_exhaustive]
pub enum CreateUserErrorKind {
AlreadyExists,
#[error(desc = "Usernames must be between 1 and 20 non-whitespace characters long")]
BadUsername,
PasswordMismatch,
#[error(desc = "Password must have at least 4 and at most 50 characters")]
BadPassword,
#[error(desc = "Display name must be less than 100 characters long")]
BadDisplayname,
BadEmail,
UnknownDBError,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
pub struct SignupForm {
pub username: String,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub displayname: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub email: Option<String>,
pub password: String,
pub pw_verify: String,
}
//-************************************************************************
// User creation route handlers
//-************************************************************************
/// Get Handler: displays the form to create a user
pub async fn get_create_user() -> SignupPage {
SignupPage::default()
}
/// Post Handler: validates form values and calls the actual, private user
/// creation function
#[axum::debug_handler]
pub async fn post_create_user(
State(pool): State<SqlitePool>,
Form(signup): Form<SignupForm>,
) -> Result<impl IntoResponse, CreateUserError> {
use crate::util::validate_optional_length;
let username = signup.username.trim();
let password = signup.password.trim();
let verify = signup.pw_verify.trim();
let name_len = username.graphemes(true).size_hint().1.unwrap();
// we are not ascii exclusivists around here
if !(1..=20).contains(&name_len) {
return Err(CreateUserErrorKind::BadUsername.into());
}
if password != verify {
return Err(CreateUserErrorKind::PasswordMismatch.into());
}
let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0);
let password = password.as_bytes();
if !(4..=50).contains(&pwlen) {
return Err(CreateUserErrorKind::BadPassword.into());
}
// clean up the optionals
let displayname = validate_optional_length(
&signup.displayname,
0..100,
CreateUserErrorKind::BadDisplayname,
)?;
let email = validate_optional_length(&signup.email, 5..30, CreateUserErrorKind::BadEmail)?;
let id = DbId::new();
let user = create_user(username, &displayname, &email, password, &pool, id).await?;
let now = user.id.created_at();
tracing::debug!("created {user:?} at {now:?}");
let id = user.id.as_string();
let location = format!("/signup_success/{id}");
let resp = axum::response::Redirect::to(&location);
Ok(resp)
}
/// Generic handler for successful signup
pub async fn get_signup_success(
Path(id): Path<String>,
State(pool): State<SqlitePool>,
) -> Response {
let id = id.trim();
let id = DbId::from_string(id).unwrap_or_default();
let user: User = {
query_as(ID_QUERY)
.bind(id)
.fetch_one(&pool)
.await
.unwrap_or_default()
};
let mut resp = SignupSuccessPage(user.clone()).into_response();
if user.username.is_empty() || id.is_nil() {
// redirect to front page if we got here without a valid user ID
*resp.status_mut() = StatusCode::SEE_OTHER;
resp.headers_mut().insert("Location", "/".parse().unwrap());
}
resp
}
//-************************************************************************
// private fns
//-************************************************************************
pub(crate) async fn create_user(
username: &str,
displayname: &Option<String>,
email: &Option<String>,
password: &[u8],
pool: &SqlitePool,
id: DbId,
) -> Result<User, CreateUserError> {
// Argon2 with default params (Argon2id v19)
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let pwhash = argon2
.hash_password(password, &salt)
.unwrap() // safe to unwrap, we know the salt is valid
.to_string();
let query = sqlx::query(CREATE_QUERY)
.bind(id)
.bind(username)
.bind(displayname)
.bind(email)
.bind(&pwhash);
let res = query.execute(pool).await;
match res {
Ok(_) => {
let user = User {
id,
username: username.to_string(),
displayname: displayname.to_owned(),
email: email.to_owned(),
last_seen: None,
pwhash,
};
Ok(user)
}
Err(sqlx::Error::Database(db)) => {
if let Some(exit) = db.code() {
let exit = exit.parse().unwrap_or(0u32);
// https://www.sqlite.org/rescode.html codes for unique constraint violations:
if exit == 2067u32 || exit == 1555 {
Err(CreateUserErrorKind::AlreadyExists.into())
} else {
Err(CreateUserErrorKind::UnknownDBError.into())
}
} else {
Err(CreateUserErrorKind::UnknownDBError.into())
}
}
_ => Err(CreateUserErrorKind::UnknownDBError.into()),
}
}
//-************************************************************************
// TESTS
//-************************************************************************
#[cfg(test)]
mod test {
use axum::http::StatusCode;
use crate::{
db::get_db_pool,
templates::{SignupPage, SignupSuccessPage},
test_utils::{get_test_user, insert_user, massage, server_with_pool, FORM_CONTENT_TYPE},
User,
};
const GOOD_FORM: &str = "username=test_user&displayname=Test+User&password=aaaa&pw_verify=aaaa";
#[tokio::test]
async fn post_create_user() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(GOOD_FORM);
let resp = server
.post("/signup")
.expect_failure() // 303 is "failure"
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
assert_eq!(StatusCode::SEE_OTHER, resp.status_code());
// get the new user from the db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_ok());
}
#[tokio::test]
async fn get_create_user() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let resp = server.get("/signup").await;
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = SignupPage::default().to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn handle_signup_success() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let user = get_test_user();
insert_user(&user, &pool).await;
let id = user.id.0.to_string();
let path = format!("/signup_success/{id}");
let resp = server.get(&path).expect_success().await;
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = SignupSuccessPage(user).to_string();
assert_eq!(&expected, body);
}
//-************************************************************************
// honestly this is basically the whole suite here
//-************************************************************************
mod failure {
use super::*;
use crate::signup::{CreateUserError, CreateUserErrorKind};
// various ways to fuck up signup
const PASSWORD_MISMATCH_FORM: &str =
"username=test_user&displayname=Test+User&password=aaaa&pw_verify=bbbb";
const PASSWORD_SHORT_FORM: &str =
"username=test_user&displayname=Test+User&password=a&pw_verify=a";
const PASSWORD_LONG_FORM: &str = "username=test_user&displayname=Test+User&password=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda&pw_verify=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda";
const USERNAME_SHORT_FORM: &str =
"username=&displayname=Test+User&password=aaaa&pw_verify=aaaa";
const USERNAME_LONG_FORM: &str =
"username=test_user12345678901234567890&displayname=Test+User&password=aaaa&pw_verify=aaaa";
const DISPLAYNAME_LONG_FORM: &str = "username=test_user&displayname=Since+time+immemorial%2C+display+names+have+been+subject+to+a+number+of+conventions%2C+restrictions%2C+usages%2C+and+even+incentives.+Have+we+finally+gone+too+far%3F+In+this+essay%2C+&password=aaaa&pw_verify=aaaa";
#[tokio::test]
async fn password_mismatch() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_MISMATCH_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::PasswordMismatch).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn password_short() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_SHORT_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn password_long() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn multibyte_password_too_short() {
let pw = "🤡";
// min length is 4
assert_eq!(pw.len(), 4);
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let form =
format!("username=test_user&displayname=Test+User&password={pw}&pw_verify={pw}");
let body = massage(&form);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn username_short() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(USERNAME_SHORT_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn username_long() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(USERNAME_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn username_duplicate() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(GOOD_FORM);
let _resp = server
.post("/signup")
.expect_failure() // 303 is "failure"
.bytes(body.clone())
.content_type(FORM_CONTENT_TYPE)
.await;
// get the new user from the db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_ok());
// now try again
let resp = server
.post("/signup")
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
assert_eq!(resp.status_code(), StatusCode::OK);
let expected = CreateUserError(CreateUserErrorKind::AlreadyExists).to_string();
let body = std::str::from_utf8(resp.bytes()).unwrap();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn displayname_long() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let body = massage(DISPLAYNAME_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("test_user", &pool).await;
assert!(user.is_err());
let body = std::str::from_utf8(resp.bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadDisplayname).to_string();
assert_eq!(&expected, body);
}
#[tokio::test]
async fn handle_signup_success() {
let pool = get_db_pool().await;
let server = server_with_pool(&pool).await;
let path = format!("/signup_success/nope");
let resp = server.get(&path).expect_failure().await;
assert_eq!(resp.status_code(), StatusCode::SEE_OTHER);
}
}
}

702
src/signup/handlers.rs Normal file
View file

@ -0,0 +1,702 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use askama::Template;
use axum::{
extract::{Form, Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use julid::Julid;
use serde::Deserialize;
use sqlx::{query_as, Sqlite, SqlitePool};
use unicode_segmentation::UnicodeSegmentation;
use super::{templates::*, CreateUserError, CreateUserErrorKind, Invitation, SignupError};
use crate::{misc_util::empty_string_as_none, User, WatchError, Wender};
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
pub struct SignupForm {
pub username: String,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub displayname: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub email: Option<String>,
pub password: String,
pub pw_verify: String,
pub invitation: String,
}
//-************************************************************************
// User creation route handlers
//-************************************************************************
/// Get Handler: displays the form to create a user
#[axum::debug_handler]
pub async fn get_create_user(
State(_pool): State<SqlitePool>,
invitation: Option<Path<String>>,
) -> Result<impl IntoResponse, WatchError> {
let Path(invitation) = invitation.ok_or_else(|| {
let e: CreateUserError = CreateUserErrorKind::BadInvitation.into();
let e: SignupError = e.into();
e
})?;
let invitation = Julid::from_str(&invitation).map_err(|_| {
let e: CreateUserError = CreateUserErrorKind::BadInvitation.into();
let e: SignupError = e.into();
e
})?;
Ok(SignupPage {
invitation,
..Default::default()
}
.render()
.wender())
}
/// Post Handler: validates form values and calls the actual, private user
/// creation function
#[axum::debug_handler]
pub async fn post_create_user(
State(pool): State<SqlitePool>,
Form(signup): Form<SignupForm>,
) -> Result<impl IntoResponse, SignupError> {
use crate::misc_util::validate_optional_length;
let username = signup.username.trim();
let password = signup.password.trim();
let verify = signup.pw_verify.trim();
let name_len = username.graphemes(true).size_hint().1.unwrap();
// we are not ascii exclusivists around here
if !(1..=20).contains(&name_len) {
return Err(CreateUserErrorKind::BadUsername.into());
}
if password != verify {
return Err(CreateUserErrorKind::PasswordMismatch.into());
}
let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0);
let password = password.as_bytes();
if !(4..=50).contains(&pwlen) {
return Err(CreateUserErrorKind::BadPassword.into());
}
// clean up the optionals
let displayname = validate_optional_length(
&signup.displayname,
0..100,
CreateUserErrorKind::BadDisplayname,
)?;
let email = validate_optional_length(&signup.email, 5..30, CreateUserErrorKind::BadEmail)?;
let user = create_user(
username,
&displayname,
&email,
password,
&pool,
&signup.invitation,
)
.await?;
let when = user.id.created_at();
tracing::debug!("created {user:?} at {when}");
let resp = axum::response::Redirect::to(&format!("/signup_success/{}", user.id));
Ok(resp)
}
/// Generic handler for successful signup
pub async fn get_signup_success(
Path(id): Path<String>,
State(pool): State<SqlitePool>,
) -> Response {
const ID_QUERY: &str = "select * from users where id = ?";
let id = id.trim();
let id = Julid::from_str(id).unwrap_or_default();
let user: User = {
query_as(ID_QUERY)
.bind(id)
.fetch_one(&pool)
.await
.unwrap_or_default()
};
let mut resp = SignupSuccessPage(user.clone()).render().wender();
if user.username.is_empty() || id.is_alpha() {
// redirect to front page if we got here without a valid user ID
*resp.status_mut() = StatusCode::SEE_OTHER;
resp.headers_mut().insert("Location", "/".parse().unwrap());
}
resp
}
//-************************************************************************
// private fns
//-************************************************************************
pub(crate) async fn create_user(
username: &str,
displayname: &Option<String>,
email: &Option<String>,
password: &[u8],
pool: &SqlitePool,
invitation: &str,
) -> Result<User, SignupError> {
const CREATE_QUERY: &str =
"insert into users (username, displayname, email, pwhash, invited_by) values ($1, $2, $3, $4, $5) returning *";
// Argon2 with default params (Argon2id v19)
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let pwhash = argon2
.hash_password(password, &salt)
.unwrap() // safe to unwrap, we know the salt is valid
.to_string();
let mut tx = pool.begin().await.map_err(|e| {
tracing::debug!("db error: {e}");
CreateUserErrorKind::DBError
})?;
let invitation = Julid::from_str(invitation).map_err(|_| CreateUserErrorKind::BadInvitation)?;
let invited_by = validate_invitation(invitation, &mut tx).await?;
let user = sqlx::query_as(CREATE_QUERY)
.bind(username)
.bind(displayname)
.bind(email)
.bind(&pwhash)
.bind(invited_by)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::info!("Got error inserting new user: {e}");
match e {
sqlx::Error::Database(db) => {
let exit = db.code().unwrap_or_default().parse().unwrap_or(0);
// https://www.sqlite.org/rescode.html codes for unique constraint violations:
if exit == 2067u32 || exit == 1555 {
CreateUserErrorKind::AlreadyExists
} else {
CreateUserErrorKind::DBError
}
}
_ => CreateUserErrorKind::DBError,
}
})?;
tx.commit().await.map_err(|e| {
tracing::debug!("db error: {e}");
CreateUserErrorKind::DBError
})?;
Ok(user)
}
async fn validate_invitation(
invitation: Julid,
tx: &mut sqlx::Transaction<'_, Sqlite>,
) -> Result<Julid, CreateUserErrorKind> {
let invitation: Invitation = sqlx::query_as("select * from invites where id = ?")
.bind(invitation)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
tracing::debug!("db error: {e}");
CreateUserErrorKind::DBError
})?
.ok_or(CreateUserErrorKind::BadInvitation)?;
let remaining = invitation.remaining;
if remaining < 1 {
return Err(CreateUserErrorKind::BadInvitation);
}
if let Some(ts) = invitation.expires_at {
let now = chrono::Utc::now();
if ts < now {
return Err(CreateUserErrorKind::BadInvitation);
}
}
let _ = sqlx::query("update invites set remaining = ? where id = ?")
.bind(remaining - 1)
.bind(invitation.id)
.execute(&mut **tx)
.await
.map_err(|e| {
tracing::debug!("db error: {e}");
CreateUserErrorKind::DBError
})?;
Ok(invitation.owner)
}
//-************************************************************************
// TESTS
//-************************************************************************
#[cfg(test)]
mod test {
use axum::http::StatusCode;
use julid::Julid;
use tokio::runtime::Runtime;
use crate::{
db::get_db_pool,
signup::templates::{SignupPage, SignupSuccessPage},
test_utils::{massage, server_with_pool, FORM_CONTENT_TYPE, INVITE_ID_INT},
User,
};
#[test]
fn post_create_user() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let id: Julid = INVITE_ID_INT.into();
let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id);
let body = massage(&form);
let resp = server
.post("/signup")
.expect_failure() // 303 is "failure"
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
assert_eq!(StatusCode::SEE_OTHER, resp.status_code());
// get the new user from the db
let user = User::try_get("good_user", &pool).await;
assert!(user.is_ok());
assert!(user.unwrap().is_some());
});
}
#[test]
fn get_create_user() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let invitation: Julid = INVITE_ID_INT.into();
let path = format!("/signup/{invitation}");
let resp = server.get(&path).await;
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = SignupPage {
invitation,
..Default::default()
}
.to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn handle_signup_success() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let id: Julid = INVITE_ID_INT.into();
let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id);
let body = massage(&form);
let resp = server
.post("/signup")
.expect_failure() // 303 is "failure"
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
assert_eq!(StatusCode::SEE_OTHER, resp.status_code());
// get the new user from the db
let user = User::try_get("good_user", &pool).await.unwrap().unwrap();
let id = user.id;
let path = format!("/signup_success/{id}");
let resp = server.get(&path).expect_success().await;
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = SignupSuccessPage(user).to_string();
assert_eq!(&expected, body);
});
}
//-************************************************************************
// honestly this is basically the whole suite here
//-************************************************************************
mod failure {
use std::time::Duration;
use axum::response::IntoResponse;
use super::*;
use crate::{
signup::handlers::{CreateUserError, CreateUserErrorKind},
Invitation,
};
#[test]
fn used_up_invite() {
let lucky1 = "username=lucky1&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let lucky2 = "username=lucky2&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let unlucky = "username=unlucky&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(lucky1);
let _ = server
.post("/signup")
// 303 is "failure", but that's a successful signup
.expect_failure()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
let user = User::try_get("lucky1", &pool).await;
assert!(user.is_ok() && user.unwrap().is_some());
let body = massage(lucky2);
let _ = server
.post("/signup")
.expect_failure()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
let user = User::try_get("lucky2", &pool).await;
assert!(user.is_ok() && user.unwrap().is_some());
let body = massage(unlucky);
let resp = server
.post("/signup")
// failure to sign up is not a failed request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
let user = User::try_get("unlucky", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = resp.as_bytes();
let expected: CreateUserError = CreateUserErrorKind::BadInvitation.into();
let expected = expected.into_response().into_body();
let expected = axum::body::to_bytes(expected, usize::MAX).await.unwrap();
assert_eq!(&expected, body);
});
}
#[test]
fn expired_invite() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
// this function adds a user with the omega id, so the invite can be added
let server = server_with_pool(&pool).await;
let invite = Invitation::new(Julid::omega())
.with_expires_in(Duration::from_millis(1))
.commit(&pool)
.await
.unwrap();
std::thread::sleep(Duration::from_millis(500));
let username = "too slow";
let tooslow =
format!("username={username}&password=aaaa&pw_verify=aaaa&invitation={invite}");
let body = massage(&tooslow);
let resp = server
.post("/signup")
// failure to sign up is not a failed request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
let user = User::try_get(username, &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = String::from_utf8(resp.as_bytes().to_vec()).unwrap();
let expected: CreateUserError = CreateUserErrorKind::BadInvitation.into();
let expected = expected.into_response().into_body();
let bytes = axum::body::to_bytes(expected, usize::MAX).await.unwrap();
let expected = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(&expected, &body);
});
}
#[test]
fn password_mismatch() {
const PASSWORD_MISMATCH_FORM: &str =
"username=bad_user&displayname=Bad+User&password=aaaa&pw_verify=bbbb&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_MISMATCH_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::PasswordMismatch).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn password_short() {
const PASSWORD_SHORT_FORM: &str =
"username=bad_user&displayname=Bad+User&password=a&pw_verify=a&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_SHORT_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn password_long() {
const PASSWORD_LONG_FORM: &str = "username=bad_user&displayname=Bad+User&password=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda&pw_verify=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(PASSWORD_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn multibyte_password_too_short() {
let pw = "🤡";
// min length is 4 distinct graphemes; this is one grapheme that is four bytes,
// so it's not valid
assert_eq!(pw.len(), 4);
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
let invitation: Julid = INVITE_ID_INT.into();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let form =
format!("username=bad_user&displayname=Test+User&password={pw}&pw_verify={pw}&invitation={invitation}");
let body = massage(&form);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn username_short() {
const USERNAME_SHORT_FORM: &str =
"username=&displayname=Bad+User&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(USERNAME_SHORT_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn username_long() {
const USERNAME_LONG_FORM: &str =
"username=bad_user12345678901234567890&displayname=Bad+User&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(USERNAME_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user12345678901234567890", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn username_duplicate() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let id: Julid = INVITE_ID_INT.into();
let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id);
let body = massage(&form);
//let body = massage(GOOD_FORM);
let _resp = server
.post("/signup")
.expect_failure() // 303 is "failure"
.bytes(body.clone())
.content_type(FORM_CONTENT_TYPE)
.await;
// get the new user from the db
let user = User::try_get("good_user", &pool).await;
assert!(user.unwrap().is_some());
// now try again
let resp = server
.post("/signup")
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
assert_eq!(resp.status_code(), StatusCode::OK);
let expected = CreateUserError(CreateUserErrorKind::AlreadyExists).to_string();
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
assert_eq!(&expected, body);
});
}
#[test]
fn displayname_long() {
const DISPLAYNAME_LONG_FORM: &str = "username=bad_user&displayname=Since+time+immemorial%2C+display+names+have+been+subject+to+a+number+of+conventions%2C+restrictions%2C+usages%2C+and+even+incentives.+Have+we+finally+gone+too+far%3F+In+this+essay%2C+&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A";
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let body = massage(DISPLAYNAME_LONG_FORM);
let resp = server
.post("/signup")
// failure to sign up is not failure to submit the request
.expect_success()
.bytes(body)
.content_type(FORM_CONTENT_TYPE)
.await;
// no user in db
let user = User::try_get("bad_user", &pool).await;
assert!(user.is_ok() && user.unwrap().is_none());
let body = std::str::from_utf8(resp.as_bytes()).unwrap();
let expected = CreateUserError(CreateUserErrorKind::BadDisplayname).to_string();
assert_eq!(&expected, body);
});
}
#[test]
fn handle_signup_success() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let server = server_with_pool(&pool).await;
let path = "/signup_success/nope";
let resp = server.get(path).expect_failure().await;
assert_eq!(resp.status_code(), StatusCode::SEE_OTHER);
});
}
}
}

237
src/signup/mod.rs Normal file
View file

@ -0,0 +1,237 @@
use std::time::Duration;
use askama::Template;
use axum::response::{IntoResponse, Response};
use http::StatusCode;
use julid::Julid;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::{WatchDate, Wender};
pub mod handlers;
pub mod templates;
use templates::SignupErrorPage;
//-************************************************************************
// Error types for user creation
//-************************************************************************
#[Error]
pub enum SignupError {
Invite(CreateInviteError),
User(CreateUserError),
}
#[Error(desc = "Could not create invitation.")]
#[non_exhaustive]
#[derive(PartialEq, Eq)]
pub struct CreateInviteError(#[from] CreateInviteErrorKind);
#[Error]
#[non_exhaustive]
#[derive(PartialEq, Eq)]
pub enum CreateInviteErrorKind {
DBError,
NoOwner,
Unknown,
}
#[Error(desc = "Could not create user.")]
#[non_exhaustive]
pub struct CreateUserError(#[from] CreateUserErrorKind);
#[Error]
#[non_exhaustive]
pub enum CreateUserErrorKind {
BadInvitation,
AlreadyExists,
#[error(desc = "Usernames must be between 1 and 20 characters long")]
BadUsername,
PasswordMismatch,
#[error(desc = "Password must have at least 4 and at most 50 characters")]
BadPassword,
#[error(desc = "Display name must be less than 100 characters long")]
BadDisplayname,
BadEmail,
DBError,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invitation {
id: Julid,
owner: Julid,
expires_at: Option<WatchDate>,
remaining: i16,
}
impl Default for Invitation {
fn default() -> Self {
Self {
id: 0.into(),
owner: 0.into(),
expires_at: None,
remaining: 1,
}
}
}
impl Invitation {
pub async fn commit(&self, db: &SqlitePool) -> Result<Julid, CreateInviteError> {
let mut tx = db.begin().await.unwrap();
let id = sqlx::query_scalar(
"insert into invites (owner, expires_at, remaining) values (?, ?, ?) returning id",
)
.bind(self.owner)
.bind(self.expires_at)
.bind(self.remaining)
.fetch_optional(&mut *tx)
.await
.map_err(|e| {
tracing::debug!("Got error creating invite: {e}");
if let sqlx::Error::Database(e) = e {
let exit = e.code().unwrap_or_default().parse().unwrap_or(0);
// https://www.sqlite.org/rescode.html#constraint_foreignkey
if exit == 787u32 {
CreateInviteErrorKind::NoOwner
} else {
CreateInviteErrorKind::DBError
}
} else {
CreateInviteErrorKind::Unknown
}
})?
.ok_or(CreateInviteErrorKind::Unknown.into());
tx.commit().await.unwrap();
id
}
pub fn new(owner: Julid) -> Self {
Self {
owner,
..Default::default()
}
}
pub fn with_uses(&self, uses: u8) -> Self {
Self {
remaining: uses as i16,
..*self
}
}
pub fn with_expires_in(&self, expires_in: Duration) -> Self {
let now = chrono::Utc::now();
Self {
expires_at: Some(now + expires_in),
..*self
}
}
}
//-************************************************************************
// internal impls
//-************************************************************************
impl From<CreateUserError> for SignupError {
fn from(value: CreateUserError) -> Self {
SignupError::User(value)
}
}
impl From<CreateUserErrorKind> for SignupError {
fn from(value: CreateUserErrorKind) -> Self {
let e: CreateUserError = value.into();
e.into()
}
}
impl From<CreateInviteError> for SignupError {
fn from(value: CreateInviteError) -> Self {
SignupError::Invite(value)
}
}
impl From<CreateInviteErrorKind> for SignupError {
fn from(value: CreateInviteErrorKind) -> Self {
let e: CreateInviteError = value.into();
e.into()
}
}
impl IntoResponse for SignupError {
fn into_response(self) -> Response {
match self {
Self::Invite(ie) => ie.into_response(),
Self::User(ue) => ue.into_response(),
}
}
}
impl IntoResponse for CreateInviteError {
fn into_response(self) -> Response {
match self.0 {
CreateInviteErrorKind::DBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
CreateInviteErrorKind::NoOwner => (
StatusCode::OK,
SignupErrorPage("Sorry, invitations require an owner".to_string())
.render()
.wender(),
)
.into_response(),
_ => (StatusCode::OK, format!("{self}")).into_response(),
}
}
}
impl IntoResponse for CreateUserError {
fn into_response(self) -> Response {
match self.0 {
CreateUserErrorKind::DBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
CreateUserErrorKind::BadInvitation => (
StatusCode::OK,
SignupErrorPage("Sorry, that invitation isn't valid.".to_string())
.render()
.wender(),
)
.into_response(),
_ => (StatusCode::OK, format!("{self}")).into_response(),
}
}
}
#[cfg(test)]
mod test {
use tokio::runtime::Runtime;
use super::*;
use crate::{get_db_pool, User};
#[test]
fn can_create() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
User::omega().try_insert(&pool).await.unwrap();
let invite = Invitation::new(Julid::omega());
invite.commit(&pool).await.unwrap();
});
}
#[test]
fn bad_owner() {
let pool = get_db_pool();
let rt = Runtime::new().unwrap();
rt.block_on(async {
User::omega().try_insert(&pool).await.unwrap();
let invite = Invitation::new(Julid::alpha());
let res = invite.commit(&pool).await;
assert_eq!(res, Err(CreateInviteErrorKind::NoOwner.into()));
});
}
}

28
src/signup/templates.rs Normal file
View file

@ -0,0 +1,28 @@
use askama::Template;
use julid::Julid;
use serde::{Deserialize, Serialize};
use crate::{OptionalOptionalUser, User};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "signup.html")]
pub struct SignupPage {
pub username: String,
pub displayname: Option<String>,
pub email: Option<String>,
pub password: String,
pub pw_verify: String,
pub invitation: Julid,
}
#[derive(
Debug, Clone, Template, Default, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser,
)]
#[template(path = "signup_success.html")]
pub struct SignupSuccessPage(pub User);
#[derive(
Debug, Clone, Template, Default, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser,
)]
#[template(path = "signup_error.html")]
pub struct SignupErrorPage(pub String);

19
src/stars.rs Normal file
View file

@ -0,0 +1,19 @@
use julid::Julid;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
pub struct Star {
pub id: Julid,
pub name: String,
pub metadata_url: Option<String>,
pub born: Option<String>,
pub died: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
pub struct Credit {
pub star: Julid,
pub watch: Julid,
pub credit: Option<String>,
}

View file

@ -3,22 +3,6 @@ use serde::{Deserialize, Serialize};
use crate::{OptionalOptionalUser, User};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "signup.html")]
pub struct SignupPage {
pub username: String,
pub displayname: Option<String>,
pub email: Option<String>,
pub password: String,
pub pw_verify: String,
}
#[derive(
Debug, Clone, Template, Default, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser,
)]
#[template(path = "signup_success.html")]
pub struct SignupSuccessPage(pub User);
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "login_page.html")]
pub struct LoginPage {

View file

@ -1,75 +1,55 @@
use axum::body::Bytes;
use axum_test::{TestServer, TestServerConfig};
use julid::Julid;
use sqlx::SqlitePool;
use crate::{DbId, User};
use crate::User;
pub const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
pub fn get_test_user() -> User {
pub const INVITE_ID_INT: u128 = 42;
pub async fn server_with_pool(pool: &SqlitePool) -> TestServer {
//User::omega().try_insert(pool).await.unwrap();
let user = get_test_user();
user.try_insert(pool).await.unwrap();
add_test_invite(pool).await;
let r: i32 = sqlx::query_scalar("select count(*) from users")
.fetch_one(pool)
.await
.unwrap_or_default();
assert!(r == 1);
let app = crate::app(pool.clone()).await;
let config = TestServerConfig {
save_cookies: true,
..Default::default()
};
TestServer::new_with_config(app, config).unwrap()
}
fn get_test_user() -> User {
User {
username: "test_user".to_string(),
// corresponding to a password of "a":
pwhash: "$argon2id$v=19$m=19456,t=2,p=1$GWsCH1w5RYaP9WWmq+xw0g$hmOEqC+MU+vnEk3bOdkoE+z01mOmmOeX08XyPyjqua8".to_string(),
id: DbId::from_string("00041061050R3GG28A1C60T3GF").unwrap(),
id: Julid::omega(),
displayname: Some("Test User".to_string()),
invited_by: Julid::omega(),
..Default::default()
}
}
pub async fn server() -> TestServer {
let pool = crate::db::get_db_pool().await;
let secret = [0u8; 64];
let user = get_test_user();
sqlx::query(crate::signup::CREATE_QUERY)
.bind(user.id)
.bind(&user.username)
.bind(&user.displayname)
.bind(&user.email)
.bind(&user.pwhash)
.execute(&pool)
.await
.unwrap();
let r = sqlx::query("select count(*) from users")
.fetch_one(&pool)
.await;
assert!(r.is_ok());
let app = crate::app(pool, &secret).await.into_make_service();
let config = TestServerConfig {
save_cookies: true,
..Default::default()
};
TestServer::new_with_config(app, config).unwrap()
}
pub async fn server_with_pool(pool: &SqlitePool) -> TestServer {
let secret = [0u8; 64];
let r = sqlx::query("select count(*) from users")
.fetch_one(pool)
.await;
assert!(r.is_ok());
let app = crate::app(pool.clone(), &secret).await.into_make_service();
let config = TestServerConfig {
save_cookies: true,
..Default::default()
};
TestServer::new_with_config(app, config).unwrap()
}
pub async fn insert_user(user: &User, pool: &SqlitePool) {
sqlx::query(crate::signup::CREATE_QUERY)
.bind(user.id)
.bind(&user.username)
.bind(&user.displayname)
.bind(&user.email)
.bind(&user.pwhash)
async fn add_test_invite(pool: &SqlitePool) {
let id: Julid = INVITE_ID_INT.into();
sqlx::query("insert into invites (id, owner, remaining) values (?, ?, ?)")
.bind(id)
.bind(Julid::omega())
.bind(2)
.execute(pool)
.await
.unwrap();

View file

@ -1,37 +1,74 @@
use std::{
fmt::{Debug, Display},
time::{SystemTime, UNIX_EPOCH},
use std::fmt::{Debug, Display};
use axum::{
extract::{Request, State},
middleware::Next,
response::IntoResponse,
};
use axum::{extract::State, http::Request, middleware::Next, response::IntoResponse};
use axum_login::{secrecy::SecretVec, AuthUser};
use julid::Julid;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
use crate::{AuthContext, DbId};
use crate::{AuthSession, WatchDate};
const USERNAME_QUERY: &str = "select * from users where username = $1";
const LAST_SEEN_QUERY: &str = "update users set last_seen = (select unixepoch()) where id = $1";
#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct User {
pub id: DbId,
pub id: Julid,
pub username: String,
pub displayname: Option<String>,
pub email: Option<String>,
pub last_seen: Option<i64>,
pub last_seen: Option<WatchDate>,
pub pwhash: String,
pub invited_by: Julid,
pub is_active: bool,
pub digest: String,
}
impl sqlx::FromRow<'_, SqliteRow> for User {
fn from_row(row: &SqliteRow) -> Result<Self, sqlx::Error> {
let pwhash = row.try_get("pwhash")?;
let digest = sha256::digest(&pwhash);
Ok(Self {
id: row.try_get("id")?,
username: row.try_get("username")?,
displayname: row.try_get("displayname")?,
email: row.try_get("email")?,
last_seen: row.try_get("last_seen")?,
invited_by: row.try_get("invited_by")?,
is_active: row.try_get("is_active")?,
pwhash,
digest,
})
}
}
impl Default for User {
fn default() -> Self {
Self {
is_active: true,
id: Default::default(),
username: Default::default(),
displayname: Default::default(),
email: Default::default(),
last_seen: Default::default(),
pwhash: Default::default(),
invited_by: Default::default(),
digest: Default::default(),
}
}
}
impl Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id.as_string())
.field("username", &self.username)
.field("id", &self.id.as_string())
.field("displayname", &self.displayname)
.field("email", &self.email)
.field("last_seen", &self.last_seen)
.field("pwhash", &"<redacted>")
.field("digest", &self.digest)
.field("invited_by", &self.invited_by.as_string())
.field("is_active", &self.is_active)
.finish()
}
}
@ -39,67 +76,79 @@ impl Debug for User {
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let uname = &self.username;
let dname = if let Some(ref n) = self.displayname {
n
} else {
""
};
let email = if let Some(ref e) = self.email { e } else { "" };
let dname = self.displayname.as_deref().unwrap_or("");
let email = self.email.as_deref().unwrap_or("");
write!(f, "Username: {uname}\nDisplayname: {dname}\nEmail: {email}")
}
}
impl AuthUser<DbId> for User {
fn get_id(&self) -> DbId {
self.id
}
fn get_password_hash(&self) -> SecretVec<u8> {
SecretVec::new(self.pwhash.as_bytes().to_vec())
}
}
impl User {
pub async fn try_get(username: &str, db: &SqlitePool) -> Result<User, impl std::error::Error> {
sqlx::query_as(USERNAME_QUERY)
pub async fn try_get(username: &str, db: &SqlitePool) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as("select * from users where username = ?")
.bind(username)
.fetch_one(db)
.fetch_optional(db)
.await
}
/// This is mostly for tests and to ensure that the system accounts are
/// present. Most of the time, users should not be inserted with an ID, but
/// should let the DB assign them an ID.
pub async fn try_insert(&self, db: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query!("insert into users (id, username, displayname, email, pwhash, invited_by) values (?, ?, ?, ?, ?, ?)",
self.id,
self.username,
self.displayname,
self.email,
self.pwhash,
self.invited_by)
.execute(db)
.await
.map(|_| ())
}
pub async fn update_last_seen(&self, pool: &SqlitePool) {
match sqlx::query(LAST_SEEN_QUERY)
.bind(self.id)
.execute(pool)
.await
match sqlx::query!(
"update users set last_seen = CURRENT_TIMESTAMP where id = ?",
self.id
)
.execute(pool)
.await
{
Ok(_) => {}
Err(e) => {
let id = self.id.0.to_string();
let id = self.id.to_string();
tracing::error!("Could not update last_seen for user {id}; got {e:?}");
}
}
}
pub fn omega() -> Self {
User {
id: Julid::omega(),
username: "omega".into(),
displayname: Some("The One Who Is".into()),
invited_by: Julid::omega(),
..Default::default()
}
}
}
//-************************************************************************
// User-specific middleware
//-************************************************************************
pub async fn handle_update_last_seen<BodyT>(
pub async fn handle_update_last_seen(
State(pool): State<SqlitePool>,
auth: AuthContext,
request: Request<BodyT>,
next: Next<BodyT>,
auth: AuthSession,
request: Request,
next: Next,
) -> impl IntoResponse {
if let Some(user) = auth.current_user {
if let Some(then) = user.last_seen {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if let Some(user) = auth.user {
if let Some(then) = &user.last_seen {
let now = chrono::Utc::now();
// The Nyquist frequency for 1-day tracking resolution is 12 hours.
if now - then > 12 * 3600 {
let dur = chrono::Duration::hours(12);
if (now - then) > dur {
user.update_last_seen(&pool).await;
}
} else {

View file

@ -1,83 +1,41 @@
use askama::Template;
use axum::{
extract::{Form, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect, Response},
extract::{Form, Path, State},
response::{IntoResponse, Redirect},
};
use http::HeaderValue;
use julid::Julid;
use serde::Deserialize;
use sqlx::{query, query_as, SqlitePool};
use sqlx::{query, query_as, query_scalar, SqlitePool};
use super::templates::{AddNewWatchPage, GetWatchPage, SearchWatchesPage};
use super::{
templates::{AddNewWatchPage, GetWatchPage, WatchStatusMenus},
AddError, AddErrorKind, EditError, EditErrorKind, WatchesError,
};
use crate::{
db_id::DbId,
util::{empty_string_as_none, year_to_epoch},
AuthContext, MyWatchesPage, ShowKind, Watch, WatchQuest,
misc_util::empty_string_as_none, AuthSession, MyWatchesPage, ShowKind, Watch, WatchQuest,
Wender,
};
//-************************************************************************
// Constants
//-************************************************************************
const GET_SAVED_WATCHES_QUERY: &str =
"select * from watches left join watch_quests on $1 = watch_quests.user and watches.id = watch_quests.watch";
const GET_SAVED_WATCHES_QUERY: &str = "select * from watches inner join watch_quests quests on quests.user = $1 and quests.watch = watches.id";
const GET_QUEST_QUERY: &str = "select * from watch_quests where user = ? and watch = ?";
const GET_WATCH_QUERY: &str = "select * from watches where id = $1";
const ADD_WATCH_QUERY: &str = "insert into watches (id, title, kind, release_date, metadata_url, added_by) values ($1, $2, $3, $4, $5, $6)";
const ADD_WATCH_QUERY: &str = "insert into watches (title, kind, release_date, metadata_url, added_by, length) values ($1, $2, $3, $4, $5, $6) returning id";
const ADD_WATCH_QUEST_QUERY: &str =
"insert into watch_quests (user, watch, public, watched) values ($1, $2, $3, $4)";
const EMPTY_SEARCH_QUERY_STRUCT: SearchQuery = SearchQuery {
title: None,
kind: None,
year: None,
search: None,
};
//-************************************************************************
// Error types for Watch creation
//-************************************************************************
#[Error]
pub struct WatchAddError(#[from] WatchAddErrorKind);
#[Error]
#[non_exhaustive]
pub enum WatchAddErrorKind {
UnknownDBError,
NotSignedIn,
}
impl IntoResponse for WatchAddError {
fn into_response(self) -> Response {
match &self.0 {
WatchAddErrorKind::UnknownDBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
WatchAddErrorKind::NotSignedIn => (
StatusCode::OK,
"Ope, you need to sign in first!".to_string(),
)
.into_response(),
}
}
}
const CHECKMARK: &str = "&#10003;";
//-************************************************************************
// Types for receiving arguments from forms
//-************************************************************************
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
pub struct SearchQuery {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub search: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub title: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub kind: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<i64>,
}
// kinda the main form?
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
pub struct PostAddNewWatch {
@ -95,127 +53,220 @@ pub struct PostAddNewWatch {
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
pub struct PostAddExistingWatch {
pub id: String,
pub watch: String,
pub public: bool,
pub watched_already: bool,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
pub struct PostEditQuest {
pub watch: String,
pub act: String,
}
//-************************************************************************
// handlers
//-************************************************************************
pub async fn get_add_new_watch(auth: AuthContext) -> impl IntoResponse {
AddNewWatchPage {
user: auth.current_user,
}
pub async fn get_add_new_watch(auth: AuthSession) -> impl IntoResponse {
AddNewWatchPage { user: auth.user }.render().wender()
}
/// Add a Watch to your watchlist (side effects system-add)
pub async fn post_add_new_watch(
auth: AuthContext,
auth: AuthSession,
State(pool): State<SqlitePool>,
Form(form): Form<PostAddNewWatch>,
) -> Result<impl IntoResponse, WatchAddError> {
if let Some(user) = auth.current_user {
) -> Result<impl IntoResponse, WatchesError> {
if let Some(user) = auth.user {
{
let watch_id = DbId::new();
let release_date = year_to_epoch(form.year.as_deref());
let watch = Watch {
id: watch_id,
title: form.title,
kind: form.kind,
metadata_url: form.metadata_url,
length: None,
release_date,
release_date: form.year,
added_by: user.id,
};
let quest = WatchQuest {
user: user.id,
watch: watch_id,
is_public: !form.private,
already_watched: form.watched_already,
..Default::default()
};
add_new_watch_impl(&pool, &watch, Some(quest)).await?;
let watch_id = add_new_watch_impl(&pool, &watch).await?;
let quest = WatchQuest {
user: user.id,
public: !form.private,
watched: false,
watch: watch_id,
};
add_watch_quest_impl(&pool, &quest).await?;
let location = format!("/watch/{watch_id}");
Ok(Redirect::to(&location))
}
} else {
Err(WatchAddErrorKind::NotSignedIn.into())
let e: AddError = AddErrorKind::NotSignedIn.into();
Err(e.into())
}
}
pub(crate) async fn add_new_watch_impl(
db_pool: &SqlitePool,
watch: &Watch,
quest: Option<WatchQuest>,
) -> Result<(), WatchAddError> {
let mut tx = db_pool
.begin()
.await
.map_err(|_| WatchAddErrorKind::UnknownDBError)?;
query(ADD_WATCH_QUERY)
.bind(watch.id)
async fn add_new_watch_impl(db_pool: &SqlitePool, watch: &Watch) -> Result<Julid, AddError> {
let watch_id: Julid = query_scalar(ADD_WATCH_QUERY)
.bind(&watch.title)
.bind(watch.kind)
.bind(watch.release_date)
.bind(&watch.release_date)
.bind(&watch.metadata_url)
.bind(watch.added_by)
.execute(&mut tx)
.bind(watch.length)
.fetch_one(db_pool)
.await
.map_err(|err| {
tracing::error!("Got error: {err}");
WatchAddErrorKind::UnknownDBError
AddErrorKind::DBError
})?;
if let Some(quest) = quest {
query(ADD_WATCH_QUEST_QUERY)
.bind(quest.user)
.bind(quest.watch)
.bind(quest.is_public)
.bind(quest.already_watched)
.execute(&mut tx)
.await
.map_err(|err| {
tracing::error!("Got error: {err}");
WatchAddErrorKind::UnknownDBError
})?;
}
tx.commit().await.map_err(|err| {
tracing::error!("Got error: {err}");
WatchAddErrorKind::UnknownDBError
})?;
Ok(())
Ok(watch_id)
}
/// Add a Watch to your watchlist by selecting it with a checkbox
pub async fn post_add_watch_quest(
_auth: AuthContext,
State(_pool): State<SqlitePool>,
Form(_form): Form<PostAddExistingWatch>,
) -> impl IntoResponse {
todo!()
auth: AuthSession,
State(pool): State<SqlitePool>,
Form(form): Form<PostAddExistingWatch>,
) -> Result<impl IntoResponse, WatchesError> {
if let Some(user) = auth.user {
let quest = WatchQuest {
user: user.id,
watch: Julid::from_str(&form.watch).unwrap(),
public: form.public,
watched: false,
};
add_watch_quest_impl(&pool, &quest).await?;
let resp = checkmark(form.public);
Ok(resp.into_response())
} else {
let resp = Redirect::to("/login");
let mut resp = resp.into_response();
resp.headers_mut()
.insert("HX-Redirect", HeaderValue::from_str("/login").unwrap());
Ok(resp)
}
}
pub async fn _add_watch_quest_impl(pool: &SqlitePool, quest: &WatchQuest) -> Result<(), ()> {
async fn add_watch_quest_impl(pool: &SqlitePool, quest: &WatchQuest) -> Result<(), AddError> {
query(ADD_WATCH_QUEST_QUERY)
.bind(quest.user)
.bind(quest.watch)
.bind(quest.is_public)
.bind(quest.already_watched)
.bind(quest.public)
.bind(quest.watched)
.execute(pool)
.await
.map_err(|err| {
tracing::error!("Got error: {err}");
AddErrorKind::DBError
})?;
Ok(())
}
#[axum::debug_handler]
pub async fn edit_watch_quest(
auth: AuthSession,
State(pool): State<SqlitePool>,
Form(form): Form<PostEditQuest>,
) -> Result<impl IntoResponse, WatchesError> {
if let Some(user) = auth.user {
let watch = Julid::from_str(form.watch.trim()).map_err(|_| EditErrorKind::NotFound)?;
Ok(
edit_watch_quest_impl(&pool, form.act.trim(), user.id, watch)
.await?
.render()
.wender(),
)
} else {
Err(EditErrorKind::NotSignedIn.into())
}
}
async fn edit_watch_quest_impl(
pool: &SqlitePool,
action: &str,
user: Julid,
watch: Julid,
) -> Result<WatchStatusMenus, EditError> {
let quest: Option<WatchQuest> = query_as(GET_QUEST_QUERY)
.bind(user)
.bind(watch)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Got error from checking watch status: {e:?}");
EditErrorKind::DBError
})?;
if let Some(quest) = quest {
match action {
"remove" => {
sqlx::query!(
"delete from watch_quests where user = ? and watch = ?",
user,
watch
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Error removing quest: {e}");
EditErrorKind::DBError
})?;
Ok(WatchStatusMenus { watch, quest: None })
}
"watched" => {
let watched = !quest.watched;
sqlx::query!(
"update watch_quests set watched = ? where user = ? and watch = ?",
watched,
user,
watch
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Error updating quest: {e}");
EditErrorKind::DBError
})?;
let quest = WatchQuest { watched, ..quest };
Ok(WatchStatusMenus {
watch,
quest: Some(quest),
})
}
"viz" => {
let public = !quest.public;
sqlx::query!(
"update watch_quests set public = ? where user = ? and watch = ?",
public,
user,
watch
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Error updating quest: {e}");
EditErrorKind::DBError
})?;
let quest = WatchQuest { public, ..quest };
Ok(WatchStatusMenus {
watch,
quest: Some(quest),
})
}
_ => Ok(WatchStatusMenus {
watch,
quest: Some(quest),
}),
}
} else {
Err(EditErrorKind::NotFound.into())
}
}
/// A single Watch
pub async fn get_watch(
auth: AuthContext,
auth: AuthSession,
watch: Option<Path<String>>,
State(pool): State<SqlitePool>,
) -> impl IntoResponse {
@ -225,22 +276,24 @@ pub async fn get_watch(
"".to_string()
};
let id = id.trim();
let id = DbId::from_string(id).unwrap_or_default();
let id = Julid::from_str(id).unwrap_or_default();
let q = query_as(GET_WATCH_QUERY).bind(id);
let watch: Option<Watch> = q.fetch_one(&pool).await.ok();
GetWatchPage {
watch,
user: auth.current_user,
user: auth.user,
}
.render()
.wender()
}
/// everything the user has saved
pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> impl IntoResponse {
let user = auth.current_user;
let watches: Vec<Watch> = if (user).is_some() {
pub async fn get_watches(auth: AuthSession, State(pool): State<SqlitePool>) -> impl IntoResponse {
let user = auth.user;
let watches: Vec<Watch> = if let Some(ref user) = user {
query_as(GET_SAVED_WATCHES_QUERY)
.bind(user.as_ref().unwrap().id)
.bind(user.id)
.fetch_all(&pool)
.await
.unwrap_or_default()
@ -248,40 +301,31 @@ pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> i
vec![]
};
MyWatchesPage { watches, user }
MyWatchesPage { watches, user }.render().wender()
}
pub async fn get_search_watch(
auth: AuthContext,
pub async fn get_watch_status(
auth: AuthSession,
State(pool): State<SqlitePool>,
search: Query<SearchQuery>,
) -> impl IntoResponse {
let user = auth.current_user;
let (search_string, qstring) = if search.0 != EMPTY_SEARCH_QUERY_STRUCT {
let s = search.0;
let q = if let Some(title) = &s.title {
title
} else if let Some(search) = &s.search {
search
} else {
""
};
(format!("{s:?}"), format!("%{}%", q.trim()))
Path(watch): Path<String>,
) -> Result<impl IntoResponse, ()> {
if let Some(user) = auth.user {
let watch = Julid::from_str(&watch).unwrap();
let quest: Option<WatchQuest> = query_as(GET_QUEST_QUERY)
.bind(user.id)
.bind(watch)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Got error from checking watch status: {e:?}");
})?;
Ok(WatchStatusMenus { watch, quest }.render().wender())
} else {
("".to_string(), "%".to_string())
};
// until tantivy search
let watches: Vec<Watch> = query_as("select * from watches where title like ?")
.bind(&qstring)
.fetch_all(&pool)
.await
.unwrap();
SearchWatchesPage {
watches,
user,
search: search_string,
Ok("<a href='/login'>Login to add</a>".into_response())
}
}
fn checkmark(public: bool) -> String {
let public = if public { "public" } else { "private" };
format!("{CHECKMARK} ({public})")
}

View file

@ -1,10 +1,42 @@
use axum::response::{IntoResponse, Response};
use http::StatusCode;
use julid::Julid;
use serde::{Deserialize, Serialize};
use crate::DbId;
pub mod handlers;
pub mod templates;
//-************************************************************************
// Error types for Watch creation
//-************************************************************************
#[Error]
pub enum WatchesError {
Add(AddError),
Edit(EditError),
}
#[Error]
pub struct AddError(#[from] AddErrorKind);
#[Error]
#[non_exhaustive]
pub enum AddErrorKind {
DBError,
NotSignedIn,
}
#[Error]
pub struct EditError(#[from] EditErrorKind);
#[Error]
#[non_exhaustive]
pub enum EditErrorKind {
DBError,
NotSignedIn,
NotFound,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
)]
@ -69,40 +101,109 @@ impl From<i64> for ShowKind {
sqlx::FromRow,
)]
pub struct Watch {
pub id: DbId,
pub id: Julid,
pub title: String,
pub kind: ShowKind,
pub metadata_url: Option<String>,
pub length: Option<i64>,
pub release_date: Option<i64>,
pub added_by: DbId, // this shouldn't be exposed to randos in the application
}
impl Watch {
pub fn year(&self) -> Option<String> {
if let Some(year) = self.release_date {
let date = chrono::NaiveDateTime::from_timestamp_opt(year, 0)?;
let year = format!("{}", date.format("%Y"));
Some(year)
} else {
None
}
}
pub release_date: Option<String>,
pub added_by: Julid, // this shouldn't be exposed to randos in the application
}
//-************************************************************************
/// Something a user wants to watch
//-************************************************************************
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::FromRow,
)]
pub struct WatchQuest {
pub user: DbId,
pub watch: DbId,
pub is_public: bool,
pub already_watched: bool,
pub user: Julid,
pub watch: Julid,
pub public: bool,
pub watched: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QuestId {
pub user: Julid,
pub watch: Julid,
}
impl WatchQuest {
pub fn id(&self) -> (DbId, DbId) {
(self.user, self.watch)
pub fn id(&self) -> QuestId {
QuestId {
user: self.user,
watch: self.watch,
}
}
}
//-************************************************************************
// error impls
//-************************************************************************
impl IntoResponse for WatchesError {
fn into_response(self) -> Response {
match self {
Self::Add(ae) => ae.into_response(),
Self::Edit(ee) => ee.into_response(),
}
}
}
impl From<AddError> for WatchesError {
fn from(value: AddError) -> Self {
WatchesError::Add(value)
}
}
impl From<AddErrorKind> for WatchesError {
fn from(value: AddErrorKind) -> Self {
let e: AddError = value.into();
e.into()
}
}
impl From<EditError> for WatchesError {
fn from(value: EditError) -> Self {
WatchesError::Edit(value)
}
}
impl From<EditErrorKind> for WatchesError {
fn from(value: EditErrorKind) -> Self {
let e: EditError = value.into();
e.into()
}
}
impl IntoResponse for EditError {
fn into_response(self) -> Response {
match &self.0 {
EditErrorKind::DBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
EditErrorKind::NotSignedIn => (
StatusCode::OK,
"Ope, you need to sign in first!".to_string(),
)
.into_response(),
EditErrorKind::NotFound => (StatusCode::OK, "Could not find watch").into_response(),
}
}
}
impl IntoResponse for AddError {
fn into_response(self) -> Response {
match &self.0 {
AddErrorKind::DBError => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
}
AddErrorKind::NotSignedIn => (
StatusCode::OK,
"Ope, you need to sign in first!".to_string(),
)
.into_response(),
}
}
}

View file

@ -1,7 +1,8 @@
use askama::Template;
use julid::Julid;
use serde::{Deserialize, Serialize};
use crate::{OptionalOptionalUser, User, Watch};
use crate::{OptionalOptionalUser, User, Watch, WatchQuest};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "my_watches_page.html")]
@ -10,14 +11,6 @@ pub struct MyWatchesPage {
pub user: Option<User>,
}
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "search_watches_page.html")]
pub struct SearchWatchesPage {
pub watches: Vec<Watch>,
pub user: Option<User>,
pub search: String,
}
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "get_watch_page.html")]
pub struct GetWatchPage {
@ -30,3 +23,10 @@ pub struct GetWatchPage {
pub struct AddNewWatchPage {
pub user: Option<User>,
}
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq)]
#[template(path = "elements/add_watch_button.html")]
pub struct WatchStatusMenus {
pub watch: Julid,
pub quest: Option<WatchQuest>,
}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
{% block content %}
@ -10,7 +10,7 @@
{% if user.is_some() %}
<div class="add-watch">
<form action="/add" enctype="application/x-www-form-urlencoded" method="post">
<form action="/watch/add" enctype="application/x-www-form-urlencoded" method="post">
<label for="title">Title</label>
<input type="text" name="title" id="title"></br>
@ -34,7 +34,7 @@
<input type="checkbox" name="watched_already" id="is-watched" value="true" default="false"></br>
<label for="md-url">Metadata URL (TMDB, OMDB, IMDb, etc.)</label>
<input type="text" name="metadata_url" id="md-url"></br>
<input type="text" name="metadata_url" id="md-url"></br>
<input type="submit" value="Let's go!">
</form>
@ -45,8 +45,9 @@
{% else %}
<div class="add-watch">
<span class="not-logged-in">Oh dang, you need to <a href="/login">login</a> or <a href="/signup">signup</a> to add something to watch!</span>
<span class="not-logged-in">Oh dang, you need to <a href="/login">login</a> or <a href="/signup">signup</a> to add
something to watch!</span>
</div>
{% endif %}

View file

@ -1,20 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title }} - What 2 Watch{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
<div id="header">
{% block header %}{% include "header_with_user.html" %}{% endblock %}
</div>
<div id="content">
{% block content %}{% endblock %}
</div>
<div id="footer">
{% block footer %}{% endblock %}
</div>
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title }} - What 2 Watch{% endblock %}</title>
<link rel="stylesheet" href="/assets/ww.css">
{% block head %}{% endblock %}
</head>
<body>
<div id="header">
{% block header %}{% include "header_with_user.html" %}{% endblock %}
</div>
<div id="content">
<hr />
{% block content %}{% endblock %}
</div>
<div id="footer">
{% block footer %}{% endblock %}
</div>
<script src="/assets/htmx.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,67 @@
{% match quest %}
{% when Some with (q) %}
{% let viz %}
{% let np %}
{% if q.public -%}
{% let viz = "public" %}
{% let np = "private" %}
{% else %}
{% let viz = "private" %}
{% let np = "public" %}
{%- endif %}
{% let status %}
{% if q.watched -%}
{% let status = "watched" %}
{% else %}
{% let status = "want to watch" %}
{% endif %}
<table>
<tbody>
<tr>
<td>{{- status -}}</td>
<td>{{- viz -}}</td>
</tr>
<tr>
<td>
<form id="edit-quest-{{self.watch}}">
{% if q.watched %}
<button hx-post="/quest/edit" hx-target="#add-watch-{{self.watch}}" hx-trigger="click"
hx-swap="outerHTML">remove</button>
<input type="hidden" name="watch" value="{{self.watch}}">
<input type="hidden" name="act" value="remove">
{% else %}
<button hx-post="/quest/edit" hx-target="#add-watch-{{self.watch}}" hx-trigger="click"
hx-swap="outerHTML">mark watched</button>
<input type="hidden" name="watch" value="{{self.watch}}">
<input type="hidden" name="act" value="watched">
{% endif %}
</form>
</td>
<td>
<form id="edit-visibility-{{self.watch}}">
<button hx-post="/quest/edit" hx-target="#add-watch-{{self.watch}}" hx-trigger="click"
hx-swap="outerHTML">mark as {{ np }}</button>
<input type="hidden" name="watch" value="{{self.watch}}">
<input type="hidden" name="act" value="viz">
</form>
</td>
</tr>
</tbody>
</table>
{% when None %}
<form id="add-watch-{{self.watch}}">
<button hx-post="/quest/add" hx-target="#add-watch-{{self.watch}}" hx-trigger="click"
hx-swap="outerHTML">add</button>
<select name="public" id="add-public-watch">
<option value="true">public</option>
<option value="false">private</option>
</select>
<input type="hidden" name="watch" value="{{self.watch}}">
</form>
{% endmatch %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
{% block content %}
@ -12,7 +12,7 @@
{% when Some with (watch) %}
<div class="watch">
<span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.year(), "when??") %}
<span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.release_date, "when??") %}
</div>
{% else %}

View file

@ -4,19 +4,21 @@
{% when Some with (usr) %}
<div class="header_logged_in">
Hello, {{ usr.username }}!
<div>
<form action="/logout" enctype="application/x-www-form-urlencoded" method="post">
<button>
<input type="submit" value="sign out?" class="warning">
</button>
</form>
</div>
</div>
<div class="header_signout">
<input type="submit" value="sign out?">
</form>
</div>
{% else %}
{% when None %}
<div class="header_logged_out">
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
<a href="/login">log in</a> or <a href="/signup">sign up</a>?
</div>
{% endmatch %}
{% else %}
{% else %} <!-- this is for whether or not the template has a user field or not -->
{% endif %}
<hr/>

View file

@ -1,25 +1,25 @@
{% extends "base.html" %}
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
{% block content %}
<h1>Welcome to What 2 Watch</h1>
{% match user %}
{% when Some with (usr) %}
{% when Some with (usr) %}
<p>
Hello, {{ usr.username }}! It's nice to see you. <a href="watches">Let's get watchin'!</a>
</p>
</br>
<p>
<form action="/logout" enctype="application/x-www-form-urlencoded" method="post">
<form action="/logout" enctype="application/x-www-form-urlencoded" method="post">
<input type="submit" value="sign out?">
</form>
</form>
</p>
{% else %}
{% when None %}
<p>
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
</p>
{% endmatch %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Login to What 2 Watch, Bish{% endblock %}
{% block title %}Login to What 2 Watch, Buddy{% endblock %}
{% block header %}{% endblock %}
@ -12,7 +12,7 @@
<input type="text" name="username" id="username" minlength="1" maxlength="20" required></br>
<label for="password">Password</label>
<input type="password" name="password" id="password" required></br>
<input type="submit" value="Signup">
<input type="submit" value="Login">
</form>
</p>

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Logout of What 2 Watch, Bish{% endblock %}
{% block title %}Logout of What 2 Watch, Buddy{% endblock %}
{% block header %}{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Thanks for Signing Up for What 2 Watch, Bish{% endblock %}
{% block title %}Thanks for Signing Up for What 2 Watch, Buddy{% endblock %}
{% block header %}{% endblock %}

View file

@ -1,36 +1,51 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
{% block content %}
<h1>Whatcha Watchin?</h1>
{% match user %}
{% when Some with (usr) %}
{% when Some with (usr) %}
<div>
<p>
Hello, {{ usr.username }}! It's nice to see you.
</p>
</div>
<p>
Hello, {{ usr.username }}! It's nice to see you.
<form action="/title-search" enctype="application/x-www-form-urlencoded" method="get">
<label for="title">Looking for something else to watch?</label>
<input type="text" name="title" id="title"></br>
<input type="submit" value="Let's go!">
</form>
</p>
</br>
<p>Here are your things to watch:</p>
<div class="watchlist">
<ul>
{% for watch in watches %}
<li><span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.year(), "when??") %}: </li>
{% endfor %}
</ul>
<table>
<thead>
<tr>
<th>Title</th>
<th>Link</th>
<th>Type</th>
<th>Year</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for watch in watches %}
{% include "watchlist-item.html" %}
{% endfor %}
</tbody>
</table>
</div>
<p>
<form action="/search" enctype="application/x-www-form-urlencoded" method="get">
<label for="search">Looking for something else to watch?</label>
<input type="text" name="search" id="search"></br>
<input type="submit" value="Let's go!">
</form>
</p>
{% else %}
<p>
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
</p>
{% endmatch %}

View file

@ -1,50 +1,53 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
{% block content %}
<h1>Whatcha Watchin?</h1>
<div class="quicksearch-query">{{self.search}}</div>
<div class="watchlist">
<ul>
{% for watch in watches %}
<li><span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.year(), "when??") -%}: {{watch.kind}} </li>
{% endfor %}
</ul>
</div>
<h2>Simple Search</h2>
<div class="simplesearch">
<form action="/search" enctype="application/x-www-form-urlencoded" method="get">
<label for="search">Looking for something else to watch?</label>
<input type="text" name="search" id="search"></br>
<input type="submit" value="Let's go!">
</div>
<h2>Fussy Search</h2>
<div class="fullsearch">
<form action="/title-search" enctype="application/x-www-form-urlencoded" method="get">
<h2>Title Search</h2>
<label for="title">Title</label>
<input type="text" name="title" id="title"></br>
<input type="text" name="title" id="title-search"></br>
<label for="year">Release Year</label>
<input type="text" name="year" id="year"></br>
<label for="kind">Type</label>
<select id="kind" name="kind">
<option value="">Unknown</option>
<option value="0">Movie</option>
<option value="1">Series</option>
<option value="2">Limited Series</option>
<option value="3">Short</option>
<option value="4">Other</option>
</select>
<input type="text" name="year" id="title-year-search">
<input type="submit" value="Let's go!">
</form>
<hr>
<form action="/person-search" enctype="application/x-www-form-urlencoded" method="get">
<label for="person">Person</label>
<input type="text" name="person" id="person-search">
<input type="submit" value="Let's go!">
</form>
</div>
<hr />
<div class="watchlist">
<table>
<thead>
<tr>
<th>Title</th>
<th>Link</th>
<th>Type</th>
<th>Year</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for watch in results %}
{% include "watchlist-item.html" %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Sign Up for What 2 Watch, Bish{% endblock %}
{% block title %}Sign Up for What 2 Watch, Buddy{% endblock %}
{% block header %} {% endblock %}
@ -8,6 +8,7 @@
<p>
<form action="/signup" enctype="application/x-www-form-urlencoded" method="post">
<input type="hidden" name="invitation" value="{{self.invitation}}">
<label for="username">Username</label>
<input type="text" name="username" id="username" minlength="1" maxlength="20" required></br>
<label for="displayname">Displayname (optional)</label>

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Dang, Buddy{% endblock %}
{% block content %}
{% block header %}{% endblock %}
<h1>Oh dang!</h1>
<div id="signup_success">
<p>
Sorry, something went wrong: {{self.0}}
</p>
</div>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Thanks for Signing Up for What 2 Watch, Bish{% endblock %}
{% block title %}Thanks for Signing Up for What 2 Watch, Buddy{% endblock %}
{% block content %}
{% block header %}{% endblock %}

View file

@ -0,0 +1,19 @@
<tr id="watchlist-item-{{watch.id}}">
<td><span class="watchtitle"><a href="/watch/{{watch.id}}">{{watch.title}}</a></span></td>
<td><span>
{% match watch.metadata_url %}
{% when Some with (mdurl) %}
<a href="{{ mdurl }}">{{ mdurl }}</a>
{% when None %}
{% endmatch %}
</span>
</td>
<td>{{watch.kind}}</td>
<td> {% call m::get_or_default(watch.release_date, "when??") -%}</td>
<td>
<span id="add-watch-{{watch.id}}">
<span hx-get="/watch/status/{{watch.id}}" hx-target="this" hx-trigger="load, reveal"
hx-swap="outerHTML">???</span>
</span>
</td>
</tr>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 115 KiB