Compare commits

..

1 Commits

Author SHA1 Message Date
blacksmith-sh[bot]
6c4d935d90 Migrate workflows to Blacksmith 2025-07-16 21:13:39 +00:00
78 changed files with 365 additions and 1012 deletions

View File

@@ -2,8 +2,5 @@
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -19,29 +19,26 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/daedalus
- name: Login to GitHub Images
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
id: docker_build
uses: docker/build-push-action@v2
with:
file: ./apps/daedalus_client/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
cache-to: type=inline

View File

@@ -8,7 +8,7 @@ on:
jobs:
run-docker:
if: github.repository_owner == 'modrinth'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository

View File

@@ -8,7 +8,7 @@ on:
jobs:
wait:
if: github.repository_owner == 'modrinth'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
deployments: write

View File

@@ -17,29 +17,26 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/labrinth
- name: Login to GitHub Images
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
id: docker_build
uses: docker/build-push-action@v2
with:
file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
cache-to: type=inline

View File

@@ -28,13 +28,13 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
platform: [macos-latest, windows-latest, blacksmith-4vcpu-ubuntu-2204]
include:
- platform: macos-latest
artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-22.04
- platform: blacksmith-4vcpu-ubuntu-2204
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
@@ -75,7 +75,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version and environment
- name: ⚙️ Set application version
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -84,8 +84,6 @@ jobs:
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8

View File

@@ -15,7 +15,7 @@ on:
jobs:
release:
name: Release Modrinth App
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)

View File

