forked from 2sys/shoutdotdev
initial commit
This commit is contained in:
commit
1524c2025e
35 changed files with 4608 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/target
|
||||||
|
.wrangler
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
5
.ignore
Normal file
5
.ignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
target
|
||||||
|
node_modules
|
||||||
|
.wrangler
|
||||||
|
build
|
||||||
|
.DS_Store
|
3144
Cargo.lock
generated
Normal file
3144
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
Cargo.toml
Normal file
35
Cargo.toml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
[package]
|
||||||
|
name = "callout_dev"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[unstable]
|
||||||
|
async_iterator = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tower-service = "0.3.2"
|
||||||
|
console_error_panic_hook = { version = "0.1.1" }
|
||||||
|
anyhow = "1.0.91"
|
||||||
|
serde_json = "1.0.132"
|
||||||
|
oauth2 = "4.4.2"
|
||||||
|
config = "0.14.1"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
serde = { version = "1.0.213", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12.8", features = ["json"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
async-session = "3.0.0"
|
||||||
|
askama_axum = { version = "0.4.0", features = ["urlencode"] }
|
||||||
|
askama = { version = "0.12.1", features = ["with-axum"] }
|
||||||
|
futures = "0.3.31"
|
||||||
|
uuid = { version = "1.11.0", features = ["js", "serde", "v4", "v7"] }
|
||||||
|
rand = "0.8.5"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
||||||
|
tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "fs", "trace", "tracing"] }
|
||||||
|
tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] }
|
||||||
|
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
|
||||||
|
axum = "0.8.1"
|
||||||
|
axum-extra = { version = "0.10.0", features = ["cookie", "typed-header"] }
|
||||||
|
chrono = { version = "0.4.39", features = ["serde"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] }
|
||||||
|
tower = "0.5.2"
|
7
bacon.toml
Normal file
7
bacon.toml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
[jobs.webserver]
|
||||||
|
command = ["cargo", "run"]
|
||||||
|
need_stdout = true
|
||||||
|
background = false
|
||||||
|
on_change_strategy = "kill_then_restart"
|
||||||
|
kill = ["kill", "-s", "INT"]
|
||||||
|
watch =["src", "templates"]
|
13
dev-services/docker-compose.yaml
Normal file
13
dev-services/docker-compose.yaml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name: callout
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: postgres:17
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: callous
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5447:5432"
|
||||||
|
volumes:
|
||||||
|
- "./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro"
|
3
dev-services/docker-entrypoint-initdb.d/init-callout.sql
Normal file
3
dev-services/docker-entrypoint-initdb.d/init-callout.sql
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
CREATE USER callout WITH ENCRYPTED PASSWORD 'callous';
|
||||||
|
CREATE DATABASE callout;
|
||||||
|
ALTER DATABASE callout OWNER TO callout;
|
9
diesel.toml
Normal file
9
diesel.toml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# For documentation on how to configure this file,
|
||||||
|
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||||
|
|
||||||
|
[print_schema]
|
||||||
|
file = "src/schema.rs"
|
||||||
|
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||||
|
|
||||||
|
[migrations_directory]
|
||||||
|
dir = "/Users/brent-personal/Developer/callout_dev/migrations"
|
0
migrations/.keep
Normal file
0
migrations/.keep
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
-- This file was automatically created by Diesel to setup helper functions
|
||||||
|
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||||
|
-- changes will be added to existing projects as new migrations.
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
||||||
|
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal file
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
-- This file was automatically created by Diesel to setup helper functions
|
||||||
|
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||||
|
-- changes will be added to existing projects as new migrations.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Sets up a trigger for the given table to automatically set a column called
|
||||||
|
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||||
|
-- in the modified columns)
|
||||||
|
--
|
||||||
|
-- # Example
|
||||||
|
--
|
||||||
|
-- ```sql
|
||||||
|
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
|
||||||
|
--
|
||||||
|
-- SELECT diesel_manage_updated_at('users');
|
||||||
|
-- ```
|
||||||
|
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (
|
||||||
|
NEW IS DISTINCT FROM OLD AND
|
||||||
|
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
||||||
|
) THEN
|
||||||
|
NEW.updated_at := current_timestamp;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
6
migrations/2024-11-25-232658_init/down.sql
Normal file
6
migrations/2024-11-25-232658_init/down.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
DROP TABLE IF EXISTS api_keys;
|
||||||
|
DROP TABLE IF EXISTS team_memberships;
|
||||||
|
DROP TABLE IF EXISTS teams;
|
||||||
|
DROP TABLE IF EXISTS csrf_tokens;
|
||||||
|
DROP TABLE IF EXISTS users;
|
39
migrations/2024-11-25-232658_init/up.sql
Normal file
39
migrations/2024-11-25-232658_init/up.sql
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX ON users (uid);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS csrf_tokens (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX ON csrf_tokens (expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE teams (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE team_memberships (
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
PRIMARY KEY (team_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON team_memberships (team_id);
|
||||||
|
CREATE INDEX ON team_memberships (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON api_Keys (team_id);
|
||||||
|
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
1
migrations/2025-01-08-211839_sessions/down.sql
Normal file
1
migrations/2025-01-08-211839_sessions/down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS browser_sessions;
|
4
migrations/2025-01-08-211839_sessions/up.sql
Normal file
4
migrations/2025-01-08-211839_sessions/up.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE browser_sessions (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
serialized TEXT NOT NULL
|
||||||
|
);
|
35
src/api_keys.rs
Normal file
35
src/api_keys.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use deadpool_diesel::postgres::Pool;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{app_error::AppError, models::Team, schema};
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::api_keys)]
|
||||||
|
#[diesel(belongs_to(Team))]
|
||||||
|
pub struct ApiKey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub team_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiKey {
|
||||||
|
pub async fn generate_for_team(db_pool: &Pool, team_id: Uuid) -> Result<Self, AppError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let api_key = db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
diesel::insert_into(schema::api_keys::table)
|
||||||
|
.values((
|
||||||
|
schema::api_keys::id.eq(id),
|
||||||
|
schema::api_keys::team_id.eq(team_id),
|
||||||
|
))
|
||||||
|
.returning(ApiKey::as_select())
|
||||||
|
.get_result(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
Ok(api_key)
|
||||||
|
}
|
||||||
|
}
|
33
src/app_error.rs
Normal file
33
src/app_error.rs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
use anyhow::Error;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
// Use anyhow, define error and enable '?'
|
||||||
|
// For a simplified example of using anyhow in axum check /examples/anyhow-error-response
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
InternalServerError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell axum how to convert `AppError` into a response.
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
Self::InternalServerError(err) => {
|
||||||
|
tracing::error!("Application error: {:#}", err);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
|
||||||
|
// `Result<_, AppError>`. That way you don't need to do that manually.
|
||||||
|
impl<E> From<E> for AppError
|
||||||
|
where
|
||||||
|
E: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self::InternalServerError(Into::<anyhow::Error>::into(err))
|
||||||
|
}
|
||||||
|
}
|
19
src/app_state.rs
Normal file
19
src/app_state.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use axum::extract::FromRef;
|
||||||
|
use deadpool_diesel::postgres::Pool;
|
||||||
|
use oauth2::basic::BasicClient;
|
||||||
|
|
||||||
|
use crate::{sessions::PgStore, settings::Settings};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct AppState {
|
||||||
|
pub db_pool: Pool,
|
||||||
|
pub oauth_client: BasicClient,
|
||||||
|
pub session_store: PgStore,
|
||||||
|
pub settings: Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for PgStore {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.session_store.clone()
|
||||||
|
}
|
||||||
|
}
|
180
src/auth.rs
Normal file
180
src/auth.rs
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use async_session::{Session, SessionStore as _};
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequestParts, Query, State},
|
||||||
|
http::request::Parts,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
routing::get,
|
||||||
|
RequestPartsExt, Router,
|
||||||
|
};
|
||||||
|
use axum_extra::{
|
||||||
|
extract::cookie::{Cookie, CookieJar, SameSite},
|
||||||
|
headers, TypedHeader,
|
||||||
|
};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use oauth2::{
|
||||||
|
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
|
||||||
|
ClientSecret, CsrfToken, RedirectUrl, TokenResponse, TokenUrl,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, span, trace_span, Level};
|
||||||
|
|
||||||
|
use crate::{app_error::AppError, app_state::AppState, schema, settings::Settings};
|
||||||
|
|
||||||
|
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient, AppError> {
|
||||||
|
Ok(BasicClient::new(
|
||||||
|
ClientId::new(settings.auth.client_id.clone()),
|
||||||
|
Some(ClientSecret::new(settings.auth.client_secret.clone())),
|
||||||
|
AuthUrl::new(settings.auth.auth_url.clone())
|
||||||
|
.context("failed to create new authorization server URL")?,
|
||||||
|
Some(
|
||||||
|
TokenUrl::new(settings.auth.token_url.clone())
|
||||||
|
.context("failed to create new token endpoint URL")?,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(
|
||||||
|
RedirectUrl::new(settings.auth.redirect_url.clone())
|
||||||
|
.context("failed to create new redirection URL")?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/login", get(propel_auth))
|
||||||
|
.route("/callback", get(login_authorized))
|
||||||
|
.route("/logout", get(logout))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn propel_auth(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let csrf_token = CsrfToken::new_random();
|
||||||
|
let (auth_url, _csrf_token) = state
|
||||||
|
.oauth_client
|
||||||
|
.authorize_url(|| csrf_token)
|
||||||
|
// .add_scopes(vec![Scope::new("openid".to_string())])
|
||||||
|
.url();
|
||||||
|
// FIXME: check CSRF token
|
||||||
|
Redirect::to(auth_url.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
TypedHeader(cookies): TypedHeader<headers::Cookie>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let cookie = cookies
|
||||||
|
.get(state.settings.auth.cookie_name.as_str())
|
||||||
|
.context("couldn't get session cookie")?;
|
||||||
|
let session_id = Session::id_from_cookie_value(cookie)?;
|
||||||
|
state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
diesel::delete(schema::browser_sessions::table)
|
||||||
|
.filter(schema::browser_sessions::id.eq(session_id))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
// FIXME: call logout endpoint of OIDC provider
|
||||||
|
Ok(Redirect::to(&state.settings.base_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthRequestQuery {
|
||||||
|
code: String,
|
||||||
|
state: String, // CSRF token
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const AUTH_INFO_SESSION_KEY: &'static str = "user";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct AuthInfo {
|
||||||
|
pub sub: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_info(
|
||||||
|
settings: &Settings,
|
||||||
|
access_token: &oauth2::AccessToken,
|
||||||
|
) -> Result<AuthInfo, AppError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
Ok(client
|
||||||
|
.get(settings.auth.userinfo_url.as_str())
|
||||||
|
.bearer_auth(access_token.secret())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_authorized(
|
||||||
|
Query(query): Query<AuthRequestQuery>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let response = state
|
||||||
|
.oauth_client
|
||||||
|
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.await?;
|
||||||
|
let user_info = get_user_info(&state.settings, response.access_token()).await?;
|
||||||
|
let mut session = Session::new();
|
||||||
|
session.insert(AUTH_INFO_SESSION_KEY, &user_info)?;
|
||||||
|
let cookie_value = state
|
||||||
|
.session_store
|
||||||
|
.store_session(session)
|
||||||
|
.await?
|
||||||
|
.context("cookie value from store_session() is None")?;
|
||||||
|
let jar = jar.add(
|
||||||
|
Cookie::build((state.settings.auth.cookie_name.clone(), cookie_value))
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/"),
|
||||||
|
);
|
||||||
|
Ok((jar, Redirect::to(&format!("{}/", state.settings.base_path))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuthRedirect {
|
||||||
|
base_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRedirect {
|
||||||
|
pub fn new(base_path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
base_path: base_path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AuthRedirect {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
Redirect::to(&format!("{}/auth/login", self.base_path)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for AuthInfo {
|
||||||
|
type Rejection = AuthRedirect;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
|
||||||
|
let _ = trace_span!("AuthInfo from_request_parts()").enter();
|
||||||
|
let jar = parts.extract::<CookieJar>().await.unwrap();
|
||||||
|
let session_cookie = jar
|
||||||
|
.get(&state.settings.auth.cookie_name)
|
||||||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||||||
|
debug!("session cookie loaded");
|
||||||
|
let session = state
|
||||||
|
.session_store
|
||||||
|
.load_session(session_cookie.value().to_string())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||||||
|
debug!("session loaded");
|
||||||
|
let user = session
|
||||||
|
.get::<AuthInfo>(AUTH_INFO_SESSION_KEY)
|
||||||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
60
src/csrf.rs
Normal file
60
src/csrf.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
use deadpool_diesel::postgres::Pool;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{app_error::AppError, models::CsrfToken, schema};
|
||||||
|
|
||||||
|
const TOKEN_PREFIX: &'static str = "csrf__";
|
||||||
|
const TTL_SEC: i64 = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
pub async fn generate_csrf_token_for_user(
|
||||||
|
db_pool: &Pool,
|
||||||
|
uid: Option<Uuid>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let expires_at =
|
||||||
|
Utc::now() + TimeDelta::new(TTL_SEC, 0).context("Failed to generate TimeDelta")?;
|
||||||
|
db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
diesel::insert_into(schema::csrf_tokens::table)
|
||||||
|
.values((
|
||||||
|
schema::csrf_tokens::id.eq(id),
|
||||||
|
schema::csrf_tokens::user_id.eq(uid),
|
||||||
|
schema::csrf_tokens::expires_at.eq(expires_at),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
Ok(format!("{}{}", TOKEN_PREFIX, id.hyphenated().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_csrf_token_for_user(
|
||||||
|
db_pool: &Pool,
|
||||||
|
token: &str,
|
||||||
|
uid: Option<Uuid>,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let id = match Uuid::try_parse(&token[TOKEN_PREFIX.len()..]) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
let row = db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::csrf_tokens::table
|
||||||
|
.select(CsrfToken::as_select())
|
||||||
|
.filter(schema::csrf_tokens::id.eq(id))
|
||||||
|
.filter(schema::csrf_tokens::expires_at.gt(Utc::now()))
|
||||||
|
.filter(schema::csrf_tokens::user_id.is_not_distinct_from(uid))
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
52
src/main.rs
Normal file
52
src/main.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
mod api_keys;
|
||||||
|
mod app_error;
|
||||||
|
mod app_state;
|
||||||
|
mod auth;
|
||||||
|
mod csrf;
|
||||||
|
mod models;
|
||||||
|
mod projects;
|
||||||
|
mod router;
|
||||||
|
mod schema;
|
||||||
|
mod sessions;
|
||||||
|
mod settings;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
use app_state::AppState;
|
||||||
|
use router::new_router;
|
||||||
|
use sessions::PgStore;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let settings = Settings::load().unwrap();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let database_url = settings.database_url.clone();
|
||||||
|
let manager =
|
||||||
|
deadpool_diesel::postgres::Manager::new(database_url, deadpool_diesel::Runtime::Tokio1);
|
||||||
|
let db_pool = deadpool_diesel::postgres::Pool::builder(manager)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session_store = PgStore::new(db_pool.clone());
|
||||||
|
|
||||||
|
let oauth_client = auth::new_oauth_client(&settings).unwrap();
|
||||||
|
let app_state = AppState {
|
||||||
|
db_pool,
|
||||||
|
oauth_client,
|
||||||
|
session_store,
|
||||||
|
settings: settings.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = new_router(app_state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind((settings.host, settings.port))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
}
|
42
src/models.rs
Normal file
42
src/models.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
use chrono::{offset::Utc, DateTime};
|
||||||
|
use diesel::{pg::Pg, prelude::*};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::schema;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::teams)]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct Team {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::team_memberships)]
|
||||||
|
#[diesel(belongs_to(Team))]
|
||||||
|
#[diesel(belongs_to(crate::users::User))]
|
||||||
|
#[diesel(primary_key(team_id, user_id))]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct TeamMembership {
|
||||||
|
pub team_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub roles: Vec<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::browser_sessions)]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct BrowserSession {
|
||||||
|
pub id: String,
|
||||||
|
pub serialized: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::csrf_tokens)]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct CsrfToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
13
src/projects.rs
Normal file
13
src/projects.rs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{models::Team, schema};
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::projects)]
|
||||||
|
#[diesel(belongs_to(Team))]
|
||||||
|
pub struct Project {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub team_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
}
|
337
src/router.rs
Normal file
337
src/router.rs
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use askama_axum::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::status::StatusCode,
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
routing::{get, post},
|
||||||
|
Form, Router,
|
||||||
|
};
|
||||||
|
use diesel::{dsl::insert_into, prelude::*, result::Error::NotFound};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
|
services::{ServeDir, ServeFile},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api_keys::ApiKey,
|
||||||
|
app_error::AppError,
|
||||||
|
app_state::AppState,
|
||||||
|
auth::{self, AuthInfo},
|
||||||
|
csrf::{generate_csrf_token_for_user, validate_csrf_token_for_user},
|
||||||
|
models::{Team, TeamMembership},
|
||||||
|
projects::Project,
|
||||||
|
schema,
|
||||||
|
users::{CurrentUser, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn new_router(state: AppState) -> Router<()> {
|
||||||
|
let base_path = state.settings.base_path.clone();
|
||||||
|
Router::new().nest(
|
||||||
|
format!("{}", base_path).as_str(),
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(landing_page))
|
||||||
|
.route("/teams", get(teams_page))
|
||||||
|
.route("/teams/{team_id}", get(team_page))
|
||||||
|
.route("/teams/{team_id}/projects", get(projects_page))
|
||||||
|
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
||||||
|
.route("/new-team", get(new_team_page))
|
||||||
|
.route("/new-team", post(post_new_team))
|
||||||
|
.nest("/auth", auth::new_router())
|
||||||
|
.fallback_service(
|
||||||
|
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
|
||||||
|
)
|
||||||
|
.layer(
|
||||||
|
ServiceBuilder::new()
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(CompressionLayer::new()),
|
||||||
|
)
|
||||||
|
.with_state(state),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn landing_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
Redirect::to(&format!("{}/teams", state.settings.base_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teams_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let current_user_id = current_user.id.clone();
|
||||||
|
let teams_of_current_user = state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::team_memberships::table
|
||||||
|
.inner_join(schema::teams::table)
|
||||||
|
.filter(schema::team_memberships::user_id.eq(current_user_id))
|
||||||
|
.select(Team::as_select())
|
||||||
|
.load(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "teams.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
teams: Vec<Team>,
|
||||||
|
current_user: User,
|
||||||
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
current_user,
|
||||||
|
base_path: state.settings.base_path,
|
||||||
|
teams: teams_of_current_user,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn team_page(State(state): State<AppState>, Path(team_id): Path<Uuid>) -> impl IntoResponse {
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/projects",
|
||||||
|
state.settings.base_path, team_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostNewApiKeyForm {
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_new_api_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
user_info: AuthInfo,
|
||||||
|
Form(form): Form<PostNewApiKeyForm>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let current_uid = user_info.sub.clone();
|
||||||
|
let current_user = state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::users::table
|
||||||
|
.filter(schema::users::uid.eq(current_uid))
|
||||||
|
.select(User::as_select())
|
||||||
|
.first(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap() // Bubble up panic from callback
|
||||||
|
.map_err(|diesel_err| match diesel_err {
|
||||||
|
NotFound => AppError::InternalServerError(anyhow!(
|
||||||
|
"user not found in database for uid {}",
|
||||||
|
user_info.sub
|
||||||
|
)),
|
||||||
|
_ => AppError::InternalServerError(diesel_err.into()),
|
||||||
|
})?;
|
||||||
|
if !validate_csrf_token_for_user(&state.db_pool, &form.csrf_token, Some(current_user.id))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok((StatusCode::FORBIDDEN, "invalid CSRF token".to_string()).into_response());
|
||||||
|
}
|
||||||
|
let team_membership = match state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::team_memberships::table
|
||||||
|
.filter(schema::team_memberships::team_id.eq(team_id))
|
||||||
|
.filter(schema::team_memberships::user_id.eq(current_user.id))
|
||||||
|
.select(TeamMembership::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Some(team_membership) => team_membership,
|
||||||
|
None => {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"not a member of requested team".to_string(),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ApiKey::generate_for_team(&state.db_pool, team_membership.team_id.clone()).await?;
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/projects",
|
||||||
|
state.settings.base_path,
|
||||||
|
team_membership.team_id.hyphenated().to_string()
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_team_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let csrf_token =
|
||||||
|
generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?;
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "new-team.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
csrf_token: String,
|
||||||
|
current_user: User,
|
||||||
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
csrf_token,
|
||||||
|
current_user,
|
||||||
|
base_path: state.settings.base_path,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostNewTeamForm {
|
||||||
|
name: String,
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_new_team(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user_info: AuthInfo,
|
||||||
|
Form(form): Form<PostNewTeamForm>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let current_uid = user_info.sub.clone();
|
||||||
|
let current_user = state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::users::table
|
||||||
|
.filter(schema::users::uid.eq(current_uid))
|
||||||
|
.select(User::as_select())
|
||||||
|
.first(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
if !validate_csrf_token_for_user(
|
||||||
|
&state.db_pool,
|
||||||
|
&form.csrf_token,
|
||||||
|
Some(current_user.id.clone()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(anyhow!("Invalid CSRF token").into());
|
||||||
|
}
|
||||||
|
let team_id = Uuid::now_v7();
|
||||||
|
let team = Team {
|
||||||
|
id: team_id.clone(),
|
||||||
|
name: form.name,
|
||||||
|
};
|
||||||
|
let team_membership = TeamMembership {
|
||||||
|
team_id: team_id.clone(),
|
||||||
|
user_id: current_user.id,
|
||||||
|
roles: vec![Some("OWNER".to_string())],
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.transaction(move |conn| {
|
||||||
|
insert_into(schema::teams::table)
|
||||||
|
.values(team)
|
||||||
|
.execute(conn)?;
|
||||||
|
insert_into(schema::team_memberships::table)
|
||||||
|
.values(team_membership)
|
||||||
|
.execute(conn)?;
|
||||||
|
diesel::QueryResult::Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
ApiKey::generate_for_team(&state.db_pool, team_id.clone()).await?;
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/projects",
|
||||||
|
state.settings.base_path, team_id
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn projects_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let current_user_id = current_user.id.clone();
|
||||||
|
let team = match state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::team_memberships::table
|
||||||
|
.inner_join(schema::teams::table)
|
||||||
|
.filter(schema::team_memberships::user_id.eq(current_user_id))
|
||||||
|
.filter(schema::teams::id.eq(team_id))
|
||||||
|
.select(Team::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Some(team) => team,
|
||||||
|
None => {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"not a member of requested team".to_string(),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let team_id = team.id.clone();
|
||||||
|
let api_keys = state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::api_keys::table
|
||||||
|
.filter(schema::api_keys::team_id.eq(team_id))
|
||||||
|
.select(ApiKey::as_select())
|
||||||
|
.load(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "projects.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
csrf_token: String,
|
||||||
|
keys: Vec<ApiKey>,
|
||||||
|
projects: Vec<Project>,
|
||||||
|
team: Team,
|
||||||
|
current_user: User,
|
||||||
|
}
|
||||||
|
let csrf_token =
|
||||||
|
generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?;
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
csrf_token,
|
||||||
|
current_user,
|
||||||
|
team,
|
||||||
|
base_path: state.settings.base_path,
|
||||||
|
keys: api_keys,
|
||||||
|
projects: vec![],
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
70
src/schema.rs
Normal file
70
src/schema.rs
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
api_keys (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
team_id -> Uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
browser_sessions (id) {
|
||||||
|
id -> Text,
|
||||||
|
serialized -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
csrf_tokens (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Nullable<Uuid>,
|
||||||
|
expires_at -> Timestamptz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
projects (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
team_id -> Uuid,
|
||||||
|
name -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
team_memberships (team_id, user_id) {
|
||||||
|
team_id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
roles -> Array<Nullable<Text>>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
teams (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
name -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
users (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
uid -> Text,
|
||||||
|
email -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::joinable!(api_keys -> teams (team_id));
|
||||||
|
diesel::joinable!(csrf_tokens -> users (user_id));
|
||||||
|
diesel::joinable!(projects -> teams (team_id));
|
||||||
|
diesel::joinable!(team_memberships -> teams (team_id));
|
||||||
|
diesel::joinable!(team_memberships -> users (user_id));
|
||||||
|
|
||||||
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
api_keys,
|
||||||
|
browser_sessions,
|
||||||
|
csrf_tokens,
|
||||||
|
projects,
|
||||||
|
team_memberships,
|
||||||
|
teams,
|
||||||
|
users,
|
||||||
|
);
|
91
src/sessions.rs
Normal file
91
src/sessions.rs
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_session::{async_trait, Session, SessionStore};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{models::BrowserSession, schema};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PgStore {
|
||||||
|
// TODO: reference instead of clone
|
||||||
|
pool: deadpool_diesel::postgres::Pool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgStore {
|
||||||
|
pub fn new(pool: deadpool_diesel::postgres::Pool) -> PgStore {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PgStore {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "PgStore {{ pool }}")?;
|
||||||
|
Ok(()).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SessionStore for PgStore {
|
||||||
|
async fn load_session(&self, cookie_value: String) -> Result<Option<Session>> {
|
||||||
|
let conn = self.pool.get().await?;
|
||||||
|
let session_id = Session::id_from_cookie_value(&cookie_value)?;
|
||||||
|
let rows = conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
schema::browser_sessions::table
|
||||||
|
.filter(schema::browser_sessions::id.eq(session_id))
|
||||||
|
.select(BrowserSession::as_select())
|
||||||
|
.load(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
if rows.len() == 0 {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(serde_json::from_str::<Session>(
|
||||||
|
rows[0].serialized.as_str(),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_session(&self, session: Session) -> Result<Option<String>> {
|
||||||
|
let serialized = serde_json::to_string(&session)?;
|
||||||
|
let conn = self.pool.get().await?;
|
||||||
|
let session_id = session.id().to_string();
|
||||||
|
conn.interact(move |conn| {
|
||||||
|
diesel::insert_into(schema::browser_sessions::table)
|
||||||
|
.values((
|
||||||
|
schema::browser_sessions::id.eq(session_id),
|
||||||
|
schema::browser_sessions::serialized.eq(serialized.clone()),
|
||||||
|
))
|
||||||
|
.on_conflict(schema::browser_sessions::id)
|
||||||
|
.do_update()
|
||||||
|
.set(schema::browser_sessions::serialized.eq(serialized.clone()))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
session.reset_data_changed();
|
||||||
|
Ok(session.into_cookie_value())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn destroy_session(&self, session: Session) -> Result<()> {
|
||||||
|
let conn = self.pool.get().await?;
|
||||||
|
conn.interact(move |conn| {
|
||||||
|
diesel::delete(
|
||||||
|
schema::browser_sessions::table
|
||||||
|
.filter(schema::browser_sessions::id.eq(session.id().to_string())),
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_store(&self) -> Result<()> {
|
||||||
|
let conn = self.pool.get().await?;
|
||||||
|
conn.interact(move |conn| diesel::delete(schema::browser_sessions::table).execute(conn))
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
56
src/settings.rs
Normal file
56
src/settings.rs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
use config::{Config, ConfigError, Environment};
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_path: String,
|
||||||
|
|
||||||
|
pub database_url: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_host")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
pub auth: Auth,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
3000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_host() -> String {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Auth {
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub redirect_url: String,
|
||||||
|
pub auth_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
pub userinfo_url: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_cookie_name")]
|
||||||
|
pub cookie_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cookie_name() -> String {
|
||||||
|
"CALLOUT_SESSION".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
|
if let Err(_) = dotenv() {
|
||||||
|
println!("Couldn't load .env file.");
|
||||||
|
}
|
||||||
|
let s = Config::builder()
|
||||||
|
.add_source(Environment::default())
|
||||||
|
.build()?;
|
||||||
|
s.try_deserialize()
|
||||||
|
}
|
||||||
|
}
|
90
src/users.rs
Normal file
90
src/users.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::request::Parts,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
RequestPartsExt,
|
||||||
|
};
|
||||||
|
use diesel::{
|
||||||
|
associations::Identifiable, deserialize::Queryable, dsl::insert_into, pg::Pg, prelude::*,
|
||||||
|
Selectable,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app_error::AppError,
|
||||||
|
app_state::AppState,
|
||||||
|
auth::AuthInfo,
|
||||||
|
schema::{self, users},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = schema::users)]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub uid: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CurrentUser(pub User);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for CurrentUser {
|
||||||
|
type Rejection = CurrentUserRejection;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
|
||||||
|
let auth_info = parts
|
||||||
|
.extract_with_state::<AuthInfo, AppState>(state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| CurrentUserRejection::AuthRequired(state.settings.base_path.clone()))?;
|
||||||
|
let current_user = state
|
||||||
|
.db_pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?
|
||||||
|
.interact(move |conn| {
|
||||||
|
let maybe_current_user = users::table
|
||||||
|
.filter(users::uid.eq(auth_info.sub.clone()))
|
||||||
|
.select(User::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(current_user) = maybe_current_user {
|
||||||
|
return Ok(current_user);
|
||||||
|
}
|
||||||
|
let new_user = User {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
uid: auth_info.sub,
|
||||||
|
email: auth_info.email,
|
||||||
|
};
|
||||||
|
insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.returning(User::as_returning())
|
||||||
|
.on_conflict(users::uid)
|
||||||
|
.do_nothing()
|
||||||
|
.get_result(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?;
|
||||||
|
Ok(CurrentUser(current_user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CurrentUserRejection {
|
||||||
|
AuthRequired(String),
|
||||||
|
InternalServerError(AppError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for CurrentUserRejection {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
Self::AuthRequired(base_path) => {
|
||||||
|
Redirect::to(&format!("{}/auth/login", base_path)).into_response()
|
||||||
|
}
|
||||||
|
Self::InternalServerError(err) => err.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
static/bulma.min.css
vendored
Normal file
3
static/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/main.css
Normal file
1
static/main.css
Normal file
|
@ -0,0 +1 @@
|
||||||
|
|
13
templates/base.html
Normal file
13
templates/base.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}callout.dev{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ base_path }}/static/bulma.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ base_path }}/static/main.css">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "nav.html" %}
|
||||||
|
{% block main %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
27
templates/nav.html
Normal file
27
templates/nav.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<nav class="navbar block" role="navigation" aria-label="main navigation">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-menu">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<button class="navbar-link" type="button">Team: XXX</button>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/teams/xxx">XXX</a>
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/teams/yyy">YYY</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/">Projects</a>
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/">Output channels</a>
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/">Members</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<button class="navbar-link" type="button">Account ({{ current_user.email }})</button>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/settings">Settings</a>
|
||||||
|
<a class="navbar-item" href="{{ base_path }}/auth/logout">Log out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
30
templates/new-team.html
Normal file
30
templates/new-team.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<main>
|
||||||
|
<section class="block">
|
||||||
|
<div class="container is-max-tablet">
|
||||||
|
<h1 class="title">callout.dev: New Team</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="team-name">Team name</label>
|
||||||
|
<div class="control">
|
||||||
|
<input id="team-name" class="input" type="text" name="name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link" type="submit">Submit</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="{{ base_path }}/teams">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
101
templates/projects.html
Normal file
101
templates/projects.html
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<nav class="breadcrumb container" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ base_path }}/teams">Teams</a></li>
|
||||||
|
<li><a href="{{ base_path }}/teams/{{ team.id }}">{{ team.name }}</a></li>
|
||||||
|
<li><a href="{{ base_path }}/teams/{{ team.id }}/projects">Projects</a></li>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div class="container columns">
|
||||||
|
<div class="column is-two-thirds">
|
||||||
|
<section class="block">
|
||||||
|
<h1 class="title">Projects</h1>
|
||||||
|
</section>
|
||||||
|
<section class="block">
|
||||||
|
<article class="message is-info">
|
||||||
|
<div class="message-body">
|
||||||
|
<p>
|
||||||
|
Projects are created automatically when referenced in a client
|
||||||
|
request. Make your first request:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>
|
||||||
|
https://callout.dev{{ base_path }}/v0/say?project=my-first-project&key=***&message=Hello,%20World
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>
|
||||||
|
https://callout.dev{{ base_path }}/v0/watchdog?project=my-first-project&key=***&seconds=300
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="block">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for project in projects %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ base_path }}/projects/{{ project.id }}">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<section class="block">
|
||||||
|
<h1 class="title">API Keys</h1>
|
||||||
|
</section>
|
||||||
|
<section class="block">
|
||||||
|
<form method="POST" action="{{ base_path }}/teams/{{ team.id }}/new-api-key">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="button" type="submit">Generate Key</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="block">
|
||||||
|
<table class="table is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
API key
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Last used
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key in keys %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>
|
||||||
|
********{{ key.id.simple().to_string()[key.id.simple().to_string().char_indices().nth_back(3).unwrap().0..] }}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Unknown
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a>Copy</a>
|
||||||
|
|
|
||||||
|
<a>Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
42
templates/teams.html
Normal file
42
templates/teams.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}callout.dev: Teams{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<main>
|
||||||
|
<section class="block">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Callout.dev: Teams</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% if teams.len() == 0 %}
|
||||||
|
<section class="block">
|
||||||
|
<div class="container">
|
||||||
|
<article class="message is-info">
|
||||||
|
<div class="message-body">
|
||||||
|
Doesn't look like you've created or been invited to any teams yet.
|
||||||
|
<a href="{{ base_path }}/new-team">Click here</a> to create one.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
<section class="block">
|
||||||
|
<div class="container">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for team in teams %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ base_path }}/teams/{{ team.id }}">
|
||||||
|
{{ team.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
Loading…
Add table
Reference in a new issue