Compare commits

..
Author SHA1 Message Date
aecsocket da30c155a0 properly clear cache 2026-03-25 22:43:43 +00:00
aecsocket 59f8a178fa use INCR 2026-03-25 22:40:25 +00:00
aecsocket c726d5d940 wip: online status fix 2026-03-25 22:40:25 +00:00
Calum H.andGitHub 81f19eeb8d fix: various content tab hosting bugs (#5662)
* fix: qa

* fix: lint
2026-03-25 17:58:13 +00:00
Calum H.andGitHub 4b4282cfbf fix: 500 on oauth authorize page (#5661) 2026-03-25 17:52:12 +00:00
François-Xavier TalbotandGitHub 7b3471944d hosting: "Reset to onboarding" support-only action (#5659)
* Reset to onboarding button

* Lint

* Intl
2026-03-25 14:50:31 +00:00
Prospector d2abeb434c changelog 2026-03-24 14:21:31 -07:00
François-Xavier TalbotandGitHub 0aecfa3140 hosting: support java 25 (#5654)
* Add Java 25, handle new version format

* Changelog

* Revert "Changelog"

This reverts commit 0c1c7e2fe8.
2026-03-24 20:39:04 +00:00
15 changed files with 235 additions and 37 deletions
@@ -1313,6 +1313,9 @@
"hosting.loader.failed-to-repair": {
"message": "Failed to repair server"
},
"hosting.loader.failed-to-reset-to-onboarding": {
"message": "Failed to reset server to onboarding"
},
"hosting.loader.failed-to-save-settings": {
"message": "Failed to save installation settings"
},
@@ -1334,6 +1337,24 @@
"hosting.loader.reset-server-description": {
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
},
"hosting.loader.reset-to-onboarding-button": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-modal-description": {
"message": "This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?"
},
"hosting.loader.reset-to-onboarding-modal-title": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-success-description": {
"message": "The server has been returned to the onboarding flow."
},
"hosting.loader.reset-to-onboarding-success-title": {
"message": "Server reset to onboarding"
},
"hosting.loader.support-options-title": {
"message": "Support options"
},
"hosting.plan.out-of-stock": {
"message": "Out of stock"
},
+12 -5
View File
@@ -5,11 +5,11 @@
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
</div>
<p>
<span>{{ error.data.error }}: </span>
{{ error.data.description }}
<span>{{ error.data?.error }}: </span>
{{ error.data?.description }}
</p>
</div>
<div v-else class="oauth-items">
<div v-else-if="app && createdBy && authorizationData" class="oauth-items">
<div class="connected-items">
<div class="profile-pics">
<Avatar size="md" :src="app.icon_url" />
@@ -164,24 +164,31 @@ const {
data: authorizationData,
isPending: pending,
error,
suspense: authSusp,
} = useQuery({
queryKey: computed(() => ['authorization', clientId, redirectUri, scope, state]),
queryFn: getFlowIdAuthorization,
enabled: computed(() => !!clientId && !!redirectUri && !!scope),
})
const { data: app } = useQuery({
const { data: app, suspense: appSusp } = useQuery({
queryKey: computed(() => ['oauth/app', clientId]),
queryFn: () => client.labrinth.oauth_internal.getApp(clientId),
enabled: computed(() => !!clientId),
})
const { data: createdBy } = useQuery({
const { data: createdBy, suspense: userSusp } = useQuery({
queryKey: computed(() => ['user', app.value?.created_by]),
queryFn: () => client.labrinth.users_v2.get(app.value.created_by),
enabled: computed(() => !!app.value?.created_by),
})
onServerPrefetch(async () => {
await authSusp()
await appSusp()
await userSusp()
})
const scopeDefinitions = computed(() =>
scopesToDefinitions(BigInt(authorizationData.value?.requested_scopes || 0)),
)
@@ -1,5 +1,13 @@
<template>
<div class="flex flex-col gap-6 rounded-2xl bg-surface-3 p-6">
<ConfirmModal
ref="resetToOnboardingModal"
:title="formatMessage(messages.resetToOnboardingModalTitle)"
:description="formatMessage(messages.resetToOnboardingModalDescription)"
:proceed-label="formatMessage(messages.resetToOnboardingButton)"
@proceed="confirmResetToOnboarding"
/>
<InstallationSettingsLayout ref="installationSettingsLayout">
<template #extra>
<div class="flex flex-col gap-2.5">
@@ -28,6 +36,24 @@
/>
</template>
</InstallationSettingsLayout>
<div v-if="isSiteAdmin" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.supportOptionsTitle) }}
</span>
<div>
<ButtonStyled color="red">
<button
class="!shadow-none"
:disabled="!worldId || isResettingToOnboarding"
@click="resetToOnboardingModal?.show()"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.resetToOnboardingButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
@@ -37,6 +63,7 @@ import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
@@ -106,6 +133,35 @@ const messages = defineMessages({
id: 'hosting.loader.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
supportOptionsTitle: {
id: 'hosting.loader.support-options-title',
defaultMessage: 'Support options',
},
resetToOnboardingButton: {
id: 'hosting.loader.reset-to-onboarding-button',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalTitle: {
id: 'hosting.loader.reset-to-onboarding-modal-title',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalDescription: {
id: 'hosting.loader.reset-to-onboarding-modal-description',
defaultMessage:
'This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?',
},
resetToOnboardingSuccessTitle: {
id: 'hosting.loader.reset-to-onboarding-success-title',
defaultMessage: 'Server reset to onboarding',
},
resetToOnboardingSuccessDescription: {
id: 'hosting.loader.reset-to-onboarding-success-description',
defaultMessage: 'The server has been returned to the onboarding flow.',
},
failedToResetToOnboarding: {
id: 'hosting.loader.failed-to-reset-to-onboarding',
defaultMessage: 'Failed to reset server to onboarding',
},
})
const emit = defineEmits<{
@@ -156,8 +212,13 @@ const modpackVersionsQuery = useQuery({
enabled: computed(() => !!modpack.value?.spec.project_id),
})
const auth = await useAuth()
const isSiteAdmin = computed(() => auth.value?.user?.role === 'admin')
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const resetToOnboardingModal = ref<InstanceType<typeof ConfirmModal>>()
const isResettingToOnboarding = ref(false)
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -337,11 +398,17 @@ provideInstallationSettings({
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged
? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion }
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
@@ -349,7 +416,7 @@ provideInstallationSettings({
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: loaderVersionId ?? '',
version: resolvedLoaderVersion ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
@@ -590,4 +657,30 @@ function onBrowseModpacks() {
query: { sid: serverId, from: 'reset-server', wid: worldId.value },
})
}
async function confirmResetToOnboarding() {
if (!worldId.value) return
try {
isResettingToOnboarding.value = true
await client.archon.servers_v1.resetToOnboarding(serverId, worldId.value)
server.value.flows = { intro: true }
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] }),
])
addNotification({
type: 'success',
title: formatMessage(messages.resetToOnboardingSuccessTitle),
text: formatMessage(messages.resetToOnboardingSuccessDescription),
})
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToResetToOnboarding),
})
} finally {
isResettingToOnboarding.value = false
}
}
</script>
@@ -29,7 +29,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -45,7 +46,7 @@
false,
true,
false,
false
true
]
},
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"
@@ -44,7 +44,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -79,7 +80,7 @@
true,
false,
false,
false,
true,
null
]
},
@@ -25,7 +25,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -22,7 +22,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ When the user refers to "perform[ing] pre-PR checks", do the following:
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare`
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
- NEVER run `cargo sqlx prepare --workspace`
## Testing
+14
View File
@@ -788,6 +788,20 @@ impl RedisConnection {
.await?;
Ok(values)
}
#[tracing::instrument(skip(self))]
pub async fn incr(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<u64>, DatabaseError> {
let key = format!("{}_{namespace}:{id}", self.meta_namespace);
let value = cmd("INCR")
.arg(key)
.query_async(&mut self.connection)
.await?;
Ok(value)
}
}
#[derive(Serialize, Deserialize)]
+1
View File
@@ -293,4 +293,5 @@ vars! {
SERVER_PING_RETRIES: usize = 3usize;
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;
}
+57 -18
View File
@@ -24,6 +24,7 @@ pub struct ServerPingQueue {
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping";
pub const REDIS_FAILURE_NAMESPACE: &str = "minecraft_java_server_ping_failures";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
@@ -118,27 +119,65 @@ impl ServerPingQueue {
.await
.wrap_err("failed to write ping record")?;
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
let mut updated_project = false;
if data.is_some() {
// ping succeeded; immediately update its online status in redis
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err("failed to set redis key")?;
updated_project = true;
redis
.delete(REDIS_FAILURE_NAMESPACE, project_id)
.await
.wrap_err("failed to delete failure count")?;
} else {
// ping failed; if it's failed too many times, mark it as offline in redis
// otherwise, just add to the fail counter
let failure_count = redis
.incr(REDIS_FAILURE_NAMESPACE, &project_id.to_string())
.await
.wrap_err("failed to increment failure count")?;
if let Some(count) = failure_count
&& count >= ENV.SERVER_PING_MAX_FAIL_COUNT
{
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err(
"failed to set failed ping record in redis",
)?;
updated_project = true;
}
}
if updated_project {
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.wrap_err("failed to set redis key")?;
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
}
}
ch.end()
@@ -54,4 +54,16 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'DELETE',
})
}
/**
* Reset a world to onboarding
* POST /v1/servers/:id/worlds/:wid/onboard
*/
public async resetToOnboarding(serverId: string, worldId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/worlds/${worldId}/onboard`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
}
+6
View File
@@ -10,6 +10,12 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-03-24T21:14:30-08:00`,
product: 'hosting',
body: `## Added
- Added support for Java 25.`,
},
{
date: `2026-03-17T19:30:00-08:00`,
product: 'web',
@@ -212,8 +212,8 @@ const deleteHovered = ref(false)
>
<span ref="versionNumberRef" class="truncate">{{
version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
}}</span>
<span class="shrink-0">{{
}}</span
><span class="shrink-0">{{
version.version_number.slice(Math.ceil(version.version_number.length / 2))
}}</span>
</AutoLink>
@@ -223,8 +223,8 @@ const deleteHovered = ref(false)
>
<span ref="fileNameRef" class="truncate">{{
version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
}}</span>
<span class="shrink-0">{{
}}</span
><span class="shrink-0">{{
version.file_name.slice(Math.ceil(version.file_name.length / 2))
}}</span>
</span>
@@ -115,6 +115,7 @@ const contentQuery = useQuery({
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
staleTime: 0,
})
const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null)
@@ -483,7 +484,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
link: `/${addon.owner.type}/${addon.owner.id}`,
}
: undefined,
id: addon.id,
id: addon.id ?? addon.filename,
enabled: !addon.disabled,
file_name: addon.filename,
project_type: addon.kind,