From 04a67ea8452ec7b19752ea94de7aa60acb5b4a54 Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Tue, 6 Jun 2023 17:55:52 +0200 Subject: [PATCH] feat(playground): playground back-end (#222) This allows for sharing, loading and flagging playgrounds on MDN. We use gists as the storage back-end but track them in our db to mitigate and add features in the future. --- .settings.test.toml | 7 +- Cargo.lock | 198 +++++++++++++++- Cargo.toml | 4 + .../down.sql | 3 + .../2023-06-02-151013_playground-share/up.sql | 25 ++ src/api/api_v1.rs | 11 + src/api/error.rs | 20 ++ src/api/mod.rs | 1 + src/api/play.rs | 220 ++++++++++++++++++ src/db/mod.rs | 1 + src/db/model.rs | 20 ++ src/db/play.rs | 20 ++ src/db/schema.rs | 16 ++ src/helpers.rs | 2 +- src/main.rs | 9 + src/settings.rs | 10 + tests/api/mod.rs | 1 + tests/api/play.rs | 65 ++++++ tests/helpers/app.rs | 8 + tests/stubs/load_gist.json | 43 ++++ tests/stubs/save_gist.json | 42 ++++ 21 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 migrations/2023-06-02-151013_playground-share/down.sql create mode 100644 migrations/2023-06-02-151013_playground-share/up.sql create mode 100644 src/api/play.rs create mode 100644 src/db/play.rs create mode 100644 tests/api/play.rs create mode 100644 tests/stubs/load_gist.json create mode 100644 tests/stubs/save_gist.json diff --git a/.settings.test.toml b/.settings.test.toml index 836d6fd1..fc46e22e 100644 --- a/.settings.test.toml +++ b/.settings.test.toml @@ -40,4 +40,9 @@ statsd_port = 8125 [basket] api_key = "foobar" -basket_url = "http://localhost:4321" \ No newline at end of file +basket_url = "http://localhost:4321" + +[playground] +github_token = "foobar" +crypt_key = "IXAe2h1MekK4LKysmMvxomja69PT6c20A3nmcDHQ2eQ=" +flag_repo = "flags" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7c0c73be..c626dd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,9 +270,9 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" +checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237" dependencies = [ "aead", "aes", @@ -1191,6 +1191,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" version = "1.0.10" @@ -1664,6 +1670,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + [[package]] name = "http-types" version = "2.12.0" @@ -1735,11 +1747,25 @@ checksum = "0646026eb1b3eea4cd9ba47912ea5ce9cc07713d105b1a14698f4e6433d348b7" dependencies = [ "http", "hyper", + "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -2227,6 +2253,43 @@ dependencies = [ "memchr", ] +[[package]] +name = "octocrab" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab66307295163fe58fde1c323554044abdd3517a301f62bcd471c90cccb2459" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.21.2", + "bytes", + "cfg-if", + "chrono", + "either", + "futures", + "futures-util", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-timeout", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", +] + [[package]] name = "once_cell" version = "1.18.0" @@ -2523,6 +2586,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95a7476719eab1e366eaf73d0260af3021184f18177925b07f54b30089ceead" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -2923,6 +3006,7 @@ dependencies = [ "actix-session", "actix-web", "actix-web-httpauth", + "aes-gcm", "anyhow", "assert-json-diff", "base64 0.21.2", @@ -2941,8 +3025,10 @@ dependencies = [ "hostname", "itertools", "jsonwebtoken", + "octocrab", "once_cell", "openidconnect", + "percent-encoding", "r2d2", "regex", "reqwest", @@ -3015,6 +3101,18 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.2" @@ -3100,6 +3198,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.8.2" @@ -3572,6 +3679,29 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +[[package]] +name = "snafu" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0656e7e3ffb70f6c39b3c2a86332bb74aa3c679da781642590f3c1118c5045" +dependencies = [ + "backtrace", + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475b3bbe5245c26f2d8a6f62d67c1f30eb9fffeccee721c45d162c3ebbdf81b2" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.108", +] + [[package]] name = "socket2" version = "0.4.7" @@ -3825,6 +3955,16 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -3902,6 +4042,48 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -3917,9 +4099,21 @@ dependencies = [ "cfg-if", "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + [[package]] name = "tracing-core" version = "0.1.30" diff --git a/Cargo.toml b/Cargo.toml index c0a11981..80e27bbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ serde_with = { version = "3", features = ["base64"]} serde_urlencoded = "0.7" form_urlencoded = "1" serde_path_to_error = "0.1" +percent-encoding = "2" config = "0.13" hostname = "0.3" @@ -69,6 +70,9 @@ sentry-actix = "0.31" basket = "0.0.5" +octocrab = "0.25" +aes-gcm = { version = "0.10", features = ["default", "std"] } + [dev-dependencies] stubr = "0.5" stubr-attributes = "0.5" diff --git a/migrations/2023-06-02-151013_playground-share/down.sql b/migrations/2023-06-02-151013_playground-share/down.sql new file mode 100644 index 00000000..51fba6f8 --- /dev/null +++ b/migrations/2023-06-02-151013_playground-share/down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER set_deleted_user_id ON playground; +DROP FUNCTION set_deleted_user_id; +DROP TABLE playground; diff --git a/migrations/2023-06-02-151013_playground-share/up.sql b/migrations/2023-06-02-151013_playground-share/up.sql new file mode 100644 index 00000000..d947bd06 --- /dev/null +++ b/migrations/2023-06-02-151013_playground-share/up.sql @@ -0,0 +1,25 @@ +CREATE TABLE playground ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NULL REFERENCES users (id) ON DELETE SET NULL, + gist TEXT NOT NULL UNIQUE, + active BOOLEAN NOT NULL DEFAULT TRUE, + flagged BOOLEAN NOT NULL DEFAULT FALSE, + deleted_user_id BIGINT DEFAULT NULL +); + +CREATE INDEX playground_gist ON playground (gist); + +CREATE FUNCTION set_deleted_user_id() RETURNS trigger AS $$ + BEGIN + IF OLD.user_id IS NOT NULL AND NEW.user_id IS NULL THEN + NEW.deleted_user_id := OLD.user_id; + END IF; + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_deleted_user_id +BEFORE UPDATE ON playground +FOR EACH ROW +WHEN (OLD.user_id IS NOT NULL AND NEW.user_id IS NULL) +EXECUTE FUNCTION set_deleted_user_id(); diff --git a/src/api/api_v1.rs b/src/api/api_v1.rs index b820c930..fc3dc014 100644 --- a/src/api/api_v1.rs +++ b/src/api/api_v1.rs @@ -2,6 +2,7 @@ use crate::api::newsletter::{ is_subscribed, subscribe_anonymous_handler, subscribe_handler, unsubscribe_handler, }; use crate::api::ping::ping; +use crate::api::play::{flag, load, save}; use crate::api::root::root_service; use crate::api::search::search; use crate::api::settings::update_settings; @@ -10,6 +11,9 @@ use actix_web::dev::HttpServiceFactory; use actix_web::web; pub fn api_v1_service() -> impl HttpServiceFactory { + let json_cfg_1mb_limit = web::JsonConfig::default() + // limit request payload size to 1MB + .limit(1_048_576); web::scope("/api/v1") .service( web::scope("/plus") @@ -25,5 +29,12 @@ pub fn api_v1_service() -> impl HttpServiceFactory { .service(web::resource("/whoami").route(web::get().to(whoami))) .service(web::resource("/ping").route(web::post().to(ping))) .service(web::resource("/newsletter").route(web::post().to(subscribe_anonymous_handler))) + .service( + web::scope("/play") + .app_data(json_cfg_1mb_limit) + .service(web::resource("/").route(web::post().to(save))) + .service(web::resource("/flag").route(web::post().to(flag))) + .service(web::resource("/{gist_id}").route(web::get().to(load))), + ) .service(root_service()) } diff --git a/src/api/error.rs b/src/api/error.rs index c30743ff..bbc5241b 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -1,3 +1,5 @@ +use std::string::FromUtf8Error; + use crate::db::error::DbError; use actix_http::header::HeaderValue; use actix_web::http::header::HeaderName; @@ -41,6 +43,21 @@ pub enum FxaWebhookError { #[error("Invalid signature")] InvalidSignature(#[from] openidconnect::SignatureVerificationError), } + +#[derive(Error, Debug)] +pub enum PlaygroundError { + #[error("Octocrab error: {0}")] + OctocrabError(#[from] octocrab::Error), + #[error("Crypt error: {0}")] + CryptError(#[from] aes_gcm::Error), + #[error("Crypt decoding error: {0}")] + DecodeError(#[from] base64::DecodeError), + #[error("Crypt utf error: {0}")] + UtfDecodeError(#[from] FromUtf8Error), + #[error("Playground error: no settings")] + SettingsError, +} + #[derive(Error, Debug)] pub enum ApiError { #[error("Artificial error")] @@ -81,6 +98,8 @@ pub enum ApiError { LoginRequiredForFeature(String), #[error("Newsletter error: {0}")] BasketError(#[from] BasketError), + #[error("Playground error: {0}")] + PlaygroundError(#[from] PlaygroundError), #[error("Unknown error: {0}")] Generic(String), } @@ -106,6 +125,7 @@ impl ApiError { Self::ValidationError(_) => "Validation Error", Self::MultipleCollectionSubscriptionLimitReached => "Subscription limit reached", Self::BasketError(_) => "Error managing newsletter", + Self::PlaygroundError(_) => "Error querying playground", Self::Generic(err) => err, Self::LoginRequiredForFeature(_) => "Login Required", } diff --git a/src/api/mod.rs b/src/api/mod.rs index d5ed6248..4913fb2b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -8,6 +8,7 @@ pub mod fxa_webhook; pub mod healthz; pub mod newsletter; pub mod ping; +pub mod play; pub mod root; pub mod search; pub mod settings; diff --git a/src/api/play.rs b/src/api/play.rs new file mode 100644 index 00000000..35274ac3 --- /dev/null +++ b/src/api/play.rs @@ -0,0 +1,220 @@ +use actix_identity::Identity; +use actix_web::{web, HttpResponse}; +use aes_gcm::{ + aead::{generic_array::GenericArray, rand_core::RngCore, Aead, OsRng}, + Aes256Gcm, KeyInit, Nonce, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use octocrab::Octocrab; +use once_cell::sync::Lazy; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + api::error::{ApiError, PlaygroundError}, + db::{ + self, + model::PlaygroundInsert, + play::{create_playground, flag_playground}, + Pool, + }, + settings::SETTINGS, +}; + +const FILENAME: &str = "playground.json"; +const DESCRIPTION: &str = "Code shared from the MDN Playground"; + +#[derive(Deserialize, Serialize, Default, Debug)] +pub struct PlayCode { + html: Option, + css: Option, + js: Option, + src: Option, +} +#[derive(Serialize)] +pub struct PlaySaveResponse { + id: String, +} + +#[derive(Debug)] +pub struct Gist { + pub id: String, + pub url: Url, + pub code: PlayCode, +} + +#[derive(Deserialize)] +pub struct PlayFlagRequest { + id: String, + reason: Option, +} + +pub(crate) const NONCE_LEN: usize = 12; +static CIPHER: Lazy> = Lazy::new(|| { + SETTINGS + .playground + .as_ref() + .map(|playground| Aes256Gcm::new(GenericArray::from_slice(&playground.crypt_key))) +}); + +fn encrypt(gist_id: &str) -> Result { + if let Some(cipher) = &*CIPHER { + let mut nonce = vec![0; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + let nonce = Nonce::from_slice(&nonce); + let mut data = cipher.encrypt(nonce, gist_id.as_bytes())?; + data.extend_from_slice(nonce.as_slice()); + + Ok(STANDARD.encode(data)) + } else { + Err(PlaygroundError::SettingsError) + } +} + +fn decrypt(encoded: &str) -> Result { + if let Some(cipher) = &*CIPHER { + let data = STANDARD.decode(encoded)?; + let (enc, nonce) = data.split_at(data.len() - NONCE_LEN); + let nonce = Nonce::from_slice(nonce); + let data = cipher.decrypt(nonce, enc)?; + + Ok(String::from_utf8(data)?) + } else { + Err(PlaygroundError::SettingsError) + } +} + +impl From for Gist { + fn from(other: octocrab::models::gists::Gist) -> Self { + let mut files: Vec<_> = other + .files + .into_iter() + .map(|(name, file)| (name, file.content)) + .collect(); + + files.sort_by(|(name1, _), (name2, _)| name1.cmp(name2)); + let code = if files.len() != 1 { + PlayCode::default() + } else { + let (_, content) = files.pop().unwrap(); + serde_json::from_str(&content.unwrap_or_default()).unwrap_or_default() + }; + + Gist { + id: other.id, + url: other.html_url, + code, + } + } +} + +pub async fn create_gist( + client: &Octocrab, + code: impl Into, +) -> Result { + client + .gists() + .create() + .description(DESCRIPTION) + .public(false) + .file(FILENAME, code) + .send() + .await + .map(Into::into) + .map_err(Into::into) +} + +pub async fn create_flag_issue( + client: &Octocrab, + gist_id: String, + id: String, + reason: Option, +) -> Result<(), PlaygroundError> { + let repo = SETTINGS + .playground + .as_ref() + .map(|p| p.flag_repo.as_str()) + .ok_or(PlaygroundError::SettingsError)?; + let issues = client.issues("mdn", repo); + let mut issue = issues.create(&format!("flag-{gist_id}")); + if let Some(reason) = reason { + issue = issue.body(&format!( + "url: {}/en-US/play?id={}\n{reason}", + &SETTINGS.application.document_base_url, + utf8_percent_encode(&id, NON_ALPHANUMERIC) + )); + } + issue.send().await.map(|_| ()).map_err(Into::into) +} + +pub async fn load_gist(client: &Octocrab, id: &str) -> Result { + client + .gists() + .get(id) + .await + .map(Into::into) + .map_err(Into::into) +} + +pub async fn save( + save: web::Json, + id: Option, + pool: web::Data, + github_client: web::Data>, +) -> Result { + if let Some(client) = &**github_client { + if let Some(user_id) = id { + let gist = + create_gist(client, serde_json::to_string_pretty(&save.into_inner())?).await?; + let mut conn = pool.get()?; + let user = db::users::get_user(&mut conn, user_id.id().unwrap())?; + create_playground( + &mut conn, + PlaygroundInsert { + user_id: Some(user.id), + gist: gist.id.clone(), + active: true, + ..Default::default() + }, + )?; + + let id = encrypt(&gist.id)?; + Ok(HttpResponse::Created().json(PlaySaveResponse { id })) + } else { + Ok(HttpResponse::Unauthorized().finish()) + } + } else { + Ok(HttpResponse::NotImplemented().finish()) + } +} + +pub async fn load( + gist_id: web::Path, + github_client: web::Data>, +) -> Result { + if let Some(client) = &**github_client { + let id = decrypt(&gist_id.into_inner())?; + let gist = load_gist(client, &id).await?; + Ok(HttpResponse::Ok().json(gist.code)) + } else { + Ok(HttpResponse::NotImplemented().finish()) + } +} + +pub async fn flag( + flag: web::Json, + pool: web::Data, + github_client: web::Data>, +) -> Result { + if let Some(client) = &**github_client { + let PlayFlagRequest { id, reason } = flag.into_inner(); + let gist_id = decrypt(&id)?; + let mut conn = pool.get()?; + flag_playground(&mut conn, &gist_id)?; + create_flag_issue(client, gist_id, id, reason).await?; + Ok(HttpResponse::Created().finish()) + } else { + Ok(HttpResponse::NotImplemented().finish()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 7a2c1f73..16c322f9 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -4,6 +4,7 @@ pub mod fxa_webhook; #[allow(clippy::extra_unused_lifetimes)] pub mod model; pub mod ping; +pub mod play; #[allow(unused_imports)] pub mod schema; pub mod schema_manual; diff --git a/src/db/model.rs b/src/db/model.rs index 769de133..26f9dd6a 100644 --- a/src/db/model.rs +++ b/src/db/model.rs @@ -168,3 +168,23 @@ pub struct ActivityPingInsert { pub user_id: i64, pub activity: Value, } + +#[derive(Insertable, Serialize, Debug, Default)] +#[diesel(table_name = playground)] +pub struct PlaygroundInsert { + pub user_id: Option, + pub gist: String, + pub active: bool, + pub flagged: bool, +} + +#[derive(Queryable, Serialize, Debug, Default)] +#[diesel(table_name = playground)] +pub struct PlaygroundQuery { + pub id: i64, + pub user_id: Option, + pub gist: String, + pub active: bool, + pub flagged: bool, + pub deleted_user_id: Option, +} diff --git a/src/db/play.rs b/src/db/play.rs new file mode 100644 index 00000000..d134c96b --- /dev/null +++ b/src/db/play.rs @@ -0,0 +1,20 @@ +use crate::db::model::PlaygroundInsert; +use crate::db::schema; + +use diesel::{insert_into, PgConnection}; +use diesel::{prelude::*, update}; + +pub fn create_playground( + conn: &mut PgConnection, + playground: PlaygroundInsert, +) -> QueryResult { + insert_into(schema::playground::table) + .values(&playground) + .execute(conn) +} + +pub fn flag_playground(conn: &mut PgConnection, gist_id: &str) -> QueryResult { + update(schema::playground::table.filter(schema::playground::gist.eq(gist_id))) + .set(schema::playground::flagged.eq(true)) + .execute(conn) +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 40e23fe7..f9390deb 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -150,6 +150,20 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use crate::db::types::*; + + playground (id) { + id -> Int8, + user_id -> Nullable, + gist -> Text, + active -> Bool, + flagged -> Bool, + deleted_user_id -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use crate::db::types::*; @@ -223,6 +237,7 @@ diesel::joinable!(collection_items -> documents (document_id)); diesel::joinable!(collection_items -> multiple_collections (multiple_collection_id)); diesel::joinable!(collection_items -> users (user_id)); diesel::joinable!(multiple_collections -> users (user_id)); +diesel::joinable!(playground -> users (user_id)); diesel::joinable!(settings -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -234,6 +249,7 @@ diesel::allow_tables_to_appear_in_same_query!( collection_items, documents, multiple_collections, + playground, raw_webhook_events_tokens, settings, users, diff --git a/src/helpers.rs b/src/helpers.rs index b4dcfa81..4963f5ab 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -191,7 +191,7 @@ mod test { assert_eq!(dt, dt_serde.dt); let millis = Millis { dt }; - let millis_json = serde_json::to_value(&millis)?; + let millis_json = serde_json::to_value(millis)?; assert_eq!(json, millis_json); Ok(()) diff --git a/src/main.rs b/src/main.rs index 5f919b96..0a766895 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use const_format::formatcp; use diesel_migrations::MigrationHarness; use elasticsearch::http::transport::Transport; use elasticsearch::Elasticsearch; +use octocrab::OctocrabBuilder; use reqwest::Client as HttpClient; use rumba::{ add_services, @@ -87,6 +88,13 @@ async fn main() -> anyhow::Result<()> { .map(|b| Basket::new(&b.api_key, b.basket_url.clone())), ); + let github_client = Data::new(SETTINGS.playground.as_ref().and_then(|p| { + OctocrabBuilder::new() + .personal_token(p.github_token.clone()) + .build() + .ok() + })); + HttpServer::new(move || { let app = App::new() .wrap(error_handler()) @@ -104,6 +112,7 @@ async fn main() -> anyhow::Result<()> { .build(), ) .wrap(Logger::new(LOG_FMT).exclude("/healthz")) + .app_data(Data::clone(&github_client)) .app_data(Data::clone(&basket_client)) .app_data(Data::clone(&metrics)) .app_data(Data::clone(&pool)) diff --git a/src/settings.rs b/src/settings.rs index 04f42b48..7643d53a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -73,6 +73,15 @@ pub struct Basket { pub basket_url: Url, } +#[serde_as] +#[derive(Debug, Deserialize)] +pub struct Playground { + pub github_token: String, + #[serde_as(as = "Base64")] + pub crypt_key: [u8; 32], + pub flag_repo: String, +} + #[derive(Deserialize)] pub struct Settings { pub db: DB, @@ -84,6 +93,7 @@ pub struct Settings { pub metrics: Metrics, pub sentry: Option, pub basket: Option, + pub playground: Option, #[serde(default)] pub skip_migrations: bool, pub maintenance: Option, diff --git a/tests/api/mod.rs b/tests/api/mod.rs index 02d517b6..8bceb596 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -4,6 +4,7 @@ pub mod healthz; mod multiple_collections; mod newsletter; mod ping; +mod play; mod root; mod search; mod settings; diff --git a/tests/api/play.rs b/tests/api/play.rs new file mode 100644 index 00000000..029f0d8e --- /dev/null +++ b/tests/api/play.rs @@ -0,0 +1,65 @@ +use crate::helpers::app::test_app_with_login; +use crate::helpers::db::reset; +use crate::helpers::http_client::TestHttpClient; +use crate::helpers::{read_json, wait_for_stubr}; +use actix_web::test; +use anyhow::Error; +use assert_json_diff::assert_json_eq; +use diesel::prelude::*; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use rumba::db::model::PlaygroundQuery; +use rumba::db::schema; +use serde_json::json; + +#[actix_rt::test] +#[stubr::mock(port = 4321)] +async fn test_playground() -> Result<(), Error> { + let pool = reset()?; + wait_for_stubr().await?; + let app = test_app_with_login(&pool).await?; + let service = test::init_service(app).await; + let mut client = TestHttpClient::new(service).await; + let save = client + .post( + "/api/v1/play/", + None, + Some(crate::helpers::http_client::PostPayload::Json(json!({ + "html":"

foo

", + "css":"h1 { font-size: 4rem; }", + "js":"const foo = 1;","src":null + }))), + ) + .await; + assert_eq!(save.status(), 201); + let json = read_json(save).await; + assert!(json["id"].is_string()); + let gist_id = json["id"].as_str().unwrap(); + let load = client + .get( + &format!( + "/api/v1/play/{}", + utf8_percent_encode(gist_id, NON_ALPHANUMERIC) + ), + None, + ) + .await; + assert_eq!(load.status(), 200); + let json = read_json(load).await; + assert_json_eq!( + json, + json!({"html":"

foo

","css":"h1 { font-size: 4rem; }","js":"const foo = 1;","src":null}) + ); + + let mut conn = pool.get()?; + let user_id = schema::users::table + .filter(schema::users::fxa_uid.eq("TEST_SUB")) + .select(schema::users::id) + .first::(&mut conn)?; + let d = diesel::delete(schema::users::table.filter(schema::users::id.eq(user_id))) + .execute(&mut conn)?; + assert_eq!(d, 1); + let playground: PlaygroundQuery = schema::playground::table.first(&mut conn)?; + assert_eq!(playground.user_id, None); + assert_eq!(playground.deleted_user_id, Some(user_id)); + Ok(()) +} diff --git a/tests/helpers/app.rs b/tests/helpers/app.rs index 4e5f0b94..a57f58a1 100644 --- a/tests/helpers/app.rs +++ b/tests/helpers/app.rs @@ -15,6 +15,7 @@ use actix_web::{ use basket::Basket; use elasticsearch::http::transport::Transport; use elasticsearch::Elasticsearch; +use octocrab::OctocrabBuilder; use reqwest::Client; use rumba::add_services; use rumba::api::error::error_handler; @@ -63,6 +64,12 @@ pub async fn test_app_with_login( let arbiter = Arbiter::new(); let arbiter_handle = Data::new(arbiter.handle()); let session_cookie_key = Key::derive_from(&SETTINGS.auth.cookie_key); + let github_client = Data::new(Some( + OctocrabBuilder::new() + .base_uri("http://localhost:4321") + .unwrap() + .build()?, + )); let basket_client = Data::new( SETTINGS .basket @@ -80,6 +87,7 @@ pub async fn test_app_with_login( .build(), ) .app_data(Data::clone(&arbiter_handle)) + .app_data(Data::clone(&github_client)) .app_data(Data::clone(&pool)) .app_data(Data::clone(&client)) .app_data(Data::clone(&basket_client)) diff --git a/tests/stubs/load_gist.json b/tests/stubs/load_gist.json new file mode 100644 index 00000000..948d5397 --- /dev/null +++ b/tests/stubs/load_gist.json @@ -0,0 +1,43 @@ +{ + "uuid": "load_gist", + "request": { + "method": "GET", + "url": "/gists/2decf6c462d9b4418f2" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "url": "https://api.github.com/gists/2decf6c462d9b4418f2", + "forks_url": "https://api.github.com/gists/2decf6c462d9b4418f2/forks", + "commits_url": "https://api.github.com/gists/2decf6c462d9b4418f2/commits", + "id": "2decf6c462d9b4418f2", + "node_id": "G_kwDOBhHyLdZDliNDQxOGYy", + "git_pull_url": "https://gist.github.com/2decf6c462d9b4418f2.git", + "git_push_url": "https://gist.github.com/2decf6c462d9b4418f2.git", + "html_url": "https://gist.github.com/2decf6c462d9b4418f2", + "files": { + "playground.json": { + "filename": "playground.json", + "type": "text/json", + "language": "json", + "raw_url": "https://gist.githubusercontent.com/monalisa/2decf6c462d9b4418f2/raw/ac3e6daf176fafe73609fd000cd188e4472010fb/playground.json", + "size": 23, + "truncated": false, + "content": "{\"js\":\"const foo = 1;\",\"css\":\"h1 { font-size: 4rem; }\",\"html\":\"

foo

\"}" + } + }, + "public": true, + "created_at": "2022-09-20T12:11:58Z", + "updated_at": "2022-09-21T10:28:06Z", + "description": "An updated gist description.", + "comments": 0, + "user": null, + "comments_url": "https://api.github.com/gists/2decf6c462d9b4418f2/comments", + "forks": [], + "truncated": false + } + } +} diff --git a/tests/stubs/save_gist.json b/tests/stubs/save_gist.json new file mode 100644 index 00000000..57f23ca7 --- /dev/null +++ b/tests/stubs/save_gist.json @@ -0,0 +1,42 @@ +{ + "uuid": "save_gist", + "request": { + "method": "POST", + "url": "/gists" + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "url": "https://api.github.com/gists/2decf6c462d9b4418f2", + "forks_url": "https://api.github.com/gists/2decf6c462d9b4418f2/forks", + "commits_url": "https://api.github.com/gists/2decf6c462d9b4418f2/commits", + "id": "2decf6c462d9b4418f2", + "node_id": "G_kwDOBhHyLdZDliNDQxOGYy", + "git_pull_url": "https://gist.github.com/2decf6c462d9b4418f2.git", + "git_push_url": "https://gist.github.com/2decf6c462d9b4418f2.git", + "html_url": "https://gist.github.com/2decf6c462d9b4418f2", + "files": { + "playground.json": { + "filename": "playground.json", + "type": "text/json", + "language": "json", + "raw_url": "https://gist.githubusercontent.com/monalisa/2decf6c462d9b4418f2/raw/ac3e6daf176fafe73609fd000cd188e4472010fb/playground.json", + "size": 23, + "truncated": false, + "content": "Hello world from GitHub" + } + }, + "public": true, + "created_at": "2022-09-20T12:11:58Z", + "updated_at": "2022-09-21T10:28:06Z", + "description": "An updated gist description.", + "comments": 0, + "comments_url": "https://api.github.com/gists/2decf6c462d9b4418f2/comments", + "forks": [], + "truncated": false + } + } +}