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