11 Commits

Author SHA1 Message Date
f90998157d Merge branch 'beta' into release 2025-07-24 16:39:31 +03:00
634000cdb6 Merge commit '15892a88d345f7ff67e2e46e298560afb635ac23' into beta 2025-07-24 16:38:58 +03:00
tomasalias
5fd8c38c1c Implement Curseforge profile codes 2025-07-24 03:41:41 +02:00
IMB11
15892a88d3 fix: handle identified files properly in the checklist (#4004)
* fix: handle identified files from the backend

* fix: allFiles not being emitted after permissions flow completed

* fix: properly handle identified projects

* fix: jade issues

* fix: import

* fix: issue with perm gen msgs

* fix: incomplete error
2025-07-23 08:34:55 +00:00
Alejandro González
32793c50e1 feat(app): better external browser Modrinth login flow (#4033)
* fix(app-frontend): do not emit exceptions when no loaders are available

* refactor(app): simplify Microsoft login code without functional changes

* feat(app): external browser auth flow for Modrinth account login

* chore: address Clippy lint

* chore(app/oauth_utils): simplify `handle_reply` error handling according to review

* chore(app-lib): simplify `Url` usage out of MC auth module
2025-07-22 22:55:18 +00:00
Alejandro González
0e0ca1971a chore(ci): switch back to upstream cache-cargo-install-action (#4047) 2025-07-22 22:43:04 +00:00
Alejandro González
bb9af18eed perf(docker): cache image builds through cache mounts and GHA cache (#4020)
* perf(docker): cache image builds through cache mounts and GHA cache

* tweak(ci/docker): switch to inline registry cache
2025-07-22 22:31:56 +00:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
7716a0c524 Merge pull request 'beta' (#7) from beta into release
Reviewed-on: didirus/AstralRinth#7
2025-07-15 00:47:12 +03:00
46 changed files with 1804 additions and 271 deletions

View File

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

3
Cargo.lock generated
View File

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

View File

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

View File

@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
// import { check } from '@tauri-apps/plugin-updater' // import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' // import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' // import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
@@ -283,6 +284,8 @@ const incompatibilityWarningModal = ref()
const credentials = ref() const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() { async function fetchCredentials() {
const creds = await getCreds().catch(handleError) const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) { if (creds && creds.user_id) {
@@ -292,8 +295,24 @@ async function fetchCredentials() {
} }
async function signIn() { async function signIn() {
await login().catch(handleError) modrinthLoginFlowWaitModal.value.show()
await fetchCredentials()
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()
}
} }
async function logOut() { async function logOut() {
@@ -422,6 +441,9 @@ function handleAuxClick(e) {
<Suspense> <Suspense>
<AppSettingsModal ref="settingsModal" /> <AppSettingsModal ref="settingsModal" />
</Suspense> </Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense> <Suspense>
<InstanceCreationModal ref="installationModal" /> <InstanceCreationModal ref="installationModal" />
</Suspense> </Suspense>

View File

@@ -0,0 +1,401 @@
<template>
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
<div class="modal-body">
<div class="input-row">
<p class="input-label">Profile Code</p>
<div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
ref="codeInput"
v-model="profileCode"
autocomplete="off"
class="h-12 card-shadow"
spellcheck="false"
type="text"
placeholder="Enter CurseForge profile code"
maxlength="20"
@keyup.enter="importProfile"
/>
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
<XIcon />
</Button>
</div>
</div>
<div v-if="metadata && !importing" class="profile-info">
<h3>Profile Information</h3>
<p><strong>Name:</strong> {{ metadata.name }}</p>
</div>
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div v-if="importing && importProgress.visible" class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ importProgress.message }}</span>
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{ width: `${importProgress.percentage}%` }"
></div>
</div>
</div>
<div class="button-row">
<Button @click="hide" :disabled="importing">
<XIcon />
Cancel
</Button>
<Button
v-if="!metadata"
@click="fetchMetadata"
:disabled="!profileCode.trim() || fetching"
color="secondary"
>
<SearchIcon v-if="!fetching" />
{{ fetching ? 'Checking...' : 'Check Profile' }}
</Button>
<Button
v-if="metadata"
@click="importProfile"
:disabled="importing"
color="primary"
>
<DownloadIcon v-if="!importing" />
{{ importing ? 'Importing...' : 'Import Profile' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { Button } from '@modrinth/ui'
import {
XIcon,
SearchIcon,
DownloadIcon
} from '@modrinth/assets'
import {
fetch_curseforge_profile_metadata,
import_curseforge_profile
} from '@/helpers/import.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener } from '@/helpers/events.js'
const props = defineProps({
closeParent: {
type: Function,
default: null
}
})
const router = useRouter()
const modal = ref(null)
const codeInput = ref(null)
const profileCode = ref('')
const metadata = ref(null)
const fetching = ref(false)
const importing = ref(false)
const error = ref('')
const importProgress = ref({
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
})
let unlistenLoading = null
let activeLoadingBarId = null
let progressFallbackTimer = null
defineExpose({
show: () => {
profileCode.value = ''
metadata.value = null
fetching.value = false
importing.value = false
error.value = ''
importProgress.value = {
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
modal.value?.show()
nextTick(() => {
setTimeout(() => {
codeInput.value?.focus()
}, 100)
})
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
},
})
const hide = () => {
modal.value?.hide()
}
const fetchMetadata = async () => {
if (!profileCode.value.trim()) return
fetching.value = true
error.value = ''
try {
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
metadata.value = result
trackEvent('CurseForgeProfileMetadataFetched', {
profileCode: profileCode.value.trim()
})
} catch (err) {
console.error('Failed to fetch CurseForge profile metadata:', err)
error.value = 'Failed to fetch profile information. Please check the code and try again.'
handleError(err)
} finally {
fetching.value = false
}
}
const importProfile = async () => {
if (!profileCode.value.trim()) return
importing.value = true
error.value = ''
activeLoadingBarId = null // Reset for new import session
importProgress.value = {
visible: true,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
// Fallback progress timer in case loading events don't work
progressFallbackTimer = setInterval(() => {
if (importing.value && importProgress.value.percentage < 90) {
// Slowly increment progress as a fallback
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
}
}, 1000)
try {
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
trackEvent('CurseForgeProfileImported', {
profileCode: profileCode.value.trim()
})
hide()
// Close the parent modal if provided
if (props.closeParent) {
props.closeParent()
}
// Navigate to the imported profile
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
} catch (err) {
console.error('Failed to import CurseForge profile:', err)
error.value = 'Failed to import profile. Please try again.'
handleError(err)
} finally {
importing.value = false
importProgress.value.visible = false
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
progressFallbackTimer = null
}
activeLoadingBarId = null
}
}
onMounted(async () => {
// Listen for loading events to update progress
unlistenLoading = await loading_listener((event) => {
console.log('Loading event received:', event) // Debug log
// Handle all loading events that could be related to CurseForge profile import
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
const hasProfileName = event.event?.profile_name && importing.value
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
// Store the loading bar ID for this import session
if (!activeLoadingBarId) {
activeLoadingBarId = event.loader_uuid
}
// Only process events for our current import session
if (event.loader_uuid === activeLoadingBarId) {
if (event.fraction !== null && event.fraction !== undefined) {
const baseProgress = (event.fraction || 0) * 100
// Calculate custom progress based on the message
let finalProgress = baseProgress
const message = event.message || 'Importing profile...'
// Custom progress calculation for different stages
if (message.includes('Fetching') || message.includes('metadata')) {
finalProgress = Math.min(10, baseProgress)
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
} else if (message.includes('Extracting') || message.includes('ZIP')) {
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
} else if (message.includes('Configuring') || message.includes('profile')) {
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
} else if (message.includes('Copying') || message.includes('files')) {
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
// Parse "Downloaded mod X of Y" message
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
if (match) {
const current = parseInt(match[1])
const total = parseInt(match[2])
// Mods take 40% of progress (from 40% to 80%)
const modProgress = (current / total) * 40
finalProgress = 40 + modProgress
} else {
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
}
} else if (message.includes('Downloading mod') || message.includes('mods')) {
// General mod downloading stage (40% to 80%)
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
} else if (message.includes('Finalizing') || message.includes('completed')) {
finalProgress = Math.min(100, 95 + (baseProgress - 95))
} else {
// Default: use the base progress but ensure minimum progression
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
}
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
importProgress.value.message = message
} else {
// Loading complete
importProgress.value.percentage = 100
importProgress.value.message = 'Import completed!'
activeLoadingBarId = null
}
}
}
})
})
onUnmounted(() => {
if (unlistenLoading) {
unlistenLoading()
}
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
}
})
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.input-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-label {
font-weight: 600;
color: var(--color-contrast);
margin: 0;
}
.profile-info {
background: var(--color-bg);
border: 1px solid var(--color-button);
border-radius: var(--radius-md);
padding: 1rem;
h3 {
margin: 0 0 0.5rem 0;
color: var(--color-contrast);
}
p {
margin: 0.25rem 0;
color: var(--color-base);
}
}
.error-message {
background: var(--color-red);
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
padding: 0.75rem;
p {
margin: 0;
color: var(--color-contrast);
font-weight: 500;
}
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
color: var(--color-base);
font-size: 0.875rem;
}
.progress-percentage {
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
}
}
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--color-button);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--color-brand);
border-radius: 2px;
transition: width 0.3s ease;
min-width: 0;
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: auto;
}
</style>

View File

@@ -163,6 +163,14 @@
<div v-else class="table-content empty">No profiles found</div> <div v-else class="table-content empty">No profiles found</div>
</div> </div>
<div class="button-row"> <div class="button-row">
<Button
v-if="selectedProfileType.name === 'Curseforge'"
@click="showCurseForgeProfileModal"
:disabled="loading"
>
<CodeIcon />
Import from Profile Code
</Button>
<Button <Button
:disabled=" :disabled="
loading || loading ||
@@ -194,10 +202,12 @@
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
</template> </template>
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
import { import {
CodeIcon, CodeIcon,
FolderOpenIcon, FolderOpenIcon,
@@ -283,6 +293,11 @@ const hide = () => {
unlistener.value = null unlistener.value = null
} }
} }
const showCurseForgeProfileModal = () => {
curseforgeProfileModal.value?.show()
}
onUnmounted(() => { onUnmounted(() => {
if (unlistener.value) { if (unlistener.value) {
unlistener.value() unlistener.value()
@@ -305,12 +320,16 @@ const [
get_game_versions().then(shallowRef).catch(handleError), get_game_versions().then(shallowRef).catch(handleError),
get_loaders() get_loaders()
.then((value) => .then((value) =>
value ref(
.filter((item) => item.supported_project_types.includes('modpack')) value
.map((item) => item.name.toLowerCase()), .filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
) )
.then(ref) .catch((err) => {
.catch(handleError), handleError(err)
return ref([])
}),
]) ])
loaders.value.unshift('vanilla') loaders.value.unshift('vanilla')
@@ -334,6 +353,7 @@ const game_versions = computed(() => {
}) })
const modal = ref(null) const modal = ref(null)
const curseforgeProfileModal = ref(null)
const check_valid = computed(() => { const check_valid = computed(() => {
return ( return (

View File

@@ -0,0 +1,42 @@
<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

@@ -61,3 +61,31 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
export async function get_default_launcher_path(launcherType) { export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|get_default_launcher_path', { launcherType }) return await invoke('plugin:import|get_default_launcher_path', { launcherType })
} }
/// Fetch CurseForge profile metadata from profile code
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
export async function fetch_curseforge_profile_metadata(profileCode) {
return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
}
/// Import a CurseForge profile from profile code
/// eg: import_curseforge_profile("eSrNlKNo")
export async function import_curseforge_profile(profileCode) {
try {
// First, fetch the profile metadata to get the actual name
const metadata = await fetch_curseforge_profile_metadata(profileCode)
// create a basic, empty instance using the actual profile name
const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
const result = await invoke('plugin:import|import_curseforge_profile', {
profilePath,
profileCode,
})
// Return the profile path for navigation
return { result, profilePath }
} catch (error) {
throw error
}
}

View File

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

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."); println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?; let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str()); println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Please enter URL code: "); println!("Please enter URL code: ");
let mut input = String::new(); let mut input = String::new();

View File

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

View File

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

View File

@@ -22,14 +22,13 @@
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [ "allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" }
]
}, },
"auth:default", "auth:default",
"import:default", "import:default",
"import:allow-fetch-curseforge-profile-metadata",
"import:allow-import-curseforge-profile",
"jre:default", "jre:default",
"logs:default", "logs:default",
"metadata:default", "metadata:default",

View File

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

View File

@@ -2,6 +2,11 @@ use std::path::PathBuf;
use crate::api::Result; use crate::api::Result;
use theseus::pack::import::ImportLauncherType; use theseus::pack::import::ImportLauncherType;
use theseus::pack::import::curseforge_profile::{
CurseForgeProfileMetadata,
fetch_curseforge_profile_metadata as fetch_cf_metadata,
import_curseforge_profile as import_cf_profile,
};
use theseus::pack::import; use theseus::pack::import;
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
import_instance, import_instance,
is_valid_importable_instance, is_valid_importable_instance,
get_default_launcher_path, get_default_launcher_path,
fetch_curseforge_profile_metadata,
import_curseforge_profile,
]) ])
.build() .build()
} }
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
) -> Result<Option<PathBuf>> { ) -> Result<Option<PathBuf>> {
Ok(import::get_default_launcher_path(launcher_type)) Ok(import::get_default_launcher_path(launcher_type))
} }
/// Fetch CurseForge profile metadata from profile code
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
#[tauri::command]
pub async fn fetch_curseforge_profile_metadata(
profile_code: String,
) -> Result<CurseForgeProfileMetadata> {
Ok(fetch_cf_metadata(&profile_code).await?)
}
/// Import a CurseForge profile from profile code
/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
/// eg: import_curseforge_profile("profile-path", "eSrNlKNo")
#[tauri::command]
pub async fn import_curseforge_profile(
profile_path: String,
profile_code: String,
) -> Result<()> {
import_cf_profile(&profile_code, &profile_path).await?;
Ok(())
}

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
//! 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

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
<div v-if="!modPackData">Loading data...</div> <div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0"> <div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p> <p>All permissions already obtained.</p>
</div> </div>
<div v-else-if="!modPackData[currentIndex]"> <div v-else-if="!modPackData[currentIndex]">
@@ -157,7 +157,7 @@ import type {
} from "@modrinth/utils"; } from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue"; import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{ const props = defineProps<{
projectId: string; projectId: string;
@@ -182,7 +182,26 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0); const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = ref<ModerationModpackItem[] | null>(null); 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 currentIndex = ref(0); const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [ const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
@@ -251,7 +270,45 @@ async function fetchModPackData(): Promise<void> {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, { const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true, internal: true,
})) as ModerationModpackResponse; })) 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[] = [ 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 || {}) ...Object.entries(data.unknown_files || {})
.map( .map(
([sha1, fileName]): ModerationUnknownModpackItem => ({ ([sha1, fileName]): ModerationUnknownModpackItem => ({
@@ -310,6 +367,7 @@ async function fetchModPackData(): Promise<void> {
} catch (error) { } catch (error) {
console.error("Failed to fetch modpack data:", error); console.error("Failed to fetch modpack data:", error);
modPackData.value = []; modPackData.value = [];
permanentNoFiles.value = [];
persistAll(); persistAll();
} }
} }
@@ -321,6 +379,14 @@ function goToPrevious(): void {
} }
} }
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void { function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) { if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++; currentIndex.value++;
@@ -396,6 +462,17 @@ onMounted(() => {
} }
}); });
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch( watch(
() => props.projectId, () => props.projectId,
() => { () => {
@@ -406,6 +483,20 @@ watch(
} }
}, },
); );
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -240,24 +240,6 @@
</div> </div>
<div v-else-if="generatedMessage" class="flex items-center gap-2"> <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> <ButtonStyled>
<button @click="goBackToStages"> <button @click="goBackToStages">
<LeftArrowIcon aria-hidden="true" /> <LeftArrowIcon aria-hidden="true" />
@@ -368,21 +350,26 @@ import {
DropdownSelect, DropdownSelect,
MarkdownEditor, MarkdownEditor,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils"; import {
type Project,
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core"; import { computedAsync, useLocalStorage } from "@vueuse/core";
import type { import {
Action, type Action,
MultiSelectChipsAction, type MultiSelectChipsAction,
DropdownAction, type DropdownAction,
ButtonAction, type ButtonAction,
ToggleAction, type ToggleAction,
ConditionalButtonAction, type ConditionalButtonAction,
Stage, type Stage,
finalPermissionMessages,
} from "@modrinth/moderation"; } from "@modrinth/moderation";
import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue"; import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.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>>(); const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
@@ -419,7 +406,6 @@ const done = ref(false);
function handleModpackPermissionsComplete() { function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true; modpackPermissionsComplete.value = true;
nextStage();
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -823,6 +809,31 @@ 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() { async function assembleFullMessage() {
const messageParts: MessagePart[] = []; const messageParts: MessagePart[] = [];
@@ -1092,13 +1103,14 @@ async function generateMessage() {
const baseMessage = await assembleFullMessage(); const baseMessage = await assembleFullMessage();
let fullMessage = baseMessage; let fullMessage = baseMessage;
if ( if (props.project.project_type === "modpack") {
props.project.project_type === "modpack" && const modpackFilesData = getModpackFilesFromStorage();
Object.keys(modpackJudgements.value).length > 0
) { if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
const modpackMessage = generateModpackMessage(modpackJudgements.value); const modpackMessage = generateModpackMessage(modpackFilesData);
if (modpackMessage) { if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage; fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
}
} }
} }
@@ -1129,25 +1141,32 @@ async function generateMessage() {
} }
} }
function generateModpackMessage(judgements: ModerationJudgements) { function generateModpackMessage(allFiles: {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
}) {
const issues = []; const issues = [];
const attributeMods = []; const attributeMods: string[] = [];
const noMods = []; const noMods: string[] = [];
const permanentNoMods = []; const permanentNoMods: string[] = [];
const unidentifiedMods = []; const unidentifiedMods: string[] = [];
for (const [, judgement] of Object.entries(judgements)) { allFiles.interactive.forEach((file) => {
if (judgement.status === "with-attribution") { if (file.status === "unidentified") {
attributeMods.push(judgement.file_name); if (file.approved === "no") {
} else if (judgement.status === "no") { unidentifiedMods.push(file.file_name);
noMods.push(judgement.file_name); }
} else if (judgement.status === "permanent-no") { } else if (file.status === "with-attribution" && file.approved === "no") {
permanentNoMods.push(judgement.file_name); attributeMods.push(file.file_name);
} else if (judgement.status === "unidentified") { } else if (file.status === "no" && file.approved === "no") {
unidentifiedMods.push(judgement.file_name); noMods.push(file.file_name);
} }
} });
allFiles.permanentNo.forEach((file) => {
permanentNoMods.push(file.file_name);
});
if ( if (
attributeMods.length > 0 || attributeMods.length > 0 ||
@@ -1157,6 +1176,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
) { ) {
issues.push("## Copyrighted content"); issues.push("## Copyrighted content");
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (attributeMods.length > 0) { if (attributeMods.length > 0) {
issues.push( issues.push(
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`, `${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
@@ -1172,12 +1197,6 @@ function generateModpackMessage(judgements: ModerationJudgements) {
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`, `${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"); return issues.join("\n\n");

View File

@@ -150,9 +150,26 @@
</template> </template>
</span> </span>
<span class="text-sm text-secondary"> <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") }} {{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span> <span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</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"> <div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }} {{ charge.status }}

View File

@@ -1,6 +1,12 @@
<template> <template>
<div> <div v-if="subtleLauncherRedirectUri">
<template v-if="flow"> <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">
<label for="two-factor-code"> <label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span> <span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description"> <span class="label__description">
@@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute(); const route = useNativeRoute();
const redirectTarget = route.query.redirect || ""; const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) { if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn(); await finishSignIn();
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) { async function finishSignIn(token) {
if (route.query.launcher) { if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true }); 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,
});
}
return; return;
} }

View File

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

View File

@@ -1,8 +1,21 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build FROM rust:1.88.0 AS build
WORKDIR /usr/src/labrinth WORKDIR /usr/src/labrinth
COPY . . COPY . .
RUN SQLX_OFFLINE=true cargo build --release --package labrinth 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
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -14,10 +27,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \ && apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth COPY --from=artifacts /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
WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"] CMD ["/labrinth/labrinth"]

View File

@@ -1,2 +1,10 @@
# SQLite database file location MODRINTH_URL=http://localhost:3000/
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db 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

View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,10 @@
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,6 +82,7 @@ ariadne.workspace = true
winreg.workspace = true winreg.workspace = true
[build-dependencies] [build-dependencies]
dotenvy.workspace = true
dunce.workspace = true dunce.workspace = true
[features] [features]

View File

@@ -4,12 +4,31 @@ use std::process::{Command, exit};
use std::{env, fs}; use std::{env, fs};
fn main() { fn main() {
println!("cargo::rerun-if-changed=.env");
println!("cargo::rerun-if-changed=java/gradle"); println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src"); println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts"); println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts"); println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties"); 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 = let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap())) dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap(); .unwrap();
@@ -37,6 +56,7 @@ fn main() {
.current_dir(dunce::canonicalize("java").unwrap()) .current_dir(dunce::canonicalize("java").unwrap())
.status() .status()
.expect("Failed to wait on Gradle build"); .expect("Failed to wait on Gradle build");
if !exit_status.success() { if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}"); println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1)); exit(exit_status.code().unwrap_or(1));

View File

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

View File

@@ -0,0 +1,604 @@
use std::io::Cursor;
use async_zip::base::read::seek::ZipFileReader;
use serde::{Deserialize, Serialize};
use crate::{
State,
event::{LoadingBarType, ProfilePayloadType},
prelude::ModLoader,
state::{LinkedData, ProfileInstallStage},
util::fetch::fetch,
};
use super::copy_dotminecraft;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManifest {
pub minecraft: CurseForgeMinecraft,
pub manifest_type: String,
pub manifest_version: i32,
pub name: String,
pub version: String,
pub author: String,
pub files: Vec<CurseForgeFile>,
pub overrides: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeMinecraft {
pub version: String,
pub mod_loaders: Vec<CurseForgeModLoader>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeModLoader {
pub id: String,
pub primary: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CurseForgeFile {
#[serde(rename = "projectID")]
pub project_id: u32,
#[serde(rename = "fileID")]
pub file_id: u32,
pub required: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CurseForgeProfileMetadata {
pub name: String,
pub download_url: String,
}
/// Fetch CurseForge profile metadata from profile code
pub async fn fetch_curseforge_profile_metadata(
profile_code: &str,
) -> crate::Result<CurseForgeProfileMetadata> {
let state = State::get().await?;
// Make initial request to get redirect URL
let url = format!(
"https://api.curseforge.com/v1/shared-profile/{}",
profile_code
);
// Try to fetch the profile - the CurseForge API should redirect to the ZIP file
let response = fetch(&url, None, &state.fetch_semaphore, &state.pool).await;
let download_url = match response {
Ok(_bytes) => {
// If we get bytes back, use the original URL
url
}
Err(e) => {
// If we get an error, it might contain redirect information
let error_msg = format!("{:?}", e);
if let Some(redirect_start) =
error_msg.find("https://shared-profile-media.forgecdn.net/")
{
let redirect_end = error_msg[redirect_start..]
.find(' ')
.unwrap_or(error_msg.len() - redirect_start);
error_msg[redirect_start..redirect_start + redirect_end]
.to_string()
} else {
return Err(crate::ErrorKind::InputError(format!(
"Failed to fetch CurseForge profile metadata: {}",
e
))
.into());
}
}
};
// Now fetch the ZIP file and extract the name from manifest.json
let zip_bytes =
fetch(&download_url, None, &state.fetch_semaphore, &state.pool).await?;
// Create a cursor for the ZIP data
let cursor = std::io::Cursor::new(zip_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read profile ZIP: {}",
e
))
})?;
// Find and extract manifest.json
let manifest_index = zip_reader
.file()
.entries()
.iter()
.position(|f| {
f.filename().as_str().unwrap_or_default() == "manifest.json"
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No manifest.json found in profile".to_string(),
)
})?;
let mut manifest_content = String::new();
let mut reader = zip_reader
.reader_with_entry(manifest_index)
.await
.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read manifest.json: {}",
e
))
})?;
reader.read_to_string_checked(&mut manifest_content).await?;
// Parse the manifest to get the actual name
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
let profile_name = if manifest.name.is_empty() {
format!("CurseForge Profile {}", profile_code)
} else {
manifest.name.clone()
};
Ok(CurseForgeProfileMetadata {
name: profile_name,
download_url,
})
}
/// Import a CurseForge profile from profile code
pub async fn import_curseforge_profile(
profile_code: &str,
profile_path: &str,
) -> crate::Result<()> {
let state = State::get().await?;
// Initialize loading bar
let loading_bar = crate::event::emit::init_loading(
LoadingBarType::CurseForgeProfileDownload {
profile_name: profile_path.to_string(),
},
100.0,
"Importing CurseForge profile...",
)
.await?;
// First, fetch the profile metadata to get the download URL
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Fetching profile metadata..."),
)?;
let metadata = fetch_curseforge_profile_metadata(profile_code).await?;
// Download the profile ZIP file
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Downloading profile ZIP..."),
)?;
let zip_bytes = fetch(
&metadata.download_url,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
// Create a cursor for the ZIP data
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Extracting ZIP contents..."),
)?;
let cursor = Cursor::new(zip_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read profile ZIP: {}",
e
))
})?;
// Find and extract manifest.json
let manifest_index = zip_reader
.file()
.entries()
.iter()
.position(|f| {
f.filename().as_str().unwrap_or_default() == "manifest.json"
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No manifest.json found in profile".to_string(),
)
})?;
let mut manifest_content = String::new();
let mut reader = zip_reader
.reader_with_entry(manifest_index)
.await
.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read manifest.json: {}",
e
))
})?;
reader.read_to_string_checked(&mut manifest_content).await?;
// Parse the manifest
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Parsing profile manifest..."),
)?;
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
// Determine modloader and version
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Configuring profile..."),
)?;
let (mod_loader, loader_version) = if let Some(primary_loader) =
manifest.minecraft.mod_loaders.iter().find(|l| l.primary)
{
parse_modloader(&primary_loader.id)
} else if let Some(first_loader) = manifest.minecraft.mod_loaders.first() {
parse_modloader(&first_loader.id)
} else {
(ModLoader::Vanilla, None)
};
let game_version = manifest.minecraft.version.clone();
// Get appropriate loader version if needed
let final_loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
// Set profile data
crate::api::profile::edit(profile_path, |prof| {
prof.name = if manifest.name.is_empty() {
format!("CurseForge Profile {}", profile_code)
} else {
manifest.name.clone()
};
prof.install_stage = ProfileInstallStage::PackInstalling;
prof.game_version = game_version.clone();
prof.loader_version = final_loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
// Set linked data for modpack management
prof.linked_data = Some(LinkedData {
project_id: String::new(),
version_id: String::new(),
locked: false,
});
async { Ok(()) }
})
.await?;
// Create a temporary directory to extract overrides
let temp_dir = state
.directories
.caches_dir()
.join(format!("curseforge_profile_{}", profile_code));
tokio::fs::create_dir_all(&temp_dir).await?;
// Extract overrides directory if it exists
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Extracting profile files..."),
)?;
let overrides_dir = temp_dir.join(&manifest.overrides);
tokio::fs::create_dir_all(&overrides_dir).await?;
// Extract all files that are in the overrides directory
// First collect the entries we need to extract to avoid borrowing conflicts
let entries_to_extract: Vec<(usize, String)> = {
let zip_file = zip_reader.file();
zip_file
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let file_path = entry.filename().as_str().unwrap_or_default();
if file_path.starts_with(&format!("{}/", manifest.overrides)) {
Some((index, file_path.to_string()))
} else {
None
}
})
.collect()
};
// Now extract each file
for (index, file_path) in entries_to_extract {
let relative_path = file_path
.strip_prefix(&format!("{}/", manifest.overrides))
.unwrap();
let output_path = overrides_dir.join(relative_path);
// Create parent directories
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Extract file
let mut reader =
zip_reader.reader_with_entry(index).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read file {}: {}",
file_path, e
))
})?;
let mut file_content = Vec::new();
reader.read_to_end_checked(&mut file_content).await?;
tokio::fs::write(&output_path, file_content).await?;
}
// Copy overrides to profile
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Copying profile files..."),
)?;
let _loading_bar = copy_dotminecraft(
profile_path,
overrides_dir,
&state.io_semaphore,
None,
)
.await?;
// Download and install mods from CurseForge
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Downloading mods..."),
)?;
install_curseforge_mods(
&manifest.files,
profile_path,
&state,
&loading_bar,
)
.await?;
// Clean up temporary directory
tokio::fs::remove_dir_all(&temp_dir).await.ok();
// Install Minecraft if needed
crate::event::emit::emit_loading(
&loading_bar,
20.0,
Some("Installing Minecraft..."),
)?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(_loading_bar),
false,
)
.await?;
}
// Mark the profile as fully installed
crate::event::emit::emit_loading(
&loading_bar,
20.0,
Some("Finalizing profile..."),
)?;
crate::api::profile::edit(profile_path, |prof| {
prof.install_stage = ProfileInstallStage::Installed;
async { Ok(()) }
})
.await?;
// Emit profile sync event to trigger file system watcher refresh
crate::event::emit::emit_profile(profile_path, ProfilePayloadType::Synced)
.await?;
// Complete the loading bar
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Import completed!"),
)?;
Ok(())
}
/// Parse CurseForge modloader ID into ModLoader and version
fn parse_modloader(id: &str) -> (ModLoader, Option<String>) {
if id.starts_with("forge-") {
let version = id.strip_prefix("forge-").unwrap_or("").to_string();
(ModLoader::Forge, Some(version))
} else if id.starts_with("fabric-") {
let version = id.strip_prefix("fabric-").unwrap_or("").to_string();
(ModLoader::Fabric, Some(version))
} else if id.starts_with("quilt-") {
let version = id.strip_prefix("quilt-").unwrap_or("").to_string();
(ModLoader::Quilt, Some(version))
} else if id.starts_with("neoforge-") {
let version = id.strip_prefix("neoforge-").unwrap_or("").to_string();
(ModLoader::NeoForge, Some(version))
} else {
(ModLoader::Vanilla, None)
}
}
/// Install mods from CurseForge files list
async fn install_curseforge_mods(
files: &[CurseForgeFile],
profile_path: &str,
state: &State,
loading_bar: &crate::event::LoadingBarId,
) -> crate::Result<()> {
if files.is_empty() {
return Ok(());
}
let num_files = files.len();
tracing::info!("Installing {} CurseForge mods", num_files);
// Download mods sequentially to track progress properly
for (index, file) in files.iter().enumerate() {
// Update progress message with current mod
let progress_message =
format!("Downloading mod {} of {}", index + 1, num_files);
crate::event::emit::emit_loading(
loading_bar,
0.0, // Don't increment here, just update message
Some(&progress_message),
)?;
download_curseforge_mod(file, profile_path, state).await?;
// Emit progress for each downloaded mod (20% total for mods, divided by number of mods)
let mod_progress = 20.0 / num_files as f64;
crate::event::emit::emit_loading(
loading_bar,
mod_progress,
Some(&format!("Downloaded mod {} of {}", index + 1, num_files)),
)?;
}
Ok(())
}
/// Download a single mod from CurseForge
async fn download_curseforge_mod(
file: &CurseForgeFile,
profile_path: &str,
_state: &State,
) -> crate::Result<()> {
// Log the download attempt
tracing::info!(
"Downloading CurseForge mod: project_id={}, file_id={}",
file.project_id,
file.file_id
);
// Get profile path and create mods directory first
let profile_full_path =
crate::api::profile::get_full_path(profile_path).await?;
let mods_dir = profile_full_path.join("mods");
tokio::fs::create_dir_all(&mods_dir).await?;
// First, get the file metadata to get the correct filename
let metadata_url = format!(
"https://www.curseforge.com/api/v1/mods/{}/files/{}",
file.project_id, file.file_id
);
tracing::info!("Fetching metadata from: {}", metadata_url);
let client = reqwest::Client::new();
let metadata_response =
client.get(&metadata_url).send().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to fetch metadata for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
if !metadata_response.status().is_success() {
return Err(crate::ErrorKind::InputError(format!(
"HTTP error fetching metadata for mod {}/{}: {}",
file.project_id,
file.file_id,
metadata_response.status()
))
.into());
}
// Parse the metadata JSON to get the filename
let metadata_json: serde_json::Value =
metadata_response.json().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to parse metadata JSON for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
let original_filename = metadata_json
.get("data")
.and_then(|data| data.get("fileName"))
.and_then(|name| name.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
// Fallback to the old format if API response is unexpected
format!("mod_{}_{}.jar", file.project_id, file.file_id)
});
tracing::info!("Original filename: {}", original_filename);
// Now download the mod using the direct download URL
let download_url = format!(
"https://www.curseforge.com/api/v1/mods/{}/files/{}/download",
file.project_id, file.file_id
);
tracing::info!("Downloading from: {}", download_url);
let response = client.get(&download_url).send().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to download mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
if !response.status().is_success() {
return Err(crate::ErrorKind::InputError(format!(
"HTTP error downloading mod {}/{}: {}",
file.project_id,
file.file_id,
response.status()
))
.into());
}
// Write the file with its original name
let final_path = mods_dir.join(&original_filename);
let bytes = response.bytes().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read response bytes for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
tokio::fs::write(&final_path, &bytes).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to write mod file {:?}: {}",
final_path, e
))
})?;
tracing::info!(
"Successfully downloaded mod: {} ({} bytes)",
original_filename,
bytes.len()
);
Ok(())
}

