diff --git a/.env b/.env new file mode 100644 index 0000000..15b7d07 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="localpw" +POSTGRES_URI="localhost" +POSTGRES_PORT="5432" +POSTGRES_DB_NAME="forc_pub" +DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/${POSTGRES_DB_NAME}" diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 5d94f0a..b5cace2 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -74,6 +74,9 @@ jobs: with: profile: minimal toolchain: ${{ env.RUST_VERSION }} + - uses: docker-practice/actions-setup-docker@master + timeout-minutes: 12 + - run: ./scripts/start_local_db.sh - uses: Swatinem/rust-cache@v1 - name: Run tests uses: actions-rs/cargo@v1 diff --git a/Cargo.lock b/Cargo.lock index 14524b5..19dea3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,12 @@ version = "3.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.3.0" @@ -309,6 +315,53 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "diesel" +version = "2.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff236accb9a5069572099f0b350a92e9560e8e63a9b8d546162f4a5e03026bb2" +dependencies = [ + "bitflags 2.5.0", + "byteorder", + "diesel_derives", + "itoa", + "pq-sys", + "r2d2", + "uuid", +] + +[[package]] +name = "diesel_derives" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14701062d6bed917b5c7103bdffaee1e4609279e240488ad24e7bd979ca6866c" +dependencies = [ + "diesel_table_macro_syntax", + "proc-macro2", + "quote", + "syn 2.0.57", +] + +[[package]] +name = "diesel_migrations" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" +dependencies = [ + "syn 2.0.57", +] + [[package]] name = "digest" version = "0.10.7" @@ -320,6 +373,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.8.0" @@ -359,7 +418,7 @@ dependencies = [ "atomic", "pear", "serde", - "toml", + "toml 0.5.10", "uncased", "version_check", ] @@ -374,6 +433,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" name = "forc_pub" version = "0.1.0" dependencies = [ + "diesel", + "diesel_migrations", + "dotenvy", "hex", "nanoid", "regex", @@ -383,6 +445,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "uuid", ] [[package]] @@ -898,6 +961,27 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "migrations_internals" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" +dependencies = [ + "serde", + "toml 0.7.8", +] + +[[package]] +name = "migrations_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.16" @@ -1170,6 +1254,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pq-sys" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31c0052426df997c0cbd30789eb44ca097e3541717a7b8fa36b1c464ee7edebd" +dependencies = [ + "vcpkg", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -1201,6 +1294,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.5" @@ -1485,6 +1589,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1561,6 +1674,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1892,6 +2014,40 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2068,6 +2224,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" + [[package]] name = "valuable" version = "0.1.0" @@ -2442,6 +2604,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 4b777e6..72cd0b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "forc_pub" version = "0.1.0" edition = "2021" +[lib] +name = "forc_pub" +path = "src/lib.rs" + [dependencies] nanoid = "0.4.0" hex = "0.4.3" @@ -13,3 +17,7 @@ rocket = { version = "0.5.0-rc.2", features = ["tls", "json"] } serde = { version = "1.0", features = ["derive"] } reqwest = { version = "0.12.2", features = ["json"] } thiserror = "1.0.58" +diesel = { version = "2.1.6", features = ["postgres", "uuid", "r2d2"] } +dotenvy = "0.15" +uuid = "1.8.0" +diesel_migrations = "2.1.0" diff --git a/app/src/constants.ts b/app/src/constants.ts new file mode 100644 index 0000000..9fc5846 --- /dev/null +++ b/app/src/constants.ts @@ -0,0 +1 @@ +export const SERVER_URI = process.env.REACT_APP_SERVER_URI ?? "http://localhost:8080" \ No newline at end of file diff --git a/app/src/features/toolbar/components/UserButton.tsx b/app/src/features/toolbar/components/UserButton.tsx index bfd9ce9..0bb7a4d 100644 --- a/app/src/features/toolbar/components/UserButton.tsx +++ b/app/src/features/toolbar/components/UserButton.tsx @@ -52,12 +52,12 @@ function UserButton() { void] { const params = { code: githubCode, }; - const request = new Request('http://localhost:8080/login', { + const request = new Request(`${SERVER_URI}/login`, { method: 'POST', body: JSON.stringify(params), }); @@ -93,12 +97,9 @@ export function useGithubAuth(): [AuthenticatedUser | null, () => void] { return; } - const request = new Request( - `http://localhost:8080/session?id=${sessionId}`, - { - method: 'GET', - } - ); + const request = new Request(`${SERVER_URI}/session?id=${sessionId}`, { + method: 'GET', + }); fetch(request) .then((response) => { if (response.status < 400) { diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..efbd85d --- /dev/null +++ b/config.toml @@ -0,0 +1,7 @@ +[target.aarch64-apple-darwin] +rustflags = [ + '-L', + '/opt/homebrew/opt/libpq/lib', + '-L', + '/opt/homebrew/lib' +] diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/diesel.toml @@ -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"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -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(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -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; diff --git a/migrations/2024-04-19-174554_create_users/down.sql b/migrations/2024-04-19-174554_create_users/down.sql new file mode 100644 index 0000000..9951735 --- /dev/null +++ b/migrations/2024-04-19-174554_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users diff --git a/migrations/2024-04-19-174554_create_users/up.sql b/migrations/2024-04-19-174554_create_users/up.sql new file mode 100644 index 0000000..12464ce --- /dev/null +++ b/migrations/2024-04-19-174554_create_users/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + full_name VARCHAR NOT NULL, + github_login VARCHAR NOT NULL UNIQUE, + github_url VARCHAR NOT NULL, + avatar_url VARCHAR, + email VARCHAR, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/migrations/2024-04-19-174903_create_sessions/down.sql b/migrations/2024-04-19-174903_create_sessions/down.sql new file mode 100644 index 0000000..5d11a4a --- /dev/null +++ b/migrations/2024-04-19-174903_create_sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE sessions diff --git a/migrations/2024-04-19-174903_create_sessions/up.sql b/migrations/2024-04-19-174903_create_sessions/up.sql new file mode 100644 index 0000000..e8e62ae --- /dev/null +++ b/migrations/2024-04-19-174903_create_sessions/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/scripts/start_local_db.sh b/scripts/start_local_db.sh new file mode 100755 index 0000000..4188f4f --- /dev/null +++ b/scripts/start_local_db.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "Docker is not installed. Please install Docker to run this script." + exit 1 +fi + +# Check if the PostgreSQL container is already running +if docker ps --format '{{.Names}}' | grep -q '^postgres$'; then + echo "PostgreSQL container is already running." + exit 0 +fi + +# Source environment variables +source .env + +# Start PostgreSQL container +docker run --name $POSTGRES_USER -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD -e POSTGRES_DB=$POSTGRES_DB_NAME -d -p $POSTGRES_PORT:$POSTGRES_PORT postgres + +echo "PostgreSQL container started successfully." \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index bdf896b..3448dba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,10 +1,29 @@ use rocket::serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Clone)] +use crate::models; + +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] pub struct User { - pub name: String, + pub full_name: String, pub email: Option, pub avatar_url: Option, + pub github_url: String, + pub github_login: String, + pub is_admin: bool, +} + +impl From for User { + fn from(user: models::User) -> Self { + User { + full_name: user.full_name, + email: user.email, + avatar_url: user.avatar_url, + github_url: user.github_url, + github_login: user.github_login, + is_admin: user.is_admin, + } + } } /// The login request. diff --git a/src/db/error.rs b/src/db/error.rs new file mode 100644 index 0000000..097ecbb --- /dev/null +++ b/src/db/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DatabaseError { + #[error("Invalid UUID: {0}")] + InvalidUuid(String), + #[error("Entry for ID not found: {0}")] + NotFound(String), + #[error("Failed to save user: {0}")] + InsertUserFailed(String), + #[error("Failed to save session for user; {0}")] + InsertSessionFailed(String), +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..327cf5d --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,55 @@ +mod error; +mod user_session; + +use self::error::DatabaseError; +use crate::{api, models, schema}; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use dotenvy::dotenv; +use std::env; +use uuid::Uuid; + +pub type DbPool = Pool>; +pub type DbConnection = PooledConnection>; + +/// The representation of a SQL database connection pool and its operations. +pub struct Database { + pub pool: DbPool, +} + +impl Default for Database { + fn default() -> Self { + Database::new() + } +} + +impl Database { + pub fn new() -> Self { + // Create a connection pool + dotenv().ok(); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = Pool::builder() + .build(ConnectionManager::::new(database_url)) + .expect("db connection pool"); + + // Run migrations + const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + let mut connection = pool.get().expect("db connection"); + let migrations = connection + .run_pending_migrations(MIGRATIONS) + .expect("diesel migrations"); + println!("Ran {} migrations", migrations.len()); + + Database { pool } + } + + /// Get a connection from the pool. + pub fn connection(&self) -> DbConnection { + self.pool.get().expect("db connection") + } +} + +pub(crate) fn string_to_uuid(s: String) -> Result { + Uuid::parse_str(s.as_str()).map_err(|_| DatabaseError::InvalidUuid(s)) +} diff --git a/src/db/user_session.rs b/src/db/user_session.rs new file mode 100644 index 0000000..30d826d --- /dev/null +++ b/src/db/user_session.rs @@ -0,0 +1,67 @@ +use super::error::DatabaseError; +use super::{api, models, schema}; +use super::{string_to_uuid, Database}; +use diesel::prelude::*; +use diesel::upsert::excluded; +use std::time::{Duration, SystemTime}; + +impl Database { + /// Insert a user session into the database and return the session ID. + /// If the user doesn't exist, insert the user as well. + /// If the user does exist, update the user's full name and avatar URL if they have changed. + pub fn insert_user_session( + &self, + user: &api::User, + expires_in: u32, + ) -> Result { + let connection = &mut self.connection(); + + // Insert or update a user + let new_user = models::NewUser { + full_name: user.full_name.clone(), + github_login: user.github_login.clone(), + github_url: user.github_url.clone(), + avatar_url: user.avatar_url.clone(), + email: user.email.clone(), + is_admin: user.is_admin, + }; + + let saved_user = diesel::insert_into(schema::users::table) + .values(&new_user) + .returning(models::User::as_returning()) + .on_conflict(schema::users::github_login) + .do_update() + .set(( + schema::users::full_name.eq(excluded(schema::users::full_name)), + schema::users::avatar_url.eq(excluded(schema::users::avatar_url)), + )) + .get_result(connection) + .map_err(|_| DatabaseError::InsertUserFailed(user.github_login.clone()))?; + + let new_session = models::NewSession { + user_id: saved_user.id, + expires_at: SystemTime::now() + Duration::from_secs(u64::from(expires_in)), + }; + + // Insert new session + let saved_session = diesel::insert_into(schema::sessions::table) + .values(&new_session) + .returning(models::Session::as_returning()) + .get_result(connection) + .map_err(|_| DatabaseError::InsertSessionFailed(user.github_login.clone()))?; + + Ok(saved_session.id.to_string()) + } + + /// Fetch a user from the database for a given session ID. + pub fn get_user_for_session(&self, session_id: String) -> Result { + let session_uuid = string_to_uuid(session_id.clone())?; + let connection = &mut self.connection(); + schema::sessions::table + .inner_join(schema::users::table) + .filter(schema::sessions::id.eq(session_uuid)) + .select(models::User::as_returning()) + .first::(connection) + .map_err(|_| DatabaseError::NotFound(session_id)) + } +} diff --git a/src/github.rs b/src/github.rs index 717647b..8da941e 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,14 +1,23 @@ extern crate reqwest; -use std::env; - use crate::api::User; use serde::Deserialize; +use std::env; use thiserror::Error; #[derive(Deserialize, Debug)] struct GithubOauthResponse { access_token: String, + expires_in: u32, +} + +#[derive(Deserialize, Debug)] +struct GithubUserResponse { + pub name: String, + pub email: Option, + pub avatar_url: Option, + pub html_url: String, + pub login: String, } #[derive(Error, Debug)] @@ -23,12 +32,13 @@ pub enum GithubError { const GITHUB_CLIENT_ID: &str = "Iv1.ebdf596c6c548759"; -pub async fn handle_login(code: String) -> Result { - let access_token = exchange_code(code).await?; - fetch_user(access_token).await +pub async fn handle_login(code: String) -> Result<(User, u32), GithubError> { + let (access_token, expires_in) = exchange_code(code).await?; + let user = fetch_user(access_token).await?; + Ok((user, expires_in)) } -async fn exchange_code(code: String) -> Result { +async fn exchange_code(code: String) -> Result<(String, u32), GithubError> { let client = reqwest::Client::new(); // todo: reuse client? let client_secret = env::var("GITHUB_CLIENT_SECRET").expect("GITHUB_CLIENT_SECRET not set"); @@ -51,7 +61,7 @@ async fn exchange_code(code: String) -> Result { .await .map_err(|_| GithubError::Auth(status.to_string()))?; - Ok(body.access_token) + Ok((body.access_token, body.expires_in)) } async fn fetch_user(token: String) -> Result { @@ -68,10 +78,21 @@ async fn fetch_user(token: String) -> Result { let status = res.status(); - let body = res.json::().await.map_err(|_| GithubError::Api { - name: "user".to_string(), - status: status.to_string(), - })?; - - Ok(body) + let body = res + .json::() + .await + .map_err(|_| GithubError::Api { + name: "user".to_string(), + status: status.to_string(), + })?; + + let user = User { + full_name: body.name, + email: body.email, + avatar_url: body.avatar_url, + github_url: body.html_url, + github_login: body.login, + is_admin: false, // TODO: Check if user is admin + }; + Ok(user) } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..537aaa1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod cors; +pub mod db; +pub mod github; +pub mod models; +pub mod schema; diff --git a/src/main.rs b/src/main.rs index 94dc680..f61b992 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,51 +3,35 @@ #[macro_use] extern crate rocket; -mod api; -mod cors; -mod github; - -use crate::github::handle_login; -use api::{LoginRequest, LoginResponse, PublishRequest, PublishResponse, SessionResponse, User}; -use cors::Cors; +use forc_pub::api::{ + LoginRequest, LoginResponse, PublishRequest, PublishResponse, SessionResponse, User, +}; +use forc_pub::cors::Cors; +use forc_pub::db::Database; +use forc_pub::github::handle_login; use rocket::{serde::json::Json, State}; -use std::{collections::HashMap, sync::Mutex}; #[derive(Default)] struct ServerState { - // TODO: Set up SQL database and use sessions table. - sessions: Mutex>, -} - -impl ServerState { - pub fn new() -> Self { - ServerState { - sessions: Mutex::new(HashMap::new()), - } - } - - pub fn insert(&self, user: &User) -> String { - let session_id = nanoid::nanoid!(); - self.sessions - .lock() - .expect("lock sessions") - .insert(session_id.clone(), user.clone()); - session_id - } + pub db: Database, } /// The endpoint to authenticate with GitHub. #[post("/login", data = "")] async fn login(state: &State, request: Json) -> Json { match handle_login(request.code.clone()).await { - Ok(user) => { - let session_id = state.insert(&user); - Json(LoginResponse { + Ok((user, expires_in)) => match state.db.insert_user_session(&user, expires_in) { + Ok(session_id) => Json(LoginResponse { user: Some(user), session_id: Some(session_id), error: None, - }) - } + }), + Err(e) => Json(LoginResponse { + user: None, + session_id: None, + error: Some(e.to_string()), + }), + }, Err(e) => Json(LoginResponse { user: None, session_id: None, @@ -59,15 +43,14 @@ async fn login(state: &State, request: Json) -> Json< /// The endpoint to authenticate with GitHub. #[get("/session?")] async fn session(state: &State, id: String) -> Json { - let sessions = state.sessions.lock().expect("lock sessions"); - match sessions.get(&id) { - Some(user) => Json(SessionResponse { - user: Some(user.clone()), + match state.db.get_user_for_session(id) { + Ok(user) => Json(SessionResponse { + user: Some(User::from(user)), error: None, }), - None => Json(SessionResponse { + Err(error) => Json(SessionResponse { user: None, - error: Some("Invalid session".to_string()), + error: Some(error.to_string()), }), } } @@ -101,7 +84,7 @@ fn health() -> String { #[launch] fn rocket() -> _ { rocket::build() - .manage(ServerState::new()) + .manage(ServerState::default()) .attach(Cors) .mount("/", routes![login, session, publish, all_options, health]) .register("/", catchers![not_found]) diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..93c2096 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,45 @@ +use diesel::prelude::*; +use std::time::SystemTime; +use uuid::Uuid; + +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = crate::schema::users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct User { + pub id: Uuid, + pub full_name: String, + pub github_login: String, + pub github_url: String, + pub avatar_url: Option, + pub email: Option, + pub is_admin: bool, + pub created_at: SystemTime, +} + +#[derive(Insertable)] +#[diesel(table_name = crate::schema::users)] +pub struct NewUser { + pub full_name: String, + pub github_login: String, + pub github_url: String, + pub avatar_url: Option, + pub email: Option, + pub is_admin: bool, +} + +#[derive(Queryable, Selectable)] +#[diesel(table_name = crate::schema::sessions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + pub expires_at: SystemTime, + pub created_at: SystemTime, +} + +#[derive(Insertable)] +#[diesel(table_name = crate::schema::sessions)] +pub struct NewSession { + pub user_id: Uuid, + pub expires_at: SystemTime, +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..9f19130 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,27 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + sessions (id) { + id -> Uuid, + user_id -> Uuid, + expires_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + users (id) { + id -> Uuid, + full_name -> Varchar, + github_login -> Varchar, + github_url -> Varchar, + avatar_url -> Nullable, + email -> Nullable, + is_admin -> Bool, + created_at -> Timestamp, + } +} + +diesel::joinable!(sessions -> users (user_id)); + +diesel::allow_tables_to_appear_in_same_query!(sessions, users,); diff --git a/tests/db_integration.rs b/tests/db_integration.rs new file mode 100644 index 0000000..61af983 --- /dev/null +++ b/tests/db_integration.rs @@ -0,0 +1,77 @@ +use diesel::RunQueryDsl as _; +use forc_pub::api; +use forc_pub::db::Database; +use uuid::Uuid; + +/// Note: Integration tests for the database module assume that the database is running and that the DATABASE_URL environment variable is set. +/// This should be done by running `./scripts/start_local_db.sh` before running the tests. + +const TEST_LOGIN_1: &str = "AliceBobbins"; +const TEST_FULL_NAME_1: &str = "Alice Bobbins"; +const TEST_EMAIL_1: &str = "alice@bob.com"; +const TEST_URL_1: &str = "url1.url"; +const TEST_URL_2: &str = "url2.url"; +const TEST_LOGIN_2: &str = "foobar"; + +fn clear_tables(db: &Database) { + let connection = &mut db.connection(); + diesel::delete(forc_pub::schema::sessions::table) + .execute(connection) + .expect("clear sessions table"); + diesel::delete(forc_pub::schema::users::table) + .execute(connection) + .expect("clear users table"); +} + +fn mock_user_1() -> api::User { + api::User { + github_login: TEST_LOGIN_1.to_string(), + full_name: TEST_FULL_NAME_1.to_string(), + email: Some(TEST_EMAIL_1.to_string()), + avatar_url: Some(TEST_URL_1.to_string()), + github_url: TEST_URL_2.to_string(), + is_admin: true, + } +} + +fn mock_user_2() -> api::User { + api::User { + github_login: TEST_LOGIN_2.to_string(), + ..Default::default() + } +} + +#[test] +fn test_multiple_user_sessions() { + let db = Database::default(); + + let user1 = mock_user_1(); + let user2 = mock_user_2(); + + let session1 = db.insert_user_session(&user1, 1000).expect("result is ok"); + Uuid::parse_str(session1.as_str()).expect("result is a valid UUID"); + + // Insert an existing user + let session2 = db.insert_user_session(&user1, 1000).expect("result is ok"); + Uuid::parse_str(session2.as_str()).expect("result is a valid UUID"); + + // Insert another user + let session3 = db.insert_user_session(&user2, 1000).expect("result is ok"); + Uuid::parse_str(session3.as_str()).expect("result is a valid UUID"); + + let result = db.get_user_for_session(session1).expect("result is ok"); + assert_eq!(result.github_login, TEST_LOGIN_1); + assert_eq!(result.full_name, TEST_FULL_NAME_1); + assert_eq!(result.email.expect("is some"), TEST_EMAIL_1); + assert_eq!(result.avatar_url.expect("is some"), TEST_URL_1); + assert_eq!(result.github_url, TEST_URL_2); + assert!(result.is_admin); + + let result = db.get_user_for_session(session2).expect("result is ok"); + assert_eq!(result.github_login, TEST_LOGIN_1); + + let result = db.get_user_for_session(session3).expect("result is ok"); + assert_eq!(result.github_login, TEST_LOGIN_2); + + clear_tables(&db); +}