Compare commits

...
Author SHA1 Message Date
ProspectorandGitHub 64e0346c87 Merge branch 'main' into prospector/add-complementary-installer-source 2026-07-08 11:45:58 -07:00
Prospector d75c8e8adf feat: add complementary installer source support 2026-07-08 11:26:50 -07:00
Truman GaoandGitHub 9ca181dc1a fix: privacy popup in app bounded to tiny webview (#6657) 2026-07-08 16:29:41 +00:00
aecsocketandGitHub c84b658e41 feat: tech review global trace database (#6648)
* global detail verdicts system

* global traces frontend

* split global routes into own mod

* tweak migration to only do no-key check at app level

* prepr
2026-07-08 16:06:45 +00:00
Prospector 9006dce2b0 changelog 2026-07-08 08:06:13 -07:00
ProspectorandGitHub efdfb51149 fix: add DEI (downloads, equality, and inequality) (#6664)
fix: add DEI
2026-07-08 15:04:21 +00:00
ProspectorandGitHub 79cc5fef72 feat: exclude project types filter (#6660)
* feat: exclude project types filter

* fix merge issue
2026-07-08 14:30:47 +00:00
Truman GaoandGitHub ddefc28ca6 fix: instance edits not emitting edit event to reload state (#6658) 2026-07-08 09:16:32 +00:00
654757f9fb docs: replace broken loc count with static count (#6615)
* docs: replace broken loc count with static count

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>

* feat: update to use loctopus api

credits to @creeperkatze <3

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>

---------

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-08 05:21:47 +00:00
François-Xavier TalbotandGitHub 407641ee7c fix(labrinth): serde(default) new search document fields (#6656) 2026-07-07 15:28:54 -07:00
ProspectorandGitHub 96ab120cd0 feat: add all_project_types to search results (#6654)
* feat: add all_project_types to search results

* update docs
2026-07-07 21:30:42 +00:00
François-Xavier TalbotandGitHub 3ed75a146a fix(labrinth): fix routing config for /v3/content (#6649) 2026-07-07 16:48:14 +00:00
aecsocketandGitHub 5a1c2a46bc Generate different Daedalus Quilt templates for 26.x/non-26.x (#6554)
* Generate different Daedalus Quilt templates for 26.x/non-26.x

* add docs

* wip: daedalus version groups

* wip: app-side logic

* finish app-side logic, fix certain libs not included

* lints

* fmt

* fmt

* Split frontend changes out of Daedalus branch
2026-07-07 16:45:33 +00:00
61 changed files with 3213 additions and 400 deletions
+2 -2
View File
@@ -3,13 +3,13 @@
![Issues](https://img.shields.io/github/issues-raw/Modrinth/code?color=c78aff&label=issues&style=for-the-badge)
![Pull Requests](https://img.shields.io/github/issues-pr-raw/Modrinth/code?color=c78aff&label=PRs&style=for-the-badge)
![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=c78aff&label=contributors&style=for-the-badge)
![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Commit Activity](https://img.shields.io/github/commit-activity/m/Modrinth/code?color=c78aff&label=commits&style=for-the-badge)
![Last Commit](https://img.shields.io/github/last-commit/Modrinth/code?color=c78aff&label=last%20commit&style=for-the-badge)
## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/olympus/badge?logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
+15
View File
@@ -49,6 +49,7 @@ import {
get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
@@ -1041,10 +1042,24 @@ function getProjectBrowseQuery() {
}
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({
tags,
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(),
+1
View File
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
pride_fundraiser: true,
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+2
View File
@@ -276,6 +276,8 @@ fn main() {
"hide_ads_window",
"scroll_ads_window",
"show_ads_window",
"show_ads_consent_overlay",
"hide_ads_consent_overlay",
"record_ads_click",
"open_link",
"get_ads_personalization",
+94 -3
View File
@@ -1,14 +1,16 @@
const MODRINTH_ORIGIN = 'https://modrinth.com'
document.addEventListener(
'click',
function (e) {
window.top.postMessage({ modrinthAdClick: true }, 'https://modrinth.com')
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
if (target.href) {
window.top.postMessage({ modrinthOpenUrl: target.href }, 'https://modrinth.com')
window.top.postMessage({ modrinthOpenUrl: target.href }, MODRINTH_ORIGIN)
}
break
}
@@ -19,7 +21,93 @@ document.addEventListener(
)
window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
}
let modrinthAdsConsentOverlayShown = false
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
function installAdsConsentOverlayStyle() {
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
return
}
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-overlay-style'
style.textContent = `
html.modrinth-ads-consent-overlay #modrinth-rail-1 {
visibility: hidden !important;
}
`
document.documentElement.appendChild(style)
}
function getTauriInvoke() {
return window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke
}
function invokeAdsConsentOverlayCommand(shown) {
const invoke = getTauriInvoke()
if (typeof invoke !== 'function') {
return
}
const command = shown ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay'
const args = shown ? {} : { dpr: window.devicePixelRatio }
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
if (window.top === window) {
invokeAdsConsentOverlayCommand(shown)
} else {
window.top.postMessage({ modrinthAdsConsentOverlay: shown }, MODRINTH_ORIGIN)
}
}
if (window.top === window) {
window.addEventListener('message', (event) => {
if (
event.origin === MODRINTH_ORIGIN &&
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
) {
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
}
})
}
function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
if (tcData.eventStatus === 'cmpuishown') {
setAdsConsentOverlay(true)
} else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
setAdsConsentOverlay(false)
}
}
// polling to install listener on tcf api
function installTcfConsentListener() {
if (modrinthTcfListenerInstalled) return
if (typeof window.__tcfapi === 'function') {
modrinthTcfListenerInstalled = true
window.__tcfapi('addEventListener', 2, handleTcfConsentEvent)
return
}
if (modrinthTcfListenerAttempts < 60) {
modrinthTcfListenerAttempts += 1
setTimeout(installTcfConsentListener, 500)
}
}
function muteAudioContext() {
@@ -100,7 +188,10 @@ function muteVideos() {
document.addEventListener('DOMContentLoaded', () => {
muteVideos()
muteAudioContext()
installTcfConsentListener()
const observer = new MutationObserver(muteVideos)
observer.observe(document.body, { childList: true, subtree: true })
})
installTcfConsentListener()
+157 -18
View File
@@ -11,12 +11,14 @@ use tokio::sync::RwLock;
pub struct AdsState {
pub shown: bool,
pub modal_shown: bool,
pub consent_overlay_shown: bool,
pub occluded: bool,
pub last_click: Option<Instant>,
pub malicious_origins: HashSet<String>,
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
@@ -131,13 +133,16 @@ fn set_webview_visible_for_window<R: Runtime>(
.and_then(|window| window.is_minimized().ok())
.unwrap_or(false);
let is_occluded = app
let (is_occluded, consent_overlay_shown) = app
.state::<RwLock<AdsState>>()
.try_read()
.map(|state| state.occluded)
.unwrap_or(false);
.map(|state| (state.occluded, state.consent_overlay_shown))
.unwrap_or((false, false));
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
set_webview_visible(
webview,
visible && !is_minimized && (!is_occluded || consent_overlay_shown),
);
}
#[cfg(any(windows, target_os = "macos"))]
@@ -195,7 +200,8 @@ async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
}
state.occluded = occluded;
let visible = state.shown && !state.modal_shown;
let visible =
state.shown && (!state.modal_shown || state.consent_overlay_shown);
drop(state);
if let Some(webview) = app.webviews().get("ads-window") {
@@ -211,21 +217,36 @@ fn sync_webview_visibility_for_main_window<R: Runtime>(
let is_minimized = main_window.is_minimized().unwrap_or(false);
let was = was_minimized.load(Ordering::SeqCst);
let ads_state = if is_minimized {
None
} else {
match app.state::<RwLock<AdsState>>().try_read() {
Ok(state) => Some((
state.shown
&& (!state.modal_shown || state.consent_overlay_shown)
&& (!state.occluded || state.consent_overlay_shown),
state.consent_overlay_shown,
)),
Err(_) => None,
}
};
let ads_visible = ads_state.map(|state| state.0).unwrap_or(false);
if ads_state.map(|state| state.1).unwrap_or(false)
&& let Some(webview) = app.webviews().get("ads-window")
&& let Ok((position, size)) =
get_overlay_webview_position_for_window(main_window)
{
webview.set_position(position).ok();
webview.set_size(size).ok();
}
if is_minimized == was {
return;
}
was_minimized.store(is_minimized, Ordering::SeqCst);
let ads_visible = if is_minimized {
false
} else {
match app.state::<RwLock<AdsState>>().try_read() {
Ok(state) => state.shown && !state.modal_shown && !state.occluded,
Err(_) => false,
}
};
let mut webviews = Vec::new();
let mut seen_webviews = HashSet::new();
@@ -254,6 +275,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
app.manage(RwLock::new(AdsState {
shown: true,
modal_shown: false,
consent_overlay_shown: false,
occluded: false,
last_click: None,
malicious_origins: HashSet::new(),
@@ -269,7 +291,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.state::<RwLock<AdsState>>()
.try_read()
.map(|state| {
state.shown && !state.modal_shown && !state.occluded
state.shown
&& !state.modal_shown
&& !state.consent_overlay_shown
&& !state.occluded
})
.unwrap_or(false);
@@ -332,6 +357,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
init_ads_window,
hide_ads_window,
show_ads_window,
show_ads_consent_overlay,
hide_ads_consent_overlay,
record_ads_click,
open_link,
get_ads_personalization,
@@ -358,6 +385,42 @@ fn get_webview_position<R: Runtime>(
))
}
fn get_overlay_webview_position_for_window<R: Runtime>(
main_window: &tauri::Window<R>,
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
let main_window_size = main_window.outer_size()?;
let title_bar_height =
APP_TITLE_BAR_HEIGHT * main_window.scale_factor()? as f32;
Ok((
PhysicalPosition::new(0.0, title_bar_height),
PhysicalSize::new(
main_window_size.width as f32,
(main_window_size.height as f32 - title_bar_height).max(0.0),
),
))
}
fn get_overlay_webview_position<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
let main_window = app.get_window("main").unwrap();
get_overlay_webview_position_for_window(&main_window)
}
fn get_device_pixel_ratio<R: Runtime>(
app: &tauri::AppHandle<R>,
dpr: Option<f32>,
) -> f32 {
dpr.unwrap_or_else(|| {
app.get_window("main")
.and_then(|window| window.scale_factor().ok())
.map(|scale_factor| scale_factor as f32)
.unwrap_or(1.0)
})
}
#[tauri::command]
#[cfg(not(target_os = "linux"))]
pub async fn init_ads_window<R: Runtime>(
@@ -374,11 +437,17 @@ pub async fn init_ads_window<R: Runtime>(
state.shown = true;
}
if state.modal_shown {
if state.modal_shown && !state.consent_overlay_shown {
return Ok(());
}
if let Ok((position, size)) = get_webview_position(&app, dpr) {
let layout = if state.consent_overlay_shown {
get_overlay_webview_position(&app)
} else {
get_webview_position(&app, dpr)
};
if let Ok((position, size)) = layout {
let webview = if let Some(webview) = app.webviews().get("ads-window") {
// set both the `hide`/`show` state and `position`,
// to ensure that the webview is actually shown/hidden
@@ -586,7 +655,11 @@ pub async fn show_ads_window<R: Runtime>(
state.modal_shown = false;
if state.shown {
let (position, size) = get_webview_position(&app, dpr)?;
let (position, size) = if state.consent_overlay_shown {
get_overlay_webview_position(&app)?
} else {
get_webview_position(&app, dpr)?
};
// set both the `hide`/`show` state and `position`,
// to ensure that the webview is actually shown/hidden
webview.set_size(size).ok();
@@ -610,8 +683,19 @@ pub async fn hide_ads_window<R: Runtime>(
if reset.unwrap_or(false) {
state.shown = false;
state.consent_overlay_shown = false;
} else {
state.modal_shown = true;
if state.consent_overlay_shown {
let (position, size) = get_overlay_webview_position(&app)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
return Ok(());
}
}
// set both the `hide`/`show` state and `position`,
@@ -625,6 +709,61 @@ pub async fn hide_ads_window<R: Runtime>(
Ok(())
}
#[tauri::command]
pub async fn show_ads_consent_overlay<R: Runtime>(
app: tauri::AppHandle<R>,
) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
// dont show for hidden ads so consent events cannot re-enable the webview.
if !state.shown {
return Ok(());
}
state.consent_overlay_shown = true;
let (position, size) = get_overlay_webview_position(&app)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
Ok(())
}
#[tauri::command]
pub async fn hide_ads_consent_overlay<R: Runtime>(
app: tauri::AppHandle<R>,
dpr: Option<f32>,
) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
state.consent_overlay_shown = false;
if state.shown && !state.modal_shown {
let dpr = get_device_pixel_ratio(&app, dpr);
let (position, size) = get_webview_position(&app, dpr)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
} else {
webview
.set_position(PhysicalPosition::new(-1000, -1000))
.ok();
webview.hide().ok();
}
}
Ok(())
}
#[tauri::command]
pub async fn record_ads_click<R: Runtime>(
app: tauri::AppHandle<R>,
+2
View File
@@ -27,6 +27,8 @@ pub enum ErrorKind {
inner: Box<s3::error::S3Error>,
file: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Error acquiring semaphore: {0}")]
Acquire(#[from] tokio::sync::AcquireError),
#[error("Tracing error: {0}")]
+300 -93
View File
@@ -1,3 +1,19 @@
//! Fetches Fabric-compatible loader metadata.
//!
//! Fabric and Quilt both expose loader profiles for a concrete Minecraft
//! version, but Daedalus publishes templated profiles using
//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose
//! upstream loader profiles have the same structure after the concrete
//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses
//! one universal group, so its public profile paths stay as
//! `versions/{loader}.json`. Quilt has more than one group: versions before
//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For
//! Quilt, Daedalus writes one templated profile per group at
//! `version-group/{group}/loader-version/{loader}`.
use crate::metadata_groups::{
UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups,
};
use crate::util::{download_file, fetch_json, format_url};
use crate::{
Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact,
@@ -64,7 +80,112 @@ async fn fetch(
&semaphore,
)
.await?;
let all_loader_versions = fabric_manifest.loader.clone();
let all_game_versions = fabric_manifest.game.clone();
let metadata_groups = metadata_groups(
mod_loader,
all_game_versions.iter().map(|x| x.version.as_str()),
);
if metadata_groups
.iter()
.any(|group| group.id != UNIVERSAL_METADATA_GROUP)
{
let loaders = all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect::<Vec<_>>();
let profile_requests = metadata_groups
.iter()
.flat_map(|group| {
loaders.iter().map(move |loader| ProfileRequest {
group: group.id.to_string(),
loader_profile_template_game_version: group
.loader_profile_template_game_version
.clone(),
game_versions: group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url,
group.loader_profile_template_game_version,
loader.version
),
})
})
.collect::<Vec<_>>();
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
)
.await?;
let version_groups = metadata_groups
.iter()
.map(|group| daedalus::modded::VersionGroup {
id: group.id.to_string(),
loaders: loaders
.iter()
.map(|loader| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&loader.version,
group.id,
);
daedalus::modded::LoaderVersion {
id: loader.version.clone(),
url: format_url(&version_path),
stable: loader.stable,
}
})
.collect(),
})
.collect();
let manifest = daedalus::modded::Manifest {
game_versions: all_game_versions
.into_iter()
.map(|game_version| {
let group = metadata_group_for_game_version(
&metadata_groups,
mod_loader,
&game_version.version,
)
.expect("game version should have a metadata group");
daedalus::modded::Version {
id: game_version.version.clone(),
stable: game_version.stable,
version_group: Some(group.id.to_string()),
loaders: Vec::new(),
}
})
.collect(),
version_groups,
};
upload_files.insert(
format!("{mod_loader}/v{format_version}/manifest.json"),
UploadFile {
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
content_type: Some("application/json".to_string()),
},
);
return Ok(FetchResult {
upload_files,
mirror_artifacts,
});
}
// We check Modrinth's manifest to find newly added loader versions,
// intermediary/mapping artifacts, and game versions.
let (
@@ -125,8 +246,6 @@ async fn fetch(
)
};
const DUMMY_GAME_VERSION: &str = "1.21";
if !fetch_intermediary_versions.is_empty() {
for x in &fetch_intermediary_versions {
insert_mirrored_artifact(
@@ -140,94 +259,38 @@ async fn fetch(
}
if !fetch_fabric_versions.is_empty() {
let fabric_version_manifest_urls = fetch_fabric_versions
let universal_group = metadata_groups
.iter()
.map(|x| {
format!(
.find(|group| group.id == UNIVERSAL_METADATA_GROUP)
.expect("fabric metadata should have a universal group");
let profile_requests = fetch_fabric_versions
.iter()
.map(|loader| ProfileRequest {
group: universal_group.id.to_string(),
loader_profile_template_game_version: universal_group
.loader_profile_template_game_version
.clone(),
game_versions: universal_group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url, DUMMY_GAME_VERSION, x.version
)
meta_url,
universal_group.loader_profile_template_game_version,
loader.version
),
})
.collect::<Vec<_>>();
let fabric_version_manifests = futures::future::try_join_all(
fabric_version_manifest_urls
.iter()
.map(|x| download_file(x, None, &semaphore)),
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
)
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = fabric_version_manifests
.into_iter()
.map(|mut version_info| {
for lib in &mut version_info.libraries {
let new_name = lib
.name
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some(
"https://libraries.minecraft.net/".to_string(),
);
}
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![
lib.url
.clone()
.unwrap_or_else(|| maven_url.to_string()),
],
false,
&mirror_artifacts,
)?;
} else {
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id = version_info
.id
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.enumerate()
.for_each(|(index, bytes)| {
let loader = fetch_fabric_versions[index];
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
loader.version
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
.await?;
}
if !fetch_fabric_versions.is_empty()
@@ -240,17 +303,20 @@ async fn fetch(
let loader_versions = daedalus::modded::Version {
id: DUMMY_REPLACE_STRING.to_string(),
stable: true,
loaders: fabric_manifest
.loader
.into_iter()
version_group: None,
loaders: all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.map(|x| {
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.version,
let version_path = metadata_version_path(
mod_loader,
format_version,
&x.version,
UNIVERSAL_METADATA_GROUP,
);
daedalus::modded::LoaderVersion {
id: x.version,
id: x.version.clone(),
url: format_url(&version_path),
stable: x.stable,
}
@@ -260,14 +326,16 @@ async fn fetch(
let manifest = daedalus::modded::Manifest {
game_versions: std::iter::once(loader_versions)
.chain(fabric_manifest.game.into_iter().map(|x| {
.chain(all_game_versions.into_iter().map(|x| {
daedalus::modded::Version {
id: x.version,
stable: x.stable,
version_group: None,
loaders: vec![],
}
}))
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
@@ -285,6 +353,145 @@ async fn fetch(
})
}
struct ProfileRequest {
group: String,
loader_profile_template_game_version: String,
game_versions: Vec<String>,
loader_version: String,
url: String,
}
fn metadata_version_path(
mod_loader: &str,
format_version: usize,
loader_version: &str,
group: &str,
) -> String {
if group == UNIVERSAL_METADATA_GROUP {
format!("{mod_loader}/v{format_version}/versions/{loader_version}.json")
} else {
format!(
"{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}"
)
}
}
async fn fetch_metadata_profiles(
mod_loader: &str,
format_version: usize,
maven_url: &str,
profile_requests: Vec<ProfileRequest>,
upload_files: &DashMap<String, UploadFile>,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let version_manifests = futures::future::try_join_all(
profile_requests
.iter()
.map(|x| download_file(&x.url, None, semaphore)),
)
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = version_manifests
.into_iter()
.zip(profile_requests.iter())
.map(|(mut version_info, request)| {
patch_version_info(
&mut version_info,
&request.loader_profile_template_game_version,
&request.game_versions,
maven_url,
mirror_artifacts,
)?;
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.zip(profile_requests)
.for_each(|(bytes, request)| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&request.loader_version,
&request.group,
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
Ok(())
}
fn patch_version_info(
version_info: &mut PartialVersionInfo,
game_version: &str,
game_versions: &[String],
maven_url: &str,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
) -> Result<(), Error> {
for lib in &mut version_info.libraries {
let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some("https://libraries.minecraft.net/".to_string());
}
let source_url =
lib.url.clone().unwrap_or_else(|| maven_url.to_string());
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![source_url],
false,
mirror_artifacts,
)?;
} else {
for concrete_game_version in game_versions {
let concrete_name =
lib.name.replace(game_version, concrete_game_version);
insert_mirrored_artifact(
&concrete_name,
None,
vec![source_url.clone()],
false,
mirror_artifacts,
)?;
}
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id =
version_info.id.replace(game_version, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(game_version, DUMMY_REPLACE_STRING);
Ok(())
}
#[derive(Deserialize, Debug, Clone)]
struct FabricVersions {
pub loader: Vec<FabricLoaderVersion>,
+58 -20
View File
@@ -12,7 +12,10 @@ use itertools::Itertools;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
#[tracing::instrument(skip(semaphore))]
@@ -243,12 +246,45 @@ async fn fetch(
};
if !fetch_versions.is_empty() {
let forge_installers = futures::future::try_join_all(
fetch_versions
.iter()
.map(|x| download_file(&x.installer_url, None, &semaphore)),
)
.await?;
let total_installers = fetch_versions.len();
let downloaded_installers = Arc::new(AtomicUsize::new(0));
tracing::info!(
mod_loader,
total_files = total_installers,
"Downloading loader installers"
);
let forge_installers =
futures::future::try_join_all(fetch_versions.iter().map(|x| {
let downloaded_installers = downloaded_installers.clone();
let semaphore = semaphore.clone();
async move {
let installer =
download_file(&x.installer_url, None, &semaphore)
.await?;
let downloaded = downloaded_installers
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100)
|| downloaded == total_installers
{
tracing::info!(
mod_loader,
downloaded_files = downloaded,
remaining_files =
total_installers.saturating_sub(downloaded),
total_files = total_installers,
"Downloaded loader installers"
);
}
Ok::<_, Error>(installer)
}
}))
.await?;
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
async fn read_forge_installer(
@@ -761,21 +797,23 @@ async fn fetch(
.into_iter()
.map(|(game_version, loaders)| {
daedalus::modded::Version {
id: game_version,
stable: true,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
id: game_version,
stable: true,
version_group: None,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
})
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
+208 -41
View File
@@ -1,10 +1,14 @@
use crate::util::{
REQWEST_CLIENT, format_url, upload_file_to_bucket,
upload_url_to_bucket_mirrors,
upload_url_to_bucket_mirrors, write_file_to_local_output,
write_url_to_local_output_mirrors,
};
use daedalus::get_path_from_artifact;
use dashmap::{DashMap, DashSet};
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
@@ -12,6 +16,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod error;
mod fabric;
mod forge;
mod metadata_groups;
mod minecraft;
pub mod util;
@@ -44,51 +49,92 @@ async fn main() -> Result<()> {
));
let mut fetch_result = FetchResult::default();
let only_loader = dotenvy::var("DAEDALUS_ONLY").ok();
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
if should_fetch(only_loader.as_deref(), "minecraft") {
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
}
}
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
if should_fetch(only_loader.as_deref(), "fabric") {
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
}
}
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
if should_fetch(only_loader.as_deref(), "quilt") {
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
}
}
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
if should_fetch(only_loader.as_deref(), "neo") {
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
}
}
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
if should_fetch(only_loader.as_deref(), "forge") {
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
}
}
let FetchResult {
upload_files,
mirror_artifacts,
} = fetch_result;
let upload_file_total = upload_files.len();
let mirror_file_total = mirror_artifacts.len();
futures::future::try_join_all(upload_files.iter().map(|entry| {
upload_file_to_bucket(
entry.key().clone(),
entry.value().file.clone(),
entry.value().content_type.clone(),
&semaphore,
)
}))
.await?;
if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() {
let written_files = Arc::new(AtomicUsize::new(0));
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
upload_url_to_bucket_mirrors(
format!("maven/{}", entry.key()),
entry
tracing::info!(
total_files = upload_file_total,
"Writing local metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let written_files = written_files.clone();
async move {
write_file_to_local_output(&path, file).await?;
let written = written_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == upload_file_total {
tracing::info!(
written_files = written,
remaining_files =
upload_file_total.saturating_sub(written),
total_files = upload_file_total,
"Wrote local metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let written_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Writing local mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
.value()
.mirrors
.iter()
@@ -99,12 +145,117 @@ async fn main() -> Result<()> {
format!("{}{}", mirror.path, entry.key())
}
})
.collect(),
entry.value().sha1.clone(),
&semaphore,
)
}))
.await?;
.collect();
let sha1 = entry.value().sha1.clone();
let written_mirror_files = written_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
write_url_to_local_output_mirrors(
path, mirrors, sha1, &semaphore,
)
.await?;
let written =
written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == mirror_file_total {
tracing::info!(
written_files = written,
remaining_files =
mirror_file_total.saturating_sub(written),
total_files = mirror_file_total,
"Wrote local mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
} else {
let uploaded_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = upload_file_total,
"Uploading metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let content_type = entry.value().content_type.clone();
let uploaded_files = uploaded_files.clone();
let semaphore = semaphore.clone();
async move {
upload_file_to_bucket(path, file, content_type, &semaphore)
.await?;
let uploaded =
uploaded_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == upload_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
upload_file_total.saturating_sub(uploaded),
total_files = upload_file_total,
"Uploaded metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let uploaded_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Uploading mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
.value()
.mirrors
.iter()
.map(|mirror| {
if mirror.entire_url {
mirror.path.clone()
} else {
format!("{}{}", mirror.path, entry.key())
}
})
.collect();
let sha1 = entry.value().sha1.clone();
let uploaded_mirror_files = uploaded_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore)
.await?;
let uploaded =
uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == mirror_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
mirror_file_total.saturating_sub(uploaded),
total_files = mirror_file_total,
"Uploaded mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
}
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
@@ -151,6 +302,20 @@ async fn main() -> Result<()> {
Ok(())
}
fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool {
let Some(only_loader) = only_loader else {
return true;
};
only_loader.split(',').any(|entry| {
let entry = entry.trim();
entry.eq_ignore_ascii_case("all")
|| entry.eq_ignore_ascii_case(loader)
|| (loader == "neo" && entry.eq_ignore_ascii_case("neoforge"))
})
}
pub struct UploadFile {
file: bytes::Bytes,
content_type: Option<String>,
@@ -248,11 +413,13 @@ fn check_env_vars() -> bool {
failed |= check_var::<String>("BASE_URL");
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() {
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
}
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
+145
View File
@@ -0,0 +1,145 @@
//! Determines how version info is generated for pairs of game and loader
//! versions.
//!
//! When a user installs a version of the game, they install two things: the
//! game (of some specific version), and a loader (of some other specific
//! version). Each combination of game and loader version requires a specific
//! configuration, like a specific set of libraries that must be downloaded and
//! run along with the game. However, some versions of the game or loader may
//! change configuration requirements without the other version being affected.
//! For example, pre-26.x game versions with Quilt require the Quilt `hashed`
//! libraries to also be downloaded. However, 26.x and later don't require the
//! `hashed` libraries, and don't even have a download for them. The problem is
//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0
//! manifest files can't differentiate the two. The result is that you either
//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0
//! on 26.x and later.
//!
//! To fix this, v1 introduces the concept of *version groups*: game versions
//! before 26.x are version group v1, and 26.x and later are v2. Then, we
//! parameterize our version info on both version group and loader version,
//! letting us specify the right configuration based on both game version and
//! loader version.
//!
//! Why not parameterize on game version and loader version directly? Most game
//! versions have the same configuration as their surrounding game versions, so
//! we'd end up with many duplicate configurations: the number of game versions
//! multiplied by the number of loader versions.
//!
//! This file lets you configure what game versions are grouped together.
//!
//! Each version group is templated from a specific game version - e.g. game
//! version 1.21 is used as the template file for 1.20, 1.19, etc.
pub const UNIVERSAL_METADATA_GROUP: &str = "universal";
pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1";
pub const QUILT_MODERN_METADATA_GROUP: &str = "v2";
pub struct MetadataGroup {
pub id: &'static str,
/// Minecraft version used to fetch and template this group's loader profiles.
pub loader_profile_template_game_version: String,
pub game_versions: Vec<String>,
}
pub fn metadata_groups<'a>(
mod_loader: &str,
game_versions: impl IntoIterator<Item = &'a str>,
) -> Vec<MetadataGroup> {
// Non-Quilt loaders don't need the concept of version groups, so we just
// make one "universal" group, and template it on 1.21.
if mod_loader != "quilt" {
return vec![MetadataGroup {
id: UNIVERSAL_METADATA_GROUP,
loader_profile_template_game_version: "1.21".to_string(),
game_versions: game_versions
.into_iter()
.map(|x| x.to_string())
.collect(),
}];
}
let game_versions = game_versions.into_iter().collect::<Vec<_>>();
let legacy_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_LEGACY_METADATA_GROUP
})
.collect::<Vec<_>>();
let modern_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_MODERN_METADATA_GROUP
})
.collect::<Vec<_>>();
let mut groups = Vec::new();
if !legacy_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_LEGACY_METADATA_GROUP,
loader_profile_template_game_version: legacy_game_versions
.iter()
.find(|x| **x == "1.21")
.copied()
.unwrap_or(legacy_game_versions[0])
.to_string(),
game_versions: legacy_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
if !modern_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_MODERN_METADATA_GROUP,
loader_profile_template_game_version: modern_game_versions[0]
.to_string(),
game_versions: modern_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
groups
}
pub fn metadata_group_for_game_version<'a>(
groups: &'a [MetadataGroup],
mod_loader: &str,
game_version: &str,
) -> Option<&'a MetadataGroup> {
let group_id = metadata_group_id_for_game_version(mod_loader, game_version);
groups.iter().find(|group| group.id == group_id)
}
fn metadata_group_id_for_game_version(
mod_loader: &str,
game_version: &str,
) -> &'static str {
if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) {
QUILT_MODERN_METADATA_GROUP
} else if mod_loader == "quilt" {
QUILT_LEGACY_METADATA_GROUP
} else {
UNIVERSAL_METADATA_GROUP
}
}
// Update these Quilt group boundaries if upstream loader profiles gain another
// structural incompatibility between Minecraft versions.
fn is_modern_quilt_game_version(game_version: &str) -> bool {
let major = game_version
.split(['.', 'w'])
.next()
.and_then(|x| x.parse::<usize>().ok());
major.is_some_and(|x| x >= 26)
}
+85 -1
View File
@@ -3,7 +3,11 @@ use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::de::DeserializeOwned;
use std::sync::{Arc, LazyLock};
use std::path::PathBuf;
use std::sync::{
Arc, LazyLock,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
@@ -55,6 +59,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn upload_file_to_bucket(
path: String,
@@ -135,6 +141,74 @@ pub async fn upload_url_to_bucket(
Ok(())
}
pub async fn write_file_to_local_output(
path: &str,
bytes: Bytes,
) -> Result<(), Error> {
let output_path = local_output_path(path)?;
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(output_path, bytes).await?;
Ok(())
}
pub async fn write_url_to_local_output_mirrors(
output_path: String,
mirrors: Vec<String>,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
if mirrors.is_empty() {
return Err(ErrorKind::InvalidInput(
"No mirrors provided!".to_string(),
)
.into());
}
for (index, mirror) in mirrors.iter().enumerate() {
let result = write_url_to_local_output(
output_path.clone(),
mirror.clone(),
sha1.clone(),
semaphore,
)
.await;
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
return result;
}
}
unreachable!()
}
async fn write_url_to_local_output(
path: String,
url: String,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
write_file_to_local_output(&path, data).await?;
Ok(())
}
fn local_output_path(path: &str) -> Result<PathBuf, Error> {
let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| {
ErrorKind::InvalidInput(
"LOCAL_OUTPUT_DIR is required for local output".to_string(),
)
})?;
Ok(PathBuf::from(output_dir).join(path))
}
#[tracing::instrument(skip(bytes))]
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
let hash = tokio::task::spawn_blocking(move || {
@@ -182,6 +256,16 @@ pub async fn download_file(
}
}
let downloaded = DOWNLOADED_FILE_COUNT
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100) {
tracing::info!(
downloaded_files = downloaded,
"Downloaded metadata files"
);
}
return Ok(bytes);
} else if attempt <= RETRIES {
continue;
+8
View File
@@ -692,6 +692,12 @@ components:
type: string
description: The ID of the project
example: AABBCCDD
all_project_types:
type: array
items:
type: string
description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type
example: [mod, plugin, datapack]
author:
type: string
description: The username of the project's author
@@ -748,6 +754,7 @@ components:
- client_side
- server_side
- project_id
- all_project_types
- author
- versions
- follows
@@ -1948,6 +1955,7 @@ paths:
These are the most commonly used facet types:
- `project_type`
- `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type)
- `categories` (loaders are lumped in with categories in search)
- `versions`
- `client_side`
@@ -482,3 +482,8 @@ input {
.button-transparent {
box-shadow: none;
}
.qc-cmp2-close-tooltip {
background-color: transparent;
color: hsl(145, 78%, 28%);
}
@@ -0,0 +1,63 @@
<template>
<div class="rounded-lg border border-divider bg-bg-raised p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="m-0 break-words font-semibold text-contrast">
{{ trace.project_name }}
</p>
<p class="m-0 mt-1 break-all text-sm text-secondary">
Project {{ trace.project_slug ?? trace.project_id }} / Version
{{ trace.version_number }} / File {{ trace.file_name }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-secondary">Local</span>
<Badge :type="trace.local_status" />
<span class="text-sm text-secondary">Effective</span>
<Badge :type="trace.effective_status" />
<ButtonStyled>
<NuxtLink :to="localTraceLink">
<ExternalIcon aria-hidden="true" />
View
</NuxtLink>
</ButtonStyled>
</div>
</div>
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Issue</span>
{{ trace.issue_type }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Severity</span>
{{ trace.severity }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Path</span>
{{ trace.file_path }}
</p>
<p v-if="trace.jar" class="m-0 break-all">
<span class="font-semibold text-contrast">JAR</span>
{{ trace.jar }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ExternalIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
trace: Labrinth.TechReview.Internal.GlobalIssueDetailTrace
}>()
const localTraceLink = computed(
() =>
`/moderation/technical-review/${props.trace.project_id}?detail=${encodeURIComponent(
props.trace.detail_id,
)}`,
)
</script>
@@ -0,0 +1,180 @@
<template>
<div>
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
<StyledInput
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search global trace keys..."
clearable
wrapper-class="flex-1 w-full"
/>
<ButtonStyled color="brand">
<button type="submit" :disabled="isLoading">
<SearchIcon aria-hidden="true" />
Search
</button>
</ButtonStyled>
</form>
<div
v-if="!isLoading && !loadError && total > 0"
class="mt-4 flex flex-wrap items-center justify-between gap-3"
>
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
<EmptyState
v-if="isLoading"
type="no-search-result"
heading="Loading global detail traces..."
/>
<EmptyState
v-else-if="loadError"
type="no-search-result"
heading="Failed to load global detail traces"
/>
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
<article
v-for="trace in traces"
:key="trace.detail_key"
class="universal-card flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
Trace
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
</h2>
</div>
<p class="m-0 mt-1 text-sm text-secondary">
{{ formatTraceCount(trace.local_trace_count) }}
</p>
</div>
<Badge :type="trace.verdict" />
</div>
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
<div
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
class="flex flex-wrap items-center justify-between gap-2"
>
<p class="m-0 text-sm text-secondary">
Showing first {{ getPreviewLocalTraces(trace).length }} of
{{ getVisibleLocalTraceTotal(trace) }} local traces
</p>
<ButtonStyled>
<NuxtLink :to="getGlobalTraceLink(trace)">
<ListIcon aria-hidden="true" />
View all
</NuxtLink>
</ButtonStyled>
</div>
<GlobalDetailLocalTraceCard
v-for="localTrace in getPreviewLocalTraces(trace)"
:key="localTrace.detail_id"
:trace="localTrace"
/>
</div>
<EmptyState
v-else
type="no-search-result"
heading="No local traces currently match this key"
/>
</article>
</div>
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
Pagination,
StyledInput,
} from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const query = ref('')
const activeQuery = ref<string | null>(null)
const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const itemsPerPage = 20
const localTracePreviewLimit = 10
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
const pageStart = computed(() =>
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
)
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
function formatTraceCount(count: number) {
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
}
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return trace.local_traces.slice(0, localTracePreviewLimit)
}
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return Math.max(trace.local_trace_count, trace.local_traces.length)
}
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
}
async function loadTraces() {
isLoading.value = true
loadError.value = false
try {
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
query: activeQuery.value,
limit: itemsPerPage,
page: currentPage.value - 1,
})
traces.value = response.traces
total.value = response.total
} catch (error) {
console.error('Failed to load global detail traces', error)
traces.value = []
total.value = 0
loadError.value = true
} finally {
isLoading.value = false
}
}
async function executeSearch() {
activeQuery.value = query.value.trim() || null
currentPage.value = 1
await loadTraces()
}
async function switchPage(page: number) {
currentPage.value = page
await loadTraces()
}
onMounted(loadTraces)
</script>
@@ -45,7 +45,7 @@ import {
type User,
} from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ThreadView from '~/components/ui/thread/ThreadView.vue'
@@ -89,6 +89,7 @@ const props = defineProps<{
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
}
focusedDetailId?: string | null
loadingIssues: Set<string>
decompiledSources: Map<string, string>
}>()
@@ -192,7 +193,12 @@ watch(selectedFile, (newFile) => {
const client = injectModrinthClient()
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
async function updateIssueDetails(
data: {
detail_id: string
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}[],
) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
@@ -381,6 +387,49 @@ function viewFileFlags(file: FlattenedFileReport) {
currentTab.value = 'File'
}
function getDetailElementId(detailId: string) {
return `tech-review-detail-${detailId}`
}
function findFileForDetail(detailId: string): FlattenedFileReport | null {
for (const report of props.item.reports) {
for (const issue of report.issues) {
if (issue.details.some((detail) => detail.id === detailId)) {
return report
}
}
}
return null
}
async function focusDetail(detailId: string) {
const file = findFileForDetail(detailId)
if (!file) return
viewFileFlags(file)
await nextTick()
const classItem = groupedByClass.value.find((group) =>
group.flags.some((flag) => flag.detail.id === detailId),
)
if (classItem) {
expandClass(classItem)
}
await nextTick()
if (!import.meta.client) return
window.requestAnimationFrame(() => {
document.getElementById(getDetailElementId(detailId))?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
function backToFileList() {
selectedFileId.value = null
if (currentTab.value === 'File') {
@@ -691,6 +740,16 @@ const groupedByJar = computed<JarGroup[]>(() => {
})
})
watch(
() => props.focusedDetailId,
(detailId) => {
if (detailId) {
focusDetail(detailId)
}
},
{ immediate: true },
)
// Auto-expand/load source for small files; keep larger files lazy.
watch(
[selectedFileId, groupedByClass],
@@ -1384,8 +1443,12 @@ function copyId() {
>
<div
v-for="flag in classItem.flags"
:id="getDetailElementId(flag.detail.id)"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'!border-brand bg-brand-highlight': props.focusedDetailId === flag.detail.id,
}"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -2681,6 +2681,9 @@
"moderation.page.external-projects": {
"message": "External projects"
},
"moderation.page.global-detail-traces": {
"message": "Global traces"
},
"moderation.page.projects": {
"message": "Projects"
},
@@ -372,6 +372,14 @@ const messages = defineMessages({
},
})
const advancedFiltersCollapsed = computed({
get: () => flags.value.advancedFiltersCollapsed,
set: (value) => {
flags.value.advancedFiltersCollapsed = value
saveFeatureFlags()
},
})
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value)
@@ -478,6 +486,7 @@ provideBrowseManager({
showServerOnly: showServerOnlyToggle,
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
advancedFiltersCollapsed,
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
+21 -3
View File
@@ -15,7 +15,7 @@
</template>
<script setup lang="ts">
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
definePageMeta({
@@ -47,6 +47,10 @@ const messages = defineMessages({
id: 'moderation.page.external-projects',
defaultMessage: 'External projects',
},
globalDetailTracesTitle: {
id: 'moderation.page.global-detail-traces',
defaultMessage: 'Global traces',
},
})
const moderationLinks = [
@@ -62,6 +66,11 @@ const moderationLinks = [
href: '/moderation/external-projects',
icon: GlobeIcon,
},
{
label: formatMessage(messages.globalDetailTracesTitle),
href: '/moderation/global-traces',
icon: HashIcon,
},
]
const mobileNavOptions = [
@@ -69,15 +78,20 @@ const mobileNavOptions = [
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
formatMessage(messages.externalFilesTitle),
formatMessage(messages.globalDetailTracesTitle),
]
const selectedChip = computed({
get() {
const path = route.path
if (path === '/moderation/technical-review') {
if (path.startsWith('/moderation/technical-review')) {
return formatMessage(messages.technicalReviewTitle)
} else if (path.startsWith('/moderation/reports/')) {
} else if (path.startsWith('/moderation/reports')) {
return formatMessage(messages.reportsTitle)
} else if (path.startsWith('/moderation/external-projects')) {
return formatMessage(messages.externalFilesTitle)
} else if (path.startsWith('/moderation/global-traces')) {
return formatMessage(messages.globalDetailTracesTitle)
} else {
return formatMessage(messages.projectsTitle)
}
@@ -92,6 +106,10 @@ function navigateToPage(selectedOption: string) {
router.push('/moderation/technical-review')
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push('/moderation/reports')
} else if (selectedOption === formatMessage(messages.externalFilesTitle)) {
router.push('/moderation/external-projects')
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
router.push('/moderation/global-traces')
} else {
router.push('/moderation')
}
@@ -0,0 +1,153 @@
<template>
<div class="flex flex-col gap-4">
<div>
<ButtonStyled>
<NuxtLink to="/moderation/global-traces">
<ArrowLeftIcon aria-hidden="true" />
Back to global traces
</NuxtLink>
</ButtonStyled>
</div>
<EmptyState
v-if="isLoading && !trace"
type="no-search-result"
heading="Loading global detail trace..."
/>
<EmptyState
v-else-if="loadError"
type="no-search-result"
heading="Failed to load global detail trace"
/>
<article v-else-if="trace" class="universal-card flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
Trace
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
</h2>
</div>
<p class="m-0 mt-1 text-sm text-secondary">
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
</p>
</div>
<Badge :type="trace.verdict" />
</div>
<div
v-if="trace.local_trace_count > localTracePageSize"
class="flex flex-wrap items-center justify-between gap-3"
>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
<p v-if="isLoading" class="m-0 text-sm text-secondary">Loading page...</p>
</div>
<div v-if="trace.local_traces.length > 0" class="flex flex-col gap-2">
<GlobalDetailLocalTraceCard
v-for="localTrace in trace.local_traces"
:key="localTrace.detail_id"
:trace="localTrace"
/>
</div>
<EmptyState v-else type="no-search-result" heading="No local traces match this key" />
<div v-if="trace.local_trace_count > localTracePageSize" class="mt-1 flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
</article>
<EmptyState v-else type="no-search-result" heading="Global detail trace not found" />
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const route = useRoute()
const detailKey = computed(() => {
const key = route.params.key
return Array.isArray(key) ? key.join('/') : String(key)
})
useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
const localTracePageSize = 20
const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const pageStartCursors = ref<(string | null)[]>([null])
const trace = ref<Labrinth.TechReview.Internal.GlobalIssueDetail | null>(null)
const pageCount = computed(() =>
Math.max(Math.ceil((trace.value?.local_trace_count ?? 0) / localTracePageSize), 1),
)
const pageStart = computed(() =>
trace.value && trace.value.local_trace_count > 0
? (currentPage.value - 1) * localTracePageSize + 1
: 0,
)
const pageEnd = computed(() =>
Math.min(currentPage.value * localTracePageSize, trace.value?.local_trace_count ?? 0),
)
async function fetchTracePage(afterDetailId: string | null) {
return await client.labrinth.tech_review_internal.getGlobalIssueDetail({
detail_key: detailKey.value,
limit: localTracePageSize,
after_detail_id: afterDetailId,
})
}
async function loadPage(page: number) {
if (page < 1 || isLoading.value) return
isLoading.value = true
loadError.value = false
try {
while (pageStartCursors.value.length < page) {
const cursor = pageStartCursors.value[pageStartCursors.value.length - 1]
const response = await fetchTracePage(cursor)
if (!response.next_after_detail_id) {
trace.value = response.trace
currentPage.value = pageStartCursors.value.length
return
}
pageStartCursors.value.push(response.next_after_detail_id)
}
const response = await fetchTracePage(pageStartCursors.value[page - 1])
trace.value = response.trace
currentPage.value = page
} catch (error) {
console.error('Failed to load global detail trace', error)
loadError.value = true
} finally {
isLoading.value = false
}
}
async function switchPage(page: number) {
await loadPage(page)
}
watch(
detailKey,
() => {
currentPage.value = 1
pageStartCursors.value = [null]
trace.value = null
loadPage(1)
},
{ immediate: true },
)
</script>
@@ -0,0 +1,9 @@
<template>
<GlobalDetailTracesList />
</template>
<script setup lang="ts">
import GlobalDetailTracesList from '~/components/ui/moderation/GlobalDetailTracesList.vue'
useHead({ title: 'Global detail traces - Modrinth' })
</script>
@@ -11,6 +11,7 @@ import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevC
const client = injectModrinthClient()
const queryClient = useQueryClient()
const route = useRoute()
const projectId = String(useRouteId('project'))
@@ -245,6 +246,8 @@ const reviewItem = computed(() => {
}
})
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
async function handleMarkComplete(projectId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
@@ -299,6 +302,7 @@ function refetch() {
<ModerationTechRevCard
v-else
:item="reviewItem"
:focused-detail-id="focusedDetailId"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"TextArray",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f"
}
@@ -0,0 +1,148 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n didws.key AS \"detail_key!\",\n didws.id AS \"detail_id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n dri.issue_type,\n m.id AS \"project_id!: DBProjectId\",\n m.slug AS \"project_slug?\",\n m.name AS \"project_name!\",\n v.id AS \"version_id!: DBVersionId\",\n v.version_number,\n f.id AS \"file_id!: DBFileId\",\n f.filename AS \"file_name!\",\n didws.jar AS \"jar?\",\n didws.file_path AS \"file_path!\",\n didws.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)\n AS \"local_status!: DelphiStatus\",\n didws.status AS \"effective_status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = didws.project_id\n AND didv.detail_key = didws.key\n WHERE\n didws.key = $1\n AND ($2::bigint IS NULL OR didws.id > $2)\n AND dri.issue_type != '__dummy'\n ORDER BY didws.id\n LIMIT $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "detail_key!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "detail_id!: DelphiReportIssueDetailsId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "issue_id!: DelphiReportIssueId",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "issue_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "project_id!: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "project_slug?",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "project_name!",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "version_id!: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 8,
"name": "version_number",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "file_id!: DBFileId",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "file_name!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "jar?",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "file_path!",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "severity!: DelphiSeverity",
"type_info": {
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
},
{
"ordinal": 14,
"name": "local_status!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 15,
"name": "effective_status!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
true,
true,
true,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
true,
null,
true
]
},
"hash": "055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_issue_details_with_statuses didws\n ON didws.issue_id = dri.id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND didws.status = 'pending'\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"TextArray",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "1a6d4ac11af078439cff5c772f4dc2392729f99ba1f8c7892831235f341fb276"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n dgdv.detail_key,\n dgdv.verdict AS \"verdict!: DelphiStatus\",\n COUNT(dri.id) AS \"local_trace_count!\"\n FROM delphi_global_detail_verdicts dgdv\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.key = dgdv.detail_key\n LEFT JOIN delphi_report_issues dri\n ON dri.id = didws.issue_id\n AND dri.issue_type != '__dummy'\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n )\n GROUP BY dgdv.detail_key, dgdv.verdict\n ORDER BY dgdv.detail_key\n LIMIT $2 OFFSET $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "detail_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "verdict!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 2,
"name": "local_trace_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
null
]
},
"hash": "4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a"
}
@@ -0,0 +1,45 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n dgdv.detail_key,\n dgdv.verdict AS \"verdict!: DelphiStatus\",\n COUNT(dri.id) AS \"local_trace_count!\"\n FROM delphi_global_detail_verdicts dgdv\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.key = dgdv.detail_key\n LEFT JOIN delphi_report_issues dri\n ON dri.id = didws.issue_id\n AND dri.issue_type != '__dummy'\n WHERE dgdv.detail_key = $1\n GROUP BY dgdv.detail_key, dgdv.verdict\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "detail_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "verdict!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 2,
"name": "local_trace_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
null
]
},
"hash": "5e5fcfa6ac68f296ba5238e8211f8e206473f11fe1796fc2feda15f8d0ee7f41"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict::delphi_report_issue_status\n FROM incoming\n ORDER BY detail_key, ord DESC\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "6402f359bbf733a9a2173f23c2ea222611fde49f692406123145dc6acd3dcd6a"
}
@@ -0,0 +1,147 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH ranked_local_traces AS (\n SELECT\n didws.key AS detail_key,\n didws.id AS detail_id,\n didws.issue_id,\n dri.issue_type,\n m.id AS project_id,\n m.slug AS project_slug,\n m.name AS project_name,\n v.id AS version_id,\n v.version_number,\n f.id AS file_id,\n f.filename AS file_name,\n didws.jar,\n didws.file_path,\n didws.severity,\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)\n AS local_status,\n didws.status AS effective_status,\n ROW_NUMBER() OVER (\n PARTITION BY didws.key\n ORDER BY didws.id\n ) AS row_num\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = didws.project_id\n AND didv.detail_key = didws.key\n WHERE\n didws.key = ANY($1::text[])\n AND dri.issue_type != '__dummy'\n )\n SELECT\n detail_key AS \"detail_key!\",\n detail_id AS \"detail_id!: DelphiReportIssueDetailsId\",\n issue_id AS \"issue_id!: DelphiReportIssueId\",\n issue_type,\n project_id AS \"project_id!: DBProjectId\",\n project_slug AS \"project_slug?\",\n project_name AS \"project_name!\",\n version_id AS \"version_id!: DBVersionId\",\n v.version_number,\n file_id AS \"file_id!: DBFileId\",\n file_name AS \"file_name!\",\n jar AS \"jar?\",\n file_path AS \"file_path!\",\n severity AS \"severity!: DelphiSeverity\",\n local_status AS \"local_status!: DelphiStatus\",\n effective_status AS \"effective_status!: DelphiStatus\"\n FROM ranked_local_traces v\n WHERE row_num <= $2\n ORDER BY detail_key, detail_id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "detail_key!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "detail_id!: DelphiReportIssueDetailsId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "issue_id!: DelphiReportIssueId",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "issue_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "project_id!: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "project_slug?",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "project_name!",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "version_id!: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 8,
"name": "version_number",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "file_id!: DBFileId",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "file_name!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "jar?",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "file_path!",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "severity!: DelphiSeverity",
"type_info": {
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
},
{
"ordinal": 14,
"name": "local_status!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
},
{
"ordinal": 15,
"name": "effective_status!: DelphiStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
}
}
],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": [
true,
true,
true,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
true,
null,
true
]
},
"hash": "87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.jar AS \"jar?: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ",
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
"describe": {
"columns": [
{
@@ -73,15 +73,15 @@
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
null
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a"
"hash": "951700c659d040068dae8e2f91b82b6a3af676439b47ff76d72000ddeca0e334"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_unflagged_issue_details!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_global_detail_verdicts dgdv\n ON dgdv.detail_key = incoming.detail_key\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_unflagged_issue_details!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "bab0f71c84902bac7589a30923e95cbcd76d340c21ac6116d9bd27878786bd26"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) AS \"total!\"\n FROM delphi_global_detail_verdicts dgdv\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c9d5796b2c98dedd9f2f237a1a2cf7a059a49387c0c564b730733bda7358f7fe"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict::delphi_report_issue_status AS verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "all_found!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "upserted_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n latest AS (\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_issue_detail_verdicts didv\n USING latest\n WHERE\n didv.project_id = latest.project_id\n AND didv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n project_id,\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "all_found!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "upserted_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "e8b6e423aaf62046afd19fb1e177a3ef1f18f4814df8924926c47d4b0c7145c7"
}
+1
View File
@@ -33,3 +33,4 @@
- To seed the database locally: `psql postgresql://labrinth:labrinth@localhost/labrinth -f apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql`
- When writing `sqlx` queries, prefer `r#` raw strings over escaping quotes
- When interacting with the Postgres database, prefer using a `ro_pool: ReadOnlyPgPool` when performing read-only operations
- After writing a SQL query, run `EXPLAIN ANALYZE` to see how long the query would take to run. Report to the user the estimated performance impact of the query.
@@ -0,0 +1,29 @@
CREATE TABLE delphi_global_detail_verdicts (
detail_key TEXT PRIMARY KEY,
verdict delphi_report_issue_status NOT NULL
);
CREATE INDEX delphi_report_issue_details_key
ON delphi_report_issue_details (key);
CREATE INDEX delphi_issue_detail_verdicts_detail_key
ON delphi_issue_detail_verdicts (detail_key);
DROP VIEW delphi_issue_details_with_statuses;
CREATE VIEW delphi_issue_details_with_statuses AS
SELECT
drid.*,
m.id AS project_id,
COALESCE(dgdv.verdict, didv.verdict, 'pending') AS status
FROM delphi_report_issue_details drid
INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN mods m ON m.id = v.mod_id
LEFT JOIN delphi_global_detail_verdicts dgdv
ON drid.key = dgdv.detail_key
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id
AND drid.key = didv.detail_key;
+3
View File
@@ -15,6 +15,8 @@ pub struct LegacySearchResults {
pub struct LegacyResultSearchProject {
pub project_id: String,
pub project_type: String,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
#[serde(default)]
@@ -136,6 +138,7 @@ impl LegacyResultSearchProject {
Self {
project_type,
all_project_types: result_search_project.all_project_types,
client_side,
server_side,
versions,
@@ -240,9 +240,11 @@ async fn ingest_report_deserialized(
SELECT EXISTS(
SELECT 1
FROM unnest($2::text[]) AS incoming(detail_key)
LEFT JOIN delphi_global_detail_verdicts dgdv
ON dgdv.detail_key = incoming.detail_key
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
WHERE didv.project_id IS NULL
WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL
) AS "has_unflagged_issue_details!"
"#,
DBProjectId::from(report.project_id) as _,
+3
View File
@@ -113,9 +113,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::get_issue,
moderation::tech_review::get_report,
moderation::tech_review::search_projects,
moderation::tech_review::global::search_global_issue_details,
moderation::tech_review::global::get_global_issue_detail,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
moderation::tech_review::update_issue_details,
moderation::tech_review::update_global_issue_details,
moderation::tech_review::add_report,
moderation::external_license::search,
moderation::external_license::lookup,
@@ -37,13 +37,17 @@ use crate::{
};
use eyre::eyre;
pub mod global;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(search_projects)
.configure(global::config)
.service(get_project_report)
.service(get_report)
.service(get_issue)
.service(submit_report)
.service(update_issue_details)
.service(update_global_issue_details)
.service(add_report);
}
@@ -502,23 +506,16 @@ async fn fetch_project_reports(
let detail_rows = sqlx::query!(
r#"
SELECT
drid.id AS "id!: DelphiReportIssueDetailsId",
drid.issue_id AS "issue_id!: DelphiReportIssueId",
drid.key AS "key!: String",
drid.jar AS "jar?: String",
drid.file_path AS "file_path!: String",
drid.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
drid.severity AS "severity!: DelphiSeverity",
COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS "status!: DelphiStatus"
FROM delphi_report_issue_details drid
INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN mods m ON m.id = v.mod_id
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id AND drid.key = didv.detail_key
WHERE drid.issue_id = ANY($1::bigint[])
didws.id AS "id!: DelphiReportIssueDetailsId",
didws.issue_id AS "issue_id!: DelphiReportIssueId",
didws.key AS "key!: String",
didws.jar AS "jar?: String",
didws.file_path AS "file_path!: String",
didws.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
didws.severity AS "severity!: DelphiSeverity",
didws.status AS "status!: DelphiStatus"
FROM delphi_issue_details_with_statuses didws
WHERE didws.issue_id = ANY($1::bigint[])
"#,
&issue_ids.iter().map(|i| i.0).collect::<Vec<_>>()
)
@@ -718,10 +715,8 @@ pub async fn search_projects(
INNER JOIN files f ON f.version_id = v.id
INNER JOIN delphi_reports dr ON dr.file_id = f.id
INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id
INNER JOIN delphi_report_issue_details drid
ON drid.issue_id = dri.id
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id AND drid.key = didv.detail_key
INNER JOIN delphi_issue_details_with_statuses didws
ON didws.issue_id = dri.id
LEFT JOIN threads_messages tm_last
ON tm_last.thread_id = t.id
AND tm_last.id = (
@@ -766,7 +761,7 @@ pub async fn search_projects(
AND m.status NOT IN ('draft', 'rejected', 'withheld')
AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))
AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))
AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)
AND didws.status = 'pending'
AND (
$5::text IS NULL
OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))
@@ -1170,6 +1165,17 @@ pub struct UpdateIssue {
/// ID of the issue detail to update.
pub detail_id: DelphiReportIssueDetailsId,
/// What the moderator has decided the outcome of this issue is.
///
/// `pending` removes the project-local verdict for this issue detail.
pub verdict: DelphiStatus,
}
/// See [`update_global_issue_details`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateGlobalIssue {
/// Key of the issue detail to update globally.
pub detail_key: String,
/// What the moderator has decided the outcome of this issue is globally.
pub verdict: DelphiVerdict,
}
@@ -1210,8 +1216,9 @@ pub async fn update_issue_details(
let verdicts = updates
.iter()
.map(|u| match u.verdict {
DelphiVerdict::Safe => "safe".to_string(),
DelphiVerdict::Unsafe => "unsafe".to_string(),
DelphiStatus::Safe => "safe".to_string(),
DelphiStatus::Unsafe => "unsafe".to_string(),
DelphiStatus::Pending => "pending".to_string(),
})
.collect::<Vec<_>>();
@@ -1227,7 +1234,7 @@ pub async fn update_issue_details(
i.ord,
didws.project_id,
didws.key AS detail_key,
i.verdict::delphi_report_issue_status AS verdict
i.verdict
FROM incoming i
INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
@@ -1240,18 +1247,35 @@ pub async fn update_issue_details(
(SELECT COUNT(*) FROM incoming) AS incoming_count,
(SELECT COUNT(*) FROM resolved) AS resolved_count
),
upserted AS (
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
latest AS (
SELECT DISTINCT ON (project_id, detail_key)
project_id,
detail_key,
verdict
FROM resolved
ORDER BY project_id, detail_key, ord DESC
),
deleted AS (
DELETE FROM delphi_issue_detail_verdicts didv
USING latest
WHERE
didv.project_id = latest.project_id
AND didv.detail_key = latest.detail_key
AND latest.verdict = 'pending'
RETURNING 1
),
upserted AS (
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
SELECT
project_id,
detail_key,
verdict::delphi_report_issue_status
FROM latest
WHERE verdict != 'pending'
ON CONFLICT (project_id, detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
RETURNING 1
@@ -1279,6 +1303,92 @@ pub async fn update_issue_details(
Ok(())
}
/// Update global technical review issue detail verdicts.
///
/// This marks every issue detail with a matching key as safe or unsafe.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[post("/global-issue-detail")]
pub async fn update_global_issue_details(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
update_reqs: web::Json<Vec<UpdateGlobalIssue>>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let updates = update_reqs.into_inner();
if updates.iter().any(|u| {
u.detail_key.is_empty() || u.detail_key.starts_with("<no-key-")
}) {
return Err(ApiError::Request(eyre!(
"detail key cannot be empty or generated fallback key"
)));
}
let detail_keys = updates
.iter()
.map(|u| u.detail_key.clone())
.collect::<Vec<_>>();
let verdicts = updates
.iter()
.map(|u| match u.verdict {
DelphiVerdict::Safe => "safe".to_string(),
DelphiVerdict::Unsafe => "unsafe".to_string(),
})
.collect::<Vec<_>>();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
sqlx::query!(
r#"
WITH incoming AS (
SELECT *
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY
AS u(detail_key, verdict, ord)
)
INSERT INTO delphi_global_detail_verdicts (
detail_key,
verdict
)
SELECT DISTINCT ON (detail_key)
detail_key,
verdict::delphi_report_issue_status
FROM incoming
ORDER BY detail_key, ord DESC
ON CONFLICT (detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
"#,
&detail_keys,
&verdicts,
)
.execute(&mut txn)
.await
.wrap_internal_err("failed to update global issue details")?;
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
/// See [`add_report`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AddReport {
@@ -0,0 +1,465 @@
use actix_web::{HttpRequest, post, web};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::{
auth::check_is_moderator_from_headers,
database::{
PgPool,
models::{
DBFileId, DBProjectId, DBVersionId, DelphiReportIssueDetailsId,
DelphiReportIssueId,
delphi_report_item::{DelphiSeverity, DelphiStatus},
},
redis::RedisPool,
},
models::{
ids::{FileId, ProjectId, VersionId},
pats::Scopes,
},
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
};
use eyre::eyre;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(search_global_issue_details)
.service(get_global_issue_detail);
}
fn default_limit() -> u64 {
20
}
const LOCAL_TRACE_PREVIEW_LIMIT: i64 = 10;
/// Arguments for searching globally-verdict'ed Delphi detail traces.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchGlobalIssueDetails {
#[serde(default = "default_limit")]
#[schema(default = 20)]
pub limit: u64,
#[serde(default)]
#[schema(default = 0)]
pub page: u64,
#[serde(default)]
pub query: Option<String>,
}
/// Response for searching globally-verdict'ed Delphi detail traces.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchGlobalIssueDetailsResponse {
/// Total number of matching global verdicts.
pub total: i64,
/// Globally-verdict'ed detail keys and their local matches.
pub traces: Vec<GlobalIssueDetail>,
}
/// Arguments for fetching one globally-verdict'ed Delphi detail trace.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GetGlobalIssueDetail {
/// Key used by Delphi to identify matching detail traces.
pub detail_key: String,
#[serde(default = "default_limit")]
#[schema(default = 20)]
pub limit: u64,
/// Return local traces with IDs greater than this detail ID.
#[serde(default)]
pub after_detail_id: Option<DelphiReportIssueDetailsId>,
}
/// Response for one globally-verdict'ed Delphi detail trace.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GetGlobalIssueDetailResponse {
/// Globally-verdict'ed detail key and a page of local matches.
pub trace: GlobalIssueDetail,
/// Pass this as `after_detail_id` to fetch the next page.
pub next_after_detail_id: Option<DelphiReportIssueDetailsId>,
}
/// A globally-verdict'ed Delphi detail key.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GlobalIssueDetail {
/// Key used by Delphi to identify matching detail traces.
pub detail_key: String,
/// Verdict applied to every matching detail trace.
pub verdict: DelphiStatus,
/// Number of local detail traces with this key.
pub local_trace_count: i64,
/// Local detail traces matching this key.
pub local_traces: Vec<GlobalIssueDetailTrace>,
}
/// A local Delphi detail trace matching a globally-verdict'ed key.
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GlobalIssueDetailTrace {
/// ID of the local detail trace.
pub detail_id: DelphiReportIssueDetailsId,
/// ID of the issue containing this detail trace.
pub issue_id: DelphiReportIssueId,
/// Delphi issue kind.
pub issue_type: String,
/// ID of the project containing this detail trace.
pub project_id: ProjectId,
/// Project slug, when one is set.
pub project_slug: Option<String>,
/// Project name.
pub project_name: String,
/// ID of the version containing this detail trace.
pub version_id: VersionId,
/// Version number.
pub version_number: String,
/// ID of the file containing this detail trace.
pub file_id: FileId,
/// File name.
pub file_name: String,
/// JAR containing the detail trace, when Delphi reported one.
pub jar: Option<String>,
/// File path containing the detail trace.
pub file_path: String,
/// Delphi severity for this detail trace.
pub severity: DelphiSeverity,
/// Project-local verdict for this key.
pub local_status: DelphiStatus,
/// Effective verdict after applying global fallback rules.
pub effective_status: DelphiStatus,
}
/// Search globally-verdict'ed Delphi detail traces.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK, body = SearchGlobalIssueDetailsResponse))
)]
#[post("/global-issue-detail/search")]
pub async fn search_global_issue_details(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_req: web::Json<SearchGlobalIssueDetails>,
) -> Result<web::Json<SearchGlobalIssueDetailsResponse>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let query = search_req
.query
.as_deref()
.map(str::trim)
.filter(|q| !q.is_empty());
let limit = search_req.limit.clamp(1, 100);
let offset = limit.saturating_mul(search_req.page);
let limit =
i64::try_from(limit).wrap_request_err("limit cannot fit into `i64`")?;
let offset = i64::try_from(offset)
.wrap_request_err("offset cannot fit into `i64`")?;
let total = sqlx::query!(
r#"
SELECT COUNT(*) AS "total!"
FROM delphi_global_detail_verdicts dgdv
WHERE (
$1::text IS NULL
OR dgdv.detail_key ILIKE '%' || $1 || '%'
)
"#,
query,
)
.fetch_one(&**pool)
.await
.wrap_internal_err("failed to count global issue details")?
.total;
let global_rows = sqlx::query!(
r#"
SELECT
dgdv.detail_key,
dgdv.verdict AS "verdict!: DelphiStatus",
COUNT(dri.id) AS "local_trace_count!"
FROM delphi_global_detail_verdicts dgdv
LEFT JOIN delphi_issue_details_with_statuses didws
ON didws.key = dgdv.detail_key
LEFT JOIN delphi_report_issues dri
ON dri.id = didws.issue_id
AND dri.issue_type != '__dummy'
WHERE (
$1::text IS NULL
OR dgdv.detail_key ILIKE '%' || $1 || '%'
)
GROUP BY dgdv.detail_key, dgdv.verdict
ORDER BY dgdv.detail_key
LIMIT $2 OFFSET $3
"#,
query,
limit,
offset,
)
.fetch_all(&**pool)
.await
.wrap_internal_err("failed to fetch global issue details")?;
let detail_keys = global_rows
.iter()
.map(|row| row.detail_key.clone())
.collect::<Vec<_>>();
let local_rows = sqlx::query!(
r#"
WITH ranked_local_traces AS (
SELECT
didws.key AS detail_key,
didws.id AS detail_id,
didws.issue_id,
dri.issue_type,
m.id AS project_id,
m.slug AS project_slug,
m.name AS project_name,
v.id AS version_id,
v.version_number,
f.id AS file_id,
f.filename AS file_name,
didws.jar,
didws.file_path,
didws.severity,
COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)
AS local_status,
didws.status AS effective_status,
ROW_NUMBER() OVER (
PARTITION BY didws.key
ORDER BY didws.id
) AS row_num
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN mods m ON m.id = v.mod_id
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = didws.project_id
AND didv.detail_key = didws.key
WHERE
didws.key = ANY($1::text[])
AND dri.issue_type != '__dummy'
)
SELECT
detail_key AS "detail_key!",
detail_id AS "detail_id!: DelphiReportIssueDetailsId",
issue_id AS "issue_id!: DelphiReportIssueId",
issue_type,
project_id AS "project_id!: DBProjectId",
project_slug AS "project_slug?",
project_name AS "project_name!",
version_id AS "version_id!: DBVersionId",
v.version_number,
file_id AS "file_id!: DBFileId",
file_name AS "file_name!",
jar AS "jar?",
file_path AS "file_path!",
severity AS "severity!: DelphiSeverity",
local_status AS "local_status!: DelphiStatus",
effective_status AS "effective_status!: DelphiStatus"
FROM ranked_local_traces v
WHERE row_num <= $2
ORDER BY detail_key, detail_id
"#,
&detail_keys,
LOCAL_TRACE_PREVIEW_LIMIT,
)
.fetch_all(&**pool)
.await
.wrap_internal_err("failed to fetch local issue detail traces")?;
let mut traces_by_key = local_rows
.into_iter()
.map(|row| {
(
row.detail_key,
GlobalIssueDetailTrace {
detail_id: row.detail_id,
issue_id: row.issue_id,
issue_type: row.issue_type,
project_id: ProjectId::from(row.project_id),
project_slug: row.project_slug,
project_name: row.project_name,
version_id: VersionId::from(row.version_id),
version_number: row.version_number,
file_id: FileId::from(row.file_id),
file_name: row.file_name,
jar: row.jar,
file_path: row.file_path,
severity: row.severity,
local_status: row.local_status,
effective_status: row.effective_status,
},
)
})
.into_group_map();
let traces = global_rows
.into_iter()
.map(|row| GlobalIssueDetail {
local_traces: traces_by_key
.remove(&row.detail_key)
.unwrap_or_default(),
detail_key: row.detail_key,
verdict: row.verdict,
local_trace_count: row.local_trace_count,
})
.collect();
Ok(web::Json(SearchGlobalIssueDetailsResponse {
total,
traces,
}))
}
/// Fetch one globally-verdict'ed Delphi detail trace with paginated local matches.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK, body = GetGlobalIssueDetailResponse))
)]
#[post("/global-issue-detail/local-traces")]
pub async fn get_global_issue_detail(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
get_req: web::Json<GetGlobalIssueDetail>,
) -> Result<web::Json<GetGlobalIssueDetailResponse>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let detail_key = get_req.detail_key.trim();
if detail_key.is_empty() {
return Err(ApiError::Request(eyre!("detail key cannot be empty")));
}
let limit = get_req.limit.clamp(1, 100);
let query_limit = i64::try_from(limit + 1)
.wrap_request_err("limit cannot fit into `i64`")?;
let limit = usize::try_from(limit)
.wrap_request_err("limit cannot fit into `usize`")?;
let after_detail_id = get_req.after_detail_id.map(|id| id.0);
let global_row = sqlx::query!(
r#"
SELECT
dgdv.detail_key,
dgdv.verdict AS "verdict!: DelphiStatus",
COUNT(dri.id) AS "local_trace_count!"
FROM delphi_global_detail_verdicts dgdv
LEFT JOIN delphi_issue_details_with_statuses didws
ON didws.key = dgdv.detail_key
LEFT JOIN delphi_report_issues dri
ON dri.id = didws.issue_id
AND dri.issue_type != '__dummy'
WHERE dgdv.detail_key = $1
GROUP BY dgdv.detail_key, dgdv.verdict
"#,
detail_key,
)
.fetch_optional(&**pool)
.await
.wrap_internal_err("failed to fetch global issue detail")?
.ok_or(ApiError::NotFound)?;
let local_rows = sqlx::query!(
r#"
SELECT
didws.key AS "detail_key!",
didws.id AS "detail_id!: DelphiReportIssueDetailsId",
didws.issue_id AS "issue_id!: DelphiReportIssueId",
dri.issue_type,
m.id AS "project_id!: DBProjectId",
m.slug AS "project_slug?",
m.name AS "project_name!",
v.id AS "version_id!: DBVersionId",
v.version_number,
f.id AS "file_id!: DBFileId",
f.filename AS "file_name!",
didws.jar AS "jar?",
didws.file_path AS "file_path!",
didws.severity AS "severity!: DelphiSeverity",
COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)
AS "local_status!: DelphiStatus",
didws.status AS "effective_status!: DelphiStatus"
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
INNER JOIN files f ON f.id = dr.file_id
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN mods m ON m.id = v.mod_id
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = didws.project_id
AND didv.detail_key = didws.key
WHERE
didws.key = $1
AND ($2::bigint IS NULL OR didws.id > $2)
AND dri.issue_type != '__dummy'
ORDER BY didws.id
LIMIT $3
"#,
detail_key,
after_detail_id,
query_limit,
)
.fetch_all(&**pool)
.await
.wrap_internal_err("failed to fetch local issue detail traces")?;
let mut local_traces = local_rows
.into_iter()
.map(|row| GlobalIssueDetailTrace {
detail_id: row.detail_id,
issue_id: row.issue_id,
issue_type: row.issue_type,
project_id: ProjectId::from(row.project_id),
project_slug: row.project_slug,
project_name: row.project_name,
version_id: VersionId::from(row.version_id),
version_number: row.version_number,
file_id: FileId::from(row.file_id),
file_name: row.file_name,
jar: row.jar,
file_path: row.file_path,
severity: row.severity,
local_status: row.local_status,
effective_status: row.effective_status,
})
.collect::<Vec<_>>();
let has_more = local_traces.len() > limit;
if has_more {
local_traces.pop();
}
let next_after_detail_id = has_more
.then(|| local_traces.last().map(|trace| trace.detail_id))
.flatten();
Ok(web::Json(GetGlobalIssueDetailResponse {
trace: GlobalIssueDetail {
detail_key: global_row.detail_key,
verdict: global_row.verdict,
local_trace_count: global_row.local_trace_count,
local_traces,
},
next_after_detail_id,
}))
}
@@ -611,6 +611,7 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
(r"^mrpack4server", P::Named("mrpack4server")),
(r"^DawnLauncher/", P::Named("Dawn")),
(r"^Complementary-Installer", P::Named("Complementary Installer")),
(
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
P::Website,
+2 -2
View File
@@ -28,10 +28,10 @@ const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1";
const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/v3").service(resolve_content));
cfg.service(resolve_content);
}
/// Resolve content.
/// Resolve content.
#[utoipa::path(
tag = "content",
request_body = serde_json::Value,
+2 -2
View File
@@ -69,9 +69,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(users::config)
.configure(version_file::config)
.configure(versions::config)
.configure(friends::config),
.configure(friends::config)
.configure(content::config),
);
cfg.configure(content::config);
}
#[derive(utoipa::OpenApi)]
@@ -405,6 +405,14 @@ impl SearchField {
optional: true,
token_separators: &["-"],
},
SearchField::AllProjectTypes => TypesenseFieldSpec {
path: "all_project_types",
ty: "string[]",
facet: true,
sort: false,
optional: true,
token_separators: &["-"],
},
SearchField::ProjectId => TypesenseFieldSpec {
path: "project_id",
ty: "string",
@@ -563,6 +571,7 @@ impl Typesense {
json!({"name": "indexed_name", "type": "string", "facet": false, "stem": true}),
json!({"name": "indexed_author", "type": "string", "facet": false}),
json!({"name": "log_downloads", "type": "float", "sort": true}),
json!({"name": "downloads", "type": "int32", "sort": true}),
json!({"name": "follows", "type": "int32", "facet": true, "sort": true}),
json!({"name": "created_timestamp", "type": "int64", "sort": true}),
json!({"name": "modified_timestamp", "type": "int64", "sort": true}),
@@ -1255,9 +1264,12 @@ fn facets_to_typesense(facets_json: &str) -> Result<String> {
/// Converts a single facet condition such as `"categories:mods"`,
/// `"categories=mods"`, or `"downloads!=100"` into a Typesense filter clause.
fn condition_to_typesense_filter(cond: &str) -> String {
// Handle `!=` before `=` so we don't misfire on the equality arm.
if let Some((field, value)) = cond.split_once("!=") {
return format!("{}:!= {}", field.trim(), value.trim());
// Match multi-character operators before their single-character prefixes,
// and range/inequality operators before the plain `=` equality arm.
for op in ["!=", ">=", "<=", ">", "<"] {
if let Some((field, value)) = cond.split_once(op) {
return format!("{}:{} {}", field.trim(), op, value.trim());
}
}
if let Some((field, value)) = cond.split_once(':') {
return format!("{}:= {}", field.trim(), value.trim());
+15
View File
@@ -495,6 +495,20 @@ async fn build_search_documents(
.flat_map(|x| x.loaders.clone())
.collect::<Vec<_>>();
// all valid project types across every version of the project, so that
// filters can exclude projects that have *any* version of a given
// project type (unlike the version-specific `project_types` field).
let mut all_project_types = versions
.iter()
.flat_map(|x| x.project_types.clone())
.collect::<Vec<_>>();
all_project_types.sort();
all_project_types.dedup();
exp::compat::correct_project_types(
&project.components,
&mut all_project_types,
);
for version in versions {
let version_fields = VersionField::from_query_json(
version.version_fields,
@@ -609,6 +623,7 @@ async fn build_search_documents(
slug: project.slug.clone(),
// TODO
project_types,
all_project_types: all_project_types.clone(),
gallery: gallery.clone(),
featured_gallery: featured_gallery.clone(),
open_source,
+6
View File
@@ -202,6 +202,7 @@ pub enum SearchField {
Author,
License,
ProjectTypes,
AllProjectTypes,
ProjectId,
OpenSource,
Environment,
@@ -238,6 +239,8 @@ pub struct UploadSearchProject {
pub project_id: String,
//
pub project_types: Vec<String>,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
pub author_id: String,
@@ -307,6 +310,8 @@ pub struct ResultSearchProject {
pub version_id: String,
pub project_id: String,
pub project_types: Vec<String>,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
#[serde(default)]
@@ -355,6 +360,7 @@ impl From<UploadSearchProject> for ResultSearchProject {
version_id: source.version_id,
project_id: source.project_id,
project_types: source.project_types,
all_project_types: source.all_project_types,
slug: source.slug,
author: source.author,
author_id: Some(source.author_id),
@@ -100,9 +100,15 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
*/
public async updateIssueDetail(
detailId: string,
data: Labrinth.TechReview.Internal.UpdateIssueRequest,
data: Labrinth.TechReview.Internal.UpdateIssueDetailRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/issue-detail/${detailId}`, {
return this.updateIssueDetails([{ detail_id: detailId, verdict: data.verdict }])
}
public async updateIssueDetails(
data: Labrinth.TechReview.Internal.UpdateIssueRequest[],
): Promise<void> {
return this.client.request<void>('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
@@ -110,6 +116,45 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
})
}
public async updateGlobalIssueDetails(
data: Labrinth.TechReview.Internal.UpdateGlobalIssueRequest[],
): Promise<void> {
return this.client.request<void>('/moderation/tech-review/global-issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
public async searchGlobalIssueDetails(
params: Labrinth.TechReview.Internal.SearchGlobalIssueDetailsRequest,
): Promise<Labrinth.TechReview.Internal.SearchGlobalIssueDetailsResponse> {
return this.client.request<Labrinth.TechReview.Internal.SearchGlobalIssueDetailsResponse>(
'/moderation/tech-review/global-issue-detail/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
public async getGlobalIssueDetail(
params: Labrinth.TechReview.Internal.GetGlobalIssueDetailRequest,
): Promise<Labrinth.TechReview.Internal.GetGlobalIssueDetailResponse> {
return this.client.request<Labrinth.TechReview.Internal.GetGlobalIssueDetailResponse>(
'/moderation/tech-review/global-issue-detail/local-traces',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
public async submitProject(
projectId: string,
data: Labrinth.TechReview.Internal.SubmitProjectRequest,
@@ -1742,6 +1742,7 @@ export namespace Labrinth {
export interface ResultSearchProject {
project_id: string
project_type: string
all_project_types: string[]
slug: string | null
author: string
author_id: string | null
@@ -1779,6 +1780,7 @@ export namespace Labrinth {
version_id: string
project_id: string
project_types: string[]
all_project_types: string[]
slug: string | null
author: string
author_id: string | null
@@ -2222,9 +2224,66 @@ export namespace Labrinth {
| 'severity_desc'
export type UpdateIssueRequest = {
detail_id: string
verdict: DelphiReportIssueStatus
}
export type UpdateIssueDetailRequest = {
verdict: DelphiReportIssueStatus
}
export type UpdateGlobalIssueRequest = {
detail_key: string
verdict: 'safe' | 'unsafe'
}
export type SearchGlobalIssueDetailsRequest = {
limit?: number
page?: number
query?: string | null
}
export type SearchGlobalIssueDetailsResponse = {
total: number
traces: GlobalIssueDetail[]
}
export type GetGlobalIssueDetailRequest = {
detail_key: string
limit?: number
after_detail_id?: string | null
}
export type GetGlobalIssueDetailResponse = {
trace: GlobalIssueDetail
next_after_detail_id: string | null
}
export type GlobalIssueDetail = {
detail_key: string
verdict: DelphiReportIssueStatus
local_trace_count: number
local_traces: GlobalIssueDetailTrace[]
}
export type GlobalIssueDetailTrace = {
detail_id: string
issue_id: string
issue_type: string
project_id: string
project_slug: string | null
project_name: string
version_id: string
version_number: string
file_id: string
file_name: string
jar: string | null
file_path: string
severity: DelphiSeverity
local_status: DelphiReportIssueStatus
effective_status: DelphiReportIssueStatus
}
export type SubmitProjectRequest = {
verdict: 'safe' | 'unsafe'
message?: string
@@ -61,11 +61,16 @@ pub async fn edit(
let state = State::get().await?;
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
crate::state::get_instance(instance_id, &state.pool)
let instance = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
})
crate::ErrorKind::InputError("Unknown instance".to_string())
.as_error()
})?;
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
Ok(instance)
}
pub async fn edit_icon(
+1
View File
@@ -64,6 +64,7 @@ pub enum FeatureFlag {
I18nDebug,
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
}
impl Settings {
+21
View File
@@ -10,6 +10,27 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-08T15:05:35+00:00`,
product: 'app',
version: '0.15.9',
body: `## Added
- Added new advanced filter category to Discover content.
- Added options to exclude plugins and data packs from mod search
- Added options to exclude mods and plugins from data pack search
## Fixed
- Instance edits not appearing to be immediately saved.`,
},
{
date: `2026-07-08T15:05:35+00:00`,
product: 'web',
body: `## Added
- Added new advanced filter category to Discover content.
- Added options to exclude plugins and data packs from mod search
- Added options to exclude mods and data packs from plugin search
- Added options to exclude mods and plugins from data pack search`,
},
{
date: `2026-07-06T22:19:13+00:00`,
product: 'app',
+81 -1
View File
@@ -10,10 +10,73 @@ pub const CURRENT_FABRIC_FORMAT_VERSION: usize = 0;
/// The latest version of the format the fabric model structs deserialize to
pub const CURRENT_FORGE_FORMAT_VERSION: usize = 0;
/// The latest version of the format the quilt model structs deserialize to
pub const CURRENT_QUILT_FORMAT_VERSION: usize = 0;
pub const CURRENT_QUILT_FORMAT_VERSION: usize = 1;
/// The latest version of the format the neoforge model structs deserialize to
pub const CURRENT_NEOFORGE_FORMAT_VERSION: usize = 0;
/// Metadata for locating and caching a loader manifest.
#[derive(Debug, Clone)]
pub struct LoaderManifestMetadata {
/// The canonical loader name used in launcher-meta paths.
pub loader: String,
/// The latest manifest format version for this loader.
pub format_version: usize,
/// The cache key that includes the loader format version.
pub cache_key: String,
/// The launcher-meta path to the manifest.
pub path: String,
}
/// Returns metadata for the latest manifest format for the provided loader.
pub fn loader_manifest_metadata(loader: &str) -> LoaderManifestMetadata {
let format_version = current_loader_manifest_format_version(loader);
let cache_key = format!("{loader}-v{format_version}");
let path = format!("{loader}/v{format_version}/manifest.json");
LoaderManifestMetadata {
loader: loader.to_string(),
format_version,
cache_key,
path,
}
}
/// Returns loader manifest metadata from a versioned cache key.
pub fn loader_manifest_metadata_from_cache_key(
cache_key: &str,
) -> LoaderManifestMetadata {
if let Some((loader, format_version)) =
cache_key.rsplit_once("-v").and_then(|(loader, version)| {
version
.parse::<usize>()
.ok()
.map(|version| (loader, version))
})
{
let cache_key = format!("{loader}-v{format_version}");
let path = format!("{loader}/v{format_version}/manifest.json");
LoaderManifestMetadata {
loader: loader.to_string(),
format_version,
cache_key,
path,
}
} else {
loader_manifest_metadata(cache_key)
}
}
fn current_loader_manifest_format_version(loader: &str) -> usize {
match loader {
"fabric" => CURRENT_FABRIC_FORMAT_VERSION,
"forge" => CURRENT_FORGE_FORMAT_VERSION,
"quilt" => CURRENT_QUILT_FORMAT_VERSION,
"neo" => CURRENT_NEOFORGE_FORMAT_VERSION,
_ => 0,
}
}
/// The dummy replace string library names, inheritsFrom, and version names should be replaced with
pub const DUMMY_REPLACE_STRING: &str = "${modrinth.gameVersion}";
@@ -188,19 +251,36 @@ pub fn merge_partial_version(
pub struct Manifest {
/// The game versions the mod loader supports
pub game_versions: Vec<Version>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
/// Groups of game versions that share compatible loader version profiles
pub version_groups: Vec<VersionGroup>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
/// A game version of Minecraft
pub struct Version {
/// The minecraft version ID
pub id: String,
/// Whether the release is stable or not
pub stable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
/// The loader profile group for this Minecraft version
pub version_group: Option<String>,
/// A map that contains loader versions for the game version
pub loaders: Vec<LoaderVersion>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
/// A group of Minecraft versions that share loader version profiles
pub struct VersionGroup {
/// The version group ID
pub id: String,
/// The loader versions for this version group
pub loaders: Vec<LoaderVersion>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
/// A version of a Minecraft mod loader
pub struct LoaderVersion {
@@ -71,83 +71,105 @@
</template>
<template v-else #default>
<slot name="prefix" />
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<label
v-for="option in filterType.options"
:key="`${filterType.id}-toggle-${option.id}`"
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
>
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
<Toggle
:model-value="isExcluded(option)"
small
class="shrink-0"
@update:model-value="toggleNegativeFilter(option)"
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -191,6 +213,7 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -73,6 +73,7 @@ export interface BrowseManagerContext {
showServerOnly?: ComputedRef<boolean>
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
advancedFiltersCollapsed?: Ref<boolean>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -17,6 +17,14 @@ const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
function setAdvancedFiltersCollapsed(collapsed: boolean) {
if (ctx.advancedFiltersCollapsed) {
ctx.advancedFiltersCollapsed.value = collapsed
}
}
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
ctx.filtersMenuOpen.value = false
@@ -49,6 +57,9 @@ function hasProvidedFilter(filterId: string): boolean {
}
function getFilterOpenByDefault(filterId: string): boolean {
if (filterId === 'advanced') {
return !advancedFiltersCollapsed.value
}
if (hasProvidedFilter(filterId)) {
return true
}
@@ -188,6 +199,8 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filter.id)"
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
+12
View File
@@ -3572,6 +3572,18 @@
"search.filter.option.show_more": {
"defaultMessage": "Show more"
},
"search.filter_type.advanced": {
"defaultMessage": "Advanced"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exclude data packs"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exclude mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclude plugins"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
},
+76 -1
View File
@@ -46,7 +46,7 @@ export type FilterType = {
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none'
display: 'all' | 'scrollable' | 'none' | 'toggle'
}
| {
display: 'expandable'
@@ -112,6 +112,12 @@ export interface SortType {
const PLUGIN_PLATFORMS = ['bungeecord', 'waterfall', 'velocity', 'geyser']
const PROJECT_TYPE_EXCLUSION_FILTERS: Partial<Record<ProjectType, ProjectType[]>> = {
mod: ['plugin', 'datapack'],
plugin: ['mod', 'datapack'],
datapack: ['mod', 'plugin'],
}
export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
@@ -143,6 +149,34 @@ export function useSearch(
return formatCategory(formatMessage, categoryName)
}
const formatExcludeProjectTypeLabel = (projectType: ProjectType): string => {
switch (projectType) {
case 'mod':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_mod',
defaultMessage: 'Exclude mods',
}),
)
case 'plugin':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_plugin',
defaultMessage: 'Exclude plugins',
}),
)
case 'datapack':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_datapack',
defaultMessage: 'Exclude data packs',
}),
)
default:
return projectType
}
}
const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
@@ -171,6 +205,15 @@ export function useSearch(
})
}
const excludeableProjectTypes: ProjectType[] = []
for (const projectType of projectTypes.value) {
for (const target of PROJECT_TYPE_EXCLUSION_FILTERS[projectType] ?? []) {
if (!excludeableProjectTypes.includes(target)) {
excludeableProjectTypes.push(target)
}
}
}
const filterTypes: FilterType[] = [
...Object.values(categoryFilters),
{
@@ -429,6 +472,26 @@ export function useSearch(
options: [],
allows_custom_options: 'and',
},
{
id: 'advanced',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced',
}),
),
supported_project_types: ['mod', 'plugin', 'datapack'],
display: 'toggle',
query_param: 'a',
searchable: false,
ordering: -1000,
options: excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatExcludeProjectTypeLabel(target),
method: 'and',
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
})),
},
]
return filterTypes
@@ -460,6 +523,9 @@ export function useSearch(
console.error(`Filter type ${filterValue.type} not found`)
continue
}
if (type.id === 'advanced') {
continue
}
let option = type?.options.find((option) => option.id === filterValue.option)
if (!option && type.allows_custom_options) {
option = {
@@ -550,6 +616,15 @@ export function useSearch(
parts.push(`project_types IN [${quoted}]`)
}
const excludedProjectTypes = filterValues
.filter((filterValue) => filterValue.type === 'advanced')
.map((filterValue) =>
formatSearchFilterValue(mapProjectTypeToSearch(filterValue.option as ProjectType)),
)
if (excludedProjectTypes.length > 0) {
parts.push(`all_project_types NOT IN [${excludedProjectTypes.join(', ')}]`)
}
return parts.join(' AND ')
})