@@ -11,7 +11,7 @@ on:
jobs:
build:
name: Lint and Test
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
# Ensure pnpm output is colored in GitHub Actions logs
@@ -52,7 +52,7 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: taiki-e/cache-cargo-install-action@v2
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false
@@ -74,14 +74,10 @@ jobs:
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json
git diff --exit-code */*/src/locales/en-US/index.json

3
Cargo.lock generated
View File

@@ -8930,7 +8930,6 @@ dependencies = [
"data-url",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
"encoding_rs",
@@ -8985,8 +8984,6 @@ dependencies = [
"dashmap",
"either",
"enumset",
"hyper 1.6.0",
"hyper-util",
"native-dialog",
"paste",
"serde",

View File

@@ -67,7 +67,6 @@ heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",

View File

@@ -61,10 +61,9 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
@@ -264,8 +263,6 @@ const incompatibilityWarningModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -275,24 +272,8 @@ async function fetchCredentials() {
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
await login().catch(handleError)
await fetchCredentials()
}
async function logOut() {
@@ -421,9 +402,6 @@ function handleAuxClick(e) {
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>

View File

@@ -305,16 +305,12 @@ const [
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
)
.catch((err) => {
handleError(err)
return ref([])
}),
.then(ref)
.catch(handleError),
])
loaders.value.unshift('vanilla')

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@@ -16,7 +16,3 @@ export async function logout() {
export async function get() {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -67,8 +67,9 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
for (let index = 1; index <= imageData.length; index++) {
//every fourth value in RGBA is the alpha channel
if (index % 4 == 0 && imageData[index - 1] !== 0) {
resolve('CLASSIC')
return
}

View File

@@ -220,7 +220,7 @@ async function refreshSearch() {
}
}
results.value = rawResults.result
currentPage.value = 1
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {}
@@ -266,7 +266,6 @@ async function onSearchChangeToTop() {
function clearSearch() {
query.value = ''
currentPage.value = 1
}
watch(

View File

@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();

View File

@@ -31,8 +31,6 @@ thiserror.workspace = true
daedalus.workspace = true
chrono.workspace = true
either.workspace = true
hyper = { workspace = true, features = ["server"] }
hyper-util.workspace = true
url.workspace = true
urlencoding.workspace = true

View File

@@ -120,12 +120,7 @@ fn main() {
.plugin(
"mr-auth",
InlinedPlugin::new()
.commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.commands(&["modrinth_login", "logout", "get"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),

View File

@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new(
&app,
"signin",
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
@@ -77,7 +77,6 @@ pub async fn login<R: Runtime>(
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)

View File

@@ -22,8 +22,6 @@ pub mod cache;
pub mod friends;
pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error

View File

@@ -1,70 +1,79 @@
use crate::api::Result;
use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin;
use tauri_plugin_opener::OpenerExt;
use tauri::{Manager, Runtime, UserAttentionType};
use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
.build()
}
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
) -> Result<Option<ModrinthCredentials>> {
let redirect_uri = mr_auth::authenticate_begin_flow();
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
let start = Utc::now();
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
app.opener()
.open_url(auth_request_uri, None::<&str>)
.map_err(|e| {
TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(format!(
"Failed to open auth request URI: {e}"
))
.into(),
)
})?;
let Some(auth_code) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
if let Some(main_window) = app.get_window("main") {
main_window.set_focus().ok();
if let Some(window) = app.get_webview_window("modrinth-signin") {
window.close()?;
}
Ok(credentials)
let window = tauri::WebviewWindowBuilder::new(
&app,
"modrinth-signin",
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
)
.as_error()
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
window.request_user_attention(Some(UserAttentionType::Critical))?;
while (Utc::now() - start) < Duration::minutes(10) {
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
if window
.url()?
.as_str()
.starts_with("https://launcher-files.modrinth.com")
{
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
window.close()?;
Ok(None)
}
#[tauri::command]
@@ -76,8 +85,3 @@ pub async fn logout() -> Result<()> {
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@@ -1,159 +0,0 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use tokio::{
net::TcpListener,
sync::{broadcast, oneshot},
};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<String>, theseus::Error> {
// IPv4 is tried first for the best compatibility and performance with most systems.
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
// to prevent failures deriving from improper name resolution setup. Any available
// ephemeral port is used to prevent conflicts with other services. This is all as per
// RFC 8252's recommendations
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<String>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
query_string
.split('&')
.filter_map(|query_pair| query_pair.split_once('='))
.find_map(|(key, value)| (key == "code").then_some(value))
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Success")
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
)
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Error")
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
)
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@@ -63,7 +63,6 @@
"height": 800,
"resizable": true,
"title": "Modrinth App",
"label": "main",
"width": 1280,
"minHeight": 700,
"minWidth": 1100,

View File

@@ -1,19 +1,9 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
WORKDIR /usr/src/daedalus
COPY . .
RUN --mount=type=cache,target=/usr/src/daedalus/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release --package daedalus_client
RUN cargo build --release --package daedalus_client
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
FROM debian:bookworm-slim
@@ -21,7 +11,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=artifacts /daedalus /daedalus
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
WORKDIR /daedalus_client
CMD ["/daedalus/daedalus_client"]
CMD /daedalus/daedalus_client

View File

@@ -8,7 +8,7 @@
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
<p>All permissions obtained. You may skip this step!</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
@@ -157,7 +157,7 @@ import type {
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { useLocalStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
@@ -182,26 +182,7 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const modPackData = ref<ModerationModpackItem[] | null>(null);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
@@ -270,45 +251,7 @@ async function fetchModPackData(): Promise<void> {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
@@ -367,7 +310,6 @@ async function fetchModPackData(): Promise<void> {
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
@@ -379,14 +321,6 @@ function goToPrevious(): void {
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
@@ -462,17 +396,6 @@ onMounted(() => {
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
@@ -483,20 +406,6 @@ watch(
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>

View File

@@ -240,6 +240,24 @@
</div>
<div v-else-if="generatedMessage" class="flex items-center gap-2">
<OverflowMenu :options="stageOptions" class="bg-transparent p-0">
<ButtonStyled circular>
<button v-tooltip="`Stages`">
<ListBulletedIcon />
</button>
</ButtonStyled>
<template
v-for="opt in stageOptions.filter(
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
)"
#[opt.id]
:key="opt.id"
>
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
{{ opt.text }}
</template>
</OverflowMenu>
<ButtonStyled>
<button @click="goBackToStages">
<LeftArrowIcon aria-hidden="true" />
@@ -350,26 +368,21 @@ import {
DropdownSelect,
MarkdownEditor,
} from "@modrinth/ui";
import {
type Project,
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
} from "@modrinth/utils";
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
import {
type Action,
type MultiSelectChipsAction,
type DropdownAction,
type ButtonAction,
type ToggleAction,
type ConditionalButtonAction,
type Stage,
finalPermissionMessages,
import type {
Action,
MultiSelectChipsAction,
DropdownAction,
ButtonAction,
ToggleAction,
ConditionalButtonAction,
Stage,
} from "@modrinth/moderation";
import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue";
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
import prettier from "prettier";
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
@@ -406,6 +419,7 @@ const done = ref(false);
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true;
nextStage();
}
const emit = defineEmits<{
@@ -809,31 +823,6 @@ const isAnyVisibleInputs = computed(() => {
});
});
function getModpackFilesFromStorage(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
try {
const sessionData = sessionStorage.getItem(`modpack-permissions-data-${props.project.id}`);
const interactive = sessionData ? (JSON.parse(sessionData) as ModerationModpackItem[]) : [];
const permanentNoData = sessionStorage.getItem(
`modpack-permissions-permanent-no-${props.project.id}`,
);
const permanentNo = permanentNoData
? (JSON.parse(permanentNoData) as ModerationModpackItem[])
: [];
return {
interactive: interactive || [],
permanentNo: permanentNo || [],
};
} catch (error) {
console.warn("Failed to parse session storage modpack data:", error);
return { interactive: [], permanentNo: [] };
}
}
async function assembleFullMessage() {
const messageParts: MessagePart[] = [];
@@ -1103,14 +1092,13 @@ async function generateMessage() {
const baseMessage = await assembleFullMessage();
let fullMessage = baseMessage;
if (props.project.project_type === "modpack") {
const modpackFilesData = getModpackFilesFromStorage();
if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
const modpackMessage = generateModpackMessage(modpackFilesData);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
}
if (
props.project.project_type === "modpack" &&
Object.keys(modpackJudgements.value).length > 0
) {
const modpackMessage = generateModpackMessage(modpackJudgements.value);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
}
}
@@ -1141,32 +1129,25 @@ async function generateMessage() {
}
}
function generateModpackMessage(allFiles: {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
}) {
function generateModpackMessage(judgements: ModerationJudgements) {
const issues = [];
const attributeMods: string[] = [];
const noMods: string[] = [];
const permanentNoMods: string[] = [];
const unidentifiedMods: string[] = [];
const attributeMods = [];
const noMods = [];
const permanentNoMods = [];
const unidentifiedMods = [];
allFiles.interactive.forEach((file) => {
if (file.status === "unidentified") {
if (file.approved === "no") {
unidentifiedMods.push(file.file_name);
}
} else if (file.status === "with-attribution" && file.approved === "no") {
attributeMods.push(file.file_name);
} else if (file.status === "no" && file.approved === "no") {
noMods.push(file.file_name);
for (const [, judgement] of Object.entries(judgements)) {
if (judgement.status === "with-attribution") {
attributeMods.push(judgement.file_name);
} else if (judgement.status === "no") {
noMods.push(judgement.file_name);
} else if (judgement.status === "permanent-no") {
permanentNoMods.push(judgement.file_name);
} else if (judgement.status === "unidentified") {
unidentifiedMods.push(judgement.file_name);
}
});
allFiles.permanentNo.forEach((file) => {
permanentNoMods.push(file.file_name);
});
}
if (
attributeMods.length > 0 ||
@@ -1176,12 +1157,6 @@ function generateModpackMessage(allFiles: {
) {
issues.push("## Copyrighted content");
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (attributeMods.length > 0) {
issues.push(
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
@@ -1197,6 +1172,12 @@ function generateModpackMessage(allFiles: {
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
}
return issues.join("\n\n");

View File

@@ -1,21 +1,13 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in filteredReports"
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open),
)"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
@@ -24,12 +16,11 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
const props = defineProps({
defineProps({
moderation: {
type: Boolean,
default: false,
@@ -41,14 +32,9 @@ const props = defineProps({
});
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
const MAX_REPORTS = 1500;
let { data: rawReports } = await useAsyncData("report", () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
@@ -65,7 +51,6 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
@@ -108,13 +93,4 @@ reports.value = rawReports.map((report) => {
report.open = true;
return report;
});
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
),
);
</script>

View File

@@ -150,26 +150,9 @@
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}

View File

@@ -1,12 +1,6 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<div>
<template v-if="flow">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
@@ -195,7 +189,6 @@ const auth = await useAuth();
const route = useNativeRoute();
const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn();
@@ -269,32 +262,7 @@ async function begin2FASignIn() {
async function finishSignIn(token) {
if (route.query.launcher) {
if (!token) {
token = auth.value.token;
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
return;
}

View File

@@ -247,14 +247,16 @@ async function createAccount() {
},
});
await useAuth(res.session);
await useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session);
await useUser();
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {

View File

@@ -45,9 +45,8 @@
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
>
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
and play your favorite mods and modpacks, all within the Modrinth platform.
</h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div
@@ -460,7 +459,7 @@
</p>
</details>
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -481,7 +480,7 @@
</p>
</details>
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -492,24 +491,6 @@
All prices are listed in United States Dollars (USD).
</p>
</details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
</span>
What Minecraft versions and loaders can be used?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
back to version 1.2.5, including snapshot versions.
</p>
<p class="m-0 ml-6 mt-3 leading-[160%]">
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
depends on whether the mod or plugin loader supports the selected Minecraft version.
</p>
</details>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"describe": {
"columns": [
{
@@ -102,5 +102,5 @@
true
]
},
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
}

View File

@@ -1,21 +1,8 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
WORKDIR /usr/src/labrinth
COPY . .
RUN --mount=type=cache,target=/usr/src/labrinth/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
SQLX_OFFLINE=true cargo build --release --package labrinth
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/labrinth/target \
mkdir /labrinth \
&& cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
FROM debian:bookworm-slim
@@ -27,8 +14,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=artifacts /labrinth /labrinth
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"]

View File

@@ -43,9 +43,7 @@ pub enum AuthenticationError {
InvalidAuthMethod,
#[error("GitHub Token from incorrect Client ID")]
InvalidClientId,
#[error(
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
)]
#[error("User email/account is already registered on Modrinth")]
DuplicateUser,
#[error("Invalid state sent, you probably need to get a new websocket")]
SocketError,

View File

@@ -197,7 +197,7 @@ impl DBCharge {
) -> Result<Option<DBCharge>, DatabaseError> {
let user_subscription_id = user_subscription_id.0;
let res = select_charges_with_predicate!(
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
user_subscription_id
)
.fetch_optional(exec)

View File

@@ -223,8 +223,8 @@ impl TempUser {
stripe_customer_id: None,
totp_secret: None,
username,
email: self.email.clone(),
email_verified: self.email.is_some(),
email: self.email,
email_verified: true,
avatar_url,
raw_avatar_url,
bio: self.bio,
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
.hash_password(new_account.password.as_bytes(), &salt)?
.to_string();
if !crate::database::models::DBUser::get_by_case_insensitive_email(
if crate::database::models::DBUser::get_by_email(
&new_account.email,
&**pool,
)
.await?
.is_empty()
.is_some()
{
return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(),
"Email is already registered on Modrinth!".to_string(),
));
}
@@ -2220,18 +2220,6 @@ pub async fn set_email(
.await?
.1;
if !crate::database::models::DBUser::get_by_case_insensitive_email(
&email.email,
&**pool,
)
.await?
.is_empty()
{
return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.".to_string(),
));
}
let mut transaction = pool.begin().await?;
sqlx::query!(

View File

@@ -15,7 +15,6 @@
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
"blog:fix": "turbo run fix --filter=@modrinth/blog",
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
"build": "turbo run build --continue",
"lint": "turbo run lint --continue",
"test": "turbo run test --continue",

View File

@@ -1,10 +1,2 @@
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
# SQLite database file location
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -1,10 +0,0 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -1,10 +0,0 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -82,7 +82,6 @@ ariadne.workspace = true
winreg.workspace = true
[build-dependencies]
dotenvy.workspace = true
dunce.workspace = true
[features]

View File

@@ -4,31 +4,12 @@ use std::process::{Command, exit};
use std::{env, fs};
fn main() {
println!("cargo::rerun-if-changed=.env");
println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties");
set_env();
build_java_jars();
}
fn set_env() {
for (var_name, var_value) in
dotenvy::dotenv_iter().into_iter().flatten().flatten()
{
if var_name == "DATABASE_URL" {
// The sqlx database URL is a build-time detail that should not be exposed to the crate
continue;
}
println!("cargo::rustc-env={var_name}={var_value}");
}
}
fn build_java_jars() {
let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap();
@@ -56,7 +37,6 @@ fn build_java_jars() {
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");
if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1));

View File

@@ -1,7 +1,7 @@
use crate::state::ModrinthCredentials;
#[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str {
pub fn authenticate_begin_flow() -> String {
crate::state::get_login_url()
}

View File

@@ -284,12 +284,6 @@ async fn import_mmc_unmanaged(
component.version.clone().unwrap_or_default(),
));
}
if component.uid.starts_with("net.neoforged") {
return Some((
PackDependency::NeoForge,
component.version.clone().unwrap_or_default(),
));
}
if component.uid.starts_with("org.quiltmc.quilt-loader") {
return Some((
PackDependency::QuiltLoader,

View File

@@ -0,0 +1,13 @@
//! Configuration structs
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
pub const MODRINTH_URL: &str = "https://modrinth.com/";
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";

View File

@@ -11,6 +11,7 @@ and launching Modrinth mod packs
mod util;
mod api;
mod config;
mod error;
mod event;
mod launcher;

View File

@@ -1,3 +1,4 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
use chrono::{DateTime, Utc};
@@ -7,7 +8,6 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::env;
use std::fmt::Display;
use std::hash::Hash;
use std::path::{Path, PathBuf};
@@ -945,7 +945,7 @@ impl CachedEntry {
CacheValueType::Project => {
fetch_original_values!(
Project,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"projects",
CacheValue::Project
)
@@ -953,7 +953,7 @@ impl CachedEntry {
CacheValueType::Version => {
fetch_original_values!(
Version,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"versions",
CacheValue::Version
)
@@ -961,7 +961,7 @@ impl CachedEntry {
CacheValueType::User => {
fetch_original_values!(
User,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"users",
CacheValue::User
)
@@ -969,7 +969,7 @@ impl CachedEntry {
CacheValueType::Team => {
let mut teams = fetch_many_batched::<Vec<TeamMember>>(
Method::GET,
env!("MODRINTH_API_URL_V3"),
MODRINTH_API_URL_V3,
"teams?ids=",
&keys,
fetch_semaphore,
@@ -1008,7 +1008,7 @@ impl CachedEntry {
CacheValueType::Organization => {
let mut orgs = fetch_many_batched::<Organization>(
Method::GET,
env!("MODRINTH_API_URL_V3"),
MODRINTH_API_URL_V3,
"organizations?ids=",
&keys,
fetch_semaphore,
@@ -1063,7 +1063,7 @@ impl CachedEntry {
CacheValueType::File => {
let mut versions = fetch_json::<HashMap<String, Version>>(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "version_files"),
&format!("{MODRINTH_API_URL}version_files"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1119,11 +1119,7 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!(
"{}{}/v0/manifest.json",
env!("MODRINTH_LAUNCHER_META_URL"),
x.key()
),
format!("{META_URL}{}/v0/manifest.json", x.key()),
)
})
.collect::<Vec<_>>();
@@ -1158,7 +1154,7 @@ impl CachedEntry {
CacheValueType::MinecraftManifest => {
fetch_original_value!(
MinecraftManifest,
env!("MODRINTH_LAUNCHER_META_URL"),
META_URL,
format!(
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
@@ -1169,7 +1165,7 @@ impl CachedEntry {
CacheValueType::Categories => {
fetch_original_value!(
Categories,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"tag/category",
CacheValue::Categories
)
@@ -1177,7 +1173,7 @@ impl CachedEntry {
CacheValueType::ReportTypes => {
fetch_original_value!(
ReportTypes,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"tag/report_type",
CacheValue::ReportTypes
)
@@ -1185,7 +1181,7 @@ impl CachedEntry {
CacheValueType::Loaders => {
fetch_original_value!(
Loaders,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"tag/loader",
CacheValue::Loaders
)
@@ -1193,7 +1189,7 @@ impl CachedEntry {
CacheValueType::GameVersions => {
fetch_original_value!(
GameVersions,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"tag/game_version",
CacheValue::GameVersions
)
@@ -1201,7 +1197,7 @@ impl CachedEntry {
CacheValueType::DonationPlatforms => {
fetch_original_value!(
DonationPlatforms,
env!("MODRINTH_API_URL"),
MODRINTH_API_URL,
"tag/donation_platform",
CacheValue::DonationPlatforms
)
@@ -1301,12 +1297,14 @@ impl CachedEntry {
}
});
let version_update_url =
format!("{MODRINTH_API_URL}version_files/update");
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
&version_update_url,
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1370,11 +1368,7 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!(
"{}search{}",
env!("MODRINTH_API_URL"),
x.key()
),
format!("{MODRINTH_API_URL}search{}", x.key()),
)
})
.collect::<Vec<_>>();

View File

@@ -1,3 +1,4 @@
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -76,8 +77,7 @@ impl FriendsSocket {
if let Some(credentials) = credentials {
let mut request = format!(
"{}_internal/launcher_socket?code={}",
env!("MODRINTH_SOCKET_URL"),
"{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
credentials.session
)
.into_client_request()?;
@@ -303,7 +303,7 @@ impl FriendsSocket {
) -> crate::Result<Vec<UserFriend>> {
fetch_json(
Method::GET,
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
&format!("{MODRINTH_API_URL_V3}friends"),
None,
None,
semaphore,
@@ -328,7 +328,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::POST,
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
None,
None,
None,
@@ -349,7 +349,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::DELETE,
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
None,
None,
None,

View File

@@ -85,18 +85,21 @@ pub struct MinecraftLoginFlow {
pub verifier: String,
pub challenge: String,
pub session_id: String,
pub auth_request_uri: String,
pub redirect_uri: String,
}
#[tracing::instrument]
pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<MinecraftLoginFlow> {
let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let (pair, current_date, valid_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
.await?;
let verifier = generate_oauth_challenge();
let result = sha2::Sha256::digest(&verifier);
let mut hasher = sha2::Sha256::new();
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
match sisu_authenticate(
@@ -107,15 +110,46 @@ pub async fn login_begin(
)
.await
{
Ok((session_id, redirect_uri)) => {
return Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
auth_request_uri: redirect_uri.value.msa_oauth_redirect,
});
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
redirect_uri: redirect_uri.value.msa_oauth_redirect,
}),
Err(err) => {
if !valid_date {
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
Utc::now(),
false,
exec,
)
.await?;
let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new();
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
let (session_id, redirect_uri) = sisu_authenticate(
&pair.token.token,
&challenge,
&pair.key,
current_date,
)
.await?;
Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
redirect_uri: redirect_uri.value.msa_oauth_redirect,
})
} else {
Err(crate::ErrorKind::from(err).into())
}
}
Err(err) => return Err(crate::ErrorKind::from(err).into()),
}
}
@@ -125,8 +159,9 @@ pub async fn login_finish(
flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> {
let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let (pair, _, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
.await?;
let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize(
@@ -232,9 +267,10 @@ impl Credentials {
}
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date) =
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
oauth_token.date,
false,
exec,
)
.await?;
@@ -597,20 +633,21 @@ impl DeviceTokenPair {
#[tracing::instrument(skip(exec))]
async fn refresh_and_get_device_token(
current_date: DateTime<Utc>,
force_generate: bool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<(Self, DateTime<Utc>)> {
) -> crate::Result<(Self, DateTime<Utc>, bool)> {
let pair = Self::get(exec).await?;
if let Some(mut pair) = pair {
if pair.token.not_after > current_date {
Ok((pair, current_date))
if pair.token.not_after > Utc::now() && !force_generate {
Ok((pair, current_date, false))
} else {
let res = device_token(&pair.key, current_date).await?;
pair.token = res.value;
pair.upsert(exec).await?;
Ok((pair, res.date))
Ok((pair, res.date, true))
}
} else {
let key = generate_key()?;
@@ -623,7 +660,7 @@ impl DeviceTokenPair {
pair.upsert(exec).await?;
Ok((pair, res.date))
Ok((pair, res.date, true))
}
}
@@ -721,8 +758,8 @@ impl DeviceTokenPair {
}
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
struct RequestWithDate<T> {
pub date: DateTime<Utc>,
@@ -801,7 +838,7 @@ async fn sisu_authenticate(
"AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": token,
"Offers": [
REQUESTED_SCOPE
REQUESTED_SCOPES
],
"Query": {
"code_challenge": challenge,
@@ -809,7 +846,7 @@ async fn sisu_authenticate(
"state": generate_oauth_challenge(),
"prompt": "select_account"
},
"RedirectUri": AUTH_REPLY_URL,
"RedirectUri": REDIRECT_URL,
"Sandbox": "RETAIL",
"TokenType": "code",
"TitleId": "1794566092",
@@ -853,12 +890,12 @@ async fn oauth_token(
verifier: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new();
query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("client_id", "00000000402b5328");
query.insert("code", code);
query.insert("code_verifier", verifier);
query.insert("grant_type", "authorization_code");
query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", REQUESTED_SCOPE);
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -902,11 +939,11 @@ async fn oauth_refresh(
refresh_token: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new();
query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("client_id", "00000000402b5328");
query.insert("refresh_token", refresh_token);
query.insert("grant_type", "refresh_token");
query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", REQUESTED_SCOPE);
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -970,7 +1007,7 @@ async fn sisu_authorize(
"/authorize",
json!({
"AccessToken": format!("t={access_token}"),
"AppId": MICROSOFT_CLIENT_ID,
"AppId": "00000000402b5328",
"DeviceToken": device_token,
"ProofKey": {
"kty": "EC",

View File

@@ -1,3 +1,4 @@
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -30,7 +31,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "session/refresh"),
&format!("{MODRINTH_API_URL}session/refresh"),
None,
None,
Some(("Authorization", &*creds.session)),
@@ -189,8 +190,8 @@ impl ModrinthCredentials {
}
}
pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in")
pub fn get_login_url() -> String {
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
}
pub async fn finish_login_flow(
@@ -198,12 +199,6 @@ pub async fn finish_login_flow(
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentials> {
// The authorization code actually is the access token, since Labrinth doesn't
// issue separate authorization codes. Therefore, this is equivalent to an
// implicit OAuth grant flow, and no additional exchanging or finalization is
// needed. TODO not do this for the reasons outlined at
// https://oauth.net/2/grant-types/implicit/
let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentials {
@@ -221,7 +216,7 @@ async fn fetch_info(
) -> crate::Result<crate::state::cache::User> {
let result = fetch_advanced(
Method::GET,
concat!(env!("MODRINTH_API_URL"), "user"),
&format!("{MODRINTH_API_URL}user"),
None,
None,
Some(("Authorization", token)),

View File

@@ -1,5 +1,6 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
@@ -83,8 +84,8 @@ pub async fn fetch_advanced(
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
|| url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
|| url.starts_with(MODRINTH_API_URL)
|| url.starts_with(MODRINTH_API_URL_V3))
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {

View File

@@ -0,0 +1 @@
**License id:** %PROJECT_LICENSE_ID% \

View File

@@ -1,2 +1 @@
**License id:** %PROJECT_LICENSE_ID% \
**License Link:** %PROJECT_LICENSE_URL%

View File

@@ -1 +1 @@
> **{PLATFORM}:** {URL}<br />
> **{PLATFORM}:** {URL}

View File

@@ -1,2 +1 @@
<br />
<u>**Donation Links:**</u><br />
<u>**Donation Links:**</u>

View File

@@ -1,2 +0,0 @@
**Client:** `%PROJECT_CLIENT_SIDE%` \
**Server:** `%PROJECT_SERVER_SIDE%`

View File

@@ -1,5 +1,5 @@
## No English Description
Per section 2.2 of %RULES% a project's [Summary](%PROJECT_SETTINGS_LINK%) and %PROJECT_DESCRIPTION_FLINK% must be in English, unless meant exclusively for non-English use, such as translations.
Per section 2.2 of %RULES% a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your project page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator).
You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your Description page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator).

View File

@@ -1,6 +0,0 @@
## Identity Verification
**Welcome to Modrinth!** We're happy to see you here, we just want to make sure you're you.
Since this project already exists on the internet we ask that you provide evidence you are the rightful owner of this content.
For instance, by submitting a screenshot accessing the settings of the project's existing pages such as %PLATFORM%.

View File

@@ -1,5 +1,3 @@
## Misuse of custom URL
## Misuse of Slug
We ask that you ensure your project's %PROJECT_SLUG_FLINK% accurately represents your project.
Your current slug of `%PROJECT_SLUG%` may not accurately match your project's Name or contain excess information.
A mismatched URL may make it more difficult for users to find your content. Abbreviations or similar are fine to use if applicable. If your preferred URL is not available, and you cannot find a matching public project, let us know in this moderation thread when you resubmit your project, and our moderation team may be able to free it up for your project.
Per section 5.2 of %RULES% must accurately represent your project.

View File

@@ -3,6 +3,6 @@
## Private Use
Under normal circumstances, your project would be rejected due to the issues listed above.
However, since your project is not intended for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
However, since your project is not intended for for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
If you're okay with this, or submitted your project to be unlisted already, than no further action is necessary.
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.

View File

@@ -1,5 +0,0 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We also ask that you provide the source for any included binary files, as well as detailed build instructions allowing us to verify that the compiled code you are distributing matches the provided source.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -1,4 +0,0 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project and the process you used to obfuscate it before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -1,7 +0,0 @@
## Unsupported Project
Unfortunately, Modrinth does not currently support the upload of %INVALID_TYPE%.
If you would like to publish this project in the future and help Modrinth grow, consider creating an [issue](https://github.com/modrinth/code/issues) suggesting support for this type of content.
We appreciate your understanding and look forward to hosting your other creations.

View File

@@ -31,9 +31,7 @@ const categories: Stage = {
weight: 701,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) =>
project.categories.includes('optimization') ||
project.additional_categories.includes('optimization'),
shouldShow: (project) => project.categories.includes('optimization'),
message: async () =>
(await import('../messages/categories/inaccurate.md?raw')).default +
(await import('../messages/categories/optimization_misused.md?raw')).default,

View File

@@ -25,7 +25,6 @@ const gallery: Stage = {
weight: 901,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.gallery && project.gallery.length > 0,
message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default,
} as ButtonAction,
],

View File

@@ -20,7 +20,14 @@ const licensesNotRequiringSource: string[] = [
const licenseStage: Stage = {
title: 'Is this license and link valid?',
text: async () => (await import('../messages/checklist-text/licensing.md?raw')).default,
text: async (project) => {
let text = ''
text += (await import('../messages/checklist-text/license/id.md?raw')).default
if (project.license.url)
text += (await import('../messages/checklist-text/license/link.md?raw')).default
return text
},
id: 'license',
icon: BookTextIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
@@ -48,24 +55,33 @@ const licenseStage: Stage = {
},
],
},
// {
// id: 'license_no_source',
// type: 'conditional-button',
// label: 'No Source',
// fallbackWeight: 602,
// suggestedStatus: 'rejected',
// severity: 'medium',
// fallbackMessage: async () => (await import('../messages/license/no_source.md?raw')).default,
// messageVariants: [
// {
// conditions: {
// requiredActions: ['reupload_unclear_fork'],
// },
// weight: 602,
// message: async () => (await import('../messages/license/no_source-fork.md?raw')).default,
// },
// ],
// },
{
id: 'license_no_source',
type: 'conditional-button',
type: 'button',
label: 'No Source',
weight: 602,
suggestedStatus: 'rejected',
severity: 'medium',
shouldShow: (project) => !licensesNotRequiringSource.includes(project.license.id),
messageVariants: [
{
conditions: {
excludedActions: ['license_no_source-fork'],
},
weight: 602,
message: async () => (await import('../messages/license/no_source.md?raw')).default,
},
],
fallbackWeight: 602,
fallbackMessage: async () => '',
message: async () => (await import('../messages/license/no_source.md?raw')).default,
enablesActions: [
{
id: 'license_no_source-fork',

View File

@@ -61,8 +61,7 @@ const links: Stage = {
{
id: 'links_unaccessible_options',
type: 'multi-select-chips',
label: 'Warn of inaccessible link?',
shouldShow: (project) => Boolean(project.source_url || project.discord_url),
label: 'Warn of unaccessible link?',
options: [
{
label: 'Source',

View File

@@ -20,7 +20,6 @@ const reupload: Stage = {
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
relevantExtraInput: [
{
@@ -45,12 +44,11 @@ const reupload: Stage = {
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/fork.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
// disablesActions: [
// 'reupload_reupload',
// 'reupload_insufficient_fork',
// 'reupload_request_proof',
// ],
} as ButtonAction,
{
id: 'reupload_insufficient_fork',
@@ -60,12 +58,7 @@ const reupload: Stage = {
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/insufficient_fork.md?raw')).default,
disablesActions: [
'reupload_unclear_fork',
'reupload_reupload',
'reupload_request_proof',
'reupload_identity_verification',
],
// disablesActions: ['reupload_unclear_fork', 'reupload_reupload', 'reupload_request_proof'],
} as ButtonAction,
{
id: 'reupload_request_proof',
@@ -76,34 +69,7 @@ const reupload: Stage = {
severity: 'high',
message: async () =>
(await import('../messages/reupload/proof_of_permissions.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_identity_verification',
],
},
{
id: 'reupload_identity_verification',
type: 'button',
label: 'Verify Identity',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/identity_verification.md?raw')).default,
relevantExtraInput: [
{
label: 'Where else can the project be found?',
variable: 'PLATFORM',
required: true,
},
],
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
],
// disablesActions: ['reupload_reupload', 'reupload_unclear_fork', 'reupload_insufficient_fork'],
},
],
}

View File

@@ -7,8 +7,7 @@ const sideTypes: Stage = {
id: 'environment',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings',
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
navigate: '/settings#side-types',
actions: [
{
id: 'side_types_inaccurate_modpack',

View File

@@ -1,5 +1,5 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import type { ButtonAction } from '../../types/actions'
import { TriangleAlertIcon } from '@modrinth/assets'
const statusAlerts: Stage = {
@@ -39,41 +39,6 @@ const statusAlerts: Stage = {
message: async () =>
(await import('../messages/status-alerts/account_issues.md?raw')).default,
} as ButtonAction,
{
id: 'status_tec_source_request',
type: 'button',
label: `Request Source`,
suggestedStatus: 'rejected',
severity: 'critical',
disablesActions: ['status_corrections_applied', 'status_private_use'],
shouldShow: (project) =>
project.project_type === 'mod' ||
project.project_type === 'shader' ||
project.project_type.toString() === 'plugin',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'status_tec_source_request_options',
type: 'dropdown',
label: 'Why are you requesting source?',
options: [
{
label: 'Obfuscated',
weight: 999999,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-obfs.md?raw')).default,
} as DropdownActionOption,
{
label: 'Binaries',
weight: 999000,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-bins.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'status_automod_confusion',
type: 'button',

View File

@@ -25,8 +25,6 @@ const versions: Stage = {
label: 'Incorrect Project Type',
suggestedStatus: 'rejected',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
@@ -64,8 +62,6 @@ const versions: Stage = {
label: 'Alternate Versions',
suggestedStatus: 'flagged',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
@@ -114,7 +110,7 @@ const versions: Stage = {
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === 'modpack',
shouldShow: (project) => project.project_type === `modpack`,
message: async () =>
(await import('../messages/versions/alternate_versions-zip.md?raw')).default,
} as DropdownActionOption,
@@ -150,24 +146,7 @@ const versions: Stage = {
severity: `medium`,
weight: 1004,
message: async () => (await import('../messages/versions/broken_version.md?raw')).default,
} as ButtonAction,
{
id: 'unsupported_project_type',
type: 'button',
label: `Unsupported`,
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1005,
message: async () =>
(await import('../messages/versions/unsupported_project.md?raw')).default,
relevantExtraInput: [
{
label: 'Project Type',
required: true,
variable: 'INVALID_TYPE',
},
],
} as ButtonAction,
},
],
}

View File

@@ -3,7 +3,6 @@ export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
export * from './utils'
export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'

View File

@@ -317,11 +317,6 @@ export function flattenProjectVariables(project: Project): Record<string, string
vars[`PROJECT_PERMANENT_LINK`] = `https://modrinth.com/project/${project.id}`
vars[`PROJECT_SETTINGS_LINK`] = `https://modrinth.com/project/${project.id}/settings`
vars[`PROJECT_SETTINGS_FLINK`] = `[Settings](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TITLE_FLINK`] = `[Name](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SLUG_FLINK`] = `[URL](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SUMMARY_FLINK`] = `[Summary](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_ENVIRONMENT_FLINK`] =
`[Environment Information](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TAGS_LINK`] = `https://modrinth.com/project/${project.id}/settings/tags`
vars[`PROJECT_TAGS_FLINK`] = `[Tags](https://modrinth.com/project/${project.id}/settings/tags)`
vars[`PROJECT_DESCRIPTION_LINK`] =

View File

@@ -10,13 +10,6 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-07-19T15:20:00-07:00`,
product: 'web',
body: `### Improvements
- Removed Tumblr icon from footer as we no longer use it.
- Reverted changes to publishing checklist since they need more work.`,
},
{
date: `2025-07-16T12:40:00-07:00`,
product: 'web',

View File

@@ -315,7 +315,7 @@ export interface ModerationPermissionType {
export interface ModerationBaseModpackItem {
sha1: string
file_name: string
type: 'unknown' | 'flame' | 'identified'
type: 'unknown' | 'flame'
status: ModerationModpackPermissionApprovalType['id'] | null
approved: ModerationPermissionType['id'] | null
}
@@ -334,26 +334,9 @@ export interface ModerationFlameModpackItem extends ModerationBaseModpackItem {
url: string
}
export interface ModerationIdentifiedModpackItem extends ModerationBaseModpackItem {
type: 'identified'
proof?: string
url?: string
title?: string
}
export type ModerationModpackItem =
| ModerationUnknownModpackItem
| ModerationFlameModpackItem
| ModerationIdentifiedModpackItem
export type ModerationModpackItem = ModerationUnknownModpackItem | ModerationFlameModpackItem
export interface ModerationModpackResponse {
identified?: Record<
string,
{
file_name: string
status: ModerationModpackPermissionApprovalType['id']
}
>
unknown_files?: Record<string, string>
flame_files?: Record<
string,
@@ -367,8 +350,8 @@ export interface ModerationModpackResponse {
}
export interface ModerationJudgement {
type: 'flame' | 'unknown' | 'identified'
status: string | null
type: 'flame' | 'unknown'
status: string
id?: string
link?: string
title?: string