Compare commits
2 commits
main
...
original_s
Author | SHA1 | Date | |
---|---|---|---|
|
85473b4938 | ||
|
a71420fc7b |
69 changed files with 3119 additions and 4692 deletions
2
.env
2
.env
|
@ -1 +1 @@
|
|||
DATABASE_URL=sqlite://${HOME}/.local/share/what2watch/what2watch.db
|
||||
DATABASE_URL=sqlite://${HOME}/.what2watch.db
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1 @@
|
|||
/target
|
||||
/libjulid.so
|
||||
ww-style
|
||||
*.db
|
||||
|
|
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -1,3 +0,0 @@
|
|||
[submodule "julid"]
|
||||
path = julid
|
||||
url = https://gitlab.com/nebkor/julid.git
|
2649
Cargo.lock
generated
2649
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
42
Cargo.toml
42
Cargo.toml
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "what2watch"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
default-run = "what2watch"
|
||||
|
||||
[dependencies]
|
||||
|
@ -10,33 +10,33 @@ optional_optional_user = {path = "optional_optional_user"}
|
|||
|
||||
# regular external deps
|
||||
argon2 = "0.5"
|
||||
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"
|
||||
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"] }
|
||||
justerror = "1"
|
||||
parse_duration = "2"
|
||||
password-auth = "1"
|
||||
password-hash = { version = "0.5", features = ["std", "getrandom"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sha256 = { version = "1", default-features = false }
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "tls-none", "migrate", "chrono"] }
|
||||
sqlx = { version = "0.6", default-features = false, features = ["runtime-tokio-rustls", "any",
|
||||
"sqlite", "chrono", "time", "uuid"] }
|
||||
thiserror = "1"
|
||||
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"] }
|
||||
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"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-segmentation = "1"
|
||||
rand_distr = "0.4.3"
|
||||
uuid = { version = "1.4.0", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "18"
|
||||
axum-test = "9.0.0"
|
||||
serde_test = "1.0.164"
|
||||
|
||||
|
|
22
Makefile
22
Makefile
|
@ -1,22 +0,0 @@
|
|||
.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
1
assets/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -1,40 +0,0 @@
|
|||
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
1
julid
|
@ -1 +0,0 @@
|
|||
Subproject commit 1e93d0b1e4bc76ff19e1ce8e638c60204f458604
|
12
migrations/20230426221940_init.down.sql
Normal file
12
migrations/20230426221940_init.down.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
-- indices
|
||||
drop index if exists user_dex;
|
||||
drop index if exists watch_dex;
|
||||
drop index if exists w2wdex;
|
||||
drop index if exists note_dex;
|
||||
-- tables
|
||||
drop table if exists watch_quest;
|
||||
drop table if exists watch_notes;
|
||||
drop table if exists follows;
|
||||
drop table if exists users;
|
||||
drop table if exists watches;
|
||||
|
74
migrations/20230426221940_init.up.sql
Normal file
74
migrations/20230426221940_init.up.sql
Normal file
|
@ -0,0 +1,74 @@
|
|||
-- 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 (
|
||||
id blob not null primary key,
|
||||
user blob not null,
|
||||
watch blob not null,
|
||||
party blob, -- list of user IDs, but we can also scan for friends that want to watch the same thing
|
||||
priority int, -- 1-5 how much do you want to watch it
|
||||
public boolean not null,
|
||||
watched boolean not null,
|
||||
when_added int,
|
||||
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
|
||||
);
|
||||
|
||||
-- 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,
|
||||
coven 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 (email);
|
||||
create index if not exists watch_title_dex on watches (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 questswatch_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);
|
5
migrations/20230427212229_update_triggers.down.sql
Normal file
5
migrations/20230427212229_update_triggers.down.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
drop trigger if exists update_last_updated_users;
|
||||
drop trigger if exists update_last_updated_watches;
|
||||
drop trigger if exists update_last_updated_watch_quest;
|
||||
drop trigger if exists update_last_updated_follows;
|
||||
drop trigger if exists update_last_updated_watch_notes;
|
34
migrations/20230427212229_update_triggers.up.sql
Normal file
34
migrations/20230427212229_update_triggers.up.sql
Normal file
|
@ -0,0 +1,34 @@
|
|||
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_quest 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;
|
|
@ -1,2 +0,0 @@
|
|||
drop table if exists invites;
|
||||
drop table if exists users; -- must be last
|
|
@ -1,41 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
drop table if exists watch_quests;
|
||||
drop table if exists watch_notes;
|
||||
drop table if exists watches; -- must be last
|
|
@ -1,65 +0,0 @@
|
|||
-- 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;
|
|
@ -1,2 +0,0 @@
|
|||
drop table if exists credits; -- must be first
|
||||
drop table if exists stars;
|
|
@ -1,20 +0,0 @@
|
|||
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);
|
|
@ -1 +0,0 @@
|
|||
drop table if exists follows;
|
|
@ -1,11 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
drop table if exists star_search;
|
||||
drop table if exists watch_search;
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
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;
|
|
@ -1,6 +0,0 @@
|
|||
-- 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;
|
|
@ -19,7 +19,7 @@ pub fn derive_optional_optional_user(input: TokenStream) -> TokenStream {
|
|||
})
|
||||
.is_some();
|
||||
|
||||
let (use_any, user_is_option_user, user_is_mandatory) = if has_user {
|
||||
let (use_any, user_is_option_user) = if has_user {
|
||||
(
|
||||
quote!(
|
||||
use ::std::any::Any;
|
||||
|
@ -28,10 +28,9 @@ 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!(false))
|
||||
(quote!(), quote!(false))
|
||||
};
|
||||
|
||||
let output = quote!(
|
||||
|
@ -40,15 +39,6 @@ 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
105
src/auth.rs
|
@ -1,105 +0,0 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
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();
|
||||
}
|
41
src/bin/import_omega.rs
Normal file
41
src/bin/import_omega.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
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;
|
||||
}
|
159
src/bin/import_users.rs
Normal file
159
src/bin/import_users.rs
Normal file
|
@ -0,0 +1,159 @@
|
|||
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>,
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
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
48
src/conf.rs
|
@ -1,48 +0,0 @@
|
|||
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
522
src/db.rs
|
@ -1,25 +1,32 @@
|
|||
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 = 2000; // in milliseconds
|
||||
const TIMEOUT: u64 = 11;
|
||||
const SESSION_TTL: Duration = Duration::from_secs((365.2422 * 24. * 3600.0) as u64);
|
||||
|
||||
pub fn get_db_pool() -> SqlitePool {
|
||||
let conf = crate::conf::Config::get();
|
||||
pub async fn get_db_pool() -> SqlitePool {
|
||||
let db_filename = {
|
||||
std::env::var("DATABASE_FILE").unwrap_or_else(|_| {
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
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
|
||||
let home =
|
||||
std::env::var("HOME").expect("Could not determine $HOME for finding db file");
|
||||
format!("{home}/.what2watch.db")
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
|
@ -38,62 +45,497 @@ pub 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))
|
||||
.pragma("temp_store", "memory")
|
||||
.create_if_missing(true)
|
||||
.optimize_on_close(true, None)
|
||||
.pragma("mmap_size", "3000000000");
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(MAX_CONNS)
|
||||
.min_connections(MIN_CONNS)
|
||||
.idle_timeout(Some(Duration::from_secs(3)))
|
||||
.idle_timeout(Some(Duration::from_secs(30)))
|
||||
.max_lifetime(Some(Duration::from_secs(3600)))
|
||||
.connect_with(conn_opts);
|
||||
.connect_with(conn_opts)
|
||||
.await
|
||||
.expect("can't connect to database");
|
||||
|
||||
let pool = {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
rt.block_on(pool).unwrap()
|
||||
};
|
||||
// let the filesystem settle before trying anything
|
||||
// possibly not effective?
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.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.");
|
||||
|
||||
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;
|
||||
|
||||
#[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());
|
||||
});
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
61
src/db_id.rs
Normal file
61
src/db_id.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use std::fmt::{Debug, Display};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::{Error, Uuid};
|
||||
|
||||
#[derive(
|
||||
Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Serialize, Deserialize,
|
||||
)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct DbId(pub Uuid);
|
||||
|
||||
impl DbId {
|
||||
pub fn is_nil(&self) -> bool {
|
||||
self.0.is_nil()
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_string(s: &str) -> Result<Self, Error> {
|
||||
Ok(Self(Uuid::try_parse(s)?))
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
self.0.simple().to_string()
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::default()
|
||||
}
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
// 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<Uuid> for DbId {
|
||||
fn from(value: Uuid) -> Self {
|
||||
DbId(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u128> for DbId {
|
||||
fn from(value: u128) -> Self {
|
||||
DbId(Uuid::from_u128(value))
|
||||
}
|
||||
}
|
|
@ -1,53 +1,53 @@
|
|||
use askama::Template;
|
||||
use axum::response::{IntoResponse, Redirect};
|
||||
|
||||
use crate::{AuthSession, MainPage, Wender};
|
||||
use crate::{AuthContext, MainPage};
|
||||
|
||||
pub async fn handle_slash_redir() -> impl IntoResponse {
|
||||
Redirect::to("/")
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn handle_slash(auth: AuthSession) -> impl IntoResponse {
|
||||
if let Some(ref user) = auth.user {
|
||||
pub async fn handle_slash(auth: AuthContext) -> impl IntoResponse {
|
||||
if let Some(ref user) = auth.current_user {
|
||||
let name = &user.username;
|
||||
tracing::debug!("Logged in as: {name}");
|
||||
} else {
|
||||
tracing::debug!("Not logged in.");
|
||||
}
|
||||
MainPage { user: auth.user }.render().wender()
|
||||
MainPage {
|
||||
user: auth.current_user,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use axum_test::TestServer;
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
use crate::db;
|
||||
|
||||
use crate::{get_db_pool, test_utils::server_with_pool};
|
||||
#[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();
|
||||
|
||||
#[test]
|
||||
fn slash_is_ok() {
|
||||
let db = get_db_pool();
|
||||
let server = TestServer::new(app).unwrap();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let server = server_with_pool(&db).await;
|
||||
server.get("/").await
|
||||
})
|
||||
.assert_status_ok();
|
||||
server.get("/").await.assert_status_ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_is_303() {
|
||||
let rt = Runtime::new().unwrap();
|
||||
#[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();
|
||||
|
||||
let db = get_db_pool();
|
||||
let server = TestServer::new(app).unwrap();
|
||||
assert_eq!(
|
||||
rt.block_on(async {
|
||||
let server = server_with_pool(&db).await;
|
||||
server.get("/no-actual-route").expect_failure().await
|
||||
})
|
||||
.status_code(),
|
||||
server
|
||||
.get("/no-actual-route")
|
||||
.expect_failure()
|
||||
.await
|
||||
.status_code(),
|
||||
303
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,160 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
|
@ -1,79 +1,153 @@
|
|||
use julid::Julid;
|
||||
use sqlx::{query_scalar, SqliteConnection, SqlitePool};
|
||||
use sqlx::{query_scalar, SqlitePool};
|
||||
|
||||
use crate::{Credit, Star, User, Watch};
|
||||
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";
|
||||
|
||||
//-************************************************************************
|
||||
// the omega user is the system ID, but has no actual power in the app
|
||||
//-************************************************************************
|
||||
const OMEGA_ID: Julid = Julid::omega();
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
// 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 (id, user, watch, public, watched) ");
|
||||
builder.push_values(quests, |mut b, quest| {
|
||||
let id = DbId::new();
|
||||
let user = quest.user;
|
||||
let watch = quest.watch;
|
||||
//eprintln!("{user}, {watch}");
|
||||
b.push_bind(id)
|
||||
.push_bind(user)
|
||||
.push_bind(watch)
|
||||
.push_bind(true)
|
||||
.push_bind(false);
|
||||
});
|
||||
|
||||
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 q = builder.build();
|
||||
q.execute(pool).await.map_err(|e| {
|
||||
dbg!(e);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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 insert_credit(credit: &Credit, db: &mut SqliteConnection) {
|
||||
let q = "insert into credits (star, watch, credit) values (?, ?, ?)";
|
||||
pub async fn add_omega_watches(
|
||||
w2w_db: &SqlitePool,
|
||||
movie_db: &SqlitePool,
|
||||
) -> Result<Vec<DbId>, ()> {
|
||||
ensure_omega(w2w_db).await;
|
||||
|
||||
sqlx::query(q)
|
||||
.bind(credit.star)
|
||||
.bind(credit.watch)
|
||||
.bind(credit.credit.as_deref())
|
||||
.execute(db)
|
||||
let movies: Vec<ImportMovieOmega> = sqlx::query_as(MOVIE_QUERY)
|
||||
.fetch_all(movie_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();
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn ensure_omega(db_pool: &SqlitePool) -> Julid {
|
||||
pub async fn ensure_omega(db_pool: &SqlitePool) -> DbId {
|
||||
if !check_omega_exists(db_pool).await {
|
||||
User::omega().try_insert(db_pool).await.unwrap();
|
||||
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();
|
||||
}
|
||||
OMEGA_ID
|
||||
OMEGA_ID.into()
|
||||
}
|
||||
|
||||
async fn check_omega_exists(db_pool: &SqlitePool) -> bool {
|
||||
const USER_EXISTS_QUERY: &str = "select count(*) from users where id = $1";
|
||||
let id: DbId = OMEGA_ID.into();
|
||||
let count = query_scalar(USER_EXISTS_QUERY)
|
||||
.bind(OMEGA_ID)
|
||||
.bind(id)
|
||||
.fetch_one(db_pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
@ -86,21 +160,13 @@ async fn check_omega_exists(db_pool: &SqlitePool) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[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)));
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
140
src/lib.rs
140
src/lib.rs
|
@ -1,179 +1,86 @@
|
|||
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 mod conf;
|
||||
pub mod imdb_utils;
|
||||
pub use db_id::DbId;
|
||||
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::*;
|
||||
|
||||
#[Error]
|
||||
pub enum WatchError {
|
||||
Auth(auth::AuthError),
|
||||
Signup(signup::SignupError),
|
||||
Watches(watches::WatchesError),
|
||||
Render,
|
||||
}
|
||||
type AuthContext = axum_login::extractors::AuthContext<DbId, User, axum_login::SqliteStore<User>>;
|
||||
|
||||
/// Returns the router to be used as a service or test object, you do you.
|
||||
pub async fn app(db_pool: sqlx::SqlitePool) -> IntoMakeService<axum::Router> {
|
||||
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;
|
||||
|
||||
// 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 search::get_search_watch;
|
||||
use signup::handlers::{get_create_user, get_signup_success, post_create_user};
|
||||
use tower_http::services::ServeDir;
|
||||
use signup::{get_create_user, get_signup_success, post_create_user};
|
||||
use watches::handlers::{
|
||||
edit_watch_quest, get_add_new_watch, get_watch, get_watch_status, get_watches,
|
||||
post_add_new_watch, post_add_watch_quest,
|
||||
get_add_new_watch, get_search_watch, get_watch, 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/{invitation}", get(get_create_user))
|
||||
.route("/signup_success/{user}", get(get_signup_success))
|
||||
.route("/signup_success/:id", 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/{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/add",
|
||||
get(get_add_new_watch).post(post_add_new_watch),
|
||||
)
|
||||
.route("/watch/status/{watch}", get(get_watch_status))
|
||||
.route(
|
||||
"/quest/add",
|
||||
"/add/watch",
|
||||
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::{signup::templates::SignupSuccessPage, MainPage, OptionalOptionalUser, User};
|
||||
use super::{MainPage, OptionalOptionalUser, SignupSuccessPage, User};
|
||||
|
||||
#[test]
|
||||
fn main_page_has_optional_user() {
|
||||
assert!(MainPage::default().has_optional_user());
|
||||
assert!(MainPage::default().has_user());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -188,8 +95,6 @@ mod test {
|
|||
user: User,
|
||||
}
|
||||
assert!(!TestThing::default().has_optional_user());
|
||||
assert!(TestThing::default().has_mandatory_user());
|
||||
assert!(TestThing::default().has_user());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -199,6 +104,5 @@ mod test {
|
|||
user: Option<bool>,
|
||||
}
|
||||
assert!(!TestThing::default().has_optional_user());
|
||||
assert!(!TestThing::default().has_user());
|
||||
}
|
||||
}
|
||||
|
|
304
src/login.rs
304
src/login.rs
|
@ -1,34 +1,56 @@
|
|||
use askama::Template;
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
Argon2,
|
||||
};
|
||||
use axum::{
|
||||
response::{IntoResponse, Redirect},
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthError, AuthErrorKind, Credentials},
|
||||
AuthSession, LoginPage, LogoutPage, LogoutSuccessPage, WatchError, Wender,
|
||||
};
|
||||
use crate::{AuthContext, LoginPage, LogoutPage, LogoutSuccessPage, User};
|
||||
|
||||
//-************************************************************************
|
||||
// Constants
|
||||
//-************************************************************************
|
||||
|
||||
//-************************************************************************
|
||||
// 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(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
|
@ -38,194 +60,139 @@ impl From<LoginPostForm> for Credentials {
|
|||
/// Handle login queries
|
||||
#[axum::debug_handler]
|
||||
pub async fn post_login(
|
||||
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());
|
||||
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("/"))
|
||||
}
|
||||
};
|
||||
|
||||
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())
|
||||
_ => Err(LoginErrorKind::BadPassword.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_login() -> impl IntoResponse {
|
||||
LoginPage::default().render().wender()
|
||||
LoginPage::default()
|
||||
}
|
||||
|
||||
pub async fn get_logout() -> impl IntoResponse {
|
||||
LogoutPage.render().wender()
|
||||
LogoutPage
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
pub async fn post_logout(mut auth: AuthContext) -> impl IntoResponse {
|
||||
if auth.current_user.is_some() {
|
||||
auth.logout().await;
|
||||
}
|
||||
LogoutSuccessPage
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
// tests
|
||||
//-************************************************************************
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{
|
||||
get_db_pool,
|
||||
templates::{LoginPage, LogoutPage, LogoutSuccessPage, MainPage},
|
||||
test_utils::{massage, server_with_pool, FORM_CONTENT_TYPE},
|
||||
User,
|
||||
test_utils::{get_test_user, massage, server, FORM_CONTENT_TYPE},
|
||||
};
|
||||
|
||||
const LOGIN_FORM: &str = "username=test_user&password=a";
|
||||
|
||||
#[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 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 post_login_success() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
#[tokio::test]
|
||||
async fn post_login_success() {
|
||||
let s = server().await;
|
||||
|
||||
let body = massage(LOGIN_FORM);
|
||||
|
||||
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);
|
||||
})
|
||||
let resp = s
|
||||
.post("/login")
|
||||
.expect_failure()
|
||||
.content_type(FORM_CONTENT_TYPE)
|
||||
.bytes(body)
|
||||
.await;
|
||||
assert_eq!(resp.status_code(), 303);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_login_bad_user() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
#[tokio::test]
|
||||
async fn post_login_bad_user() {
|
||||
let s = server().await;
|
||||
|
||||
let form = "username=test_LOSER&password=aaaa";
|
||||
let body = massage(form);
|
||||
|
||||
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);
|
||||
})
|
||||
let resp = s
|
||||
.post("/login")
|
||||
.expect_success()
|
||||
.content_type(FORM_CONTENT_TYPE)
|
||||
.bytes(body)
|
||||
.await;
|
||||
assert_eq!(resp.status_code(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_login_bad_password() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
#[tokio::test]
|
||||
async fn post_login_bad_password() {
|
||||
let s = server().await;
|
||||
|
||||
let form = "username=test_user&password=bbbb";
|
||||
let body = massage(form);
|
||||
|
||||
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);
|
||||
})
|
||||
let resp = s
|
||||
.post("/login")
|
||||
.expect_success()
|
||||
.content_type(FORM_CONTENT_TYPE)
|
||||
.bytes(body)
|
||||
.await;
|
||||
assert_eq!(resp.status_code(), 200);
|
||||
}
|
||||
|
||||
#[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 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 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_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_logged_in() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
#[tokio::test]
|
||||
async fn post_logout_logged_in() {
|
||||
let s = server().await;
|
||||
|
||||
// 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")
|
||||
|
@ -235,18 +202,19 @@ mod test {
|
|||
.await;
|
||||
assert_eq!(resp.status_code(), 303);
|
||||
|
||||
let user = User::try_get("test_user", &db).await.unwrap();
|
||||
|
||||
let logged_in = MainPage { user }.to_string();
|
||||
let logged_in = MainPage {
|
||||
user: Some(get_test_user()),
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let main_page = s.get("/").await;
|
||||
let body = std::str::from_utf8(main_page.as_bytes()).unwrap();
|
||||
let body = std::str::from_utf8(main_page.bytes()).unwrap();
|
||||
assert_eq!(&logged_in, body);
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
let resp = s.post("/logout").await;
|
||||
let body = std::str::from_utf8(resp.bytes()).unwrap();
|
||||
let default = LogoutSuccessPage.to_string();
|
||||
assert_eq!(body, &default);
|
||||
}
|
||||
}
|
||||
|
|
54
src/main.rs
54
src/main.rs
|
@ -1,47 +1,44 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use clap::Parser;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use what2watch::get_db_pool;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {}
|
||||
|
||||
fn main() {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "what2watch=debug,axum=debug".into()),
|
||||
.unwrap_or_else(|_| "what2watch=debug,axum::routing=info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
//tracing_subscriber::fmt().with_target(false).pretty().init();
|
||||
let pool = get_db_pool().await;
|
||||
|
||||
let pool = get_db_pool();
|
||||
let secret = {
|
||||
let mut bytes = [0u8; 64];
|
||||
let mut rng = thread_rng();
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let app = what2watch::app(pool.clone(), &secret).await;
|
||||
|
||||
let app = rt.block_on(what2watch::app(pool.clone()));
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
|
||||
tracing::debug!("binding to {addr:?}");
|
||||
|
||||
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()
|
||||
});
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
rt.block_on(pool.close());
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
async fn graceful_shutdown() {
|
||||
use tokio::signal;
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
|
@ -56,11 +53,10 @@ async fn graceful_shutdown() {
|
|||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {tracing::info!("shutting down")},
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
println!("signal received, starting graceful shutdown");
|
||||
}
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
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()
|
||||
}
|
500
src/signup.rs
Normal file
500
src/signup.rs
Normal file
|
@ -0,0 +1,500 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,702 +0,0 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
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()));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
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
19
src/stars.rs
|
@ -1,19 +0,0 @@
|
|||
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>,
|
||||
}
|
|
@ -3,6 +3,22 @@ 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 {
|
||||
|
|
|
@ -1,29 +1,43 @@
|
|||
use axum::body::Bytes;
|
||||
use axum_test::{TestServer, TestServerConfig};
|
||||
use julid::Julid;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::User;
|
||||
use crate::{DbId, User};
|
||||
|
||||
pub const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
|
||||
|
||||
pub const INVITE_ID_INT: u128 = 42;
|
||||
pub 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(),
|
||||
displayname: Some("Test User".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn server_with_pool(pool: &SqlitePool) -> TestServer {
|
||||
//User::omega().try_insert(pool).await.unwrap();
|
||||
pub async fn server() -> TestServer {
|
||||
let pool = crate::db::get_db_pool().await;
|
||||
let secret = [0u8; 64];
|
||||
|
||||
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)
|
||||
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_or_default();
|
||||
assert!(r == 1);
|
||||
.unwrap();
|
||||
|
||||
let app = crate::app(pool.clone()).await;
|
||||
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,
|
||||
|
@ -32,24 +46,30 @@ pub async fn server_with_pool(pool: &SqlitePool) -> TestServer {
|
|||
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: Julid::omega(),
|
||||
displayname: Some("Test User".to_string()),
|
||||
invited_by: Julid::omega(),
|
||||
..Default::default()
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
151
src/users.rs
151
src/users.rs
|
@ -1,74 +1,37 @@
|
|||
use std::fmt::{Debug, Display};
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::IntoResponse,
|
||||
use std::{
|
||||
fmt::{Debug, Display},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use julid::Julid;
|
||||
|
||||
use axum::{extract::State, http::Request, middleware::Next, response::IntoResponse};
|
||||
use axum_login::{secrecy::SecretVec, AuthUser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{AuthSession, WatchDate};
|
||||
use crate::{AuthContext, DbId};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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)]
|
||||
pub struct User {
|
||||
pub id: Julid,
|
||||
pub id: DbId,
|
||||
pub username: String,
|
||||
pub displayname: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub last_seen: Option<WatchDate>,
|
||||
pub last_seen: Option<i64>,
|
||||
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("username", &self.username)
|
||||
.field("id", &self.id.as_string())
|
||||
.field("username", &self.username)
|
||||
.field("displayname", &self.displayname)
|
||||
.field("email", &self.email)
|
||||
.field("last_seen", &self.last_seen)
|
||||
.field("digest", &self.digest)
|
||||
.field("invited_by", &self.invited_by.as_string())
|
||||
.field("is_active", &self.is_active)
|
||||
.field("pwhash", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
@ -76,79 +39,67 @@ 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 = self.displayname.as_deref().unwrap_or("");
|
||||
let email = self.email.as_deref().unwrap_or("");
|
||||
let dname = if let Some(ref n) = self.displayname {
|
||||
n
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let email = if let Some(ref e) = self.email { e } else { "" };
|
||||
write!(f, "Username: {uname}\nDisplayname: {dname}\nEmail: {email}")
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
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_optional(db)
|
||||
.await
|
||||
impl AuthUser<DbId> for User {
|
||||
fn get_id(&self) -> DbId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// 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)
|
||||
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)
|
||||
.bind(username)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn update_last_seen(&self, pool: &SqlitePool) {
|
||||
match sqlx::query!(
|
||||
"update users set last_seen = CURRENT_TIMESTAMP where id = ?",
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
match sqlx::query(LAST_SEEN_QUERY)
|
||||
.bind(self.id)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let id = self.id.to_string();
|
||||
let id = self.id.0.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(
|
||||
pub async fn handle_update_last_seen<BodyT>(
|
||||
State(pool): State<SqlitePool>,
|
||||
auth: AuthSession,
|
||||
request: Request,
|
||||
next: Next,
|
||||
auth: AuthContext,
|
||||
request: Request<BodyT>,
|
||||
next: Next<BodyT>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(user) = auth.user {
|
||||
if let Some(then) = &user.last_seen {
|
||||
let now = chrono::Utc::now();
|
||||
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;
|
||||
// The Nyquist frequency for 1-day tracking resolution is 12 hours.
|
||||
let dur = chrono::Duration::hours(12);
|
||||
if (now - then) > dur {
|
||||
if now - then > 12 * 3600 {
|
||||
user.update_last_seen(&pool).await;
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::{error::Error, ops::Range};
|
|||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub(crate) fn validate_optional_length<E: Error>(
|
||||
pub fn validate_optional_length<E: Error>(
|
||||
opt: &Option<String>,
|
||||
len_range: Range<usize>,
|
||||
err: E,
|
||||
|
@ -21,7 +21,7 @@ pub(crate) fn validate_optional_length<E: Error>(
|
|||
}
|
||||
|
||||
/// Serde deserialization decorator to map empty Strings to None
|
||||
pub(crate) fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
||||
pub 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,3 +35,17 @@ 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()
|
||||
}
|
|
@ -1,41 +1,83 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
extract::{Form, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use http::HeaderValue;
|
||||
use julid::Julid;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query, query_as, query_scalar, SqlitePool};
|
||||
use sqlx::{query, query_as, SqlitePool};
|
||||
|
||||
use super::{
|
||||
templates::{AddNewWatchPage, GetWatchPage, WatchStatusMenus},
|
||||
AddError, AddErrorKind, EditError, EditErrorKind, WatchesError,
|
||||
};
|
||||
use super::templates::{AddNewWatchPage, GetWatchPage, SearchWatchesPage};
|
||||
use crate::{
|
||||
misc_util::empty_string_as_none, AuthSession, MyWatchesPage, ShowKind, Watch, WatchQuest,
|
||||
Wender,
|
||||
db_id::DbId,
|
||||
util::{empty_string_as_none, year_to_epoch},
|
||||
AuthContext, MyWatchesPage, ShowKind, Watch, WatchQuest,
|
||||
};
|
||||
|
||||
//-************************************************************************
|
||||
// Constants
|
||||
//-************************************************************************
|
||||
|
||||
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_SAVED_WATCHES_QUERY: &str =
|
||||
"select * from watches left join watch_quests on $1 = watch_quests.user and watches.id = watch_quests.watch";
|
||||
|
||||
const GET_WATCH_QUERY: &str = "select * from watches where id = $1";
|
||||
|
||||
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_QUERY: &str = "insert into watches (id, title, kind, release_date, metadata_url, added_by) values ($1, $2, $3, $4, $5, $6)";
|
||||
const ADD_WATCH_QUEST_QUERY: &str =
|
||||
"insert into watch_quests (user, watch, public, watched) values ($1, $2, $3, $4)";
|
||||
|
||||
const CHECKMARK: &str = "✓";
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
// 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 {
|
||||
|
@ -53,220 +95,127 @@ pub struct PostAddNewWatch {
|
|||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct PostAddExistingWatch {
|
||||
pub watch: String,
|
||||
pub id: String,
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct PostEditQuest {
|
||||
pub watch: String,
|
||||
pub act: String,
|
||||
pub watched_already: bool,
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
// handlers
|
||||
//-************************************************************************
|
||||
|
||||
pub async fn get_add_new_watch(auth: AuthSession) -> impl IntoResponse {
|
||||
AddNewWatchPage { user: auth.user }.render().wender()
|
||||
pub async fn get_add_new_watch(auth: AuthContext) -> impl IntoResponse {
|
||||
AddNewWatchPage {
|
||||
user: auth.current_user,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Watch to your watchlist (side effects system-add)
|
||||
pub async fn post_add_new_watch(
|
||||
auth: AuthSession,
|
||||
auth: AuthContext,
|
||||
State(pool): State<SqlitePool>,
|
||||
Form(form): Form<PostAddNewWatch>,
|
||||
) -> Result<impl IntoResponse, WatchesError> {
|
||||
if let Some(user) = auth.user {
|
||||
) -> Result<impl IntoResponse, WatchAddError> {
|
||||
if let Some(user) = auth.current_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,
|
||||
release_date: form.year,
|
||||
length: None,
|
||||
release_date,
|
||||
added_by: user.id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let watch_id = add_new_watch_impl(&pool, &watch).await?;
|
||||
let quest = WatchQuest {
|
||||
user: user.id,
|
||||
public: !form.private,
|
||||
watched: false,
|
||||
watch: watch_id,
|
||||
is_public: !form.private,
|
||||
already_watched: form.watched_already,
|
||||
};
|
||||
add_watch_quest_impl(&pool, &quest).await?;
|
||||
|
||||
add_new_watch_impl(&pool, &watch, Some(quest)).await?;
|
||||
|
||||
let location = format!("/watch/{watch_id}");
|
||||
Ok(Redirect::to(&location))
|
||||
}
|
||||
} else {
|
||||
let e: AddError = AddErrorKind::NotSignedIn.into();
|
||||
Err(e.into())
|
||||
Err(WatchAddErrorKind::NotSignedIn.into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_new_watch_impl(db_pool: &SqlitePool, watch: &Watch) -> Result<Julid, AddError> {
|
||||
let watch_id: Julid = query_scalar(ADD_WATCH_QUERY)
|
||||
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)
|
||||
.bind(&watch.title)
|
||||
.bind(watch.kind)
|
||||
.bind(&watch.release_date)
|
||||
.bind(watch.release_date)
|
||||
.bind(&watch.metadata_url)
|
||||
.bind(watch.added_by)
|
||||
.bind(watch.length)
|
||||
.fetch_one(db_pool)
|
||||
.execute(&mut tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!("Got error: {err}");
|
||||
AddErrorKind::DBError
|
||||
WatchAddErrorKind::UnknownDBError
|
||||
})?;
|
||||
Ok(watch_id)
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Add a Watch to your watchlist by selecting it with a checkbox
|
||||
pub async fn post_add_watch_quest(
|
||||
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)
|
||||
}
|
||||
_auth: AuthContext,
|
||||
State(_pool): State<SqlitePool>,
|
||||
Form(_form): Form<PostAddExistingWatch>,
|
||||
) -> impl IntoResponse {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn add_watch_quest_impl(pool: &SqlitePool, quest: &WatchQuest) -> Result<(), AddError> {
|
||||
pub async fn _add_watch_quest_impl(pool: &SqlitePool, quest: &WatchQuest) -> Result<(), ()> {
|
||||
query(ADD_WATCH_QUEST_QUERY)
|
||||
.bind(quest.user)
|
||||
.bind(quest.watch)
|
||||
.bind(quest.public)
|
||||
.bind(quest.watched)
|
||||
.bind(quest.is_public)
|
||||
.bind(quest.already_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: AuthSession,
|
||||
auth: AuthContext,
|
||||
watch: Option<Path<String>>,
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> impl IntoResponse {
|
||||
|
@ -276,24 +225,22 @@ pub async fn get_watch(
|
|||
"".to_string()
|
||||
};
|
||||
let id = id.trim();
|
||||
let id = Julid::from_str(id).unwrap_or_default();
|
||||
let id = DbId::from_string(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.user,
|
||||
user: auth.current_user,
|
||||
}
|
||||
.render()
|
||||
.wender()
|
||||
}
|
||||
|
||||
/// everything the user has saved
|
||||
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 {
|
||||
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() {
|
||||
query_as(GET_SAVED_WATCHES_QUERY)
|
||||
.bind(user.id)
|
||||
.bind(user.as_ref().unwrap().id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
|
@ -301,31 +248,40 @@ pub async fn get_watches(auth: AuthSession, State(pool): State<SqlitePool>) -> i
|
|||
vec![]
|
||||
};
|
||||
|
||||
MyWatchesPage { watches, user }.render().wender()
|
||||
MyWatchesPage { watches, user }
|
||||
}
|
||||
|
||||
pub async fn get_watch_status(
|
||||
auth: AuthSession,
|
||||
pub async fn get_search_watch(
|
||||
auth: AuthContext,
|
||||
State(pool): State<SqlitePool>,
|
||||
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())
|
||||
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()))
|
||||
} else {
|
||||
Ok("<a href='/login'>Login to add</a>".into_response())
|
||||
("".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,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkmark(public: bool) -> String {
|
||||
let public = if public { "public" } else { "private" };
|
||||
format!("{CHECKMARK} ({public})")
|
||||
}
|
||||
|
|
|
@ -1,42 +1,10 @@
|
|||
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,
|
||||
)]
|
||||
|
@ -101,109 +69,40 @@ impl From<i64> for ShowKind {
|
|||
sqlx::FromRow,
|
||||
)]
|
||||
pub struct Watch {
|
||||
pub id: Julid,
|
||||
pub id: DbId,
|
||||
pub title: String,
|
||||
pub kind: ShowKind,
|
||||
pub metadata_url: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
pub release_date: Option<String>,
|
||||
pub added_by: Julid, // this shouldn't be exposed to randos in the application
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//-************************************************************************
|
||||
/// Something a user wants to watch
|
||||
//-************************************************************************
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::FromRow,
|
||||
)]
|
||||
pub struct WatchQuest {
|
||||
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,
|
||||
pub struct WatchQuest {
|
||||
pub user: DbId,
|
||||
pub watch: DbId,
|
||||
pub is_public: bool,
|
||||
pub already_watched: bool,
|
||||
}
|
||||
|
||||
impl WatchQuest {
|
||||
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(),
|
||||
}
|
||||
pub fn id(&self) -> (DbId, DbId) {
|
||||
(self.user, self.watch)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
use askama::Template;
|
||||
use julid::Julid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{OptionalOptionalUser, User, Watch, WatchQuest};
|
||||
use crate::{OptionalOptionalUser, User, Watch};
|
||||
|
||||
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
|
||||
#[template(path = "my_watches_page.html")]
|
||||
|
@ -11,6 +10,14 @@ 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 {
|
||||
|
@ -23,10 +30,3 @@ 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>,
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "macros.html" as m %}
|
||||
|
||||
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
|||
{% if user.is_some() %}
|
||||
|
||||
<div class="add-watch">
|
||||
<form action="/watch/add" enctype="application/x-www-form-urlencoded" method="post">
|
||||
<form action="/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,9 +45,8 @@
|
|||
{% 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 %}
|
||||
|
|
|
@ -1,29 +1,20 @@
|
|||
<!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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "macros.html" as m %}
|
||||
|
||||
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Welcome to What 2 Watch, Bish{% 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.release_date, "when??") %}
|
||||
<span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.year(), "when??") %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
|
|
@ -4,21 +4,19 @@
|
|||
{% 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>
|
||||
|
||||
{% when None %}
|
||||
<div class="header_signout">
|
||||
<input type="submit" value="sign out?">
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="header_logged_out">
|
||||
<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>?
|
||||
</div>
|
||||
{% endmatch %}
|
||||
|
||||
{% else %} <!-- this is for whether or not the template has a user field or not -->
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<hr/>
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Welcome to What 2 Watch, Bish{% 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>
|
||||
{% when None %}
|
||||
{% 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 %}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Login to What 2 Watch, Bish{% 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="Login">
|
||||
<input type="submit" value="Signup">
|
||||
</form>
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Logout of What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Logout of What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block header %}{% endblock %}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Thanks for Signing Up for What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Thanks for Signing Up for What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block header %}{% endblock %}
|
||||
|
||||
|
|
|
@ -1,51 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "macros.html" as m %}
|
||||
|
||||
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Whatcha Watchin?</h1>
|
||||
|
||||
{% match user %}
|
||||
{% when Some with (usr) %}
|
||||
<div>
|
||||
<p>
|
||||
Hello, {{ usr.username }}! It's nice to see you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{% when Some with (usr) %}
|
||||
<p>
|
||||
<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>
|
||||
Hello, {{ usr.username }}! It's nice to see you.
|
||||
</p>
|
||||
|
||||
</br>
|
||||
<p>Here are your things to watch:</p>
|
||||
<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 watches %}
|
||||
{% include "watchlist-item.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul>
|
||||
{% for watch in watches %}
|
||||
<li><span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.year(), "when??") %}: </li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</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 %}
|
||||
|
||||
|
|
|
@ -1,53 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "macros.html" as m %}
|
||||
|
||||
{% block title %}Welcome to What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Welcome to What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Whatcha Watchin?</h1>
|
||||
|
||||
<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-search"></br>
|
||||
|
||||
<label for="year">Release Year</label>
|
||||
<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="quicksearch-query">{{self.search}}</div>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" name="title" id="title"></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="submit" value="Let's go!">
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign Up for What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Sign Up for What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block header %} {% endblock %}
|
||||
|
||||
|
@ -8,7 +8,6 @@
|
|||
|
||||
<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>
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Thanks for Signing Up for What 2 Watch, Buddy{% endblock %}
|
||||
{% block title %}Thanks for Signing Up for What 2 Watch, Bish{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block header %}{% endblock %}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
<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: 115 KiB After Width: | Height: | Size: 113 KiB |
Loading…
Reference in a new issue