Frontend build rework (rsbuild) & some GitHub GraphQL

This commit is contained in:
Luke Street
2025-04-25 19:11:05 -06:00
parent b3607c6346
commit 0b13972571
32 changed files with 71469 additions and 1827 deletions
+1
View File
@@ -4,3 +4,4 @@
*.sqlite*
config.yml
.env
dist/
Generated
+75 -1270
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -6,7 +6,6 @@ members = [
"crates/db",
"crates/github",
"crates/images",
"crates/scripts",
"crates/web",
]
+13 -1
View File
@@ -1,5 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
"files": {
"ignore": ["node_modules", "dist", "package.json", "package-lock.json"]
},
"organizeImports": {
"enabled": true
},
@@ -27,7 +30,16 @@
"recommended": true,
"a11y": {
"all": false
}
},
"complexity": {
"noForEach": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@ anyhow.workspace = true
axum.workspace = true
base64 = "0.22"
decomp-dev-core = { path = "../core" }
decomp-dev-github = { path = "../github" }
maud.workspace = true
octocrab.workspace = true
rand = "0.9"
+31 -120
View File
@@ -6,20 +6,17 @@ use axum::{
response::{IntoResponse, Redirect, Response},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use maud::{html, DOCTYPE};
use decomp_dev_core::{AppError, config::GitHubConfig};
use octocrab::{
Octocrab,
models::{Author, Permissions, Repository, RepositoryId},
};
use decomp_dev_github::graphql::{CurrentUserResponse, RepositoryPermission, fetch_current_user};
use octocrab::{Octocrab, models::Author};
use rand::{TryRngCore, rngs::OsRng};
use time::{Duration, UtcDateTime};
use tower_sessions::Session;
use url::form_urlencoded;
const GITHUB_OAUTH_STATE: &str = "github_oauth_state";
const CURRENT_USER: &str = "current_user";
const RETURN_TO: &str = "return_to";
pub const GITHUB_OAUTH_STATE: &str = "github_oauth_state";
pub const CURRENT_USER: &str = "current_user";
pub const RETURN_TO: &str = "return_to";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StoredOAuth {
@@ -48,9 +45,7 @@ pub type Profile = Author;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct CurrentUser {
pub oauth: StoredOAuth,
pub profile: Profile,
#[serde(default)]
pub repos: Vec<CurrentUserRepo>,
pub data: CurrentUserResponse,
}
impl CurrentUser {
@@ -61,95 +56,24 @@ impl CurrentUser {
.context("Failed to create GitHub client")
}
pub fn permissions_for_repo(&self, id: u64) -> Permissions {
self.repos
pub fn permissions_for_repo(&self, id: u64) -> RepositoryPermission {
self.data
.repositories
.iter()
.find(|r| r.id.into_inner() == id)
.map(|r| r.permissions.clone())
.unwrap_or_else(default_permissions)
.find(|r| r.id == id)
.map(|r| r.permission.clone())
.unwrap_or(RepositoryPermission::None)
}
pub fn can_manage_repo(&self, id: u64) -> bool {
matches!(self.permissions_for_repo(id), RepositoryPermission::Admin)
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct CurrentUserRepo {
pub id: RepositoryId,
pub owner: String,
pub repo: String,
pub permissions: Permissions,
}
fn default_permissions() -> Permissions {
serde_json::from_str::<Permissions>(r#"{"push":false,"pull":false}"#).unwrap()
}
impl From<Repository> for CurrentUserRepo {
fn from(repo: Repository) -> Self {
Self {
id: repo.id,
owner: repo.owner.map(|o| o.login).unwrap_or_default(),
repo: repo.name,
permissions: repo.permissions.unwrap_or_else(default_permissions),
}
}
}
#[derive(serde::Deserialize)]
pub struct LoginQuery {
pub return_to: Option<String>,
}
pub async fn login(
session: Session,
Query(LoginQuery { return_to }): Query<LoginQuery>,
State(config): State<GitHubConfig>,
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
if current_user.is_some() {
return Ok(Redirect::to("/").into_response());
}
let Some(config) = &config.oauth else {
tracing::warn!("No GitHub OAuth config found");
return Ok((StatusCode::INTERNAL_SERVER_ERROR, "No GitHub OAuth config").into_response());
};
pub fn generate_nonce() -> String {
let mut bytes = [0u8; 16];
OsRng.try_fill_bytes(&mut bytes)?;
let nonce = URL_SAFE_NO_PAD.encode(bytes);
session.insert(GITHUB_OAUTH_STATE, nonce.clone()).await?;
if let Some(return_to) = return_to {
if return_to.starts_with('/') {
session.insert(RETURN_TO, return_to).await?;
}
}
let mut redirect_url = url::Url::parse("https://github.com/login/oauth/authorize")?;
let mut query = redirect_url.query_pairs_mut();
query.append_pair("client_id", &config.client_id);
query.append_pair("redirect_uri", &config.redirect_uri);
query.append_pair("state", &nonce);
drop(query);
Ok(html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
title { "Logging in... • decomp.dev" }
meta http-equiv="refresh" content=(format!("0;URL={redirect_url}"));
meta name="viewport" content="width=device-width, initial-scale=1.0";
meta name="color-scheme" content="dark light";
meta name="darkreader-lock";
link rel="stylesheet" href="/css/main.min.css?3";
}
body {
.loading-container {
div aria-busy="true" { "Logging in..." }
}
}
}
}.into_response())
}
pub async fn logout(session: Session) -> Result<Response, AppError> {
session.flush().await?;
Ok(Redirect::to("/").into_response())
OsRng.try_fill_bytes(&mut bytes).unwrap();
URL_SAFE_NO_PAD.encode(bytes)
}
#[derive(serde::Deserialize)]
@@ -260,25 +184,9 @@ async fn fetch_access_token(config: &GitHubConfig, code: &str) -> Result<Current
.await?;
let oauth = StoredOAuth::from(oauth);
let client = Octocrab::builder().oauth(oauth.clone().into()).build()?;
let profile = client.current().user().await.context("Failed to fetch current user")?;
let repos = client
.all_pages(
client
.current()
.list_repos_for_authenticated_user()
.visibility("public")
.per_page(100)
.send()
.await
.context("Failed to fetch current user repositories")?,
)
.await
.context("Failed to fetch current user repositories")?
.into_iter()
.map(CurrentUserRepo::from)
.collect::<Vec<_>>();
tracing::info!("Logged in as @{} ({} repos)", profile.login, repos.len());
Ok(CurrentUser { oauth, profile, repos })
let data = fetch_current_user(&client).await?;
tracing::info!("Logged in as @{} ({} repos)", data.login, data.repositories.len());
Ok(CurrentUser { oauth, data })
}
async fn refresh_access_token(
@@ -303,10 +211,8 @@ async fn refresh_access_token(
)
.await?;
let oauth = StoredOAuth::from(oauth);
let client = Octocrab::builder().oauth(oauth.clone().into()).build()?;
let profile = client.current().user().await.context("Failed to fetch current user")?;
tracing::info!("Refreshed token for @{}", profile.login);
Ok(CurrentUser { oauth, profile, repos: prev_auth.repos.clone() })
tracing::info!("Refreshed token for @{}", prev_auth.data.login);
Ok(CurrentUser { oauth, data: prev_auth.data.clone() })
}
impl<S> FromRequestParts<S> for CurrentUser
@@ -356,8 +262,13 @@ where
) -> Result<Option<Self>, Self::Rejection> {
let session = Session::from_request_parts(parts, state).await?;
let config = GitHubConfig::from_ref(state);
let Some(user) = session.get::<CurrentUser>(CURRENT_USER).await.ok().flatten() else {
return Ok(None);
let user = match session.get::<CurrentUser>(CURRENT_USER).await {
Ok(Some(user)) => user,
Ok(None) => return Ok(None),
Err(e) => {
tracing::warn!("Failed to fetch user from session: {}", e);
return Ok(None);
}
};
if let Some(expires_at) = user.oauth.expires_at {
if (UtcDateTime::now() + Duration::seconds(30)) > expires_at {
+2 -1
View File
@@ -10,6 +10,7 @@ axum.workspace = true
decomp-dev-core = { path = "../core" }
decomp-dev-db = { path = "../db" }
futures-util.workspace = true
graphql_client = "0.14"
hex = "0.4"
hmac = "0.12"
http = "1.3"
@@ -18,9 +19,9 @@ objdiff-core.workspace = true
octocrab.workspace = true
regex = "1.11"
serde.workspace = true
serde_json.workspace = true
sha2 = "0.10"
time.workspace = true
tokio.workspace = true
tracing.workspace = true
zip = { version = "2.6", default-features = false, features = ["flate2", "deflate-flate2"] }
serde_json.workspace = true
+31
View File
@@ -0,0 +1,31 @@
query ViewerQuery($after: String) {
viewer {
login
url
repositories(
first: 100,
after: $after,
ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER],
visibility: PUBLIC,
) {
nodes {
...repositoryFields
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
fragment repositoryFields on Repository {
__typename
databaseId
owner {
__typename
login
}
name
viewerPermission
}
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
use anyhow::{Result, anyhow, bail};
use graphql_client::{GraphQLQuery, Response};
use octocrab::Octocrab;
#[allow(clippy::upper_case_acronyms)]
type URI = String;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries.graphql",
response_derives = "Debug, Clone"
)]
pub struct ViewerQuery;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CurrentUserResponse {
pub login: String,
pub url: String,
pub repositories: Vec<CurrentUserRepository>,
}
#[derive(
Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum RepositoryPermission {
None,
Read,
Triage,
Write,
Maintain,
Admin,
}
impl From<viewer_query::RepositoryPermission> for RepositoryPermission {
fn from(value: viewer_query::RepositoryPermission) -> Self {
match value {
viewer_query::RepositoryPermission::ADMIN => RepositoryPermission::Admin,
viewer_query::RepositoryPermission::MAINTAIN => RepositoryPermission::Maintain,
viewer_query::RepositoryPermission::READ => RepositoryPermission::Read,
viewer_query::RepositoryPermission::TRIAGE => RepositoryPermission::Triage,
viewer_query::RepositoryPermission::WRITE => RepositoryPermission::Write,
viewer_query::RepositoryPermission::Other(_) => RepositoryPermission::None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CurrentUserRepository {
pub id: u64,
pub owner: String,
pub name: String,
pub permission: RepositoryPermission,
}
async fn run_query<T: GraphQLQuery>(
client: &Octocrab,
variables: T::Variables,
) -> Result<T::ResponseData> {
let query = T::build_query(variables);
let response: Response<T::ResponseData> = client.graphql(&query).await?;
if let Some(errors) = response.errors {
let message = errors.into_iter().map(|error| error.message).collect::<Vec<_>>().join("\n");
bail!("GraphQL query failed: {message}");
}
response.data.ok_or_else(|| anyhow!("No data returned from GraphQL query"))
}
pub async fn fetch_current_user(client: &Octocrab) -> Result<CurrentUserResponse> {
let mut result =
CurrentUserResponse { login: String::new(), url: String::new(), repositories: Vec::new() };
let mut after = None;
loop {
let data =
run_query::<ViewerQuery>(client, viewer_query::Variables { after: after.clone() })
.await?
.viewer;
result.login = data.login;
result.url = data.url;
for repo in data.repositories.nodes.unwrap_or_default().into_iter().flatten() {
result.repositories.push(CurrentUserRepository {
id: repo.database_id.unwrap_or_default() as u64,
owner: repo.owner.login,
name: repo.name,
permission: repo
.viewer_permission
.map(RepositoryPermission::from)
.unwrap_or_else(|| RepositoryPermission::None),
});
}
if !data.repositories.page_info.has_next_page {
break;
}
let Some(end_cursor) = data.repositories.page_info.end_cursor else {
bail!("hasNextPage is true but endCursor is null");
};
if after.is_some_and(|a| a == end_cursor) {
bail!("Infinite loop detected: after cursor is the same as before");
}
after = Some(end_cursor);
}
Ok(result)
}
+32 -17
View File
@@ -1,4 +1,5 @@
pub mod changes;
pub mod graphql;
pub mod webhook;
use std::{
@@ -41,7 +42,7 @@ pub struct GitHub {
pub struct CachedInstallation {
pub client: Octocrab,
pub repositories: Vec<Repository>,
pub repositories: Vec<InstallationRepository>,
}
pub struct Installations {
@@ -50,6 +51,22 @@ pub struct Installations {
pub repo_to_installation: HashMap<u64, InstallationId>,
}
pub struct InstallationRepository {
pub id: u64,
pub owner: String,
pub name: String,
}
impl From<Repository> for InstallationRepository {
fn from(value: Repository) -> Self {
Self {
id: value.id.into_inner(),
owner: value.owner.map(|o| o.login).unwrap_or_default(),
name: value.name,
}
}
}
impl Installations {
pub async fn client_for_installation(
&mut self,
@@ -64,7 +81,7 @@ impl Installations {
.await
.context("Failed to fetch installation repositories")?;
self.repo_to_installation
.extend(repositories.iter().map(|r| (r.id.into_inner(), installation_id)));
.extend(repositories.iter().map(|r| (r.id, installation_id)));
entry.insert(CachedInstallation { client: client.clone(), repositories });
Ok(client)
}
@@ -94,7 +111,9 @@ struct PageParams {
page: Option<u32>,
}
async fn list_installation_repositories(app_client: &Octocrab) -> Result<Vec<Repository>> {
async fn list_installation_repositories(
app_client: &Octocrab,
) -> Result<Vec<InstallationRepository>> {
let mut page = 1;
let mut response: InstallationRepositories = app_client
.get(
@@ -102,7 +121,8 @@ async fn list_installation_repositories(app_client: &Octocrab) -> Result<Vec<Rep
Some(&PageParams { per_page: Some(100), page: Some(page) }),
)
.await?;
let mut repositories = response.repositories;
let mut repositories =
response.repositories.into_iter().map(InstallationRepository::from).collect::<Vec<_>>();
while repositories.len() < response.total_count as usize {
page += 1;
response = app_client
@@ -114,7 +134,7 @@ async fn list_installation_repositories(app_client: &Octocrab) -> Result<Vec<Rep
if response.repositories.is_empty() {
break;
}
repositories.extend(response.repositories);
repositories.extend(response.repositories.into_iter().map(InstallationRepository::from));
}
Ok(repositories)
}
@@ -129,13 +149,11 @@ async fn list_installations(app_client: Octocrab) -> Result<Installations> {
let client = app_client.installation(installation.id)?;
let repositories = list_installation_repositories(&client).await?;
for repository in &repositories {
if repo_to_installation
.insert(repository.id.into_inner(), installation.id)
.is_some()
{
if repo_to_installation.insert(repository.id, installation.id).is_some() {
tracing::warn!(
"Duplicate installation for repository {}",
repository.full_name.as_deref().unwrap_or_default()
"Duplicate installation for repository {}/{}",
repository.owner,
repository.name
);
}
}
@@ -167,11 +185,8 @@ impl GitHub {
list_installations(app_client).await.context("Failed to fetch installations")?;
tracing::info!("Found {} installations", result.clients.len());
for (installation_id, cached) in &result.clients {
let owners = cached
.repositories
.iter()
.map(|r| r.owner.as_ref().map(|o| o.login.as_str()).unwrap_or_default())
.collect::<HashSet<_>>();
let owners =
cached.repositories.iter().map(|r| r.owner.as_str()).collect::<HashSet<_>>();
let mut owner = String::new();
for o in owners {
if !owner.is_empty() {
@@ -622,7 +637,7 @@ pub async fn check_for_reports(
}
};
let run = items.first().unwrap();
let result = process_workflow_run(&client, &project, run.id).await?;
let result = process_workflow_run(client, project, run.id).await?;
if !result.artifacts.is_empty() {
return Ok(workflow_id.to_string());
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "decomp-dev-scripts"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
ariadne = { version = "0.4", features = ["auto-color"] }
lightningcss = "1.0.0-alpha.65"
oxc = { version = "0.31", features = ["codegen", "minifier", "transformer", "semantic"] }
tokio.workspace = true
thiserror = "2.0"
grass = "0.13"
decomp-dev-core = { path = "../core" }
axum.workspace = true
mime.workspace = true
-24
View File
@@ -1,24 +0,0 @@
use std::{ffi::OsStr, path::Path};
use anyhow::anyhow;
pub fn transform(path: &Path) -> Result<String, anyhow::Error> {
let mut path = path.with_extension("");
let printer_options = lightningcss::stylesheet::PrinterOptions {
minify: path.extension() == Some(OsStr::new("min")),
..Default::default()
};
path = path.with_extension("scss");
let options = grass::Options::default().load_path("node_modules");
let mut output = grass::from_path(&path, &options)?;
// Skip lightningcss entirely if we're not minifying
if printer_options.minify {
let options = lightningcss::stylesheet::ParserOptions::default();
let stylesheet = lightningcss::stylesheet::StyleSheet::parse(&output, options)
.map_err(|e| anyhow!(e.to_string()))?;
let result = stylesheet.to_css(printer_options)?;
drop(stylesheet);
output = result.code;
}
Ok(output)
}
-122
View File
@@ -1,122 +0,0 @@
use std::ops::Range;
use anyhow::anyhow;
use ariadne::{ColorGenerator, Label, Report, ReportBuilder, ReportKind, Source};
use oxc::{
allocator::Allocator,
codegen::{CodeGenerator, CodegenReturn},
diagnostics::{OxcDiagnostic, Severity},
minifier::{CompressOptions, Minifier, MinifierOptions},
parser::Parser,
semantic::SemanticBuilder,
span::SourceType,
transformer::{EnvOptions, Targets, TransformOptions, Transformer},
};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum JsError {
#[error("File not found")]
NotFound,
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
pub async fn transform(
path: &std::path::Path,
minify: bool,
source_map: bool,
) -> Result<CodegenReturn, JsError> {
let filename = path.file_name().ok_or(JsError::NotFound)?.to_string_lossy().into_owned();
let source_text = tokio::fs::read_to_string(&path).await.map_err(|_| JsError::NotFound)?;
let source_type = SourceType::from_path(path).map_err(|_| JsError::NotFound)?;
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, &source_text, source_type).parse();
handle_errors(parsed.errors, &filename, &source_text)?;
let program = allocator.alloc(parsed.program);
let builder_return = SemanticBuilder::new(&source_text).build(program);
handle_errors(builder_return.errors, &filename, &source_text)?;
let (symbols, scopes) = builder_return.semantic.into_symbol_table_and_scope_tree();
let transform_options = TransformOptions::from_preset_env(&EnvOptions {
targets: Targets::from_query("defaults"),
..EnvOptions::default()
})
.map_err(|v| anyhow!("{}", v.first().unwrap()))?;
let transform_return =
Transformer::new(&allocator, path, &source_text, parsed.trivias.clone(), transform_options)
.build_with_symbols_and_scopes(symbols, scopes, program);
handle_errors(transform_return.errors, &filename, &source_text)?;
let mangler = if minify {
Minifier::new(MinifierOptions {
mangle: minify,
compress: CompressOptions { drop_console: false, ..CompressOptions::all_true() },
})
.build(&allocator, program)
.mangler
} else {
None
};
let mut codegen = CodeGenerator::new()
.with_options(oxc::codegen::CodegenOptions { minify, ..Default::default() })
.with_mangler(mangler);
if source_map {
let name = path.file_name().unwrap().to_string_lossy();
codegen = codegen.enable_source_map(&name, &source_text);
}
Ok(codegen.build(program))
}
fn handle_errors(
errors: Vec<OxcDiagnostic>,
filename: &str,
source_text: &str,
) -> Result<(), anyhow::Error> {
let mut has_error = false;
for diagnostic in errors {
let mut colors = ColorGenerator::new();
type ReportSpan<'a> = (&'a str, Range<usize>);
let spans = diagnostic.labels.as_deref().unwrap_or_default();
let offset = spans
.iter()
.find_map(|label| label.primary().then_some(label.offset()))
.or_else(|| spans.first().map(|span| span.offset()))
.unwrap_or_default();
let mut report: ReportBuilder<ReportSpan> = Report::build(
match diagnostic.severity {
Severity::Advice => ReportKind::Advice,
Severity::Warning => ReportKind::Warning,
Severity::Error => ReportKind::Error,
},
filename,
offset,
)
.with_message(diagnostic.message.clone());
if let Some(number) = diagnostic.code.number.as_deref() {
report = report.with_code(number);
}
for span in spans {
let offset = span.offset();
let mut label = Label::new((filename, offset..offset)).with_color(colors.next());
if let Some(message) = span.label() {
label = label.with_message(message);
}
report = report.with_label(label);
}
if let Some(help) = diagnostic.help.as_deref() {
report = report.with_help(help);
}
if let Some(url) = diagnostic.url.as_deref() {
report = report.with_note(url);
}
report.finish().print((filename, Source::from(source_text)))?;
if diagnostic.severity == Severity::Error {
has_error = true;
}
}
if has_error { Err(anyhow!("Failed to transform JS")) } else { Ok(()) }
}
-80
View File
@@ -1,80 +0,0 @@
pub mod css;
pub mod js;
use std::ffi::OsStr;
use anyhow::anyhow;
use axum::{
extract::Path,
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use decomp_dev_core::{AppError, util::join_normalized};
pub async fn get_css(Path(filename): Path<String>) -> Result<Response, AppError> {
let path = join_normalized("css", &filename);
if path.extension() != Some(OsStr::new("css")) {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
let output = css::transform(&path).map_err(|e| AppError::Internal(anyhow!(e.to_string())))?;
Ok((
[
(header::CONTENT_TYPE, mime::TEXT_CSS_UTF_8.as_ref()),
#[cfg(not(debug_assertions))]
(header::CACHE_CONTROL, "public, max-age=3600"),
#[cfg(debug_assertions)]
(header::CACHE_CONTROL, "no-cache"),
],
output,
)
.into_response())
}
pub async fn get_js(Path(filename): Path<String>) -> Result<Response, AppError> {
let mut path = join_normalized("js", &filename);
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
enum ResponseType {
Js,
SourceMap,
}
let response_type;
if path.extension() == Some(OsStr::new("js")) {
response_type = ResponseType::Js;
} else if path.extension() == Some(OsStr::new("map")) {
path = path.with_extension("");
if path.extension() != Some(OsStr::new("js")) {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
response_type = ResponseType::SourceMap;
} else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
path = path.with_extension("");
let minify = path.extension() == Some(OsStr::new("min"));
path = path.with_extension("ts");
let ret = match js::transform(&path, minify, response_type == ResponseType::SourceMap).await {
Ok(ret) => ret,
Err(js::JsError::NotFound) => return Err(AppError::Status(StatusCode::NOT_FOUND)),
Err(js::JsError::Internal(e)) => return Err(AppError::Internal(e)),
};
let (data, content_type) = match response_type {
ResponseType::Js => (
format!("{}\n//# sourceMappingURL={}.map", ret.code, filename),
mime::APPLICATION_JAVASCRIPT_UTF_8.as_ref(),
),
ResponseType::SourceMap => {
(ret.map.unwrap().to_json_string(), mime::APPLICATION_JSON.as_ref())
}
};
Ok((
[
(header::CONTENT_TYPE, content_type),
#[cfg(not(debug_assertions))]
(header::CACHE_CONTROL, "public, max-age=3600"),
#[cfg(debug_assertions)]
(header::CACHE_CONTROL, "no-cache"),
],
data,
)
.into_response())
}
-1
View File
@@ -12,7 +12,6 @@ decomp-dev-core = { path = "../core" }
decomp-dev-db = { path = "../db" }
decomp-dev-github = { path = "../github" }
decomp-dev-images = { path = "../images" }
decomp-dev-scripts = { path = "../scripts" }
itertools = "0.14"
maud.workspace = true
mime.workspace = true
+68
View File
@@ -0,0 +1,68 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Redirect, Response},
};
use decomp_dev_auth::{CurrentUser, GITHUB_OAUTH_STATE, RETURN_TO, generate_nonce};
use decomp_dev_core::{AppError, config::GitHubConfig};
use maud::{DOCTYPE, html};
use tower_sessions::Session;
use crate::handlers::common::{chunks, header};
#[derive(serde::Deserialize)]
pub struct LoginQuery {
pub return_to: Option<String>,
}
pub async fn login(
session: Session,
Query(LoginQuery { return_to }): Query<LoginQuery>,
State(config): State<GitHubConfig>,
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
if current_user.is_some() {
return Ok(Redirect::to("/").into_response());
}
let Some(config) = &config.oauth else {
tracing::warn!("No GitHub OAuth config found");
return Ok((StatusCode::INTERNAL_SERVER_ERROR, "No GitHub OAuth config").into_response());
};
let nonce = generate_nonce();
session.insert(GITHUB_OAUTH_STATE, nonce.clone()).await?;
if let Some(return_to) = return_to {
if return_to.starts_with('/') {
session.insert(RETURN_TO, return_to).await?;
}
}
let mut redirect_url = url::Url::parse("https://github.com/login/oauth/authorize")?;
{
let mut query = redirect_url.query_pairs_mut();
query.append_pair("client_id", &config.client_id);
query.append_pair("redirect_uri", &config.redirect_uri);
query.append_pair("state", &nonce);
}
Ok(html! {
(DOCTYPE)
html {
head lang="en" {
meta charset="utf-8";
title { "Logging in... • decomp.dev" }
meta http-equiv="refresh" content=(format!("0;URL={redirect_url}"));
(header())
(chunks("main", true).await)
}
body {
.loading-container {
div aria-busy="true" { "Logging in..." }
}
}
}
}
.into_response())
}
pub async fn logout(session: Session) -> Result<Response, AppError> {
session.flush().await?;
Ok(Redirect::to("/").into_response())
}
+57 -3
View File
@@ -1,8 +1,10 @@
use std::{
collections::HashMap,
sync::LazyLock,
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use axum::http::StatusCode;
use decomp_dev_auth::CurrentUser;
use decomp_dev_core::AppError;
@@ -28,12 +30,64 @@ pub fn header() -> Markup {
meta name="viewport" content="width=device-width, initial-scale=1.0";
meta name="color-scheme" content="dark light";
meta name="darkreader-lock";
link rel="stylesheet" href="/css/main.min.css?3";
script src="/js/main.min.js" defer {}
script { (PreEscaped(r#"let t;try{t=localStorage.getItem("theme")}catch(_){}if(t)document.documentElement.setAttribute("data-theme",t);"#)) }
}
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WebpackManifest {
// pub all_files: Vec<String>,
pub entries: HashMap<String, WebpackManifestEntry>,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WebpackManifestEntry {
pub initial: WebpackManifestEntryPaths,
// pub r#async: WebpackManifestEntryPaths,
// pub html: Vec<String>,
// pub assets: Vec<String>,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WebpackManifestEntryPaths {
pub js: Vec<String>,
pub css: Vec<String>,
}
pub async fn manifest_paths(entry: &str) -> Result<WebpackManifestEntryPaths> {
let manifest_str = tokio::fs::read_to_string("dist/manifest.json").await?;
let manifest: WebpackManifest = serde_json::from_str(&manifest_str)?;
let entry = manifest
.entries
.get(entry)
.ok_or_else(|| anyhow!("Entry {} not found in manifest", entry))?;
Ok(entry.initial.clone())
}
pub async fn chunks(entry: &str, defer: bool) -> Markup {
let paths = manifest_paths(entry).await.unwrap_or_else(|e| {
tracing::error!("Failed to load chunks for {entry}: {e}");
Default::default()
});
let mut out = String::new();
for path in paths.css {
out.push_str(&html! { link rel="stylesheet" href=(path); }.0);
}
if defer {
for path in paths.js {
out.push_str(&html! { script src=(path) defer {} }.0);
}
} else {
for path in paths.js {
out.push_str(&html! { script src=(path) {} }.0);
}
}
PreEscaped(out)
}
pub fn footer(start: Instant, current_user: Option<&CurrentUser>) -> Markup {
let elapsed = start.elapsed();
html! {
@@ -51,7 +105,7 @@ pub fn footer(start: Instant, current_user: Option<&CurrentUser>) -> Markup {
span class="section" {
small class="muted" {
"Logged in as "
a href=(user.profile.html_url) { "@" (user.profile.login) }
a href=(user.data.url) { "@" (user.data.login) }
}
" | "
form action="/logout" method="post" style="display: inline" {
+16 -15
View File
@@ -12,14 +12,14 @@ use decomp_dev_core::{
AppError,
models::{ALL_PLATFORMS, Project, ProjectInfo},
};
use decomp_dev_github::{check_for_reports, refresh_project};
use decomp_dev_github::{check_for_reports, graphql::RepositoryPermission, refresh_project};
use itertools::Itertools;
use maud::{DOCTYPE, Markup, html};
use serde::Deserialize;
use crate::{
AppState,
handlers::common::{footer, header, nav_links},
handlers::common::{chunks, footer, header, nav_links},
};
pub async fn manage(
@@ -33,7 +33,7 @@ pub async fn manage(
.get_projects()
.await?
.into_iter()
.filter(|p| current_user.permissions_for_repo(p.project.id).admin)
.filter(|p| current_user.can_manage_repo(p.project.id))
.sorted_by(|a, b| a.project.name().cmp(&b.project.name()))
.collect::<Vec<_>>();
@@ -44,6 +44,8 @@ pub async fn manage(
meta charset="utf-8";
title { "Manage • decomp.dev" }
(header())
(chunks("main", true).await)
(chunks("manage", true).await)
}
body {
header {
@@ -109,15 +111,12 @@ async fn render_new(
let projects = state.db.get_projects().await?;
let repos = current_user
.repos
.data
.repositories
.iter()
.filter(|r| r.permissions.admin)
.filter(|r| r.permission == RepositoryPermission::Admin)
.map(|r| {
(
r.id.into_inner(),
format!("{}/{}", r.owner, r.repo),
projects.iter().any(|p| p.project.id == r.id.into_inner()),
)
(r.id, format!("{}/{}", r.owner, r.name), projects.iter().any(|p| p.project.id == r.id))
})
.collect::<Vec<_>>();
@@ -144,7 +143,8 @@ async fn render_new(
meta charset="utf-8";
title { "New Project • decomp.dev" }
(header())
script src="/js/manage.min.js" defer {}
(chunks("main", true).await)
(chunks("manage", true).await)
}
body {
header {
@@ -298,7 +298,7 @@ pub async fn manage_project(
else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
if !current_user.permissions_for_repo(project_info.project.id).admin {
if !current_user.can_manage_repo(project_info.project.id) {
return Err(AppError::Status(StatusCode::FORBIDDEN));
}
@@ -356,7 +356,8 @@ async fn render_manage_project(
meta charset="utf-8";
title { (project_short_name) " • Manage" }
(header())
script src="/js/manage.min.js" defer {}
(chunks("main", true).await)
(chunks("manage", true).await)
}
body {
header {
@@ -498,7 +499,7 @@ pub async fn manage_project_save(
else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
if !current_user.permissions_for_repo(project_info.project.id).admin {
if !current_user.can_manage_repo(project_info.project.id) {
return Err(AppError::Status(StatusCode::FORBIDDEN));
}
let installation_id = if let Some(installations) = &state.github.installations {
@@ -543,7 +544,7 @@ pub async fn manage_project_refresh(
else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
if !current_user.permissions_for_repo(project_info.project.id).admin {
if !current_user.can_manage_repo(project_info.project.id) {
return Err(AppError::Status(StatusCode::FORBIDDEN));
}
let client = current_user.client()?;
+5 -4
View File
@@ -7,9 +7,11 @@ use axum::{
};
use decomp_dev_images::image_mime_from_ext;
use mime::Mime;
use tower_http::services::ServeDir;
use crate::AppState;
mod auth;
mod common;
mod manage;
mod project;
@@ -18,19 +20,18 @@ mod treemap;
pub fn build_router() -> Router<AppState> {
Router::new()
.nest_service("/static", ServeDir::new("dist/static"))
.route("/robots.txt", get(common::get_robots))
.route("/api/github/webhook", post(decomp_dev_github::webhook::webhook))
.route("/api/github/oauth", get(decomp_dev_auth::oauth))
.route("/login", get(decomp_dev_auth::login))
.route("/logout", post(decomp_dev_auth::logout))
.route("/login", get(auth::login))
.route("/logout", post(auth::logout))
.route("/manage", get(manage::manage))
.route("/manage/new", get(manage::new))
.route("/manage/new", post(manage::new_save))
.route("/manage/{owner}/{repo}", get(manage::manage_project))
.route("/manage/{owner}/{repo}", post(manage::manage_project_save))
.route("/manage/{owner}/{repo}/refresh", post(manage::manage_project_refresh))
.route("/css/{*filename}", get(decomp_dev_scripts::get_css))
.route("/js/{*filename}", get(decomp_dev_scripts::get_js))
.route("/assets/{*filename}", get(decomp_dev_images::get_asset))
.route("/og.png", get(decomp_dev_images::get_og))
.route("/", get(project::get_projects))

Some files were not shown because too many files have changed in this diff Show More