View File

@@ -19,6 +19,7 @@ use crate::{
pub mod atlauncher; pub mod atlauncher;
pub mod curseforge; pub mod curseforge;
pub mod curseforge_profile;
pub mod gdlauncher; pub mod gdlauncher;
pub mod mmc; pub mod mmc;

View File

@@ -1,13 +0,0 @@
//! 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

@@ -176,6 +176,9 @@ pub enum LoadingBarType {
import_location: PathBuf, import_location: PathBuf,
profile_name: String, profile_name: String,
}, },
CurseForgeProfileDownload {
profile_name: String,
},
CheckingForUpdates, CheckingForUpdates,
LauncherUpdate { LauncherUpdate {
version: String, version: String,

View File

@@ -11,7 +11,6 @@ and launching Modrinth mod packs
pub mod util; // [AR] Refactor pub mod util; // [AR] Refactor
mod api; mod api;
mod config;
mod error; mod error;
mod event; mod event;
mod launcher; mod launcher;

View File

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

View File

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

View File

@@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
pub verifier: String, pub verifier: String,
pub challenge: String, pub challenge: String,
pub session_id: String, pub session_id: String,
pub redirect_uri: String, pub auth_request_uri: String,
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn login_begin( pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<MinecraftLoginFlow> { ) -> crate::Result<MinecraftLoginFlow> {
let (pair, current_date, valid_date) = let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec) DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
.await?;
let verifier = generate_oauth_challenge(); let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new(); let result = sha2::Sha256::digest(&verifier);
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result); let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
match sisu_authenticate( match sisu_authenticate(
@@ -110,46 +107,15 @@ pub async fn login_begin(
) )
.await .await
{ {
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow { Ok((session_id, redirect_uri)) => {
verifier, return Ok(MinecraftLoginFlow {
challenge, verifier,
session_id, challenge,
redirect_uri: redirect_uri.value.msa_oauth_redirect, session_id,
}), auth_request_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()),
} }
} }
@@ -159,9 +125,8 @@ pub async fn login_finish(
flow: MinecraftLoginFlow, flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> { ) -> crate::Result<Credentials> {
let (pair, _, _) = let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec) DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
.await?;
let oauth_token = oauth_token(code, &flow.verifier).await?; let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize( let sisu_authorize = sisu_authorize(
@@ -351,10 +316,9 @@ impl Credentials {
} }
let oauth_token = oauth_refresh(&self.refresh_token).await?; let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date, _) = let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token( DeviceTokenPair::refresh_and_get_device_token(
oauth_token.date, oauth_token.date,
false,
exec, exec,
) )
.await?; .await?;
@@ -722,21 +686,20 @@ impl DeviceTokenPair {
#[tracing::instrument(skip(exec))] #[tracing::instrument(skip(exec))]
async fn refresh_and_get_device_token( async fn refresh_and_get_device_token(
current_date: DateTime<Utc>, current_date: DateTime<Utc>,
force_generate: bool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<(Self, DateTime<Utc>, bool)> { ) -> crate::Result<(Self, DateTime<Utc>)> {
let pair = Self::get(exec).await?; let pair = Self::get(exec).await?;
if let Some(mut pair) = pair { if let Some(mut pair) = pair {
if pair.token.not_after > Utc::now() && !force_generate { if pair.token.not_after > current_date {
Ok((pair, current_date, false)) Ok((pair, current_date))
} else { } else {
let res = device_token(&pair.key, current_date).await?; let res = device_token(&pair.key, current_date).await?;
pair.token = res.value; pair.token = res.value;
pair.upsert(exec).await?; pair.upsert(exec).await?;
Ok((pair, res.date, true)) Ok((pair, res.date))
} }
} else { } else {
let key = generate_key()?; let key = generate_key()?;
@@ -749,7 +712,7 @@ impl DeviceTokenPair {
pair.upsert(exec).await?; pair.upsert(exec).await?;
Ok((pair, res.date, true)) Ok((pair, res.date))
} }
} }
@@ -847,8 +810,8 @@ impl DeviceTokenPair {
} }
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328"; const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf"; const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL"; const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
/* [AR] Fix /* [AR] Fix
* Weird visibility issue that didn't reproduce before * Weird visibility issue that didn't reproduce before
@@ -931,7 +894,7 @@ async fn sisu_authenticate(
"AppId": MICROSOFT_CLIENT_ID, "AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": token, "DeviceToken": token,
"Offers": [ "Offers": [
REQUESTED_SCOPES REQUESTED_SCOPE
], ],
"Query": { "Query": {
"code_challenge": challenge, "code_challenge": challenge,
@@ -939,7 +902,7 @@ async fn sisu_authenticate(
"state": generate_oauth_challenge(), "state": generate_oauth_challenge(),
"prompt": "select_account" "prompt": "select_account"
}, },
"RedirectUri": REDIRECT_URL, "RedirectUri": AUTH_REPLY_URL,
"Sandbox": "RETAIL", "Sandbox": "RETAIL",
"TokenType": "code", "TokenType": "code",
"TitleId": "1794566092", "TitleId": "1794566092",
@@ -983,12 +946,12 @@ async fn oauth_token(
verifier: &str, verifier: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> { ) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new(); let mut query = HashMap::new();
query.insert("client_id", "00000000402b5328"); query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("code", code); query.insert("code", code);
query.insert("code_verifier", verifier); query.insert("code_verifier", verifier);
query.insert("grant_type", "authorization_code"); query.insert("grant_type", "authorization_code");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf"); query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL"); query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| { let res = auth_retry(|| {
REQWEST_CLIENT REQWEST_CLIENT
@@ -1032,11 +995,11 @@ async fn oauth_refresh(
refresh_token: &str, refresh_token: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> { ) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new(); let mut query = HashMap::new();
query.insert("client_id", "00000000402b5328"); query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("refresh_token", refresh_token); query.insert("refresh_token", refresh_token);
query.insert("grant_type", "refresh_token"); query.insert("grant_type", "refresh_token");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf"); query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL"); query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| { let res = auth_retry(|| {
REQWEST_CLIENT REQWEST_CLIENT
@@ -1100,7 +1063,7 @@ async fn sisu_authorize(
"/authorize", "/authorize",
json!({ json!({
"AccessToken": format!("t={access_token}"), "AccessToken": format!("t={access_token}"),
"AppId": "00000000402b5328", "AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": device_token, "DeviceToken": device_token,
"ProofKey": { "ProofKey": {
"kty": "EC", "kty": "EC",

View File

@@ -1,4 +1,3 @@
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry}; use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_advanced}; use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc}; use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -31,7 +30,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced( let resp = fetch_advanced(
Method::POST, Method::POST,
&format!("{MODRINTH_API_URL}session/refresh"), concat!(env!("MODRINTH_API_URL"), "session/refresh"),
None, None,
None, None,
Some(("Authorization", &*creds.session)), Some(("Authorization", &*creds.session)),
@@ -190,8 +189,8 @@ impl ModrinthCredentials {
} }
} }
pub fn get_login_url() -> String { pub const fn get_login_url() -> &'static str {
format!("{MODRINTH_URL}auth/sign-in?launcher=true") concat!(env!("MODRINTH_URL"), "auth/sign-in")
} }
pub async fn finish_login_flow( pub async fn finish_login_flow(
@@ -199,6 +198,12 @@ pub async fn finish_login_flow(
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentials> { ) -> 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?; let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentials { Ok(ModrinthCredentials {
@@ -216,7 +221,7 @@ async fn fetch_info(
) -> crate::Result<crate::state::cache::User> { ) -> crate::Result<crate::state::cache::User> {
let result = fetch_advanced( let result = fetch_advanced(
Method::GET, Method::GET,
&format!("{MODRINTH_API_URL}user"), concat!(env!("MODRINTH_API_URL"), "user"),
None, None,
None, None,
Some(("Authorization", token)), Some(("Authorization", token)),

View File

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

View File

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

View File

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