mirror of
https://github.com/encounter/decomp.dev.git
synced 2026-07-10 03:18:48 -07:00
Frontend build rework (rsbuild) & some GitHub GraphQL
This commit is contained in:
@@ -4,3 +4,4 @@
|
||||
*.sqlite*
|
||||
config.yml
|
||||
.env
|
||||
dist/
|
||||
|
||||
Generated
+75
-1270
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ members = [
|
||||
"crates/db",
|
||||
"crates/github",
|
||||
"crates/images",
|
||||
"crates/scripts",
|
||||
"crates/web",
|
||||
]
|
||||
|
||||
|
||||
+13
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(()) }
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user