1
0
Fork 0
forked from 2sys/shoutdotdev

initial commit

This commit is contained in:
Brent Schroeter 2025-02-26 13:10:50 -08:00
commit 1524c2025e
35 changed files with 4608 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
.wrangler
build
node_modules
.DS_Store

5
.ignore Normal file
View file

@ -0,0 +1,5 @@
target
node_modules
.wrangler
build
.DS_Store

3144
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

35
Cargo.toml Normal file
View 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
View 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"]

View 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"

View 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
View 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
View file

View 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();

View 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;

View 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;

View 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
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS browser_sessions;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

1
static/main.css Normal file
View file

@ -0,0 +1 @@

13
templates/base.html Normal file
View 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
View 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
View 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
View 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&amp;key=***&amp;message=Hello,%20World
</code>
</p>
<p>
<code>
https://callout.dev{{ base_path }}/v0/watchdog?project=my-first-project&amp;key=***&amp;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
View 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 %}