Merge branch 'beta' into release

This commit is contained in:
2025-07-24 16:39:31 +03:00
218 changed files with 8578 additions and 935 deletions

View File

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

View File

@@ -144,5 +144,10 @@ jobs:
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/**
target/*/release/bundle/**
target/release/bundle/appimage/AstralRinth App_*.AppImage*
target/release/bundle/deb/AstralRinth App_*.deb*
target/release/bundle/rpm/AstralRinth App-*.rpm*
target/universal-apple-darwin/release/bundle/macos/AstralRinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/dmg/AstralRinth App_*.dmg*
target/release/bundle/nsis/AstralRinth App_*-setup.exe*
target/release/bundle/nsis/AstralRinth App_*-setup.nsis.zip*

485
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,13 @@ heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }

159
README.md
View File

@@ -1,76 +1,123 @@
# Navigation in this README
- [Install instructions](#install-instructions)
- [Features](#features)
- [Getting started](#getting-started)
- [Disclaimer](#disclaimer)
- [Donate](#support-our-project-crypto-wallets)
# 📘 Navigation
- [🔧 Install Instructions](#install-instructions)
- [✨ Features](#features)
- [🚀 Getting Started](#getting-started)
- [⚠️ Disclaimer](#disclaimer)
- [💰 Donate](#support-our-project-crypto-wallets)
## Other languages
> [Русский](readme/ru_ru/README.md)
## Support channel
> [Telegram](https://me.astralium.su/ref/telegram_channel)
---
# About Project
## AstralRinth • Empowering Your Minecraft Adventure
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
## **AstralRinth • Empowering Your Minecraft Adventure**
## About Software
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinths API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
## AR • Unlocking Minecraft's Boundless Horizon
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
- *Recently, improved integration with the Git Astralium API has been added.*
# Install instructions
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
- After you have downloaded the required executable file or archive, then open it
## **About the Software**
### Downloadable file extensions
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
**AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
### Installation subjects
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
- `dev`
- `nightly`
- `dirty`
- `dirty-dev`
- `dirty-nightly`
- `dirty_dev`
- `dirty_nightly`
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
## **AR • Unlocking Minecraft's Boundless Horizon**
This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
---
# Install Instructions
To install the launcher:
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
2. Run the downloaded file or extract and launch it, depending on the format.
### Downloadable File Extensions
| Extension | OS | Notes |
| --------- | ------- | --------------------------------------------------------------------- |
| `.msi` | Windows | Supported on all recent Windows versions |
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
### Installation Warnings
Avoid using builds with these prefixes — they may be unstable or experimental:
- `dev`
- `nightly`
- `dirty`
- `dirty-dev`
- `dirty-nightly`
- `dirty_dev`
- `dirty_nightly`
---
# Features
### Featured enhancement in AR
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
> _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
### Easy to use
- Using the launcher is intuitive, any user can figure it out.
## Included exclusive features
### Update notifies
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them.
- No ads in the entire launcher.
- Custom `.svg` vector icons for a distinct UI.
- Improved compatibility with both licensed and pirate accounts.
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
- Supports license-free access for testing or personal use.
- No dependence on official authentication services.
- Discord Rich Presence integration:
- Dynamic status messages.
- In-game timer and AFK counter.
- Strict disabling of statistics and other Modrinth metrics.
- Optimized archive/package size.
- Integrated update fetcher for seamless version management.
- Built-in update alerts for new versions posted on Git Astralium.
- Automatic download and installation capabilities.
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
- ElyBy skin system integration (AuthLib / Java)
### Enhancements
- Custom .SVG vectors for a personalized touch.
- Improved compatibility for both pirate and licensed accounts.
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
- Removal of advertisements from all launcher views.
- Optimization of packages (archives).
- Integrated update fetching feature
---
# Getting Started
To begin your AstralRinth adventure, follow these steps:
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system.
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
- [**How select file**](#downloadable-file-extensions)
- [**How select release**](#installation-subjects)
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action.
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await.
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings.
To begin using AstralRinth:
1. **Download Your OS Version**
- Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
- [How to choose a file](#downloadable-file-extensions)
- [How to choose a release](#installation-warnings)
2. **Log In**
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
3. **Launch Minecraft**
- Start Minecraft from the launcher.
- The launcher will auto-detect the recommended JVM version.
- You can also configure Java manually in the settings.
---
# Disclaimer
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
# Support our Project (Crypto Wallets)
- **AstralRinth** is intended **solely for educational and experimental use**.
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
- Respect all relevant licensing agreements and support Minecraft developers.
---
# Support Our Project (Crypto Wallets)
If you'd like to support development, you can donate via the following crypto wallets:
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe

View File

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

View File

@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version)
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})

View File

@@ -1,17 +1,9 @@
<template>
<div
v-if="mode !== 'isolated'"
ref="button"
<div v-if="mode !== 'isolated'" ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }"
@click="toggleMenu"
>
<Avatar
size="36px"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
" />
<div class="flex flex-col w-full">
<span>
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
@@ -32,31 +24,23 @@
</h4>
<p>Selected</p>
</div>
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
<TrashIcon />
</Button>
</div>
<div v-else class="login-section account">
<h4>Not signed in</h4>
<Button
v-tooltip="'Log in'"
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
<MicrosoftIcon/>
</Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon />
</Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
@@ -73,53 +57,135 @@
</div>
</div>
<div v-if="accounts.length > 0" class="login-section account centered">
<Button v-tooltip="'Log in'" icon-only @click="login()">
<MicrosoftIcon />
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon />
</Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div>
</Card>
</transition>
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
<div class="modal-body">
<div class="label">Enter offline username</div>
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
<Button icon-only color="secondary" @click="offlineLoginFinally()">
Continue
</Button>
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
header="Ely.by requested 2FA code for authentication">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label">Enter your 2FA code</label>
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
<div class="mt-6 ml-auto">
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
Continue
</Button>
</div>
</div>
</ModalWrapper>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label">Enter your player name or email (preferred)</label>
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
<label class="label">Enter your password</label>
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
<div class="mt-6 ml-auto">
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
Login
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
<div class="modal-body">
<div class="label">Error occurred while adding offline account</div>
<Button color="primary" @click="retryOfflineLogin()">
Try again
</Button>
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label">Enter your player name</label>
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
<div class="mt-6 ml-auto">
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
Login
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка">
<ModalWrapper
ref="authenticationElybyErrorModal"
class="modal"
header="Error while proceeding authentication event with Ely.by">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while logging in.
</label>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while adding the Ely.by account. Please follow the instructions below.
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name or email.</li>
<li>Check that you have entered the correct password.</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while adding the offline account. Please follow the instructions below.
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name.</li>
<li>
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
{{ maxOfflinePlayerNameLength }} characters.
</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddOfflineProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
<div class="modal-body">
<div class="label">Unexcepted error</div>
<label class="label">An unexpected error has occurred. Please try again later.</label>
</div>
</ModalWrapper>
</template>
<script setup>
import {
import {
DropdownIcon,
PlusIcon,
TrashIcon,
LogInIcon,
PirateIcon as Offline,
MicrosoftIcon as License,
ElyByIcon as Elyby,
MicrosoftIcon,
PirateIcon,
SpinnerIcon } from '@modrinth/assets'
ElyByIcon,
SpinnerIcon
} from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
elyby_auth_authenticate,
elyby_login,
offline_login,
users,
remove_user,
@@ -146,48 +212,180 @@ defineProps({
const emit = defineEmits(['change'])
const accounts = ref({})
const loginDisabled = ref(false)
const microsoftLoginDisabled = ref(false)
const elybyLoginDisabled = ref(false)
const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // [AR] Feature
loginOfflineModal.value.show()
// [AR] Feature
const clientToken = "astralrinth"
const addOfflineModal = ref(null)
const addElybyModal = ref(null)
const requestElybyTwoFactorCodeModal = ref(null)
const authenticationElybyErrorModal = ref(null)
const inputElybyErrorModal = ref(null)
const inputErrorModal = ref(null)
const exceptionErrorModal = ref(null)
const offlinePlayerName = ref('')
const elybyLogin = ref('')
const elybyPassword = ref('')
const elybyTwoFactorCode = ref('')
const minOfflinePlayerNameLength = 2
const maxOfflinePlayerNameLength = 20
// [AR] • Feature
function getAccountType(account) {
switch (account.account_type) {
case 'microsoft':
return License
case 'pirate':
return Offline
case 'elyby':
return Elyby
}
}
async function offlineLoginFinally() { // [AR] Feature
const name = playerName.value
if (name.length > 1 && name.length < 20 && name !== '') {
const loggedIn = await offline_login(name).catch(handleError)
loginOfflineModal.value.hide()
if (loggedIn) {
await setAccount(loggedIn)
// [AR] Feature
function showOfflineLoginModal() {
addOfflineModal.value?.show()
}
// [AR] • Feature
function showElybyLoginModal() {
addElybyModal.value?.show()
}
// [AR] • Feature
function retryAddOfflineProfile() {
inputErrorModal.value?.hide()
clearOfflineFields()
showOfflineLoginModal()
}
// [AR] • Feature
function retryAddElybyProfile() {
authenticationElybyErrorModal.value?.hide()
inputElybyErrorModal.value?.hide()
clearElybyFields()
showElybyLoginModal()
}
// [AR] • Feature
function clearElybyFields() {
elybyLogin.value = ''
elybyPassword.value = ''
elybyTwoFactorCode.value = ''
}
// [AR] • Feature
function clearOfflineFields() {
offlinePlayerName.value = ''
}
// [AR] • Feature
async function addOfflineProfile() {
const name = offlinePlayerName.value.trim()
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
if (!isValidName) {
addOfflineModal.value?.hide()
inputErrorModal.value?.show()
clearOfflineFields()
return
}
try {
const result = await offline_login(name)
addOfflineModal.value?.hide()
if (result) {
await setAccount(result)
await refreshValues()
} else {
unexpectedErrorModal.value.show()
exceptionErrorModal.value?.show()
}
playerName.value = ''
} else {
playerName.value = ''
loginOfflineModal.value.hide()
loginErrorModal.value.show()
} catch (error) {
handleError(error)
exceptionErrorModal.value?.show()
} finally {
clearOfflineFields()
}
}
function retryOfflineLogin() { // [AR] Feature
loginErrorModal.value.hide()
tryOfflineLogin()
}
// [AR] Feature
async function addElybyProfile() {
if (!elybyLogin.value || !elybyPassword.value) {
addElybyModal.value?.hide()
inputElybyErrorModal.value?.show()
clearElybyFields()
return
}
elybyLoginDisabled.value = true
function getAccountType(account) { // [AR] Feature
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
return License
} else {
return Offline
const login = elybyLogin.value.trim()
let password = elybyPassword.value.trim()
const twoFactorCode = elybyTwoFactorCode.value.trim()
if (password && twoFactorCode) {
password = `${password}:${twoFactorCode}`
}
try {
const raw_result = await elyby_auth_authenticate(
login,
password,
clientToken
)
const json_data = JSON.parse(raw_result)
console.log(json_data?.error)
console.log(json_data?.errorMessage)
if (!json_data.accessToken) {
if (
json_data.error === 'ForbiddenOperationException' &&
json_data.errorMessage?.includes('two factor')
) {
requestElybyTwoFactorCodeModal.value?.show()
return
}
addElybyModal.value?.hide()
requestElybyTwoFactorCodeModal.value?.hide()
authenticationElybyErrorModal.value?.show()
return
}
const accessToken = json_data.accessToken
const selectedProfileId = convertRawStringToUUIDv4(json_data.selectedProfile.id)
const selectedProfileName = json_data.selectedProfile.name
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
addElybyModal.value?.hide()
requestElybyTwoFactorCodeModal.value?.hide()
clearElybyFields()
await setAccount(result)
await refreshValues()
} catch (err) {
handleError(err)
exceptionErrorModal.value?.show()
} finally {
elybyLoginDisabled.value = false
}
}
// [AR] • Feature
function convertRawStringToUUIDv4(rawId) {
if (rawId.length !== 32) {
console.warn('Invalid UUID string:', rawId)
return rawId
}
return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20)}`
}
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
@@ -213,13 +411,13 @@ async function refreshValues() {
}
function setLoginDisabled(value) {
loginDisabled.value = value
microsoftLoginDisabled.value = value
}
defineExpose({
refreshValues,
setLoginDisabled,
loginDisabled,
loginDisabled: microsoftLoginDisabled,
})
await refreshValues()
@@ -265,7 +463,7 @@ async function setAccount(account) {
}
async function login() {
loginDisabled.value = true
microsoftLoginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
@@ -274,7 +472,7 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
microsoftLoginDisabled.value = false
}
const logout = async (id) => {

View File

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

View File

@@ -26,6 +26,7 @@ import {
type Version,
} from '@modrinth/utils'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs'
@@ -35,6 +36,11 @@ import type {
Manifest,
} from '../../../helpers/types'
import { initAuthlibPatching } from '@/helpers/utils.js'
const authLibPatchingModal = ref(null)
const isAuthLibPatchedSuccess = ref(false)
const isAuthLibPatching = ref(false)
const { formatMessage } = useVIntl()
const repairConfirmModal = ref()
@@ -447,9 +453,43 @@ const messages = defineMessages({
defaultMessage: 'reinstall',
},
})
async function handleInitAuthLibPatching(ismojang: boolean) {
isAuthLibPatching.value = true
let state = false
let instance_path = props.instance.loader_version != null ? props.instance.game_version + "-" + props.instance.loader_version : props.instance.game_version
try {
state = await initAuthlibPatching(instance_path, ismojang)
} catch (err) {
console.error(err)
}
isAuthLibPatching.value = false
isAuthLibPatchedSuccess.value = state
authLibPatchingModal.value.show()
}
</script>
<template>
<ModalWrapper
ref="authLibPatchingModal"
:header="'AuthLib installation report'"
:closable="true"
@close="authLibPatchingModal.hide()"
>
<div class="modal-body">
<h2 class="text-lg font-bold text-contrast space-y-2">
<p class="flex items-center gap-2 neon-text">
<span v-if="isAuthLibPatchedSuccess" class="neon-text">
AuthLib installation completed successfully! Now you can log in and play!
</span>
<span v-else class="neon-text">
Failed to install AuthLib. It's possible that no compatible AuthLib version was found for the selected game and/or mod loader version.
There may also be a problem with accessing resources behind CloudFlare.
</span>
</p>
</h2>
</div>
</ModalWrapper>
<ConfirmModalWrapper
ref="repairConfirmModal"
:title="formatMessage(messages.repairConfirmTitle)"
@@ -720,6 +760,24 @@ const messages = defineMessages({
</button>
</ButtonStyled>
</div>
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
<div v-if="isAuthLibPatching" class="w-6 h-6 cursor-pointer hover:brightness-75 neon-icon pulse">
<SpinnerIcon class="size-4 animate-spin" />
</div>
Auth system (Skins) <span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h2>
<div class="mt-4 flex gap-2">
<ButtonStyled class="neon-button neon">
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(true)">
Install Microsoft
</button>
</ButtonStyled>
<ButtonStyled class="neon-button neon">
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(false) ">
Install Ely.By
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked">
@@ -787,3 +845,9 @@ const messages = defineMessages({
</template>
</div>
</template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../../packages/assets/styles/neon-text.scss';
@import '../../../../../../packages/assets/styles/neon-icon.scss';
</style>

View File

@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl()
@@ -34,7 +34,7 @@ const envVars = ref(
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
const { maxMemory, snapPoints } = await useMemorySlider()
const editProfileObject = computed(() => {
const editProfile: {
@@ -156,6 +156,8 @@ const messages = defineMessages({
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">

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

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import { Slider, Toggle } from '@modrinth/ui'
import useMemorySlider from '@/composables/useMemorySlider'
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const { maxMemory, snapPoints } = await useMemorySlider()
watch(
settings,
@@ -107,6 +106,8 @@ watch(
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>

View File

@@ -118,6 +118,7 @@ import {
type Cape,
type SkinModel,
get_normalized_skin_texture,
determineModelType,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
@@ -253,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC'
variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()

View File

@@ -128,6 +128,14 @@ const messages = defineMessages({
id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open',
},
noContact: {
id: 'instance.worlds.no_contact',
defaultMessage: "Server couldn't be contacted",
},
incompatibleServer: {
id: 'instance.worlds.incompatible_server',
defaultMessage: 'Server is incompatible',
},
copyAddress: {
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
@@ -302,39 +310,33 @@ const messages = defineMessages({
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus">
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
serverIncompatible
? 'Server is incompatible'
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
!serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>

View File

@@ -0,0 +1,21 @@
import { ref, computed } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
export default async function () {
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const snapPoints = computed(() => {
let points = []
let memory = 2048
while (memory <= maxMemory.value) {
points.push(memory)
memory *= 2
}
return points
})
return { maxMemory, snapPoints }
}

View File

@@ -17,6 +17,24 @@ export async function offline_login(name) {
return await invoke('plugin:auth|offline_login', { name: name })
}
// [AR] • Feature
export async function elyby_login(uuid, login, accessToken) {
return await invoke('plugin:auth|elyby_login', {
uuid,
login,
accessToken
})
}
// [AR] • Feature
export async function elyby_auth_authenticate(login, password, clientToken) {
return await invoke('plugin:auth|elyby_auth_authenticate', {
login,
password,
clientToken,
})
}
/**
* Authenticate a user with Hydra - part 1.
* This begins the authentication flow quasi-synchronously.

View File

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

View File

@@ -2,7 +2,13 @@ import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import { setupSkinModel, disposeCaches, loadTexture, applyCapeTexture } from '@modrinth/utils'
import {
setupSkinModel,
disposeCaches,
loadTexture,
applyCapeTexture,
createTransparentTexture,
} from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
@@ -120,6 +126,9 @@ class BatchSkinRenderer {
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
}
const group = new THREE.Group()

View File

@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
context.drawImage(image, 0, 0)
const armX = 44
const armY = 16
const armWidth = 4
const armX = 54
const armY = 20
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return

View File

@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { getVersion } from '@tauri-apps/api/app'
import { getArtifact, getOS } from '@/helpers/utils.js'
import { initUpdateLauncher, getOS } from '@/helpers/utils.js'
export const allowState = ref(false)
export const installState = ref(false)
@@ -52,7 +52,7 @@ export async function getRemote(isDownloadState) {
installState.value = true;
const builds = remoteData.assets;
const fileName = getInstaller(getExtension(), builds);
result = fileName ? await getArtifact(fileName[1], fileName[0], currentOS.value, true) : false;
result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
installState.value = false;
}

View File

@@ -10,17 +10,22 @@ export async function getOS() {
return await invoke('plugin:utils|get_os')
}
// [AR] Feature
export async function getArtifact(downloadurl, filename, ostype, autoupdatesupported) {
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported)
return await invoke('plugin:utils|get_artifact', { downloadurl, filename, ostype, autoupdatesupported })
// [AR] Feature. Updater
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
}
// [AR] Patch fix
// [AR] Migration. Patch
export async function applyMigrationFix(eol) {
return await invoke('plugin:utils|apply_migration_fix', { eol })
}
// [AR] Feature. Ely.by
export async function initAuthlibPatching(minecraftVersion, isMojang) {
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
}
export async function openPath(path) {
return await invoke('plugin:utils|open_path', { path })
}

View File

@@ -377,6 +377,12 @@
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ fn main() {
InlinedPlugin::new()
.commands(&[
"offline_login",
"elyby_login",
"elyby_auth_authenticate",
"login",
"remove_user",
"get_default_user",
@@ -121,7 +123,12 @@ fn main() {
.plugin(
"mr-auth",
InlinedPlugin::new()
.commands(&["modrinth_login", "logout", "get"])
.commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
@@ -218,8 +225,9 @@ fn main() {
"utils",
InlinedPlugin::new()
.commands(&[
"init_authlib_patching",
"apply_migration_fix",
"get_artifact",
"init_update_launcher",
"get_os",
"should_disable_mouseover",
"highlight_in_folder",

View File

@@ -2,12 +2,15 @@ use crate::api::Result;
use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType};
use tauri_plugin_http::reqwest::Client;
use theseus::prelude::*;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::<R>::new("auth")
.invoke_handler(tauri::generate_handler![
offline_login,
elyby_login,
elyby_auth_authenticate,
login,
remove_user,
get_default_user,
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build()
}
/// ### AR • Feature
/// Create new offline user
/// This is custom function from Astralium Org.
#[tauri::command]
pub async fn offline_login(name: &str) -> Result<Credentials> {
let credentials = minecraft_auth::offline_auth(name).await?;
Ok(credentials)
}
/// ### AR • Feature
/// Create new Ely.by user
#[tauri::command]
pub async fn elyby_login(
uuid: uuid::Uuid,
login: &str,
access_token: &str
) -> Result<Credentials> {
let credentials = minecraft_auth::elyby_auth(uuid, login, access_token).await?;
Ok(credentials)
}
/// ### AR • Feature
/// Authenticate Ely.by user
#[tauri::command]
pub async fn elyby_auth_authenticate(
login: &str,
password: &str,
client_token: &str,
) -> Result<String> {
let client = Client::new();
let auth_body = serde_json::json!({
"username": login,
"password": password,
"clientToken": client_token,
});
let response = match client
.post("https://authserver.ely.by/auth/authenticate")
.header("Content-Type", "application/json")
.json(&auth_body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("[AR] • Failed to send request: {}", e);
return Ok("".to_string());
}
};
let text = match response.text().await {
Ok(body) => body,
Err(e) => {
tracing::error!("[AR] • Failed to read response text: {}", e);
return Ok("".to_string());
}
};
Ok(text)
}
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
#[tauri::command]
@@ -42,7 +96,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new(
&app,
"signin",
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
@@ -86,6 +140,7 @@ pub async fn login<R: Runtime>(
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)

View File

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

View File

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

View File

@@ -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

@@ -10,14 +10,15 @@ use crate::api::{Result, TheseusSerializableError};
use dashmap::DashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::canonicalize;
use url::Url;
use theseus::util::utils;
use url::Url;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils")
.invoke_handler(tauri::generate_handler![
init_authlib_patching,
apply_migration_fix,
get_artifact,
init_update_launcher,
get_os,
should_disable_mouseover,
highlight_in_folder,
@@ -29,17 +30,39 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.build()
}
/// [AR] Patch fix
/// [AR] Feature. Ely.by
#[tauri::command]
pub async fn init_authlib_patching(
minecraft_version: &str,
is_mojang: bool,
) -> Result<bool> {
let result =
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
Ok(result)
}
/// [AR] Migration. Patch
#[tauri::command]
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
let result = utils::apply_migration_fix(eol).await?;
Ok(result)
}
/// [AR] Feature
/// [AR] Feature. Updater
#[tauri::command]
pub async fn get_artifact(downloadurl: &str, filename: &str, ostype: &str, autoupdatesupported: bool) -> Result<()> {
let _ = utils::init_download(downloadurl, filename, ostype, autoupdatesupported).await;
pub async fn init_update_launcher(
download_url: &str,
filename: &str,
os_type: &str,
auto_update_supported: bool,
) -> Result<()> {
let _ = utils::init_update_launcher(
download_url,
filename,
os_type,
auto_update_supported,
)
.await;
Ok(())
}

View File

@@ -41,7 +41,7 @@
]
},
"productName": "AstralRinth App",
"version": "0.10.303",
"version": "0.10.305",
"mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp",
"plugins": {
@@ -63,6 +63,7 @@
"height": 800,
"resizable": true,
"title": "AstralRinth",
"label": "main",
"width": 1280,
"minHeight": 700,
"minWidth": 1100,
@@ -86,7 +87,7 @@
"capabilities": ["core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: https://git.astralium.su http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
"connect-src": "ipc: https://git.astralium.su https://authserver.ely.by http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",

View File

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

View File

@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
sqlx database setup
```
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
To enable labrinth to create a project, you need to add two things.
1. An entry in the `loaders` table.

View File

@@ -38,9 +38,10 @@
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/moderation": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/blog": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
@@ -58,6 +59,7 @@
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",

View File

@@ -197,13 +197,13 @@
}
> :where(
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
&:not(:empty) {
margin-block-start: var(--spacing-card-md);
}

View File

@@ -115,10 +115,12 @@ html {
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
--shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -150,8 +152,8 @@ html {
rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100%
);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -251,13 +253,15 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
--landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -284,7 +288,8 @@ html {
rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100%
);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -360,8 +365,9 @@ body {
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-standard:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;

View File

@@ -1,7 +1,10 @@
<template>
<div
class="vue-notification-group experimental-styles-within"
:class="{ 'intercom-present': isIntercomPresent }"
:class="{
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
>
<transition-group name="notifs">
<div
@@ -82,6 +85,7 @@ import {
CopyIcon,
} from "@modrinth/assets";
const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const isIntercomPresent = ref(false);
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
bottom: 5rem;
}
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;

View File

@@ -0,0 +1,116 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
const modal = ref<InstanceType<typeof NewModal>>();
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
const keys = [];
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
keys.push(mainKey);
return keys;
}
function isMac() {
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
}
function show(event?: MouseEvent) {
modal.value?.show(event);
}
function hide() {
modal.value?.hide();
}
defineExpose({
show,
hide,
});
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>

View File

@@ -0,0 +1,513 @@
<template>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import type {
ModerationJudgements,
ModerationModpackItem,
ModerationModpackResponse,
ModerationUnknownModpackItem,
ModerationFlameModpackItem,
ModerationModpackPermissionApprovalType,
ModerationPermissionType,
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
},
{
id: "with-attribution",
name: "With attribution",
},
{
id: "no",
name: "No",
},
{
id: "permanent-no",
name: "Permanent no",
},
{
id: "unidentified",
name: "Unidentified",
},
];
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
}
currentIndex.value = persistedIndex.value;
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
status: null,
approved: null,
proof: "",
url: "",
title: "",
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
}),
...(item.type === "flame" && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
}
});
}
modPackData.value = sortedData;
persistAll();
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
} else {
persistAll();
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
const judgements: ModerationJudgements = {};
modPackData.value.forEach((item) => {
if (item.type === "flame") {
judgements[item.sha1] = {
type: "flame",
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
judgements[item.sha1] = {
type: "unknown",
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
});
return judgements;
}
onMounted(() => {
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
.markdown-body {
grid-area: body;
max-width: 100%;
}
.reporter-info {

View File

@@ -31,9 +31,9 @@
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click="
versionFilter &&
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
"
>
<TagItem

View File

@@ -194,13 +194,12 @@ export class ModrinthServer {
}
async testNodeReachability(): Promise<boolean> {
if (!this.general?.datacenter) {
console.warn("No datacenter info available for ping test");
if (!this.general?.node?.instance) {
console.warn("No node instance available for ping test");
return false;
}
const datacenter = this.general.datacenter;
const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`;
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
try {
return await new Promise((resolve) => {

View File

@@ -112,7 +112,8 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
body:
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
});

View File

@@ -0,0 +1,12 @@
export const useNotificationRightwards = () => {
const isVisible = useState("moderation-checklist-notifications", () => false);
const setVisible = (visible: boolean) => {
isVisible.value = visible;
};
return {
isVisible: readonly(isVisible),
setVisible,
};
};

View File

@@ -700,7 +700,6 @@ import {
PackageOpenIcon,
DiscordIcon,
BlueskyIcon,
TumblrIcon,
TwitterIcon,
MastodonIcon,
GithubIcon,
@@ -1185,13 +1184,6 @@ const socialLinks = [
icon: MastodonIcon,
rel: "me",
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
),
href: "https://tumblr.com/modrinth",
icon: TumblrIcon,
},
{
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth",
@@ -1346,6 +1338,15 @@ const footerLinks = [
}),
),
},
{
href: "/legal/copyright",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.copyright-policy",
defaultMessage: "Copyright Policy and DMCA",
}),
),
},
],
},
];

View File

@@ -383,15 +383,15 @@
"layout.footer.about": {
"message": "About"
},
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.careers": {
"message": "Careers"
},
"layout.footer.about.changelog": {
"message": "Changelog"
},
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.rewards-program": {
"message": "Rewards Program"
},
@@ -404,6 +404,9 @@
"layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
},
"layout.footer.legal.copyright-policy": {
"message": "Copyright Policy and DMCA"
},
"layout.footer.legal.privacy-policy": {
"message": "Privacy Policy"
},
@@ -458,9 +461,6 @@
"layout.footer.social.mastodon": {
"message": "Mastodon"
},
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": {
"message": "X"
},

View File

@@ -29,12 +29,11 @@
class="settings-header__icon"
/>
<div class="settings-header__text">
<h1 class="wrap-as-needed">
{{ project.title }}
</h1>
<h1 class="wrap-as-needed">{{ project.title }}</h1>
<ProjectStatusBadge :status="project.status" />
</div>
</div>
<h2>Project settings</h2>
<NavStack>
<NavStackItem
@@ -111,6 +110,7 @@
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<ProjectMemberHeader
v-if="currentMember"
@@ -145,6 +145,7 @@
/>
</div>
</div>
<div v-else class="experimental-styles-within">
<NewModal ref="settingsModal">
<template #title>
@@ -174,9 +175,11 @@
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
@@ -219,8 +222,7 @@
:href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()"
>
<ModrinthIcon aria-hidden="true" />
Install with Modrinth App
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
@@ -240,6 +242,7 @@
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div>
</div>
<div class="mx-auto flex w-fit flex-col gap-2">
<ButtonStyled v-if="project.game_versions.length === 1">
<div class="disabled button-like">
@@ -327,8 +330,7 @@
}
"
>
{{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button>
</ButtonStyled>
</ScrollablePanel>
@@ -419,7 +421,6 @@
</ScrollablePanel>
</Accordion>
</div>
<AutomaticAccordion div class="flex flex-col gap-2">
<VersionSummary
v-if="filteredRelease"
@@ -470,10 +471,14 @@
class="new-page sidebar"
:class="{
'alt-layout': cosmetics.leftContentLayout,
'ultimate-sidebar':
'checklist-open':
showModerationChecklist &&
!collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
'checklist-collapsed':
showModerationChecklist &&
collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}"
>
<div class="normal-page__header relative my-4">
@@ -485,11 +490,11 @@
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
>
<button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" />
Download
<DownloadIcon aria-hidden="true" /> Download
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
size="large"
@@ -554,9 +559,11 @@
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle!
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span>
</p>
@@ -621,6 +628,7 @@
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">No collections found.</p>
</div>
@@ -628,8 +636,7 @@
class="btn collection-button"
@click="(event) => $refs.modal_collection.show(event)"
>
<PlusIcon aria-hidden="true" />
Create new collection
<PlusIcon aria-hidden="true" /> Create new collection
</button>
</template>
</PopoutMenu>
@@ -712,25 +719,14 @@
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #analytics>
<ChartIcon aria-hidden="true" />
Analytics
</template>
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" />
Review project
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
<ScaleIcon aria-hidden="true" /> Review project
</template>
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
</template>
</OverflowMenu>
</ButtonStyled>
@@ -756,6 +752,7 @@
updates unless the author decides to unarchive the project.
</MessageBanner>
</div>
<div class="normal-page__sidebar">
<ProjectSidebarCompatibility
:project="project"
@@ -785,6 +782,7 @@
/>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
@@ -813,53 +811,48 @@
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
</div>
<div
v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
</div>
</div>
</div>
</div>
<div class="normal-page__content">
<div class="overflow-x-auto">
<NavTabs :links="navLinks" class="mb-4" />
</div>
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
<NuxtPage
v-model:project="project"
v-model:versions="versions"
@@ -877,8 +870,10 @@
@delete-version="deleteVersion"
/>
</div>
<div class="normal-page__ultimate-sidebar">
<ModerationChecklist
<!-- Uncomment this to enable the old moderation checklist. -->
<!-- <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
@@ -886,11 +881,25 @@
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
/> -->
</div>
</div>
</div>
<div
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<NewModerationChecklist
:project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</template>
<script setup>
import {
BookmarkIcon,
@@ -950,16 +959,16 @@ import {
isUnderReview,
renderString,
} from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
import { navigateTo } from "#app";
import Accordion from "~/components/ui/Accordion.vue";
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
@@ -967,6 +976,7 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
const data = useNuxtApp();
const route = useNativeRoute();
@@ -980,6 +990,7 @@ const flags = useFeatureFlags();
const cosmetics = useCosmetics();
const { formatMessage } = useVIntl();
const { setVisible } = useNotificationRightwards();
const settingsModal = ref();
const downloadModal = ref();
@@ -1551,12 +1562,28 @@ async function copyPermalink() {
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);
const collapsedModerationChecklist = ref(false);
const futureProjects = ref([]);
const showModerationChecklist = useLocalStorage(
`show-moderation-checklist-${project.value.id}`,
false,
);
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
watch(futureProjectIds, (newValue) => {
console.log("Future project IDs updated:", newValue);
});
watch(
showModerationChecklist,
(newValue) => {
setVisible(newValue);
},
{ immediate: true },
);
if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true;
futureProjects.value = history.state.projects;
}
function closeDownloadModal(event) {
@@ -1626,6 +1653,7 @@ const navLinks = computed(() => {
];
});
</script>
<style lang="scss" scoped>
.settings-header {
display: flex;
@@ -1781,4 +1809,16 @@ const navLinks = computed(() => {
left: 18px;
}
}
.moderation-checklist {
position: fixed;
bottom: 1rem;
right: 1rem;
overflow-y: auto;
z-index: 50;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
</style>

View File

@@ -705,9 +705,9 @@ export default defineNuxtComponent({
}
.gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info {
h2 {

View File

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

View File

@@ -1421,7 +1421,8 @@ useSeoMeta({
width: 25rem;
height: 25rem;
opacity: 0.75;
background: radial-gradient(
background:
radial-gradient(
50% 50% at 50% 50%,
rgba(5, 206, 69, 0.19) 0%,
rgba(15, 19, 49, 0.25) 100%

View File

@@ -1,6 +1,12 @@
<template>
<div>
<template v-if="flow">
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
@@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute();
const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn();
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) {
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;
}

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 useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {

View File

@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
});
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
});
const withdrawAccount = computed(() => {

View File

@@ -212,6 +212,10 @@ if (projects.value) {
async function goToProjects() {
const project = projectsFiltered.value[0];
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
await router.push({
name: "type-id",
params: {
@@ -220,7 +224,6 @@ async function goToProjects() {
},
state: {
showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
},
});
}

View File

@@ -149,7 +149,8 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.main-hero {
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
background:
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;

View File

@@ -45,8 +45,9 @@
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
>
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
and play your favorite mods and modpacks, all within the Modrinth platform.
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
</h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div
@@ -427,11 +428,8 @@
Do Modrinth Servers have DDoS protection?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Yes. All Modrinth Servers come with DDoS protection powered by
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
>OVHcloud® Anti-DDoS infrastructure</a
>
which has over 17Tbps capacity. Your server is safe on Modrinth.
Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
some locations.
</p>
</details>
@@ -443,8 +441,9 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 ml-6 leading-[160%]">
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
Germany. More regions to come in the future!
We have servers available in North America and Europe at the moment that you can
choose upon purchase. More regions to come in the future! If you'd like to switch
your region, please contact support.
</p>
</details>
@@ -461,7 +460,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -482,7 +481,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -493,6 +492,24 @@
All prices are listed in United States Dollars (USD).
</p>
</details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
</span>
What Minecraft versions and loaders can be used?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
back to version 1.2.5, including snapshot versions.
</p>
<p class="m-0 ml-6 mt-3 leading-[160%]">
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
depends on whether the mod or plugin loader supports the selected Minecraft version.
</p>
</details>
</div>
</div>
</div>

View File

@@ -1020,7 +1020,7 @@ const nodeUnavailableDetails = computed(() => [
{
label: "Error message",
value: nodeAccessible.value
? server.moduleErrors?.general?.error.message ?? "Unknown"
? (server.moduleErrors?.general?.error.message ?? "Unknown")
: "Unable to reach node. Ping test failed.",
type: "block" as const,
},
@@ -1277,7 +1277,8 @@ useHead({
background-repeat: no-repeat;
filter: blur(1rem);
content: "";
background-image: linear-gradient(
background-image:
linear-gradient(
to bottom,
rgba(from var(--color-raised-bg) r g b / 0.2),
rgb(from var(--color-raised-bg) r g b / 0.8)

View File

@@ -101,7 +101,7 @@
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
{{
"current_file" in op
? op.current_file?.split("/")?.pop() ?? "unknown"
? (op.current_file?.split("/")?.pop() ?? "unknown")
: "unknown"
}}
</span>

View File

@@ -0,0 +1,6 @@
Contact: mailto:jai@modrinth.com
Expires: 2025-12-31T00:00:00.000Z
Preferred-Languages: en
Canonical: https://modrinth.com/.well-known/security.txt
Policy: https://modrinth.com/legal/security
Hiring: https://careers.modrinth.com/

View File

@@ -98,13 +98,6 @@
"date": "2023-02-01T20:00:00.000Z",
"link": "https://modrinth.com/news/article/accelerating-development"
},
{
"title": "Two years of Modrinth: a retrospective",
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
"thumbnail": "https://modrinth.com/news/default.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
},
{
"title": "Modrinth's Anniversary Update",
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
@@ -112,6 +105,13 @@
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
},
{
"title": "Two years of Modrinth: a retrospective",
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
"thumbnail": "https://modrinth.com/news/default.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
},
{
"title": "Creators can now make money on Modrinth!",
"summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.",

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -36,7 +36,7 @@ paste.workspace = true
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
rust-s3.workspace = true
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
hyper-tls.workspace = true
hyper-rustls.workspace = true
hyper-util.workspace = true
serde = { workspace = true, features = ["derive"] }

View File

@@ -1,8 +1,21 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
WORKDIR /usr/src/labrinth
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
@@ -11,13 +24,11 @@ LABEL org.opencontainers.image.description="Modrinth API"
LABEL org.opencontainers.image.licenses=AGPL-3.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl dumb-init curl \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
WORKDIR /labrinth
COPY --from=artifacts /labrinth /labrinth
WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"]

View File

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

View File

@@ -1,5 +1,4 @@
use hyper_tls::{HttpsConnector, native_tls};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::rt::TokioExecutor;
mod fetch;
@@ -15,13 +14,11 @@ pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result<clickhouse::Client> {
let client = {
let mut http_connector = HttpConnector::new();
http_connector.enforce_http(false); // allow https URLs
let tls_connector =
native_tls::TlsConnector::builder().build().unwrap().into();
let https_connector =
HttpsConnector::from((http_connector, tls_connector));
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_all_versions()
.build();
let hyper_client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(https_connector);

View File

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

View File

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

View File

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

View File

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

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

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n WHERE active = TRUE\n ",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n WHERE active = TRUE\n ",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"name": "expires",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
false,
false,
false,
false
]
},
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
"hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n ",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n ",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"name": "expires",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
false,
false,
false,
false
]
},
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
"hash": "5c803f3d90c147210e8e7a7a6d7234d3801bc38c23e1e02fbd8fa08ae51e8f08"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6,\n account_type = $7\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "d719cf2f6f87c5ea7ea6ace2d6a1828ee58a724f06a91633b8a40b4e04d0b9a0"
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
-- [AR] - SQL Migration
ALTER TABLE minecraft_users ADD COLUMN account_type varchar(32) NOT NULL DEFAULT 'unknown';
UPDATE minecraft_users SET account_type = 'microsoft' WHERE access_token != 'null';
UPDATE minecraft_users SET account_type = 'pirate' WHERE access_token == 'null';

View File

@@ -28,6 +28,16 @@ pub async fn offline_auth(
crate::state::offline_auth(name, &state.pool).await
}
#[tracing::instrument]
pub async fn elyby_auth(
uuid: uuid::Uuid,
login: &str,
access_token: &str
) -> crate::Result<Credentials> {
let state = State::get().await?;
crate::state::elyby_auth(uuid, login, access_token, &state.pool).await
}
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;

View File

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

View File

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

View File

@@ -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

@@ -151,6 +151,41 @@ pub enum ErrorKind {
"A skin texture must have a dimension of either 64x64 or 64x32 pixels"
)]
InvalidSkinTexture,
#[error(
"[AR] Target minecraft {minecraft_version} version doesn't exist."
)]
InvalidMinecraftVersion {
minecraft_version: String,
},
#[error(
"[AR] Target metadata not found for minecraft version {minecraft_version}."
)]
MinecraftMetadataNotFound {
minecraft_version: String,
},
#[error(
"[AR] Network error: {error}"
)]
NetworkErrorOccurred {
error: String,
},
#[error(
"[AR] IO error: {error}"
)]
IOErrorOccurred {
error: String,
},
#[error(
"[AR] Parse error: {reason}"
)]
ParseError {
reason: String,
},
}
#[derive(Debug)]

View File

@@ -6,9 +6,9 @@ use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError;
use crate::profile::QuickPlayType;
use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
AccountType, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::util::{io, utils};
use crate::{State, get_resource_file, process, state as st};
use chrono::Utc;
use daedalus as d;
@@ -634,16 +634,31 @@ pub async fn launch_minecraft(
}
// [AR] Patch
if credentials.access_token == "null" && credentials.refresh_token == "null" {
if credentials.account_type == AccountType::Pirate.as_lowercase_str() {
if version_jar == "1.16.4" || version_jar == "1.16.5" {
let invalid_url = "https://invalid.invalid";
tracing::info!("✅ JVM args is patched by AstralRinth for MC {}", version_jar);
tracing::info!(
"[AR] • The launcher detected the launch of {} on the offline account. Applying offline multiplayer fixes.",
version_jar
);
command.arg("-Dminecraft.api.env=custom");
command.arg(format!("-Dminecraft.api.auth.host={}", invalid_url));
command.arg(format!("-Dminecraft.api.account.host={}", invalid_url));
command.arg(format!("-Dminecraft.api.session.host={}", invalid_url));
command.arg(format!("-Dminecraft.api.services.host={}", invalid_url));
command
.arg(format!("-Dminecraft.api.account.host={}", invalid_url));
command
.arg(format!("-Dminecraft.api.session.host={}", invalid_url));
command
.arg(format!("-Dminecraft.api.services.host={}", invalid_url));
}
} else if credentials.account_type == AccountType::ElyBy.as_lowercase_str()
{
tracing::info!(
"[AR] • The launcher detected the launch of {} on the Ely.by account. Applying Ely.by Java Injector.",
version_jar
);
let path_buf = utils::get_or_download_elyby_injector().await?;
let path = path_buf.to_str().unwrap();
command.arg(format!("-javaagent:{}=ely.by", path));
}
command
@@ -745,10 +760,10 @@ pub async fn launch_minecraft(
// [AR] Feature
let selected_phrase = ACTIVE_STATE.choose(&mut rand::thread_rng()).unwrap();
let _ = state
.discord_rpc
.set_activity(&format!("{} {}", selected_phrase, profile.name), true)
.await;
let _ = state
.discord_rpc
.set_activity(&format!("{} {}", selected_phrase, profile.name), true)
.await;
let _ = state
.friends_socket

View File

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

View File

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

View File

@@ -131,6 +131,7 @@ where
expires: legacy_credentials.expires,
active: minecraft_auth.default_user == Some(uuid)
|| minecraft_users_len == 1,
account_type: legacy_credentials.account_type,
}
.upsert(exec)
.await?;
@@ -518,6 +519,7 @@ struct LegacyCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires: DateTime<Utc>,
pub account_type: String,
}
#[derive(Deserialize, Debug)]

View File

@@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
pub verifier: String,
pub challenge: String,
pub session_id: String,
pub redirect_uri: String,
pub auth_request_uri: String,
}
#[tracing::instrument]
pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<MinecraftLoginFlow> {
let (pair, current_date, valid_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
.await?;
let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new();
hasher.update(&verifier);
let result = hasher.finalize();
let result = sha2::Sha256::digest(&verifier);
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
match sisu_authenticate(
@@ -110,46 +107,15 @@ pub async fn login_begin(
)
.await
{
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
redirect_uri: redirect_uri.value.msa_oauth_redirect,
}),
Err(err) => {
if !valid_date {
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
Utc::now(),
false,
exec,
)
.await?;
let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new();
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
let (session_id, redirect_uri) = sisu_authenticate(
&pair.token.token,
&challenge,
&pair.key,
current_date,
)
.await?;
Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
redirect_uri: redirect_uri.value.msa_oauth_redirect,
})
} else {
Err(crate::ErrorKind::from(err).into())
}
Ok((session_id, redirect_uri)) => {
return Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
auth_request_uri: redirect_uri.value.msa_oauth_redirect,
});
}
Err(err) => return Err(crate::ErrorKind::from(err).into()),
}
}
@@ -159,9 +125,8 @@ pub async fn login_finish(
flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> {
let (pair, _, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
.await?;
let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize(
@@ -191,6 +156,7 @@ pub async fn login_finish(
expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64),
active: true,
account_type: AccountType::Microsoft.as_lowercase_str(),
};
// During login, we need to fetch the online profile at least once to get the
@@ -229,6 +195,7 @@ pub async fn offline_auth(
refresh_token: refresh_token,
expires: Utc::now() + Duration::days(365 * 99),
active: true,
account_type: AccountType::Pirate.as_lowercase_str(),
};
credentials.offline_profile = MinecraftProfile {
@@ -242,6 +209,58 @@ pub async fn offline_auth(
Ok(credentials)
}
// [AR] Feature
#[tracing::instrument]
pub async fn elyby_auth(
uuid: Uuid,
username: &str,
access_token: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> {
let mut credentials = Credentials {
offline_profile: MinecraftProfile::default(),
access_token: access_token.to_string(),
refresh_token: "null".to_string(),
expires: Utc::now() + Duration::days(365 * 99),
active: true,
account_type: AccountType::ElyBy.as_lowercase_str(),
};
credentials.offline_profile = MinecraftProfile {
id: uuid,
name: username.to_string(),
..credentials.offline_profile
};
credentials.upsert(exec).await?;
Ok(credentials)
}
/// [AR] • Feature
#[derive(Deserialize, Debug)]
pub enum AccountType {
Unknown,
Microsoft,
Pirate,
ElyBy,
}
impl AccountType {
fn as_str(&self) -> &'static str {
match self {
AccountType::Unknown => "Unknown",
AccountType::Microsoft => "Microsoft",
AccountType::Pirate => "Pirate",
AccountType::ElyBy => "ElyBy",
}
}
pub(crate) fn as_lowercase_str(&self) -> String {
self.as_str().to_lowercase()
}
}
#[derive(Deserialize, Debug)]
pub struct Credentials {
/// The offline profile of the user these credentials are for.
@@ -255,6 +274,7 @@ pub struct Credentials {
pub refresh_token: String,
pub expires: DateTime<Utc>,
pub active: bool,
pub account_type: String,
}
/// An entry in the player profile cache, keyed by player UUID.
@@ -296,10 +316,9 @@ impl Credentials {
}
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date, _) =
let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(
oauth_token.date,
false,
exec,
)
.await?;
@@ -480,7 +499,7 @@ impl Credentials {
let res = sqlx::query!(
"
SELECT
uuid, active, username, access_token, refresh_token, expires
uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users
WHERE active = TRUE
"
@@ -503,6 +522,7 @@ impl Credentials {
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
account_type: x.account_type,
};
credentials.refresh(exec).await.ok();
Some(credentials)
@@ -517,7 +537,7 @@ impl Credentials {
let res = sqlx::query!(
"
SELECT
uuid, active, username, access_token, refresh_token, expires
uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users
"
)
@@ -537,6 +557,7 @@ impl Credentials {
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
account_type: x.account_type,
};
async move {
@@ -572,14 +593,15 @@ impl Credentials {
sqlx::query!(
"
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET
active = $2,
username = $3,
access_token = $4,
refresh_token = $5,
expires = $6
expires = $6,
account_type = $7
",
uuid,
self.active,
@@ -587,6 +609,7 @@ impl Credentials {
self.access_token,
self.refresh_token,
expires,
self.account_type,
)
.execute(exec)
.await?;
@@ -649,6 +672,7 @@ impl Serialize for Credentials {
ser.serialize_field("refresh_token", &self.refresh_token)?;
ser.serialize_field("expires", &self.expires)?;
ser.serialize_field("active", &self.active)?;
ser.serialize_field("account_type", &self.account_type)?;
ser.end()
}
}
@@ -662,21 +686,20 @@ impl DeviceTokenPair {
#[tracing::instrument(skip(exec))]
async fn refresh_and_get_device_token(
current_date: DateTime<Utc>,
force_generate: bool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<(Self, DateTime<Utc>, bool)> {
) -> crate::Result<(Self, DateTime<Utc>)> {
let pair = Self::get(exec).await?;
if let Some(mut pair) = pair {
if pair.token.not_after > Utc::now() && !force_generate {
Ok((pair, current_date, false))
if pair.token.not_after > current_date {
Ok((pair, current_date))
} else {
let res = device_token(&pair.key, current_date).await?;
pair.token = res.value;
pair.upsert(exec).await?;
Ok((pair, res.date, true))
Ok((pair, res.date))
}
} else {
let key = generate_key()?;
@@ -689,7 +712,7 @@ impl DeviceTokenPair {
pair.upsert(exec).await?;
Ok((pair, res.date, true))
Ok((pair, res.date))
}
}
@@ -787,8 +810,8 @@ impl DeviceTokenPair {
}
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
/* [AR] Fix
* Weird visibility issue that didn't reproduce before
@@ -871,7 +894,7 @@ async fn sisu_authenticate(
"AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": token,
"Offers": [
REQUESTED_SCOPES
REQUESTED_SCOPE
],
"Query": {
"code_challenge": challenge,
@@ -879,7 +902,7 @@ async fn sisu_authenticate(
"state": generate_oauth_challenge(),
"prompt": "select_account"
},
"RedirectUri": REDIRECT_URL,
"RedirectUri": AUTH_REPLY_URL,
"Sandbox": "RETAIL",
"TokenType": "code",
"TitleId": "1794566092",
@@ -923,12 +946,12 @@ async fn oauth_token(
verifier: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new();
query.insert("client_id", "00000000402b5328");
query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("code", code);
query.insert("code_verifier", verifier);
query.insert("grant_type", "authorization_code");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -972,11 +995,11 @@ async fn oauth_refresh(
refresh_token: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
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("grant_type", "refresh_token");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
@@ -1040,7 +1063,7 @@ async fn sisu_authorize(
"/authorize",
json!({
"AccessToken": format!("t={access_token}"),
"AppId": "00000000402b5328",
"AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": device_token,
"ProofKey": {
"kty": "EC",

View File

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

View File

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

View File

@@ -1,12 +1,16 @@
///
/// [AR] Feature
///
use crate::Result;
use crate::api::update;
use crate::state::db;
///
/// [AR] Feature Utils
///
/// Version: 0.1.1
///
use crate::{Result, State};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process;
use tokio::io;
use std::time::SystemTime;
use tokio::{fs, io};
const PACKAGE_JSON_CONTENT: &str =
// include_str!("../../../../apps/app-frontend/package.json");
@@ -17,13 +21,135 @@ pub struct Launcher {
pub version: String,
}
pub fn read_package_json() -> io::Result<Launcher> {
// Deserialize the content of package.json into a Launcher struct
let launcher: Launcher = serde_json::from_str(PACKAGE_JSON_CONTENT)?;
#[derive(Debug, Deserialize)]
struct Artifact {
path: Option<String>,
sha1: Option<String>,
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Downloads {
artifact: Option<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Library {
name: String,
downloads: Option<Downloads>,
}
#[derive(Debug, Deserialize)]
struct VersionJson {
libraries: Vec<Library>,
}
/// Deserialize the content of package.json into a Launcher struct
pub fn read_package_json() -> io::Result<Launcher> {
let launcher: Launcher = serde_json::from_str(PACKAGE_JSON_CONTENT)?;
Ok(launcher)
}
/// ### AR • Ely.by Injector
/// Returns the PathBuf to the Ely.by AuthLib Injector
/// If resource doesn't exist or outdated, it will be downloaded from Git Astralium.
pub async fn get_or_download_elyby_injector() -> Result<PathBuf> {
tracing::info!("[AR] • Initializing state for Ely.by AuthLib Injector...");
let state = State::get().await?;
let libraries_dir = state.directories.libraries_dir();
// Stores the local authlib injectors from `libraries/astralrinth/authlib_injectors/` directory.
let mut local_authlib_injectors = Vec::new();
validate_astralrinth_library_dir(&libraries_dir, "authlib_injector/").await?;
let astralrinth_dir = libraries_dir.join("astralrinth/");
let authlib_injector_dir = astralrinth_dir.join("authlib_injector/");
let mut authlib_injector_dir_data = fs::read_dir(&authlib_injector_dir).await?;
// Get all local authlib injectors
while let Some(entry) = authlib_injector_dir_data.next_entry().await? {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
if file_name.starts_with("authlib-injector") {
let metadata = entry.metadata().await?;
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
local_authlib_injectors.push((path.clone(), modified));
}
}
}
// Get information about latest authlib injector from remote repository
let (asset_name, download_url) = match extract_elyby_authlib_metadata("authlib-injector").await {
Ok(data) => data,
Err(err) => {
if let Some((local_path, _)) = local_authlib_injectors
.iter()
.max_by(|a, b| a.1.cmp(&b.1))
{
tracing::info!("[AR] • Found local AuthLib Injector(s):");
for (path, time) in &local_authlib_injectors {
tracing::info!("• {:?} (modified: {:?})", path.file_name().unwrap(), time);
}
tracing::warn!("[AR] • Failed to get latest AuthLib Injector from remote, using latest local version: {}", local_path.display());
return Ok(local_path.clone());
} else {
tracing::error!("[AR] • Failed to get AuthLib Injector from remote and no local copy found.");
return Err(crate::ErrorKind::NetworkErrorOccurred { error: format!("Failed to fetch authlib-injector metadata and no local version available: {}", err) }.as_error());
}
}
};
if !local_authlib_injectors.is_empty() {
local_authlib_injectors.sort_by(|a, b| a.1.cmp(&b.1));
tracing::info!("[AR] • Found local AuthLib Injector(s):");
for (path, time) in &local_authlib_injectors {
tracing::info!("• {:?} (modified: {:?})", path.file_name().unwrap(), time);
}
}
let remote_authlib_injector = if !asset_name.is_empty() {
authlib_injector_dir.join(&asset_name)
} else {
return Err(crate::ErrorKind::ParseError { reason: "Asset name is empty from metadata".to_string() }.as_error());
};
let latest_local_authlib_injector = local_authlib_injectors
.first()
.map(|(p, _)| p.clone());
let latest_local_authlib_injector_full_path_buf = match latest_local_authlib_injector {
Some(path) => path,
None => {
tracing::info!("[AR] • No local version found, will download from remote: {}", remote_authlib_injector.display());
let bytes = fetch_bytes_from_url(download_url.as_str()).await?;
write_file_to_libraries(&remote_authlib_injector.to_string_lossy(), &bytes).await?;
tracing::info!("[AR] • Successfully saved AuthLib Injector to {}", remote_authlib_injector.display());
return Ok(remote_authlib_injector);
}
};
tracing::info!("[AR] • Remote Asset name: {}", asset_name);
tracing::info!("[AR] • Remote Download URL: {}", download_url);
tracing::info!("[AR] • Latest local AuthLib Injector: {}", latest_local_authlib_injector_full_path_buf.file_name().unwrap().to_string_lossy());
tracing::info!("[AR] • Comparing local version {} with parsed remote version {}", latest_local_authlib_injector_full_path_buf.display(), remote_authlib_injector.display());
if remote_authlib_injector == latest_local_authlib_injector_full_path_buf {
tracing::info!("[AR] • Remote version is the same as local version, using local copy.");
return Ok(latest_local_authlib_injector_full_path_buf);
} else {
tracing::info!(
"[AR] • Doesn't exist or outdated, attempting to download latest AuthLib Injector from URL: {}",
download_url
);
let bytes = fetch_bytes_from_url(download_url.as_str()).await?;
write_file_to_libraries(&remote_authlib_injector.to_string_lossy(), &bytes).await?;
tracing::info!("[AR] • Successfully saved AuthLib Injector to {}", remote_authlib_injector.display());
return Ok(remote_authlib_injector);
}
}
/// ### AR • Migration. Patch
/// Applying migration fix for SQLite database.
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
tracing::info!("[AR] • Attempting to apply migration fix");
let patched = db::apply_migration_fix(eol).await?;
@@ -35,14 +161,19 @@ pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
Ok(patched)
}
pub async fn init_download(
/// ### AR • Feature. Updater
/// Initialize the update launcher.
pub async fn init_update_launcher(
download_url: &str,
local_filename: &str,
os_type: &str,
auto_update_supported: bool,
) -> Result<()> {
println!("[AR] • Initialize downloading from • {:?}", download_url);
println!("[AR] • Save local file name • {:?}", local_filename);
tracing::info!("[AR] • Initialize downloading from • {:?}", download_url);
tracing::info!("[AR] • Save local file name • {:?}", local_filename);
tracing::info!("[AR] • OS type • {}", os_type);
tracing::info!("[AR] • Auto update supported • {}", auto_update_supported);
if let Err(e) = update::get_resource(
download_url,
local_filename,
@@ -61,3 +192,344 @@ pub async fn init_download(
}
Ok(())
}
/// ### AR • AuthLib (Ely.by)
/// Initializes the AuthLib patching process.
///
/// Returns `true` if the authlib patched successfully.
pub async fn init_authlib_patching(
minecraft_version: &str,
is_mojang: bool,
) -> Result<bool> {
let minecraft_library_metadata =
get_minecraft_library_metadata(minecraft_version).await?;
// Parses the AuthLib version from string
// Example output: "com.mojang:authlib:6.0.58" -> "6.0.58"
let authlib_version = minecraft_library_metadata
.name
.split(':')
.nth(2)
.unwrap_or("unknown");
let authlib_fullname_string = format!("authlib-{}.jar", authlib_version);
let authlib_fullname_str = authlib_fullname_string.as_str();
tracing::info!(
"[AR] • Attempting to download AuthLib -> {}.",
authlib_fullname_string
);
download_authlib(
&minecraft_library_metadata,
authlib_fullname_str,
minecraft_version,
is_mojang,
)
.await
}
/// ### AR • Universal Write (IO) Function.
/// Validating the `astralrinth/{target_directory}/` directory exists inside the libraries/astralrinth directory.
async fn validate_astralrinth_library_dir(
libraries_dir: &PathBuf,
validation_directory: &str
) -> Result<()> {
let astralrinth_path = libraries_dir.join(format!("astralrinth/{}", validation_directory));
if !astralrinth_path.exists() {
tokio::fs::create_dir_all(&astralrinth_path)
.await
.map_err(|e| {
tracing::error!(
"[AR] • Failed to create {} directory: {:?}",
astralrinth_path.display(),
e
);
crate::ErrorKind::IOErrorOccurred {
error: format!(
"Failed to create {} directory: {}",
astralrinth_path.display(),
e
),
}
.as_error()
})?;
tracing::info!(
"[AR] • Created missing {} directory",
astralrinth_path.display()
);
}
Ok(())
}
/// ### AR • Universal Write (IO) Function
/// Saves the downloaded bytes to the `libraries` directory using the given relative path.
async fn write_file_to_libraries(
relative_path: &str,
bytes: &bytes::Bytes,
) -> Result<()> {
let state = State::get().await?;
let output_path = state.directories.libraries_dir().join(relative_path);
fs::write(&output_path, bytes).await.map_err(|e| {
tracing::error!("[AR] • Failed to save file: {:?}", e);
crate::ErrorKind::IOErrorOccurred {
error: format!("Failed to save file: {e}"),
}
.as_error()
})
}
/// ### AR • AuthLib (Ely.by)
/// Downloads the AuthLib file from Mojang libraries or Git Astralium services.
async fn download_authlib(
minecraft_library_metadata: &Library,
authlib_fullname: &str,
minecraft_version: &str,
is_mojang: bool,
) -> Result<bool> {
let state = State::get().await?;
let (mut url, path) = extract_minecraft_local_download_info(
minecraft_library_metadata,
minecraft_version,
)?;
let full_path = state.directories.libraries_dir().join(path);
if !is_mojang {
tracing::info!(
"[AR] • Attempting to download AuthLib from Git Astralium"
);
(_, url) = extract_elyby_authlib_metadata(authlib_fullname).await?;
}
tracing::info!("[AR] • Downloading AuthLib from URL: {}", url);
let bytes = fetch_bytes_from_url(&url).await?;
tracing::info!("[AR] • Will save to path: {}", full_path.to_str().unwrap());
write_file_to_libraries(full_path.to_str().unwrap(), &bytes).await?;
tracing::info!("[AR] • Successfully saved AuthLib to {:?}", full_path);
Ok(true)
}
/// ### AR • AuthLib (Ely.by)
/// Parses the ElyIntegration release JSON and returns the download URL for the given AuthLib version.
async fn extract_elyby_authlib_metadata(
authlib_fullname: &str,
) -> Result<(String, String)> {
const URL: &str = "https://git.astralium.su/api/v1/repos/didirus/ElyIntegration/releases/latest";
let response = reqwest::get(URL).await.map_err(|e| {
tracing::error!(
"[AR] • Failed to fetch ElyIntegration release JSON: {:?}",
e
);
crate::ErrorKind::NetworkErrorOccurred {
error: format!(
"Failed to fetch ElyIntegration release JSON: {}",
e
),
}
.as_error()
})?;
let json: serde_json::Value = response.json().await.map_err(|e| {
tracing::error!("[AR] • Failed to parse ElyIntegration JSON: {:?}", e);
crate::ErrorKind::ParseError {
reason: format!("Failed to parse ElyIntegration JSON: {}", e),
}
.as_error()
})?;
let assets =
json.get("assets")
.and_then(|v| v.as_array())
.ok_or_else(|| {
crate::ErrorKind::ParseError {
reason: "Missing 'assets' array".into(),
}
.as_error()
})?;
let asset = assets
.iter()
.find(|a| {
a.get("name")
.and_then(|n| n.as_str())
.map(|n| n.contains(authlib_fullname))
.unwrap_or(false)
})
.ok_or_else(|| {
crate::ErrorKind::ParseError {
reason: format!(
"No matching asset for {} in ElyIntegration JSON response.",
authlib_fullname
),
}
.as_error()
})?;
let download_url = asset
.get("browser_download_url")
.and_then(|u| u.as_str())
.ok_or_else(|| {
crate::ErrorKind::ParseError {
reason: "Missing 'browser_download_url'".into(),
}
.as_error()
})?
.to_string();
let asset_name = asset
.get("name")
.and_then(|n| n.as_str())
.ok_or_else(|| {
crate::ErrorKind::ParseError {
reason: "Missing 'name'".into(),
}
.as_error()
})?
.to_string();
Ok((asset_name, download_url))
}
/// ### AR • AuthLib (Ely.by)
/// Extracts the artifact URL and Path from the library structure.
///
/// Returns a tuple of references to the URL and path strings,
/// or an error if the required metadata is missing.
fn extract_minecraft_local_download_info(
minecraft_library_metadata: &Library,
minecraft_version: &str,
) -> Result<(String, String)> {
let artifact = minecraft_library_metadata
.downloads
.as_ref()
.and_then(|d| d.artifact.as_ref())
.ok_or_else(|| {
crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(),
}
.as_error()
})?;
let url = artifact.url.clone().ok_or_else(|| {
crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(),
}
.as_error()
})?;
let path = artifact.path.clone().ok_or_else(|| {
crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(),
}
.as_error()
})?;
Ok((url, path))
}
/// ### AR • Universal Fetch Bytes (IO)
/// Downloads bytes from the provided URL with a 15 second timeout.
async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> {
// Create client instance with request timeout.
let client = reqwest::Client::new();
const TIMEOUT_SECONDS: u64 = 15;
let response = tokio::time::timeout(
std::time::Duration::from_secs(TIMEOUT_SECONDS),
client.get(url).send(),
)
.await
.map_err(|_| {
tracing::error!(
"[AR] • Download timed out after {} seconds",
TIMEOUT_SECONDS
);
crate::ErrorKind::NetworkErrorOccurred {
error: format!(
"Download timed out after {TIMEOUT_SECONDS} seconds"
)
.to_string(),
}
.as_error()
})?
.map_err(|e| {
tracing::error!("[AR] • Request error: {:?}", e);
crate::ErrorKind::NetworkErrorOccurred {
error: format!("Request error: {e}"),
}
.as_error()
})?;
if !response.status().is_success() {
let status = response.status().to_string();
tracing::error!("[AR] • Failed to download file: HTTP {}", status);
return Err(crate::ErrorKind::NetworkErrorOccurred {
error: format!("Failed to download file: HTTP {status}"),
}
.as_error());
}
response.bytes().await.map_err(|e| {
tracing::error!("[AR] • Failed to read response bytes: {:?}", e);
crate::ErrorKind::NetworkErrorOccurred {
error: format!("Failed to read response bytes: {e}"),
}
.as_error()
})
}
/// ### AR • AuthLib (Ely.by)
/// Gets the Minecraft library metadata from the local libraries directory.
async fn get_minecraft_library_metadata(
minecraft_version: &str,
) -> Result<Library> {
let state = State::get().await?;
let path = state
.directories
.version_dir(minecraft_version)
.join(format!("{}.json", minecraft_version));
if !path.exists() {
tracing::error!("[AR] • File not found: {:#?}", path);
return Err(crate::ErrorKind::InvalidMinecraftVersion {
minecraft_version: minecraft_version.to_string(),
}
.as_error());
}
let content = fs::read_to_string(&path).await?;
let version_data: VersionJson = serde_json::from_str(&content)?;
for lib in version_data.libraries {
if lib.name.contains("com.mojang:authlib") {
if let Some(downloads) = &lib.downloads {
if let Some(artifact) = &downloads.artifact {
if artifact.path.is_some()
&& artifact.url.is_some()
&& artifact.sha1.is_some()
{
tracing::info!("[AR] • Found AuthLib: {}", lib.name);
tracing::info!(
"[AR] • Path: {}",
artifact.path.as_ref().unwrap()
);
tracing::info!(
"[AR] • URL: {}",
artifact.url.as_ref().unwrap()
);
tracing::info!(
"[AR] • SHA1: {}",
artifact.sha1.as_ref().unwrap()
);
return Ok(lib);
}
}
}
}
}
Err(crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(),
}
.as_error())
}

View File

@@ -12,6 +12,7 @@ import _BellRingIcon from './icons/bell-ring.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BlocksIcon from './icons/blocks.svg?component'
import _BoldIcon from './icons/bold.svg?component'
import _BookOpenIcon from './icons/book-open.svg?component'
import _BookTextIcon from './icons/book-text.svg?component'
import _BookIcon from './icons/book.svg?component'
import _BookmarkIcon from './icons/bookmark.svg?component'
@@ -19,6 +20,7 @@ import _BotIcon from './icons/bot.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BoxIcon from './icons/box.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
@@ -86,6 +88,7 @@ import _InfoIcon from './icons/info.svg?component'
import _IssuesIcon from './icons/issues.svg?component'
import _ItalicIcon from './icons/italic.svg?component'
import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
@@ -166,6 +169,7 @@ import _TextQuoteIcon from './icons/text-quote.svg?component'
import _TimerIcon from './icons/timer.svg?component'
import _TransferIcon from './icons/transfer.svg?component'
import _TrashIcon from './icons/trash.svg?component'
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
import _UnderlineIcon from './icons/underline.svg?component'
import _UndoIcon from './icons/undo.svg?component'
import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
@@ -199,6 +203,7 @@ export const BellRingIcon = _BellRingIcon
export const BellIcon = _BellIcon
export const BlocksIcon = _BlocksIcon
export const BoldIcon = _BoldIcon
export const BookOpenIcon = _BookOpenIcon
export const BookTextIcon = _BookTextIcon
export const BookIcon = _BookIcon
export const BookmarkIcon = _BookmarkIcon
@@ -206,6 +211,7 @@ export const BotIcon = _BotIcon
export const BoxImportIcon = _BoxImportIcon
export const BoxIcon = _BoxIcon
export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const CalendarIcon = _CalendarIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
@@ -273,6 +279,7 @@ export const InfoIcon = _InfoIcon
export const IssuesIcon = _IssuesIcon
export const ItalicIcon = _ItalicIcon
export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LanguagesIcon = _LanguagesIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
@@ -353,6 +360,7 @@ export const TextQuoteIcon = _TextQuoteIcon
export const TimerIcon = _TimerIcon
export const TransferIcon = _TransferIcon
export const TrashIcon = _TrashIcon
export const TriangleAlertIcon = _TriangleAlertIcon
export const UnderlineIcon = _UnderlineIcon
export const UndoIcon = _UndoIcon
export const UnknownDonationIcon = _UnknownDonationIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-open-icon lucide-book-open"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

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