Compare commits
4 Commits
97b30b1840
...
69e84b4266
Author | SHA1 | Date |
---|---|---|
|
69e84b4266 | |
|
5017bfb710 | |
|
4902f4d1fe | |
|
89f8b08b5e |
|
@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter)
|
## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter)
|
||||||
|
|
||||||
* CLI command to add new users
|
* CLI command to add new users
|
||||||
|
* Added public sign-up page (disabled by default)
|
||||||
|
|
||||||
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)
|
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
data_dir = "./data"
|
data_dir = "./data"
|
||||||
log_level = "debug"
|
log_level = "debug"
|
||||||
|
|
||||||
|
allow_public_signup = true
|
||||||
|
|
||||||
[net]
|
[net]
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
domain = "127.0.0.1"
|
domain = "127.0.0.1"
|
||||||
|
|
|
@ -23,6 +23,7 @@ pub fn serve(config: &crate::config::Config) -> Result<(), CliError> {
|
||||||
let ctx = server::Context {
|
let ctx = server::Context {
|
||||||
store,
|
store,
|
||||||
tera: Arc::new(tera),
|
tera: Arc::new(tera),
|
||||||
|
config: config.clone(),
|
||||||
};
|
};
|
||||||
let app = server::app(ctx.clone());
|
let app = server::app(ctx.clone());
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ impl From<LogLevel> for tracing::Level {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum NetConfig {
|
pub enum NetConfig {
|
||||||
|
@ -31,12 +31,13 @@ pub enum NetConfig {
|
||||||
Unix { path: PathBuf },
|
Unix { path: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub net: NetConfig,
|
pub net: NetConfig,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub session_cleanup_interval: u64,
|
pub session_cleanup_interval: u64,
|
||||||
pub log_level: LogLevel,
|
pub log_level: LogLevel,
|
||||||
|
pub allow_public_signup: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
@ -50,6 +51,7 @@ impl Default for Config {
|
||||||
// Once per day
|
// Once per day
|
||||||
session_cleanup_interval: 60 * 60 * 24,
|
session_cleanup_interval: 60 * 60 * 24,
|
||||||
log_level: LogLevel::Warn,
|
log_level: LogLevel::Warn,
|
||||||
|
allow_public_signup: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ use tower_http::trace::TraceLayer;
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
pub store: ::gpodder::GpodderRepository,
|
pub store: ::gpodder::GpodderRepository,
|
||||||
pub tera: Arc<tera::Tera>,
|
pub tera: Arc<tera::Tera>,
|
||||||
|
pub config: crate::config::Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app(ctx: Context) -> Router {
|
pub fn app(ctx: Context) -> Router {
|
||||||
|
|
|
@ -21,7 +21,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn router(ctx: Context) -> Router<Context> {
|
pub fn router(ctx: Context) -> Router<Context> {
|
||||||
Router::new()
|
let mut router = Router::new()
|
||||||
// .layer(middleware::from_fn_with_state(
|
// .layer(middleware::from_fn_with_state(
|
||||||
// ctx.clone(),
|
// ctx.clone(),
|
||||||
// auth_web_middleware,
|
// auth_web_middleware,
|
||||||
|
@ -29,7 +29,60 @@ pub fn router(ctx: Context) -> Router<Context> {
|
||||||
// Login route needs to be handled differently, as the middleware turns it into a redirect
|
// Login route needs to be handled differently, as the middleware turns it into a redirect
|
||||||
// loop
|
// loop
|
||||||
.route("/login", get(get_login).post(post_login))
|
.route("/login", get(get_login).post(post_login))
|
||||||
.route("/logout", post(post_logout))
|
.route("/logout", post(post_logout));
|
||||||
|
|
||||||
|
// If public signups aren't allowed, we don't even register the route to prevent any dumb
|
||||||
|
// security mistakes
|
||||||
|
if ctx.config.allow_public_signup {
|
||||||
|
router = router.route("/signup", get(get_signup).post(post_signup))
|
||||||
|
}
|
||||||
|
|
||||||
|
router
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware that authenticates the current user via the session token. If the credentials are
|
||||||
|
/// invalid, the user is redirected to the login page.
|
||||||
|
pub async fn auth_web_middleware(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// SAFETY: this extractor's error type is Infallible
|
||||||
|
let jar: CookieJar = req.extract_parts().await.unwrap();
|
||||||
|
let redirect = Redirect::to("/login");
|
||||||
|
|
||||||
|
match extract_session(ctx, &jar).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
req.extensions_mut().insert(session);
|
||||||
|
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
Ok(None) => redirect.into_response(),
|
||||||
|
Err(err) => err.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
|
||||||
|
if let Some(session_id) = jar
|
||||||
|
.get(super::SESSION_ID_COOKIE)
|
||||||
|
.and_then(|c| c.value().parse::<i64>().ok())
|
||||||
|
{
|
||||||
|
match tokio::task::spawn_blocking(move || {
|
||||||
|
let session = ctx.store.get_session(session_id)?;
|
||||||
|
ctx.store.refresh_session(&session)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Ok(session) => Ok(Some(session)),
|
||||||
|
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
|
||||||
|
Err(err) => Err(AppError::from(err)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
|
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
|
||||||
|
@ -41,10 +94,12 @@ async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJa
|
||||||
{
|
{
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
} else {
|
} else {
|
||||||
View::Login
|
View::Login {
|
||||||
.page(&headers)
|
signup_note: ctx.config.allow_public_signup,
|
||||||
.response(&ctx.tera)
|
}
|
||||||
.into_response()
|
.page(&headers)
|
||||||
|
.response(&ctx.tera)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,47 +158,106 @@ async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<im
|
||||||
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
|
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
|
#[derive(Deserialize)]
|
||||||
if let Some(session_id) = jar
|
struct SignupForm {
|
||||||
.get(super::SESSION_ID_COOKIE)
|
username: String,
|
||||||
.and_then(|c| c.value().parse::<i64>().ok())
|
password: String,
|
||||||
{
|
password_confirm: String,
|
||||||
match tokio::task::spawn_blocking(move || {
|
}
|
||||||
let session = ctx.store.get_session(session_id)?;
|
|
||||||
ctx.store.refresh_session(&session)?;
|
|
||||||
|
|
||||||
Ok(session)
|
struct SignupValidation {
|
||||||
|
username_available: bool,
|
||||||
|
passwords_match: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignupForm {
|
||||||
|
fn validate(&self, ctx: &Context) -> AppResult<SignupValidation> {
|
||||||
|
let username_available = match ctx.store.get_user(&self.username) {
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(AuthErr::UnknownUser) => true,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let passwords_match = self.password == self.password_confirm;
|
||||||
|
|
||||||
|
Ok(SignupValidation {
|
||||||
|
username_available,
|
||||||
|
passwords_match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignupValidation {
|
||||||
|
pub fn valid(&self) -> bool {
|
||||||
|
self.username_available && self.passwords_match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_signup(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
|
||||||
|
if extract_session(ctx.clone(), &jar)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
} else {
|
||||||
|
View::Signup {
|
||||||
|
username: None,
|
||||||
|
username_available: true,
|
||||||
|
passwords_match: true,
|
||||||
|
}
|
||||||
|
.page(&headers)
|
||||||
|
.response(&ctx.tera)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_signup(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
user_agent: Option<TypedHeader<UserAgent>>,
|
||||||
|
Form(signup): Form<SignupForm>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let validation = signup.validate(&ctx)?;
|
||||||
|
|
||||||
|
if validation.valid() {
|
||||||
|
// Create the user and log them in
|
||||||
|
match tokio::task::spawn_blocking(move || {
|
||||||
|
let user = ctx.store.create_user(&signup.username, &signup.password)?;
|
||||||
|
let user_agent = user_agent.map(|header| header.to_string());
|
||||||
|
let session = ctx.store.user(&user).create_session(user_agent)?;
|
||||||
|
|
||||||
|
Ok::<_, AuthErr>(session)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
{
|
{
|
||||||
Ok(session) => Ok(Some(session)),
|
Ok(session) => Ok((
|
||||||
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
|
// Redirect forces htmx to reload the full page, refreshing the navbar
|
||||||
|
[("HX-Redirect", "/")],
|
||||||
|
(jar.add(
|
||||||
|
Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string()))
|
||||||
|
.secure(true)
|
||||||
|
.same_site(cookie::SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/")
|
||||||
|
.max_age(Duration::days(365)),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
Err(err) => Err(AppError::from(err)),
|
Err(err) => Err(AppError::from(err)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(View::Signup {
|
||||||
}
|
username: Some(signup.username),
|
||||||
}
|
username_available: validation.username_available,
|
||||||
|
passwords_match: validation.passwords_match,
|
||||||
/// Middleware that authenticates the current user via the session token. If the credentials are
|
|
||||||
/// invalid, the user is redirected to the login page.
|
|
||||||
pub async fn auth_web_middleware(
|
|
||||||
State(ctx): State<Context>,
|
|
||||||
mut req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
// SAFETY: this extractor's error type is Infallible
|
|
||||||
let jar: CookieJar = req.extract_parts().await.unwrap();
|
|
||||||
let redirect = Redirect::to("/login");
|
|
||||||
|
|
||||||
match extract_session(ctx, &jar).await {
|
|
||||||
Ok(Some(session)) => {
|
|
||||||
req.extensions_mut().insert(session);
|
|
||||||
|
|
||||||
next.run(req).await
|
|
||||||
}
|
}
|
||||||
Ok(None) => redirect.into_response(),
|
.page(&headers)
|
||||||
Err(err) => err.into_response(),
|
.response(&ctx.tera)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,7 +78,7 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
|
||||||
include_str!("templates/views/index.html"),
|
include_str!("templates/views/index.html"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
View::Login.template(),
|
View::Login { signup_note: false }.template(),
|
||||||
include_str!("templates/views/login.html"),
|
include_str!("templates/views/login.html"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -89,6 +89,15 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
|
||||||
View::Users(Vec::new(), 0, None).template(),
|
View::Users(Vec::new(), 0, None).template(),
|
||||||
include_str!("templates/views/users.html"),
|
include_str!("templates/views/users.html"),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
View::Signup {
|
||||||
|
username: None,
|
||||||
|
username_available: true,
|
||||||
|
passwords_match: true,
|
||||||
|
}
|
||||||
|
.template(),
|
||||||
|
include_str!("templates/views/signup.html"),
|
||||||
|
),
|
||||||
])?;
|
])?;
|
||||||
|
|
||||||
Ok(tera)
|
Ok(tera)
|
||||||
|
|
|
@ -6,4 +6,8 @@
|
||||||
<input type="password" id="password" name="password">
|
<input type="password" id="password" name="password">
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if signup_note %}
|
||||||
|
<p>Don't have an account yet? <a hx-get="/signup" hx-target="#inner" hx-push-url="/signup">Create one here</a>!</p>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
<article>
|
||||||
|
<form hx-post hx-target="#inner">
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value="{{ username }}"
|
||||||
|
{% if not username_available %}
|
||||||
|
aria-invalid="true"
|
||||||
|
aria-describedby="username-helper"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if not username_available %}
|
||||||
|
<small id="username-helper">Username not available</small>
|
||||||
|
{% endif %}
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password">
|
||||||
|
<label for="password_confirm">Confirm password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password_confirm"
|
||||||
|
name="password_confirm"
|
||||||
|
{% if not passwords_match %}
|
||||||
|
aria-invalid="true"
|
||||||
|
aria-describedby="password-helper"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if not passwords_match %}
|
||||||
|
<small id="password-helper">Passwords don't match</small>
|
||||||
|
{% endif %}
|
||||||
|
<input type="submit" value="Sign Up">
|
||||||
|
</form>
|
||||||
|
</article>
|
|
@ -12,11 +12,21 @@
|
||||||
<table id="users">
|
<table id="users">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
|
<th>Action</th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{%- for user in users %}
|
{%- for user in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ user.username }}</th>
|
<th>{{ user.username }}</th>
|
||||||
|
<th>
|
||||||
|
{%- if user.id != current_user_id -%}
|
||||||
|
<a hx-delete="/users/{{ user.id }}"
|
||||||
|
hx-target="closest tr"
|
||||||
|
>Remove</a>
|
||||||
|
{%- else -%}
|
||||||
|
Current user
|
||||||
|
{%- endif -%}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,16 @@ use super::{Query, Template};
|
||||||
|
|
||||||
pub enum View {
|
pub enum View {
|
||||||
Index,
|
Index,
|
||||||
Login,
|
Login {
|
||||||
|
signup_note: bool,
|
||||||
|
},
|
||||||
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
|
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
|
||||||
Users(Vec<gpodder::User>, i64, Option<Query>),
|
Users(Vec<gpodder::User>, i64, Option<Query>),
|
||||||
|
Signup {
|
||||||
|
username: Option<String>,
|
||||||
|
username_available: bool,
|
||||||
|
passwords_match: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
@ -27,9 +34,10 @@ impl Template for View {
|
||||||
fn template(&self) -> &'static str {
|
fn template(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Index => "views/index.html",
|
Self::Index => "views/index.html",
|
||||||
Self::Login => "views/login.html",
|
Self::Login { .. } => "views/login.html",
|
||||||
Self::Sessions(..) => "views/sessions.html",
|
Self::Sessions(..) => "views/sessions.html",
|
||||||
Self::Users(..) => "views/users.html",
|
Self::Users(..) => "views/users.html",
|
||||||
|
Self::Signup { .. } => "views/signup.html",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +69,18 @@ impl Template for View {
|
||||||
ctx.insert("next_page_query", &query.encode());
|
ctx.insert("next_page_query", &query.encode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Self::Signup {
|
||||||
|
username,
|
||||||
|
username_available,
|
||||||
|
passwords_match,
|
||||||
|
} => {
|
||||||
|
ctx.insert("username", &username);
|
||||||
|
ctx.insert("username_available", &username_available);
|
||||||
|
ctx.insert("passwords_match", &passwords_match);
|
||||||
|
}
|
||||||
|
Self::Login { signup_note } => {
|
||||||
|
ctx.insert("signup_note", &signup_note);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue