50 Commits

Author SHA1 Message Date
46d30e491a ci: another fix 2025-07-21 02:20:12 +03:00
059c0618f1 ci: reconfigure output bundles 2025-07-21 02:00:52 +03:00
7ef60fcafe fix: incorrect authlib injector setup in special cases 2025-07-21 02:00:17 +03:00
ec17e79014 Merge pull request 'feature-elyby-account' (#9) from feature-elyby-account into beta
Reviewed-on: didirus/AstralRinth#9
2025-07-21 00:49:30 +03:00
e351d674f4 refactor: Improves some features in our utils.rs 2025-07-21 00:41:23 +03:00
f555fa916a (WIP) feat: ely.by account authentication 2025-07-20 08:10:04 +03:00
dbe38cb4e7 Merge commit 'ae25a15abd6e78be3d5dbf8f23aa1a5cdc53531e' into feature-elyby-account 2025-07-20 02:09:53 +03:00
2e40e26116 Merge commit 'a8caa1afc3115cc79da25d8129e749932c7dc2a5' into feature-elyby-account 2025-07-20 02:08:02 +03:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
Emma Alexia
a8caa1afc3 Clarify that Modrinth Servers are for Java Edition (#4021) 2025-07-18 18:37:06 +00:00
Emma Alexia
98e9a8473d Fix NeoForge instance importing from MultiMC/Prism (#4016)
Fixes DEV-170
2025-07-18 13:00:11 +00:00
coolbot
936395484e fix: status alerts and version buttons no longer cause a failed to generate error. (#4017)
* add empty message to actions with no message, fixing broken message generation.

* fix typo in 2.2 / description message.
2025-07-18 05:32:31 +00:00
Emma Alexia
0c3e23db96 Improve errors when email is already in use (#4014)
Fixes #1485

Also fixes an issue where email_verified was being set to true regardless of whether the oauth provider provides an email (thus indicating that a null email is verified)
2025-07-18 01:59:48 +00:00
Gwenaël DENIEL
013ba4d86d Update Browse.vue (#4000)
Updated functions refreshSearch and clearSearch to reset the currentPage.value to 1

Signed-off-by: Gwenaël DENIEL <monsieur.potatoes93@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-17 07:58:24 +00:00
coolbot
93813c448c Add buttons for tec team, as well as other requested actions (#4012)
* add tec rev related buttons, identity verification button, and fix edge case appearance of links stage.

* lint fix
2025-07-17 07:49:11 +00:00
coolbot
c20b869e62 fix text in license and links stages (#4010)
* fix text in license and links stages, change a license option to conditional

* remove unused project definition

* Switch markdown to use <br />

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-17 03:05:00 +00:00
Alejandro González
56c556821b refactor(app-frontend): followup to PR #3999 (#4008) 2025-07-17 00:07:18 +00:00
IMB11
44267619b6 Author Validation Improvements (#3970)
* feat: set up typed nag (validators) system

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

* feat: start work implementing validation checks using new nag system

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 22:28:42 +00:00
10afd673db add download authlib injector support 2025-07-17 00:54:26 +03:00
Prospector
90043fe84d Remove tumblr from footer since it's no longer in use (#4001)
* Remove tumblr from footer since it's no longer in use

* remove import

* i18n extract

---------

Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-07-16 20:44:56 +00:00
Prospector
a6a98ff63e remove import 2025-07-16 12:35:14 -07:00
AnotherPillow
911652133b fix: report body overflowing container (#3983)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 19:09:02 +00:00
IMB11
cee1b5f522 fix: use node instance url to fix staging (#4005)
* fix: use node instance url to fix staging

* fix: check if node instance exists first
2025-07-16 18:57:31 +00:00
coolbot
62f5a23fcb Moderation Checklist V1.5 (#3980)
* starting on new checklist implementation

Change default shouldShow behavior for stages.
add new messages and stages.
Change some existing stage logic.
Add placeholder var for the rules.

Co-Authored-By: @coolbot100s

* misc fixes + corrections

* Add clickable link previews to links stage

* Correct mislabeled title message and add new title messages

* Change message formatting, use rules variable, correct wip desc and title 1.8 messages, add tags buttons

* More applications of rules placeholder

* Add new status alerts stage

* change order of statusAlerts

* Update title related messages, add navigation based vars

* Overhaul Links stage and add new messages.

* Set message weights, add some disables

* message.mds now obey lint >:(

* fixed links text message formatting and changed an icon

* Combine title and slug stages

* Add more info to some stages and properly case stage ids

* tweak summary text formatting

* Improved tags stage info and more navigation placeholders

* redo reupload stage, more navigation placeholders, licensing stage improvements, versions stage improvements, status alerts stage improvements

* Allow modpack permissions stage to appear again by adding a dummy button.

* Update modpack permissions guidance

* fix: blog path issues

* fix: lint issues

* fix license stage text formatting

* Improve license stage

* feat: move links into one md file to be cleaner

* Update packages/moderation/data/stages/links.ts

Signed-off-by: IMB11 <hendersoncal117@gmail.com>

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <calum@modrinth.com>
2025-07-16 18:48:26 +00:00
5a10292add feat: add support for multiple account types in database 2025-07-16 20:33:58 +03:00
3f606a08aa Merge commit 'eb595cdc3e4a6953cbde00c0e119e476ef767a52' into beta 2025-07-16 14:34:07 +03:00
Silcean
eb595cdc3e Feature/detect skin variant on fileinput (#3999)
* chaged detection algorithm, and added skin variant deteciton on fileinput

* Update skins.ts

removed leftover logs

* removed pnpm lock changes. Simplyfied the transparency check in skin variant detection

* fully reverted lock.yaml. my bad.

---------

Co-authored-by: Bronchiopator <70262842+Bronchiopator@users.noreply.github.com>
2025-07-16 10:43:30 +00:00
Josiah Glosson
572cd065ed Allow joining offline servers from the Worlds tab (#3998)
* Allow joining offline servers from the Worlds tab

* Run intl:extract

* Fix lint
2025-07-15 23:58:04 +00:00
Prospector
76dc8a0897 Update DDoS protection on Modrinth Servers page 2025-07-15 13:51:35 -07:00
Prospector
4723de6269 Update MRS marketing and add copyright policy to footer 2025-07-15 12:36:29 -07:00
Prospector
e15fa35bad Update changelog 2025-07-15 08:15:26 -07:00
IMB11
2cc6bc8ce4 fix: unidentified files not showing in final checklist message (#3992)
* fix: unidentified files not showing in final message

* fix: remove condition
2025-07-14 15:23:15 +00:00
Nitrrine
5d19d31b2c fix(web): prevent gallery item description from overflowing (#3990)
* fix(web): prevent gallery item description from overflowing

* break overflowing text instead of hiding it

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

* fix: fix

---------

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>
2025-07-13 23:36:43 +00:00
IMB11
c1b95ede07 fix: checklist conditional message issues + MD formatting (#3989) 2025-07-13 20:23:06 +00:00
IMB11
058185c7fd Moderation Checklist Fixes (#3986)
* fix: DEV-164

* fix: dev-163

* feat: DEV-162
2025-07-13 18:08:55 +00:00
IMB11
6fb125cf0f fix: keybind issue + view moderation page on final step (#3977)
* fix: keybind issue + view moderation page on final step

* fix: go to moderation page on generate message thing
2025-07-12 21:48:53 +00:00
Alejandro González
a945e9b005 tweak(labrinth): drop last remaining dependency on system OpenSSL (#3982)
We standarized on using `rustls` as a TLS implementation across the
monorepo, which is written in Rust and has better ergonomics,
integration with the Rust ecosystem, and consistent behavior among
platforms. However, the Labrinth Clickhouse client was the last
remaining exception to this, using the native, OS-provided TLS
implementation, which on Linux is OpenSSL and requires developers and
Docker images to install OpenSSL development packages to build Labrinth,
in addition to introducing an additional runtime dependency to Labrinth.

Let's make the process of building Labrinth slightly simpler by
switching such client to `rustls` as well, which results in finally
using the same TLS implementation for everything, a simplified build and
distribution process, less transitive dependencies, and potentially
smaller binaries (since `rustls` was already being pulled in for, e.g.,
the SMTP client).
2025-07-12 13:39:41 +00:00
Josiah Glosson
b943638afb Verify that intl:extract has been run (#3979) 2025-07-11 21:48:07 +00:00
IMB11
207dc0e2bb fix: keybind for collapse (#3971) 2025-07-11 16:41:14 +00:00
IMB11
359fbd4738 feat: moderation improvements (#3881)
* feat: rough draft of tool

* fix: example doc

* feat: multiselect chips

* feat: conditional actions+messaages + utils for handling conditions

* feat: migrate checklist v1 to new format.

* fix: lint issues

* fix: severity util

* feat: README.md

* feat: start implementing new moderation checklist

* feat: message assembly + fix imports

* fix: lint issues

* feat: add input suggestions

* feat: utility cleanup

* fix: icon

* chore: remove debug logging

* chore: remove debug button

* feat: modpack permissions flow into it's own component

* feat: icons + use id in stage selection button

* Support md/plain text in stages.

* fix: checklist not persisting/showing on subpages

* feat: message gen + appr/with/deny buttons

* feat: better notification placement + queue navigation

* fix: default props for futureProjects

* fix: modpack perms message

* fix: issue with future projects props

* fix: tab index + z index fixes

* feat: keybinds

* fix: file approval types

* fix: generate message for non-modpack projects

* feat: add generate message to stages dropdown

* fix: variables not expanding

* feat: requests

* fix: empty message approval

* fix: issues from sync

* chore: add comment for old moderation checklist impl

* fix: git artifacts

* fix: update visibility logic for stages and actions

* fix: cleanup logic for should show

* fix: markdown editor accidental edit
2025-07-11 16:09:04 +00:00
Prospector
f7700acce4 Updated changelog 2025-07-09 22:12:32 -07:00
IMB11
87a3e2d022 fix: white cape (#3959) 2025-07-09 23:31:00 +00:00
Prospector
5d17663040 Don't unnecessarily markdown-ify links pasted into markdown editor (#3958) 2025-07-09 23:11:17 +00:00
ToBinio
cff3c72f94 feat(theseus): add snapPoints for memory sliders (#1275)
* feat: add snapPoints for memory sliders

* fix lint

* Reapply changes

* Hide snap point display when disabled

* fix unused imports

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:59:59 +00:00
Erb3
fadf475f06 docs(frontend): add security.txt (#2252)
* feat: add security.txt

Security.txt is a well-known (pun intended) file among security researchers, so they don't have to go scavenging for your security information. More information is available on [securitytxt.org](https://securitytxt.org/).

I've set the following values:

- The email to contact with issues, `jai@modrinth.com`. This is the email stated in the security policy. If you wish to not include it here due to spam, you should also not have it as a `mailto` link in the security policy.
- Expiry is set to 2030. By this time Modrinth has become the biggest Minecraft mod distributor, and having expanded into other games. By this time they should also have updated this file.
- English is the preferred language
- The file is located at modrinth.com/.well-known/security.txt
- The security policy is at https://modrinth.com/legal/security

The following values have been left unset:

- PGP key, not sure where this would be located, if there is one
- Acknowledgments. Modrinth does currently not have a site for thanks
- Hiring, as it wants security-related positions
- CSAF, a Common Security Advisory Framework ?

* fix(docs): reduce security.txt expiry

This addresses a concern where the security.txt has a long expiration date. Someone could treat this as "use this until then", which we don't want since it's a long time. The specification recommends no longer than one year, as it is to mark as stale.

From the RFC:

> The "Expires" field indicates the date and time after which the data contained in the "security.txt" file is considered stale and should not be used (as per Section 5.3). The value of this field is formatted according to the Internet profiles of [ISO.8601-1] and [ISO.8601-2] as defined in [RFC3339]. It is RECOMMENDED that the value of this field be less than a year into the future to avoid staleness.

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* fix(frontend): extend security.txt expiry

It takes so long to merge the PR :(

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* docs(frontend) careers link in security.txt

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
Co-authored-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-07-09 15:51:46 -07:00
tippfehlr
7228499737 fix(theseus-gui): fix sort/group by game version (#1250)
* fix(theseus-gui): fix sort/group by game version

In the Library, game version 1.8.9 is sorted/grouped after 1.20 because
the default sorting sorts 2 < 8
therefore localeCompare(with numeric=true) is needed, it detects 8 < 20
and puts the versions in the correct order.

* lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:30:11 +00:00
Prospector
bca467a634 Add coventry europe region support (#3956)
* Add coventry europe region support, rename germany EU location to central europe

* extract messages

* extract messages again
2025-07-09 22:27:59 +00:00
184 changed files with 6690 additions and 775 deletions

View File

@@ -144,5 +144,10 @@ jobs:
with: with:
name: App bundle (${{ matrix.artifact-target-name }}) name: App bundle (${{ matrix.artifact-target-name }})
path: | path: |
target/release/bundle/** target/release/bundle/appimage/AstralRinth App_*.AppImage*
target/*/release/bundle/** 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*

482
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { 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]) 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 return instanceMap
}) })

View File

@@ -1,17 +1,9 @@
<template> <template>
<div <div v-if="mode !== 'isolated'" ref="button"
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="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }" :class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
@click="toggleMenu" <Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
> " />
<Avatar
size="36px"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<span> <span>
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" /> <component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
@@ -32,30 +24,23 @@
</h4> </h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="login-section account"> <div v-else class="login-section account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
v-tooltip="'Log in'" <MicrosoftIcon v-if="!microsoftLoginDisabled" />
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<MicrosoftIcon v-if="!loginDisabled"/>
<SpinnerIcon v-else class="animate-spin" /> <SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </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>
<div v-if="displayAccounts.length > 0" class="account-group"> <div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row"> <div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
@@ -72,35 +57,114 @@
</div> </div>
</div> </div>
<div v-if="accounts.length > 0" class="login-section account centered"> <div v-if="accounts.length > 0" class="login-section account centered">
<Button v-tooltip="'Log in'" icon-only @click="login()"> <Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
<MicrosoftIcon /> <MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </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>
</Card> </Card>
</transition> </transition>
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account"> <ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
<div class="modal-body"> <ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
<div class="label">Enter offline username</div> header="Ely.by requested 2FA code for authentication">
<input type="text" v-model="playerName" placeholder="Provide offline player name" /> <div class="flex flex-col gap-4 px-6 py-5">
<Button icon-only color="secondary" @click="offlineLoginFinally()"> <label class="label">Enter your 2FA code</label>
Continue <input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
</Button> <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> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed"> <ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
<div class="modal-body"> <div class="flex flex-col gap-4 px-6 py-5">
<div class="label">Error occurred while adding offline account</div> <label class="label">Enter your player name</label>
<Button color="primary" @click="retryOfflineLogin()"> <input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
Try again <div class="mt-6 ml-auto">
</Button> <Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
Login
</Button>
</div>
</div> </div>
</ModalWrapper> </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="modal-body">
<div class="label">Unexcepted error</div> <label class="label">An unexpected error has occurred. Please try again later.</label>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
@@ -108,17 +172,20 @@
<script setup> <script setup>
import { import {
DropdownIcon, DropdownIcon,
PlusIcon,
TrashIcon, TrashIcon,
LogInIcon,
PirateIcon as Offline, PirateIcon as Offline,
MicrosoftIcon as License, MicrosoftIcon as License,
ElyByIcon as Elyby,
MicrosoftIcon, MicrosoftIcon,
PirateIcon, PirateIcon,
SpinnerIcon } from '@modrinth/assets' ElyByIcon,
SpinnerIcon
} from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
elyby_auth_authenticate,
elyby_login,
offline_login, offline_login,
users, users,
remove_user, remove_user,
@@ -145,48 +212,180 @@ defineProps({
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const accounts = ref({}) const accounts = ref({})
const loginDisabled = ref(false) const microsoftLoginDisabled = ref(false)
const elybyLoginDisabled = ref(false)
const defaultUser = ref() const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // [AR] Feature // [AR] Feature
loginOfflineModal.value.show() 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 // [AR] Feature
const name = playerName.value function showOfflineLoginModal() {
if (name.length > 1 && name.length < 20 && name !== '') { addOfflineModal.value?.show()
const loggedIn = await offline_login(name).catch(handleError) }
loginOfflineModal.value.hide()
if (loggedIn) { // [AR] • Feature
await setAccount(loggedIn) 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() await refreshValues()
} else { } else {
unexpectedErrorModal.value.show() exceptionErrorModal.value?.show()
} }
playerName.value = '' } catch (error) {
} else { handleError(error)
playerName.value = '' exceptionErrorModal.value?.show()
loginOfflineModal.value.hide() } finally {
loginErrorModal.value.show() clearOfflineFields()
} }
} }
function retryOfflineLogin() { // [AR] Feature // [AR] Feature
loginErrorModal.value.hide() async function addElybyProfile() {
tryOfflineLogin() if (!elybyLogin.value || !elybyPassword.value) {
} addElybyModal.value?.hide()
inputElybyErrorModal.value?.show()
clearElybyFields()
return
}
elybyLoginDisabled.value = true
function getAccountType(account) { // [AR] Feature const login = elybyLogin.value.trim()
if (account.access_token != "null" && account.access_token != null && account.access_token != "") { let password = elybyPassword.value.trim()
return License const twoFactorCode = elybyTwoFactorCode.value.trim()
} else { if (password && twoFactorCode) {
return Offline 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 equippedSkin = ref(null)
const headUrlCache = ref(new Map()) const headUrlCache = ref(new Map())
@@ -212,13 +411,13 @@ async function refreshValues() {
} }
function setLoginDisabled(value) { function setLoginDisabled(value) {
loginDisabled.value = value microsoftLoginDisabled.value = value
} }
defineExpose({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled, setLoginDisabled,
loginDisabled, loginDisabled: microsoftLoginDisabled,
}) })
await refreshValues() await refreshValues()
@@ -264,7 +463,7 @@ async function setAccount(account) {
} }
async function login() { async function login() {
loginDisabled.value = true microsoftLoginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
@@ -273,7 +472,7 @@ async function login() {
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false microsoftLoginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {

View File

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

View File

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

View File

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

View File

@@ -128,6 +128,14 @@ const messages = defineMessages({
id: 'instance.worlds.game_already_open', id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is 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: { copyAddress: {
id: 'instance.worlds.copy_address', id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address', defaultMessage: 'Copy address',
@@ -302,39 +310,33 @@ const messages = defineMessages({
</template> </template>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus"> <ButtonStyled
<ButtonStyled v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" color="red"
color="red" >
> <button @click="emit('stop')">
<button @click="emit('stop')"> <StopCircleIcon aria-hidden="true" />
<StopCircleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.stopButton) }}
{{ formatMessage(commonMessages.stopButton) }} </button>
</button> </ButtonStyled>
</ButtonStyled> <ButtonStyled v-else>
<ButtonStyled v-else> <button
<button v-tooltip="
v-tooltip=" !serverStatus
serverIncompatible ? formatMessage(messages.noContact)
? 'Server is incompatible' : serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay : !supportsQuickPlay
? formatMessage(messages.noQuickPlay) ? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked : playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.gameAlreadyOpen)
: null : null
" "
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance" :disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')" @click="emit('play')"
> >
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" /> <PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </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 }) 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. * Authenticate a user with Hydra - part 1.
* This begins the authentication flow quasi-synchronously. * This begins the authentication flow quasi-synchronously.

View File

@@ -2,7 +2,13 @@ import * as THREE from 'three'
import type { Skin, Cape } from '../skins' import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins' import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue' 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 { skinPreviewStorage } from '../storage/skin-preview-storage'
import { headStorage } from '../storage/head-storage' import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets' import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
@@ -120,6 +126,9 @@ class BatchSkinRenderer {
if (capeUrl) { if (capeUrl) {
const capeTexture = await loadTexture(capeUrl) const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture) applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
} }
const group = new THREE.Group() 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) context.drawImage(image, 0, 0)
const armX = 44 const armX = 54
const armY = 16 const armY = 20
const armWidth = 4 const armWidth = 2
const armHeight = 12 const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
if (imageData[alphaIndex] !== 0) { if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC') resolve('CLASSIC')
return return

View File

@@ -10,20 +10,20 @@ export async function getOS() {
return await invoke('plugin:utils|get_os') return await invoke('plugin:utils|get_os')
} }
// [AR] Feature // [AR] Feature. Updater
export async function initUpdateLauncher(downloadurl, filename, ostype, autoupdatesupported) { export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported) console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
return await invoke('plugin:utils|init_update_launcher', { 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) { export async function applyMigrationFix(eol) {
return await invoke('plugin:utils|apply_migration_fix', { eol }) return await invoke('plugin:utils|apply_migration_fix', { eol })
} }
// [AR] Feature // [AR] Feature. Ely.by
export async function initAuthlibPatching(minecraftversion, ismojang) { export async function initAuthlibPatching(minecraftVersion, isMojang) {
return await invoke('plugin:utils|init_authlib_patching', { minecraftversion, ismojang }) return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
} }
export async function openPath(path) { export async function openPath(path) {

View File

@@ -377,6 +377,12 @@
"instance.worlds.hardcore": { "instance.worlds.hardcore": {
"message": "Hardcore mode" "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": { "instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "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 results.value = rawResults.result
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value)) currentPage.value = 1
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1
} }
watch( watch(

View File

@@ -13,6 +13,8 @@ fn main() {
InlinedPlugin::new() InlinedPlugin::new()
.commands(&[ .commands(&[
"offline_login", "offline_login",
"elyby_login",
"elyby_auth_authenticate",
"login", "login",
"remove_user", "remove_user",
"get_default_user", "get_default_user",

View File

@@ -2,12 +2,15 @@ use crate::api::Result;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType}; use tauri::{Manager, Runtime, UserAttentionType};
use tauri_plugin_http::reqwest::Client;
use theseus::prelude::*; use theseus::prelude::*;
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::<R>::new("auth") tauri::plugin::Builder::<R>::new("auth")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
offline_login, offline_login,
elyby_login,
elyby_auth_authenticate,
login, login,
remove_user, remove_user,
get_default_user, get_default_user,
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build() .build()
} }
/// ### AR • Feature
/// Create new offline user /// Create new offline user
/// This is custom function from Astralium Org.
#[tauri::command] #[tauri::command]
pub async fn offline_login(name: &str) -> Result<Credentials> { pub async fn offline_login(name: &str) -> Result<Credentials> {
let credentials = minecraft_auth::offline_auth(name).await?; let credentials = minecraft_auth::offline_auth(name).await?;
Ok(credentials) 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 /// 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) /// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
#[tauri::command] #[tauri::command]

View File

@@ -30,37 +30,37 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.build() .build()
} }
/// [AR] Feature /// [AR] Feature. Ely.by
#[tauri::command] #[tauri::command]
pub async fn init_authlib_patching( pub async fn init_authlib_patching(
minecraftversion: &str, minecraft_version: &str,
ismojang: bool, is_mojang: bool,
) -> Result<bool> { ) -> Result<bool> {
let result = let result =
utils::init_authlib_patching(minecraftversion, ismojang).await?; utils::init_authlib_patching(minecraft_version, is_mojang).await?;
Ok(result) Ok(result)
} }
/// [AR] Patch fix /// [AR] Migration. Patch
#[tauri::command] #[tauri::command]
pub async fn apply_migration_fix(eol: &str) -> Result<bool> { pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
let result = utils::apply_migration_fix(eol).await?; let result = utils::apply_migration_fix(eol).await?;
Ok(result) Ok(result)
} }
/// [AR] Feature /// [AR] Feature. Updater
#[tauri::command] #[tauri::command]
pub async fn init_update_launcher( pub async fn init_update_launcher(
downloadurl: &str, download_url: &str,
filename: &str, filename: &str,
ostype: &str, os_type: &str,
autoupdatesupported: bool, auto_update_supported: bool,
) -> Result<()> { ) -> Result<()> {
let _ = utils::init_update_launcher( let _ = utils::init_update_launcher(
downloadurl, download_url,
filename, filename,
ostype, os_type,
autoupdatesupported, auto_update_supported,
) )
.await; .await;
Ok(()) Ok(())

View File

@@ -41,7 +41,7 @@
] ]
}, },
"productName": "AstralRinth App", "productName": "AstralRinth App",
"version": "0.10.304", "version": "0.10.305",
"mainBinaryName": "AstralRinth App", "mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp", "identifier": "AstralRinthApp",
"plugins": { "plugins": {
@@ -86,7 +86,7 @@
"capabilities": ["core", "plugins"], "capabilities": ["core", "plugins"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "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/"], "font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",

View File

@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
sqlx database setup 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. To enable labrinth to create a project, you need to add two things.
1. An entry in the `loaders` table. 1. An entry in the `loaders` table.

View File

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

View File

@@ -197,13 +197,13 @@
} }
> :where( > :where(
input + *, input + *,
.input-group + *, .input-group + *,
.textarea-wrapper + *, .textarea-wrapper + *,
.chips + *, .chips + *,
.resizable-textarea-wrapper + *, .resizable-textarea-wrapper + *,
.input-div + * .input-div + *
) { ) {
&:not(:empty) { &:not(:empty) {
margin-block-start: var(--spacing-card-md); 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-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-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), 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); 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; 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; --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.35) 0%,
rgba(255, 255, 255, 0.2695) 100% rgba(255, 255, 255, 0.2695) 100%
); );
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), --landing-blob-shadow:
inset 2px 2px 64px rgba(255, 255, 255, 0.45); 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-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --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-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1); --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; 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; --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-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"); url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%); --landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -284,7 +288,8 @@ html {
rgba(44, 48, 79, 0.35) 0%, rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100% 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-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -360,8 +365,9 @@ body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, --font-standard:
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 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; --mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard); font-family: var(--font-standard);
font-size: 16px; font-size: 16px;

View File

@@ -1,7 +1,10 @@
<template> <template>
<div <div
class="vue-notification-group experimental-styles-within" class="vue-notification-group experimental-styles-within"
:class="{ 'intercom-present': isIntercomPresent }" :class="{
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
> >
<transition-group name="notifs"> <transition-group name="notifs">
<div <div
@@ -82,6 +85,7 @@ import {
CopyIcon, CopyIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
const notifications = useNotifications(); const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const isIntercomPresent = ref(false); const isIntercomPresent = ref(false);
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
bottom: 5rem; bottom: 5rem;
} }
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper { .vue-notification-wrapper {
width: 100%; width: 100%;
overflow: hidden; 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,422 @@
<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 obtained. You may skip this step!</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 } 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 = ref<ModerationModpackItem[] | null>(null);
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 sortedData: ModerationModpackItem[] = [
...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 = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
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(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
</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 { .markdown-body {
grid-area: body; grid-area: body;
max-width: 100%;
} }
.reporter-info { .reporter-info {

View File

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

View File

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

View File

@@ -112,7 +112,8 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, { const response = await $fetch<T>(fullUrl, {
method, method,
headers, headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, body:
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000, 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, PackageOpenIcon,
DiscordIcon, DiscordIcon,
BlueskyIcon, BlueskyIcon,
TumblrIcon,
TwitterIcon, TwitterIcon,
MastodonIcon, MastodonIcon,
GithubIcon, GithubIcon,
@@ -1185,13 +1184,6 @@ const socialLinks = [
icon: MastodonIcon, icon: MastodonIcon,
rel: "me", 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" })), label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth", 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": { "layout.footer.about": {
"message": "About" "message": "About"
}, },
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.careers": { "layout.footer.about.careers": {
"message": "Careers" "message": "Careers"
}, },
"layout.footer.about.changelog": { "layout.footer.about.changelog": {
"message": "Changelog" "message": "Changelog"
}, },
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.rewards-program": { "layout.footer.about.rewards-program": {
"message": "Rewards Program" "message": "Rewards Program"
}, },
@@ -404,6 +404,9 @@
"layout.footer.legal-disclaimer": { "layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." "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": { "layout.footer.legal.privacy-policy": {
"message": "Privacy Policy" "message": "Privacy Policy"
}, },
@@ -458,9 +461,6 @@
"layout.footer.social.mastodon": { "layout.footer.social.mastodon": {
"message": "Mastodon" "message": "Mastodon"
}, },
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": { "layout.footer.social.x": {
"message": "X" "message": "X"
}, },

View File

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

View File

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

View File

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

View File

@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
const maxWithdrawAmount = computed(() => { const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; 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 minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; 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(() => { const withdrawAccount = computed(() => {

View File

@@ -212,6 +212,10 @@ if (projects.value) {
async function goToProjects() { async function goToProjects() {
const project = projectsFiltered.value[0]; 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({ await router.push({
name: "type-id", name: "type-id",
params: { params: {
@@ -220,7 +224,6 @@ async function goToProjects() {
}, },
state: { state: {
showChecklist: true, showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
}, },
}); });
} }

View File

@@ -149,7 +149,8 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-hero { .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); var(--color-accent-contrast);
margin-top: -5rem; margin-top: -5rem;
padding: 11.25rem 1rem 8rem; padding: 11.25rem 1rem 8rem;

View File

@@ -45,8 +45,9 @@
<h2 <h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]" 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 Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
and play your favorite mods and modpacks, all within the Modrinth platform. Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
</h2> </h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit"> <div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div <div
@@ -427,11 +428,8 @@
Do Modrinth Servers have DDoS protection? Do Modrinth Servers have DDoS protection?
</summary> </summary>
<p class="m-0 ml-6 leading-[160%]"> <p class="m-0 ml-6 leading-[160%]">
Yes. All Modrinth Servers come with DDoS protection powered by Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank" some locations.
>OVHcloud® Anti-DDoS infrastructure</a
>
which has over 17Tbps capacity. Your server is safe on Modrinth.
</p> </p>
</details> </details>
@@ -443,8 +441,9 @@
Where are Modrinth Servers located? Can I choose a region? Where are Modrinth Servers located? Can I choose a region?
</summary> </summary>
<p class="m-0 ml-6 leading-[160%]"> <p class="m-0 ml-6 leading-[160%]">
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg, We have servers available in North America and Europe at the moment that you can
Germany. More regions to come in the future! choose upon purchase. More regions to come in the future! If you'd like to switch
your region, please contact support.
</p> </p>
</details> </details>
@@ -461,7 +460,7 @@
</p> </p>
</details> </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"> <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"> <span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon /> <RightArrowIcon />
@@ -482,7 +481,7 @@
</p> </p>
</details> </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"> <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"> <span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon /> <RightArrowIcon />
@@ -493,6 +492,24 @@
All prices are listed in United States Dollars (USD). All prices are listed in United States Dollars (USD).
</p> </p>
</details> </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> </div>
</div> </div>

View File

@@ -1020,7 +1020,7 @@ const nodeUnavailableDetails = computed(() => [
{ {
label: "Error message", label: "Error message",
value: nodeAccessible.value value: nodeAccessible.value
? server.moduleErrors?.general?.error.message ?? "Unknown" ? (server.moduleErrors?.general?.error.message ?? "Unknown")
: "Unable to reach node. Ping test failed.", : "Unable to reach node. Ping test failed.",
type: "block" as const, type: "block" as const,
}, },
@@ -1277,7 +1277,8 @@ useHead({
background-repeat: no-repeat; background-repeat: no-repeat;
filter: blur(1rem); filter: blur(1rem);
content: ""; content: "";
background-image: linear-gradient( background-image:
linear-gradient(
to bottom, to bottom,
rgba(from var(--color-raised-bg) r g b / 0.2), rgba(from var(--color-raised-bg) r g b / 0.2),
rgb(from var(--color-raised-bg) r g b / 0.8) 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 }"> <span :class="{ invisible: 'current_file' in op && !op.current_file }">
{{ {{
"current_file" in op "current_file" in op
? op.current_file?.split("/")?.pop() ?? "unknown" ? (op.current_file?.split("/")?.pop() ?? "unknown")
: "unknown" : "unknown"
}} }}
</span> </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", "date": "2023-02-01T20:00:00.000Z",
"link": "https://modrinth.com/news/article/accelerating-development" "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", "title": "Modrinth's Anniversary Update",
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.", "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", "date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth" "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!", "title": "Creators can now make money on Modrinth!",
"summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.", "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", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -102,5 +102,5 @@
true true
] ]
}, },
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2" "hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
} }

View File

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

View File

@@ -11,7 +11,7 @@ LABEL org.opencontainers.image.description="Modrinth API"
LABEL org.opencontainers.image.licenses=AGPL-3.0 LABEL org.opencontainers.image.licenses=AGPL-3.0
RUN apt-get update \ 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/* && rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth

View File

@@ -43,7 +43,9 @@ pub enum AuthenticationError {
InvalidAuthMethod, InvalidAuthMethod,
#[error("GitHub Token from incorrect Client ID")] #[error("GitHub Token from incorrect Client ID")]
InvalidClientId, 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, DuplicateUser,
#[error("Invalid state sent, you probably need to get a new websocket")] #[error("Invalid state sent, you probably need to get a new websocket")]
SocketError, SocketError,

View File

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

View File

@@ -197,7 +197,7 @@ impl DBCharge {
) -> Result<Option<DBCharge>, DatabaseError> { ) -> Result<Option<DBCharge>, DatabaseError> {
let user_subscription_id = user_subscription_id.0; let user_subscription_id = user_subscription_id.0;
let res = select_charges_with_predicate!( 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 user_subscription_id
) )
.fetch_optional(exec) .fetch_optional(exec)

View File

@@ -223,8 +223,8 @@ impl TempUser {
stripe_customer_id: None, stripe_customer_id: None,
totp_secret: None, totp_secret: None,
username, username,
email: self.email, email: self.email.clone(),
email_verified: true, email_verified: self.email.is_some(),
avatar_url, avatar_url,
raw_avatar_url, raw_avatar_url,
bio: self.bio, bio: self.bio,
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
.hash_password(new_account.password.as_bytes(), &salt)? .hash_password(new_account.password.as_bytes(), &salt)?
.to_string(); .to_string();
if crate::database::models::DBUser::get_by_email( if !crate::database::models::DBUser::get_by_case_insensitive_email(
&new_account.email, &new_account.email,
&**pool, &**pool,
) )
.await? .await?
.is_some() .is_empty()
{ {
return Err(ApiError::InvalidInput( 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? .await?
.1; .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?; let mut transaction = pool.begin().await?;
sqlx::query!( sqlx::query!(

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"name": "expires", "name": "expires",
"ordinal": 5, "ordinal": 5,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c" "hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"name": "expires", "name": "expires",
"ordinal": 5, "ordinal": 5,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
false, false,
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

@@ -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 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] #[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> { pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?; let state = State::get().await?;

View File

@@ -284,6 +284,12 @@ async fn import_mmc_unmanaged(
component.version.clone().unwrap_or_default(), 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") { if component.uid.starts_with("org.quiltmc.quilt-loader") {
return Some(( return Some((
PackDependency::QuiltLoader, PackDependency::QuiltLoader,

View File

@@ -6,9 +6,9 @@ use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError; use crate::launcher::io::IOError;
use crate::profile::QuickPlayType; use crate::profile::QuickPlayType;
use crate::state::{ 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 crate::{State, get_resource_file, process, state as st};
use chrono::Utc; use chrono::Utc;
use daedalus as d; use daedalus as d;
@@ -633,18 +633,32 @@ pub async fn launch_minecraft(
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED"); command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
} }
// FIXME: Fix ElyBy integration with this patch.
// [AR] Patch // [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" { if version_jar == "1.16.4" || version_jar == "1.16.5" {
let invalid_url = "https://invalid.invalid"; 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("-Dminecraft.api.env=custom");
command.arg(format!("-Dminecraft.api.auth.host={}", invalid_url)); command.arg(format!("-Dminecraft.api.auth.host={}", invalid_url));
command.arg(format!("-Dminecraft.api.account.host={}", invalid_url)); command
command.arg(format!("-Dminecraft.api.session.host={}", invalid_url)); .arg(format!("-Dminecraft.api.account.host={}", invalid_url));
command.arg(format!("-Dminecraft.api.services.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 command
@@ -746,10 +760,10 @@ pub async fn launch_minecraft(
// [AR] Feature // [AR] Feature
let selected_phrase = ACTIVE_STATE.choose(&mut rand::thread_rng()).unwrap(); let selected_phrase = ACTIVE_STATE.choose(&mut rand::thread_rng()).unwrap();
let _ = state let _ = state
.discord_rpc .discord_rpc
.set_activity(&format!("{} {}", selected_phrase, profile.name), true) .set_activity(&format!("{} {}", selected_phrase, profile.name), true)
.await; .await;
let _ = state let _ = state
.friends_socket .friends_socket

View File

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

View File

@@ -191,6 +191,7 @@ pub async fn login_finish(
expires: oauth_token.date expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64), + Duration::seconds(oauth_token.value.expires_in as i64),
active: true, active: true,
account_type: AccountType::Microsoft.as_lowercase_str(),
}; };
// During login, we need to fetch the online profile at least once to get the // During login, we need to fetch the online profile at least once to get the
@@ -229,6 +230,7 @@ pub async fn offline_auth(
refresh_token: refresh_token, refresh_token: refresh_token,
expires: Utc::now() + Duration::days(365 * 99), expires: Utc::now() + Duration::days(365 * 99),
active: true, active: true,
account_type: AccountType::Pirate.as_lowercase_str(),
}; };
credentials.offline_profile = MinecraftProfile { credentials.offline_profile = MinecraftProfile {
@@ -242,6 +244,58 @@ pub async fn offline_auth(
Ok(credentials) 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)] #[derive(Deserialize, Debug)]
pub struct Credentials { pub struct Credentials {
/// The offline profile of the user these credentials are for. /// The offline profile of the user these credentials are for.
@@ -255,6 +309,7 @@ pub struct Credentials {
pub refresh_token: String, pub refresh_token: String,
pub expires: DateTime<Utc>, pub expires: DateTime<Utc>,
pub active: bool, pub active: bool,
pub account_type: String,
} }
/// An entry in the player profile cache, keyed by player UUID. /// An entry in the player profile cache, keyed by player UUID.
@@ -480,7 +535,7 @@ impl Credentials {
let res = sqlx::query!( let res = sqlx::query!(
" "
SELECT SELECT
uuid, active, username, access_token, refresh_token, expires uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users FROM minecraft_users
WHERE active = TRUE WHERE active = TRUE
" "
@@ -503,6 +558,7 @@ impl Credentials {
.single() .single()
.unwrap_or_else(Utc::now), .unwrap_or_else(Utc::now),
active: x.active == 1, active: x.active == 1,
account_type: x.account_type,
}; };
credentials.refresh(exec).await.ok(); credentials.refresh(exec).await.ok();
Some(credentials) Some(credentials)
@@ -517,7 +573,7 @@ impl Credentials {
let res = sqlx::query!( let res = sqlx::query!(
" "
SELECT SELECT
uuid, active, username, access_token, refresh_token, expires uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users FROM minecraft_users
" "
) )
@@ -537,6 +593,7 @@ impl Credentials {
.single() .single()
.unwrap_or_else(Utc::now), .unwrap_or_else(Utc::now),
active: x.active == 1, active: x.active == 1,
account_type: x.account_type,
}; };
async move { async move {
@@ -572,14 +629,15 @@ impl Credentials {
sqlx::query!( sqlx::query!(
" "
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires) INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
active = $2, active = $2,
username = $3, username = $3,
access_token = $4, access_token = $4,
refresh_token = $5, refresh_token = $5,
expires = $6 expires = $6,
account_type = $7
", ",
uuid, uuid,
self.active, self.active,
@@ -587,6 +645,7 @@ impl Credentials {
self.access_token, self.access_token,
self.refresh_token, self.refresh_token,
expires, expires,
self.account_type,
) )
.execute(exec) .execute(exec)
.await?; .await?;
@@ -649,6 +708,7 @@ impl Serialize for Credentials {
ser.serialize_field("refresh_token", &self.refresh_token)?; ser.serialize_field("refresh_token", &self.refresh_token)?;
ser.serialize_field("expires", &self.expires)?; ser.serialize_field("expires", &self.expires)?;
ser.serialize_field("active", &self.active)?; ser.serialize_field("active", &self.active)?;
ser.serialize_field("account_type", &self.account_type)?;
ser.end() ser.end()
} }
} }

View File

@@ -3,9 +3,13 @@ use crate::state::db;
/// ///
/// [AR] Feature Utils /// [AR] Feature Utils
/// ///
/// Version: 0.1.1
///
use crate::{Result, State}; use crate::{Result, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process; use std::process;
use std::time::SystemTime;
use tokio::{fs, io}; use tokio::{fs, io};
const PACKAGE_JSON_CONTENT: &str = const PACKAGE_JSON_CONTENT: &str =
@@ -46,6 +50,216 @@ pub fn read_package_json() -> io::Result<Launcher> {
Ok(launcher) 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?;
if patched {
tracing::info!("[AR] • Successfully applied migration fix");
} else {
tracing::error!("[AR] • Failed to apply migration fix");
}
Ok(patched)
}
/// ### 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<()> {
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,
os_type,
auto_update_supported,
)
.await
{
eprintln!(
"[AR] • An error occurred! Failed to download the file: {}",
e
);
} else {
println!("[AR] • Code finishes without errors.");
process::exit(0)
}
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 /// ### AR • Universal Write (IO) Function
/// Saves the downloaded bytes to the `libraries` directory using the given relative path. /// Saves the downloaded bytes to the `libraries` directory using the given relative path.
async fn write_file_to_libraries( async fn write_file_to_libraries(
@@ -64,72 +278,52 @@ async fn write_file_to_libraries(
}) })
} }
/// ### AR • AuthLib (Ely By) /// ### 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");
tracing::info!(
"[AR] • Attempting to download AuthLib {}.",
authlib_version
);
download_authlib(
&minecraft_library_metadata,
authlib_version,
minecraft_version,
is_mojang,
)
.await
}
/// ### AR • AuthLib (Ely By)
/// Downloads the AuthLib file from Mojang libraries or Git Astralium services. /// Downloads the AuthLib file from Mojang libraries or Git Astralium services.
async fn download_authlib( async fn download_authlib(
minecraft_library_metadata: &Library, minecraft_library_metadata: &Library,
authlib_version: &str, authlib_fullname: &str,
minecraft_version: &str, minecraft_version: &str,
is_mojang: bool, is_mojang: bool,
) -> Result<bool> { ) -> Result<bool> {
let state = State::get().await?; let state = State::get().await?;
let (url, path) = extract_download_info(minecraft_library_metadata, minecraft_version)?; let (mut url, path) = extract_minecraft_local_download_info(
let mut download_url = url.to_string(); minecraft_library_metadata,
minecraft_version,
)?;
let full_path = state.directories.libraries_dir().join(path); let full_path = state.directories.libraries_dir().join(path);
if !is_mojang { if !is_mojang {
tracing::info!( tracing::info!(
"[AR] • Attempting to download AuthLib from Git Astralium" "[AR] • Attempting to download AuthLib from Git Astralium"
); );
download_url = extract_ely_authlib_url(authlib_version).await?; (_, url) = extract_elyby_authlib_metadata(authlib_fullname).await?;
} }
tracing::info!("[AR] • Downloading AuthLib from URL: {}", download_url); tracing::info!("[AR] • Downloading AuthLib from URL: {}", url);
let bytes = fetch_bytes_from_url(&download_url).await?; let bytes = fetch_bytes_from_url(&url).await?;
tracing::info!("[AR] • Will save to path: {}", full_path.to_str().unwrap()); tracing::info!("[AR] • Will save to path: {}", full_path.to_str().unwrap());
write_file_to_libraries(full_path.to_str().unwrap(), &bytes).await?; write_file_to_libraries(full_path.to_str().unwrap(), &bytes).await?;
tracing::info!("[AR] • Successfully saved AuthLib to {:?}", full_path); tracing::info!("[AR] • Successfully saved AuthLib to {:?}", full_path);
Ok(true) Ok(true)
} }
/// ### AR • AuthLib (Ely By) /// ### AR • AuthLib (Ely.by)
/// Parses the ElyIntegration release JSON and returns the download URL for the given AuthLib version. /// Parses the ElyIntegration release JSON and returns the download URL for the given AuthLib version.
async fn extract_ely_authlib_url(authlib_version: &str) -> Result<String> { async fn extract_elyby_authlib_metadata(
let url = "https://git.astralium.su/api/v1/repos/didirus/ElyIntegration/releases/latest"; 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| { let response = reqwest::get(URL).await.map_err(|e| {
tracing::error!( tracing::error!(
"[AR] • Failed to fetch ElyIntegration release JSON: {:?}", "[AR] • Failed to fetch ElyIntegration release JSON: {:?}",
e e
); );
crate::ErrorKind::NetworkErrorOccurred { crate::ErrorKind::NetworkErrorOccurred {
error: format!("Failed to fetch ElyIntegration release JSON: {}", e), error: format!(
"Failed to fetch ElyIntegration release JSON: {}",
e
),
} }
.as_error() .as_error()
})?; })?;
@@ -157,14 +351,14 @@ async fn extract_ely_authlib_url(authlib_version: &str) -> Result<String> {
.find(|a| { .find(|a| {
a.get("name") a.get("name")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.map(|n| n.contains(authlib_version)) .map(|n| n.contains(authlib_fullname))
.unwrap_or(false) .unwrap_or(false)
}) })
.ok_or_else(|| { .ok_or_else(|| {
crate::ErrorKind::ParseError { crate::ErrorKind::ParseError {
reason: format!( reason: format!(
"No matching asset for authlib-{}.jar", "No matching asset for {} in ElyIntegration JSON response.",
authlib_version authlib_fullname
), ),
} }
.as_error() .as_error()
@@ -178,20 +372,32 @@ async fn extract_ely_authlib_url(authlib_version: &str) -> Result<String> {
reason: "Missing 'browser_download_url'".into(), reason: "Missing 'browser_download_url'".into(),
} }
.as_error() .as_error()
})?; })?
.to_string();
Ok(download_url.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) /// ### AR • AuthLib (Ely.by)
/// Extracts the artifact URL and Path from the library structure. /// Extracts the artifact URL and Path from the library structure.
/// ///
/// Returns a tuple of references to the URL and path strings, /// Returns a tuple of references to the URL and path strings,
/// or an error if the required metadata is missing. /// or an error if the required metadata is missing.
fn extract_download_info<'a>( fn extract_minecraft_local_download_info(
minecraft_library_metadata: &'a Library, minecraft_library_metadata: &Library,
minecraft_version: &str, minecraft_version: &str,
) -> Result<(&'a str, &'a str)> { ) -> Result<(String, String)> {
let artifact = minecraft_library_metadata let artifact = minecraft_library_metadata
.downloads .downloads
.as_ref() .as_ref()
@@ -203,14 +409,14 @@ fn extract_download_info<'a>(
.as_error() .as_error()
})?; })?;
let url = artifact.url.as_deref().ok_or_else(|| { let url = artifact.url.clone().ok_or_else(|| {
crate::ErrorKind::MinecraftMetadataNotFound { crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(), minecraft_version: minecraft_version.to_string(),
} }
.as_error() .as_error()
})?; })?;
let path = artifact.path.as_deref().ok_or_else(|| { let path = artifact.path.clone().ok_or_else(|| {
crate::ErrorKind::MinecraftMetadataNotFound { crate::ErrorKind::MinecraftMetadataNotFound {
minecraft_version: minecraft_version.to_string(), minecraft_version: minecraft_version.to_string(),
} }
@@ -220,7 +426,7 @@ fn extract_download_info<'a>(
Ok((url, path)) Ok((url, path))
} }
/// ### AR • AuthLib (Ely By) /// ### AR • Universal Fetch Bytes (IO)
/// Downloads bytes from the provided URL with a 15 second timeout. /// Downloads bytes from the provided URL with a 15 second timeout.
async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> { async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> {
// Create client instance with request timeout. // Create client instance with request timeout.
@@ -233,9 +439,15 @@ async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> {
) )
.await .await
.map_err(|_| { .map_err(|_| {
tracing::error!("[AR] • Download timed out after {} seconds", TIMEOUT_SECONDS); tracing::error!(
"[AR] • Download timed out after {} seconds",
TIMEOUT_SECONDS
);
crate::ErrorKind::NetworkErrorOccurred { crate::ErrorKind::NetworkErrorOccurred {
error: format!("Download timed out after {TIMEOUT_SECONDS} seconds").to_string(), error: format!(
"Download timed out after {TIMEOUT_SECONDS} seconds"
)
.to_string(),
} }
.as_error() .as_error()
})? })?
@@ -249,9 +461,9 @@ async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status().to_string(); let status = response.status().to_string();
tracing::error!("[AR] • Failed to download authlib: HTTP {}", status); tracing::error!("[AR] • Failed to download file: HTTP {}", status);
return Err(crate::ErrorKind::NetworkErrorOccurred { return Err(crate::ErrorKind::NetworkErrorOccurred {
error: format!("Failed to download authlib: HTTP {status}"), error: format!("Failed to download file: HTTP {status}"),
} }
.as_error()); .as_error());
} }
@@ -265,9 +477,11 @@ async fn fetch_bytes_from_url(url: &str) -> Result<bytes::Bytes> {
}) })
} }
/// ### AR • AuthLib (Ely By) /// ### AR • AuthLib (Ely.by)
/// Gets the Minecraft library metadata from the local libraries directory. /// Gets the Minecraft library metadata from the local libraries directory.
async fn get_minecraft_library_metadata(minecraft_version: &str) -> Result<Library> { async fn get_minecraft_library_metadata(
minecraft_version: &str,
) -> Result<Library> {
let state = State::get().await?; let state = State::get().await?;
let path = state let path = state
@@ -319,48 +533,3 @@ async fn get_minecraft_library_metadata(minecraft_version: &str) -> Result<Libra
} }
.as_error()) .as_error())
} }
/// ### AR • Migration
/// 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?;
if patched {
tracing::info!("[AR] • Successfully applied migration fix");
} else {
tracing::error!("[AR] • Failed to apply migration fix");
}
Ok(patched)
}
/// ### AR • 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<()> {
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,
os_type,
auto_update_supported,
)
.await
{
eprintln!(
"[AR] • An error occurred! Failed to download the file: {}",
e
);
} else {
println!("[AR] • Code finishes without errors.");
process::exit(0)
}
Ok(())
}

View File

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

View File

@@ -0,0 +1,9 @@
<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-brush-cleaning-icon lucide-brush-cleaning">
<path d="m16 22-1-4" />
<path
d="M19 13.99a1 1 0 0 0 1-1V12a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v.99a1 1 0 0 0 1 1" />
<path d="M5 14h14l1.973 6.767A1 1 0 0 1 20 22H4a1 1 0 0 1-.973-1.233z" />
<path d="m8 22 1-4" />
</svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="elyby-icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="20.48"
inkscape:cx="13.891602"
inkscape:cy="18.09082"
inkscape:window-width="1776"
inkscape:window-height="1236"
inkscape:window-x="244"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#00c600;stroke-width:0.264999;paint-order:markers fill stroke;fill-opacity:1"
id="rect1"
width="10"
height="10"
x="0"
y="0"
rx="1"
ry="1" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke-width:0.80219;paint-order:markers fill stroke"
x="1.0449646"
y="9.1731787"
id="text1"
inkscape:label="text1"
transform="scale(1.1466469,0.87210806)"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.802193"
x="1.0449646"
y="9.1731787">E</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

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-keyboard-icon lucide-keyboard"><path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -0,0 +1,7 @@
<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-triangle-alert-icon lucide-triangle-alert">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@@ -89,6 +89,7 @@ import _PirateIcon from './icons/pirate.svg?component'
import _MicrosoftIcon from './icons/microsoft.svg?component' import _MicrosoftIcon from './icons/microsoft.svg?component'
import _PirateShipIcon from './icons/pirate-ship.svg?component' import _PirateShipIcon from './icons/pirate-ship.svg?component'
import _AstralRinthLogo from './icons/astralrinth-logo.svg?component' import _AstralRinthLogo from './icons/astralrinth-logo.svg?component'
import _ElyByIcon from './icons/elyby-icon.svg?component'
// [AR] Feature. Exports // [AR] Feature. Exports
@@ -96,6 +97,7 @@ export const PirateIcon = _PirateIcon
export const MicrosoftIcon = _MicrosoftIcon export const MicrosoftIcon = _MicrosoftIcon
export const PirateShipIcon = _PirateShipIcon export const PirateShipIcon = _PirateShipIcon
export const AstralRinthLogo = _AstralRinthLogo export const AstralRinthLogo = _AstralRinthLogo
export const ElyByIcon = _ElyByIcon
// Skin Models // Skin Models
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url' export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'

View File

@@ -1,16 +1,16 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import * as path from 'path' import * as path from 'path'
import fastGlob from 'fast-glob'
import { repoPath, toVarName } from './utils' import { repoPath, toVarName } from './utils'
import { glob } from 'glob'
import { PUBLIC_SRC, PUBLIC_LOCATIONS, ARTICLES_GLOB, COMPILED_DIR } from './blog.config' import { PUBLIC_SRC, PUBLIC_LOCATIONS, ARTICLES_GLOB, COMPILED_DIR } from './blog.config'
async function checkPublicAssets() { async function checkPublicAssets() {
const srcFiles = await fastGlob(['**/*'], { cwd: PUBLIC_SRC, dot: true }) const srcFiles = await glob('**/*', { cwd: PUBLIC_SRC, dot: true })
let allOk = true let allOk = true
for (const target of PUBLIC_LOCATIONS) { for (const target of PUBLIC_LOCATIONS) {
for (const relativeFile of srcFiles) { for (const relativeFile of srcFiles) {
const shouldExist = path.join(target, relativeFile) const shouldExist = path.posix.join(target, relativeFile)
try { try {
await fs.access(shouldExist) await fs.access(shouldExist)
} catch { } catch {
@@ -26,15 +26,15 @@ async function checkPublicAssets() {
} }
async function checkCompiledArticles() { async function checkCompiledArticles() {
const mdFiles = await fastGlob([ARTICLES_GLOB]) const mdFiles = await glob(ARTICLES_GLOB)
const compiledFiles = await fastGlob([`${COMPILED_DIR}/*.ts`]) const compiledFiles = await glob(`${COMPILED_DIR}/*.ts`)
const compiledVarNames = compiledFiles.map((f) => path.basename(f, '.ts')) const compiledVarNames = compiledFiles.map((f) => path.basename(f, '.ts'))
// Check all .md have compiled .ts and .content.ts and the proper public thumbnail // Check all .md have compiled .ts and .content.ts and the proper public thumbnail
for (const file of mdFiles) { for (const file of mdFiles) {
const varName = toVarName(path.basename(file, '.md')) const varName = toVarName(path.basename(file, '.md'))
const compiledPath = path.join(COMPILED_DIR, varName + '.ts') const compiledPath = path.posix.join(COMPILED_DIR, varName + '.ts')
const contentPath = path.join(COMPILED_DIR, varName + '.content.ts') const contentPath = path.posix.join(COMPILED_DIR, varName + '.content.ts')
if (!compiledVarNames.includes(varName)) { if (!compiledVarNames.includes(varName)) {
console.error(`⚠️ Missing compiled article for: ${file} (should be: ${compiledPath})`) console.error(`⚠️ Missing compiled article for: ${file} (should be: ${compiledPath})`)
process.exit(1) process.exit(1)
@@ -59,7 +59,7 @@ async function checkCompiledArticles() {
if (varName === 'index' || varName.endsWith('.content')) continue if (varName === 'index' || varName.endsWith('.content')) continue
const mdPathGlob = repoPath(`packages/blog/articles/**/${varName.replace(/_/g, '*')}.md`) const mdPathGlob = repoPath(`packages/blog/articles/**/${varName.replace(/_/g, '*')}.md`)
const found = await fastGlob([mdPathGlob]) const found = await glob(mdPathGlob)
if (!found.length) { if (!found.length) {
console.error(`❌ Compiled article ${compiled} has no matching markdown source!`) console.error(`❌ Compiled article ${compiled} has no matching markdown source!`)
process.exit(1) process.exit(1)

View File

@@ -1,12 +1,12 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import * as path from 'path' import * as path from 'path'
import fg from 'fast-glob'
import matter from 'gray-matter' import matter from 'gray-matter'
import { md } from '@modrinth/utils' import { md } from '@modrinth/utils'
import { minify } from 'html-minifier-terser' import { minify } from 'html-minifier-terser'
import { copyDir, toVarName } from './utils' import { copyDir, toVarName } from './utils'
import RSS from 'rss' import RSS from 'rss'
import { parseStringPromise } from 'xml2js' import { parseStringPromise } from 'xml2js'
import { glob } from 'glob'
import { import {
ARTICLES_GLOB, ARTICLES_GLOB,
@@ -24,7 +24,7 @@ async function ensureCompiledDir() {
} }
async function hasThumbnail(slug: string): Promise<boolean> { async function hasThumbnail(slug: string): Promise<boolean> {
const thumbnailPath = path.join(PUBLIC_SRC, slug, 'thumbnail.webp') const thumbnailPath = path.posix.join(PUBLIC_SRC, slug, 'thumbnail.webp')
try { try {
await fs.access(thumbnailPath) await fs.access(thumbnailPath)
return true return true
@@ -48,7 +48,7 @@ function getThumbnailUrl(slug: string, hasThumb: boolean): string {
async function compileArticles() { async function compileArticles() {
await ensureCompiledDir() await ensureCompiledDir()
const files = await fg([ARTICLES_GLOB]) const files = await glob(ARTICLES_GLOB)
console.log(`🔎 Found ${files.length} markdown articles!`) console.log(`🔎 Found ${files.length} markdown articles!`)
const articleExports: string[] = [] const articleExports: string[] = []
const articlesArray: string[] = [] const articlesArray: string[] = []
@@ -75,8 +75,8 @@ async function compileArticles() {
const slug = frontSlug || path.basename(file, '.md') const slug = frontSlug || path.basename(file, '.md')
const varName = toVarName(slug) const varName = toVarName(slug)
const exportFile = path.join(COMPILED_DIR, `${varName}.ts`) const exportFile = path.posix.join(COMPILED_DIR, `${varName}.ts`)
const contentFile = path.join(COMPILED_DIR, `${varName}.content.ts`) const contentFile = path.posix.join(COMPILED_DIR, `${varName}.content.ts`)
const thumbnailPresent = await hasThumbnail(slug) const thumbnailPresent = await hasThumbnail(slug)
const contentTs = ` const contentTs = `
@@ -221,7 +221,7 @@ async function deleteDirContents(dir: string) {
const entries = await fs.readdir(dir, { withFileTypes: true }) const entries = await fs.readdir(dir, { withFileTypes: true })
await Promise.all( await Promise.all(
entries.map(async (entry) => { entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name) const fullPath = path.posix.join(dir, entry.name)
if (entry.isDirectory()) { if (entry.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true }) await fs.rm(fullPath, { recursive: true, force: true })
} else { } else {

View File

@@ -1,56 +1,56 @@
// AUTO-GENERATED FILE - DO NOT EDIT // AUTO-GENERATED FILE - DO NOT EDIT
import { article as a_new_chapter_for_modrinth_servers } from './a_new_chapter_for_modrinth_servers'
import { article as accelerating_development } from './accelerating_development'
import { article as becoming_sustainable } from './becoming_sustainable'
import { article as capital_return } from './capital_return'
import { article as carbon_ads } from './carbon_ads'
import { article as creator_monetization } from './creator_monetization'
import { article as creator_update } from './creator_update'
import { article as creator_updates_july_2025 } from './creator_updates_july_2025'
import { article as design_refresh } from './design_refresh'
import { article as download_adjustment } from './download_adjustment'
import { article as knossos_v2_1_0 } from './knossos_v2_1_0'
import { article as licensing_guide } from './licensing_guide'
import { article as modpack_changes } from './modpack_changes'
import { article as modpacks_alpha } from './modpacks_alpha'
import { article as modrinth_app_beta } from './modrinth_app_beta'
import { article as modrinth_beta } from './modrinth_beta'
import { article as modrinth_servers_beta } from './modrinth_servers_beta'
import { article as new_site_beta } from './new_site_beta'
import { article as plugins_resource_packs } from './plugins_resource_packs'
import { article as pride_campaign_2025 } from './pride_campaign_2025'
import { article as redesign } from './redesign'
import { article as skins_now_in_modrinth_app } from './skins_now_in_modrinth_app'
import { article as two_years_of_modrinth_history } from './two_years_of_modrinth_history'
import { article as two_years_of_modrinth } from './two_years_of_modrinth'
import { article as whats_modrinth } from './whats_modrinth'
import { article as windows_borderless_malware_disclosure } from './windows_borderless_malware_disclosure' import { article as windows_borderless_malware_disclosure } from './windows_borderless_malware_disclosure'
import { article as whats_modrinth } from './whats_modrinth'
import { article as two_years_of_modrinth } from './two_years_of_modrinth'
import { article as two_years_of_modrinth_history } from './two_years_of_modrinth_history'
import { article as skins_now_in_modrinth_app } from './skins_now_in_modrinth_app'
import { article as redesign } from './redesign'
import { article as pride_campaign_2025 } from './pride_campaign_2025'
import { article as plugins_resource_packs } from './plugins_resource_packs'
import { article as new_site_beta } from './new_site_beta'
import { article as modrinth_servers_beta } from './modrinth_servers_beta'
import { article as modrinth_beta } from './modrinth_beta'
import { article as modrinth_app_beta } from './modrinth_app_beta'
import { article as modpacks_alpha } from './modpacks_alpha'
import { article as modpack_changes } from './modpack_changes'
import { article as licensing_guide } from './licensing_guide'
import { article as knossos_v2_1_0 } from './knossos_v2_1_0'
import { article as download_adjustment } from './download_adjustment'
import { article as design_refresh } from './design_refresh'
import { article as creator_updates_july_2025 } from './creator_updates_july_2025'
import { article as creator_update } from './creator_update'
import { article as creator_monetization } from './creator_monetization'
import { article as carbon_ads } from './carbon_ads'
import { article as capital_return } from './capital_return'
import { article as becoming_sustainable } from './becoming_sustainable'
import { article as accelerating_development } from './accelerating_development'
import { article as a_new_chapter_for_modrinth_servers } from './a_new_chapter_for_modrinth_servers'
export const articles = [ export const articles = [
a_new_chapter_for_modrinth_servers,
accelerating_development,
becoming_sustainable,
capital_return,
carbon_ads,
creator_monetization,
creator_update,
creator_updates_july_2025,
design_refresh,
download_adjustment,
knossos_v2_1_0,
licensing_guide,
modpack_changes,
modpacks_alpha,
modrinth_app_beta,
modrinth_beta,
modrinth_servers_beta,
new_site_beta,
plugins_resource_packs,
pride_campaign_2025,
redesign,
skins_now_in_modrinth_app,
two_years_of_modrinth_history,
two_years_of_modrinth,
whats_modrinth,
windows_borderless_malware_disclosure, windows_borderless_malware_disclosure,
whats_modrinth,
two_years_of_modrinth,
two_years_of_modrinth_history,
skins_now_in_modrinth_app,
redesign,
pride_campaign_2025,
plugins_resource_packs,
new_site_beta,
modrinth_servers_beta,
modrinth_beta,
modrinth_app_beta,
modpacks_alpha,
modpack_changes,
licensing_guide,
knossos_v2_1_0,
download_adjustment,
design_refresh,
creator_updates_july_2025,
creator_update,
creator_monetization,
carbon_ads,
capital_return,
becoming_sustainable,
accelerating_development,
a_new_chapter_for_modrinth_servers,
] ]

View File

@@ -9,6 +9,7 @@
"fix": "jiti ./compile.ts && eslint . --fix && prettier --write ." "fix": "jiti ./compile.ts && eslint . --fix && prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@types/glob": "^9.0.0",
"@types/html-minifier-terser": "^7.0.2", "@types/html-minifier-terser": "^7.0.2",
"@types/rss": "^0.0.32", "@types/rss": "^0.0.32",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
@@ -19,7 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"fast-glob": "^3.3.3", "glob": "^10.2.7",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"rss": "^1.2.2", "rss": "^1.2.2",

View File

@@ -8,7 +8,7 @@ export function getRepoRoot(): string {
} }
export function repoPath(...segments: string[]): string { export function repoPath(...segments: string[]): string {
return path.join(getRepoRoot(), ...segments) return path.posix.join(getRepoRoot(), ...segments)
} }
export async function copyDir( export async function copyDir(
@@ -20,8 +20,8 @@ export async function copyDir(
await fs.mkdir(dest, { recursive: true }) await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true }) const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) { for (const entry of entries) {
const srcPath = path.join(src, entry.name) const srcPath = path.posix.join(src, entry.name)
const destPath = path.join(dest, entry.name) const destPath = path.posix.join(dest, entry.name)
if (entry.isDirectory()) { if (entry.isDirectory()) {
await copyDir(srcPath, destPath, logFn) await copyDir(srcPath, destPath, logFn)
} else if (entry.isFile()) { } else if (entry.isFile()) {

View File

@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['custom/library'],
env: {
node: true,
},
}

674
packages/moderation/LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -0,0 +1,149 @@
# @modrinth/moderation
This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
## Structure
The package is organized as follows:
```
/packages/moderation/
├── data/
│ ├── checklist.ts # Main checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates
│ │ ├── title/ # Messages for the title stage
│ │ ├── description/ # Messages for the description stage
│ │ └── ... # One directory per stage
│ └── stages/ # Stage definition files
│ ├── title.ts # Title stage definition
│ ├── description.ts # Description stage definition
│ └── ... # One file per stage
└── types/ # Type definitions
├── actions.ts # Action-related types
├── messages.ts # Message-related types
└── stage.ts # Stage-related types
```
## Stages
A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has:
- A title displayed to moderators
- A link to guidance documentation
- An optional navigation path to direct moderators to the relevant part of the project page
- A list of actions that moderators can take
Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`.
## Actions
Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have:
- Labels displayed to the moderator
- Messages that are included in the final moderation decision
- Suggested moderation status and severity
- Optional text inputs for additional information
- Conditional behavior based on other selected actions
Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome.
## Messages
Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage.
### Variable replacement
You can use variables in your messages that will be replaced with user input:
1. Define a variable in the `relevantExtraInput` array of an action:
```typescript
relevantExtraInput: [
{
label: 'Explanation for the user',
variable: 'MESSAGE',
required: true,
},
],
```
2. Use the variable in your message with `%VARIABLE%` syntax:
```markdown
# Your Message Title
Here is some explanation about the issue.
%MESSAGE%
More text after the variable.
```
The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator.
## Conditional logic
The moderation system supports conditional behavior that changes based on the selection of other actions.
### Conditional messages
You can define different messages for an action based on other selected actions:
```typescript
{
id: 'my_action',
type: 'button',
label: 'My Action',
weight: 100,
message: async () => (await import('../messages/default-message.md?raw')).default,
conditionalMessages: [
{
conditions: {
requiredActions: ['other_action_id'],
excludedActions: ['another_action_id']
},
message: async () => (await import('../messages/conditional-message.md?raw')).default,
}
]
}
```
### Enabling and disabling actions
Actions can enable or disable other actions when selected:
```typescript
{
id: 'parent_action',
type: 'button',
label: 'Parent Action',
// This will show these actions when parent_action is selected
enablesActions: [
{
id: 'child_action',
type: 'button',
label: 'Child Action',
// ...other properties
}
],
// This will hide actions with these IDs when parent_action is selected
disablesActions: ['incompatible_action_id']
}
```
### Conditional text inputs
Text inputs can be conditionally shown based on selected actions:
```typescript
relevantExtraInput: [
{
label: 'Additional Information',
variable: 'INFO',
showWhen: {
requiredActions: ['specific_action_id'],
excludedActions: ['incompatible_action_id'],
},
},
]
```

View File

@@ -0,0 +1,32 @@
import type { Stage } from '../types/stage'
import modpackPermissionsStage from './modpack-permissions-stage'
import categories from './stages/categories'
import reupload from './stages/reupload'
import description from './stages/description'
import gallery from './stages/gallery'
import links from './stages/links'
import ruleFollowing from './stages/rule-following'
import sideTypes from './stages/side-types'
import summary from './stages/summary'
import titleSlug from './stages/title-slug'
import versions from './stages/versions'
import license from './stages/license'
import undefinedProject from './stages/undefined-project'
import statusAlerts from './stages/status-alerts'
export default [
titleSlug,
summary,
description,
links,
license,
categories,
sideTypes,
gallery,
versions,
reupload,
ruleFollowing,
modpackPermissionsStage,
statusAlerts,
undefinedProject,
] as ReadonlyArray<Stage>

View File

@@ -0,0 +1,45 @@
import type { KeybindListener } from '../types/keybinds'
const keybinds: KeybindListener[] = [
{
id: 'next-stage',
keybind: 'ArrowRight',
description: 'Go to next stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoNext(),
},
{
id: 'previous-stage',
keybind: 'ArrowLeft',
description: 'Go to previous stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoBack(),
},
{
id: 'generate-message',
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
{
id: 'toggle-collapse',
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
{
id: 'reset-progress',
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'skip-project',
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
]
export default keybinds

View File

@@ -0,0 +1,3 @@
## Misuse of Tags
Per section 5.1 of %RULES%, it is important that the metadata of your projects is accurate. Including that selected tags honestly represent your project.

View File

@@ -0,0 +1 @@
It looks like the Optimization tag is not accurate for this project.

View File

@@ -0,0 +1,5 @@
Currently, your pack has multiple Resolutions selected, in most cases, this will misrepresent your project.
For a brief rundown of how this works:
Vanilla Minecraft textures like blocks and items have a width and height of 16 pixels.
This means that packs with textures the same size as the default are considered 16x.
Some packs make the resolution of textures smaller than the default, such as 8x, and some bigger, such as 32x.

View File

@@ -0,0 +1,2 @@
**Featured Tags:** %PROJECT_CATEGORIES% \
**Additional Tags:** %PROJECT_ADDITIONAL_CATEGORIES%

View File

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

View File

@@ -0,0 +1,4 @@
**Discord:** %PROJECT_DISCORD_URL% \
**Issues:** %PROJECT_ISSUES_URL% \
**Source:** %PROJECT_SOURCE_URL% \
**Wiki:** %PROJECT_WIKI_URL%

View File

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

View File

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

View File

@@ -0,0 +1 @@
**Applying for:** `%PROJECT_REQUESTED_STATUS%`

View File

@@ -0,0 +1,2 @@
**Summary:**
`%PROJECT_SUMMARY%`

View File

@@ -0,0 +1,4 @@
**Title:** %PROJECT_TITLE% \
**Slug:** `%PROJECT_SLUG%`
**Title issues?**

View File

@@ -0,0 +1,7 @@
## Description Accessibility
In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we request that `# header`s not be used as body text.
Headers are interpreted differently by screen-readers and thus should generally only be used for things like separating sections of your Description.
If you would like to emphasize a particular sentence or paragraph, instead consider using `**bold**` text using the **B** button above the text editor.

View File

@@ -0,0 +1,9 @@
## Image Descriptions
In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you provide a text alternative to your current Description.
It is important that your Description contains enough detail about your project that a user can have a full understanding of it from text alone.
A text-based transcription allows for those using screen readers, and users with slow internet connections unable to load images to be able to access the contents of your Description. This also acts as a backup in case the image in your Description ever goes offline for some reason.
We appreciate how much effort you put into your Description, but accessibility is important to us at Modrinth, if you would like you could put the transcription of your Description entirely in a `details` tag, so as to not spoil the visuals of your Description.

View File

@@ -0,0 +1,8 @@
## Insufficient Description
Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
What does your modpack add? What features does it have? Why would a user want to download it? Be specific!
See descriptions like [Simply Optimized](https://modrinth.com/modpack/sop) or [Aged](https://modrinth.com/modpack/aged) for examples of what a good description looks like.

View File

@@ -0,0 +1,8 @@
## Insufficient Description
Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
What does your project add? What features does it have? Why would a user want to download it? Be specific!
See descriptions like [Sodium](https://modrinth.com/mod/sodium) or [LambDynamicLights](https://modrinth.com/mod/lambdynamiclights) for examples of what a good description looks like.

View File

@@ -0,0 +1,6 @@
## Insufficient Description
Per section 2.1 of %RULES% your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
%EXPLAINER%

View File

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

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