Compare commits

...
Author SHA1 Message Date
Calum H.andGitHub 4d68f3cea4 chore: changelog (#5823) 2026-04-15 22:09:54 +02:00
aecsocketandGitHub 546b117437 Tweak token prioritization in Typesense (#5776)
* Tweak toke prioritization in typesense

* tweaks

* allow configuring max_candidates

* tweak max_candidates

* final changes
2026-04-15 19:45:41 +00:00
Calum H.andGitHub 3d5f29a7a2 feat: continued post qa for servers in app (#5818)
* fix: intercom in app

* feat: Logs.vue dynamic console resizing with window + padding problem

* fix: search highlight with decorator + change to be better

* fix: qa

* fix: allow paper+purpur into app csp

* fix: lint
2026-04-15 19:16:05 +00:00
Truman GaoandGitHub 37b0f7ff98 fix: displayUrlEnding is undefined at times (#5811) 2026-04-15 18:50:05 +00:00
Truman GaoandGitHub 1603796856 feat: implement overlay scrollbar for app sidebar (#5820)
* feat: implement overlay scrollbar for app sidebar

* pnpm prepr
2026-04-15 16:53:08 +00:00
Truman GaoandGitHub 7c9a9f22d4 fix: project page header in mobile (#5817) 2026-04-15 16:14:19 +00:00
Truman GaoandGitHub d38d23dbf3 fix: post servers in app release QA (#5815)
* fix: billing page server plan heading

* fix: matching server page spacing with instance page

* feat: update server header buttons

* feat: add show ram as bytes always on

* fix: revert to large buttons

* feat: add hostname and server states in info card
2026-04-15 14:55:11 +00:00
aecsocketandGitHub f12bd7b4b8 Add utoipa info for v2 routes (#5775)
* wip: add v2 docs, routes to config, paths

* fix up path prefixes

* fix leading slashes

* fix slash route

* fix more slashes

* wip: full utopification of v2

* convert last few v2 routes to utoipa
2026-04-15 13:25:35 +00:00
Truman GaoandGitHub baee34b0b6 feat: add moderation checklist back to project page (#5814)
* fix: billing page server plan heading

* fix: matching server page spacing with instance page

* feat: update server header buttons

* feat: add show ram as bytes always on

* fix: revert to large buttons

* feat: add hostname and server states in info card

* feat: add publishing checklist to project page

* fix: markdown table style and max width

* fix: teleport overflow menu bad anchoring
2026-04-15 09:12:31 +00:00
75 changed files with 3230 additions and 400 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: review-changelog
description: Review the latest changelog entry in packages/blog/changelog.ts against the project's changelog style guide and flag bullets that need rewriting. Use when checking a freshly added changelog entry before opening a PR, or when the user asks to review/lint the latest changelog.
argument-hint: [product?]
---
Refer to the standard: @standards/maintaining/CHANGELOG.md
## Steps
1. **Locate the latest entry:**
- Open `packages/blog/changelog.ts`.
- The latest entries are at the top of the `VERSIONS` array.
- If `$ARGUMENTS` specifies a product (`web`, `hosting`, `app`), review the most recent entry for that product. Otherwise, review the most recent entry overall, plus any sibling entries sharing the same `date` (coordinated releases ship together).
2. **Read the standard above** in full before reviewing. The bullet rules, section/verb agreement, and "Don't" list are the source of truth.
3. **Check the entry shell:**
- `date` is a valid ISO 8601 timestamp.
- `product` is one of `web`, `hosting`, `app`.
- `version` is present for `app` entries and omitted for `web`/`hosting`.
- Section headings use `## Added`, `## Changed`, `## Fixed`, `## Security` (or a featured-release linked heading). Flag legacy `## Improvements`.
4. **Review each bullet** against the standard. For each bullet, check:
- Voice/tense matches the section heading.
- Opening verb agrees with its section.
- Describes observable behavior, not implementation.
- Specific enough to identify the surface (names the tab/page/modal).
- One sentence, ends with a period, sentence case.
- Uses branded names (Modrinth App, Modrinth Hosting) correctly.
- No filler ("issue with", "issue where", "various", "some"), no vague intensifiers, no apologies, no PR/commit references (unless crediting a third-party contributor with a linked GitHub profile).
- Not a duplicate sub-fix of a bigger change already listed.
5. **Report findings** as a short list grouped by entry. For each problem bullet, show the original line and a suggested rewrite. If the entry is clean, say so explicitly. Do not edit the file unless the user asks - this skill is a review pass, not a rewrite pass.
6. **If the user then asks to apply fixes**, edit `packages/blog/changelog.ts` directly using the suggested rewrites. Preserve tab indentation and template literal formatting.
+1
View File
@@ -35,6 +35,7 @@
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
+46 -11
View File
@@ -60,6 +60,7 @@ import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
@@ -157,6 +158,11 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => openUrl(url),
})
provideModalBehavior({
@@ -423,6 +429,13 @@ loading.startLoading()
let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({
overflow: {
x: 'hidden',
y: 'scroll',
},
})
router.beforeEach(() => {
suspensePending = false
loading.startLoading()
@@ -434,7 +447,7 @@ router.afterEach((to, from, failure) => {
failed: failure,
})
setTimeout(() => {
if (!suspensePending) {
if (!suspensePending && stateInitialized.value) {
loading.stopLoading()
}
}, 100)
@@ -492,9 +505,27 @@ setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
async function validateSession(sessionToken) {
try {
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
method: 'GET',
headers: { Authorization: sessionToken },
})
if (response.status === 401) return false
return true
} catch {
return true
}
}
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
await logout().catch(handleError)
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
}
credentials.value = creds ?? null
@@ -1274,29 +1305,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</RouterView>
</div>
<div
class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid overflow-auto"
v-overlay-scrollbars="sidebarOverlayScrollbarsOptions"
class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid"
:class="{ 'has-plus': hasPlus }"
data-overlayscrollbars-initialize
>
<div
class="app-sidebar-scrollable flex-grow shrink overflow-y-auto relative"
:class="{ 'pb-12': !hasPlus }"
>
<div class="app-sidebar-scrollable flex-grow shrink relative" :class="{ 'pb-12': !hasPlus }">
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div
class="p-4 pr-1 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
</div>
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
@@ -1644,6 +1672,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
</style>
<style>
.os-theme-dark,
.os-theme-light {
--os-handle-bg: var(--color-scrollbar) !important;
--os-handle-bg-hover: var(--color-scrollbar) !important;
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
@@ -100,24 +100,38 @@ const loadingProgress = ref(0)
const hidden = ref(false)
const message = ref()
// const MIN_DISPLAY_MS = 1000
// const mountedAt = Date.now()
const loading = useLoading()
watch(loading, (newValue) => {
if (!newValue.barEnabled) {
if (loading.loading) {
loadingProgress.value = 0
fakeLoadingIncrease()
} else {
loadingProgress.value = 100
doneLoading.value = true
watch(
loading,
(newValue) => {
if (!newValue.barEnabled) {
if (loading.loading) {
loadingProgress.value = 0
fakeLoadingIncrease()
} else {
// const elapsed = Date.now() - mountedAt
// const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
setTimeout(() => {
hidden.value = true
loading.setEnabled(true)
}, 50)
// setTimeout(() => {
// if (loading.loading) return
loadingProgress.value = 100
doneLoading.value = true
setTimeout(() => {
hidden.value = true
loading.setEnabled(true)
}, 50)
// }, delay)
}
}
}
})
},
{ immediate: true },
)
function fakeLoadingIncrease() {
if (loadingProgress.value < 95) {
@@ -288,7 +288,7 @@ const messages = defineMessages({
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
@@ -309,7 +309,7 @@ const messages = defineMessages({
@keyup.esc="search = ''"
/>
</template>
<h3 v-else class="ml-2 w-full text-base text-primary font-medium m-0">
<h3 v-else class="w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
@@ -331,11 +331,11 @@ const messages = defineMessages({
</ButtonStyled>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
@@ -344,7 +344,7 @@ const messages = defineMessages({
</div>
</template>
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm ml-4 mr-1">
<div class="text-sm">
<div v-if="!userCredentials">
<IntlFormatted :message-id="messages.signInToAddFriends">
<template #link="{ children }">
@@ -106,7 +106,7 @@ const messages = defineMessages({
:open-by-default="openByDefault"
:force-open="isSearching"
:button-class="
'pl-4 pr-3 flex w-full items-center bg-transparent border-0 p-0' +
'flex w-full items-center bg-transparent border-0 p-0' +
(isSearching
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
@@ -122,7 +122,7 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full ml-4 mr-1"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
@@ -0,0 +1,35 @@
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { ObjectDirective } from 'vue'
const defaultOverlayScrollbarsOptions = Object.freeze<PartialOptions>({
scrollbars: {
theme: 'os-theme-dark',
autoHide: 'leave',
autoHideSuspend: true,
},
})
const mergeOptions = (options: PartialOptions = {}): PartialOptions => ({
...defaultOverlayScrollbarsOptions,
...options,
scrollbars: {
...defaultOverlayScrollbarsOptions.scrollbars,
...(options.scrollbars ?? {}),
},
})
export const overlayScrollbarsDirective: ObjectDirective<HTMLElement, PartialOptions | undefined> =
{
mounted(el, binding) {
OverlayScrollbars(el, mergeOptions(binding.value))
},
updated(el, binding) {
if (binding.value === binding.oldValue) return
const instance = OverlayScrollbars(el)
instance?.options(mergeOptions(binding.value))
},
unmounted(el) {
const instance = OverlayScrollbars(el)
instance?.destroy()
},
}
+3
View File
@@ -1,4 +1,5 @@
import 'floating-vue/dist/style.css'
import 'overlayscrollbars/overlayscrollbars.css'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
@@ -8,6 +9,7 @@ import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
import i18nPlugin from '@/plugins/i18n'
import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes'
@@ -50,5 +52,6 @@ app.use(FloatingVue, {
})
app.use(i18nPlugin)
app.use(i18nDebugPlugin)
app.directive('overlay-scrollbars', overlayScrollbarsDirective)
app.mount('#app')
@@ -1,10 +1,12 @@
<template>
<div class="h-full w-full py-6">
<div class="h-full w-full pt-6">
<ServersManageRootLayout
:server-id="serverId"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -48,10 +50,12 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
@@ -97,6 +101,37 @@ watch(
},
)
const authUser = computed(() => {
const user = auth.user.value
if (!user?.id) return undefined
return {
id: user.id,
username: user.username,
email: user.email ?? '',
created: user.created,
}
})
async function fetchIntercomToken(): Promise<{ token: string }> {
const credentials = await getCreds()
if (!credentials?.session) {
throw new Error('Not authenticated')
}
const response = await tauriFetch(
`${config.siteUrl}/api/intercom/messenger-jwt?server_id=${encodeURIComponent(serverId.value)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${credentials.session}`,
},
},
)
if (!response.ok) {
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
}
return (await response.json()) as { token: string }
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) {
@@ -1,6 +1,9 @@
<template>
<div v-if="instance">
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<div v-if="instance" class="flex h-full flex-col">
<div
class="shrink-0 p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal
:key="instance.path"
@@ -205,10 +208,10 @@
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<div class="shrink-0 px-6">
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<div v-if="!!instance" class="min-h-0 flex-1 overflow-y-auto p-6 pt-4">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
+1
View File
@@ -6,6 +6,7 @@ export const DEFAULT_FEATURE_FLAGS = {
worlds_tab: false,
worlds_in_home: true,
server_project_qa: false,
server_ram_as_bytes_always_on: false,
i18n_debug: false,
}
+1 -1
View File
@@ -87,7 +87,7 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net 'self' data: blob:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
@@ -62,6 +62,8 @@
.normal-page__content {
grid-area: content;
width: 100%;
min-width: 0;
}
.normal-page__header {
@@ -116,6 +118,8 @@
}
.normal-page__content {
width: 100%;
min-width: 0;
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
@@ -164,6 +168,8 @@
.normal-page__content {
grid-area: content;
width: 100%;
min-width: 0;
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
@@ -38,6 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showProjectPageQuickServerButton: false,
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
serverRamAsBytesAlwaysOn: false,
archonSentryCapture: false,
hideRussiaCensorshipBanner: false,
disablePrettyProjectUrlRedirects: false,
+19
View File
@@ -439,6 +439,24 @@
}"
>
<div class="normal-page__header relative my-4">
<div class="mb-6">
<ModerationProjectNags
v-if="
projectV3 &&
((currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status))
"
:project="project"
:project-v3="projectV3"
:versions="versions ?? undefined"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
</div>
<ProjectHeader
v-if="projectV3Loaded"
:project="project"
@@ -1120,6 +1138,7 @@ import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
@@ -37,7 +37,7 @@
<nuxt-link
:to="`/${projectV2.project_type}/${
projectV2.slug ? projectV2.slug : projectV2.id
}/version/${encodeURI(version.displayUrlEnding)}`"
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`"
>
{{ version.name }}
</nuxt-link>
@@ -28,7 +28,7 @@
(version: any) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`
"
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
@@ -89,7 +89,7 @@
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`,
external: true,
},
{
@@ -98,7 +98,7 @@
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`,
),
},
{
@@ -128,7 +128,7 @@
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`"
>
<XIcon aria-hidden="true" />
Discard changes
@@ -110,7 +110,7 @@
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`,
external: true,
},
{
@@ -119,7 +119,7 @@
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`,
),
},
{
@@ -280,7 +280,7 @@
<div class="flex flex-col justify-between gap-4">
<div class="flex flex-col gap-4">
<ModrinthServersIcon class="flex h-8 w-fit" />
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-6">
<ServerListing
v-if="subscription.serverInfo"
v-bind="subscription.serverInfo"
@@ -311,15 +311,16 @@
/>
</div>
</div>
<h3 class="m-0 mt-4 text-xl font-semibold leading-none text-contrast">
{{
formatMessage(messages.planTitle, {
size: getProductSize(getPyroProduct(subscription)),
})
}}
</h3>
<div class="flex flex-row justify-between">
<div class="mt-2 flex flex-col gap-2">
<div class="flex flex-col gap-2">
<h3 class="m-0 mb-1 text-xl font-semibold leading-none">
{{
formatMessage(messages.planTitle, {
size: getProductSize(getPyroProduct(subscription)),
})
}}
</h3>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span>
@@ -370,8 +371,8 @@
</div>
<div class="flex flex-col items-end justify-between">
<div class="flex flex-col items-end gap-2">
<div class="flex text-2xl font-bold text-contrast">
<span class="text-contrast">
<h3 class="m-0 flex text-lg font-semibold text-contrast">
<span class="leading-none text-contrast">
{{
getProductPrice(getPyroProduct(subscription), subscription.interval)
? formatPrice(
@@ -383,14 +384,14 @@
: ''
}}
</span>
<span>
<span class="leading-none">
{{
formatMessage(messages.slashInterval, {
interval: getIntervalNounLabel(subscription.interval),
})
}}
</span>
</div>
</h3>
<div
v-if="
getPyroCharge(subscription) &&
@@ -1,12 +1,18 @@
import { provideModalBehavior, providePageContext } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
export function setupPageContextProvider() {
const cosmetics = useCosmetics()
const featureFlags = useFeatureFlags()
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() => featureFlags.value.serverRamAsBytesAlwaysOn),
},
openExternalUrl: (url) => window.open(url, '_blank'),
})
provideModalBehavior({
@@ -57,7 +57,11 @@ export default defineEventHandler(async (event): Promise<IntercomTokenResponse>
})
}
const authToken = getCookie(event, 'auth-token')
const authHeader = getRequestHeader(event, 'authorization')
const bearerToken = authHeader?.toLowerCase().startsWith('bearer ')
? authHeader.slice(7).trim()
: undefined
const authToken = bearerToken || getCookie(event, 'auth-token')
if (!authToken) {
throw createError({
statusCode: 401,
+1 -1
View File
@@ -16,7 +16,7 @@ DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
SEARCH_BACKEND=meilisearch
SEARCH_BACKEND=typesense
# Meilisearch configuration
MEILISEARCH_READ_ADDR=http://localhost:7700
+1 -1
View File
@@ -353,7 +353,6 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
.app_data(labrinth_config.rate_limiter.clone())
.configure(routes::v2::config)
.configure(routes::v3::config)
.configure(routes::internal::config)
.configure(routes::root_config)
@@ -374,6 +373,7 @@ pub fn utoipa_app_config(
|_cfg| ()
}
})
.configure(routes::v2::utoipa_config)
.configure(routes::v3::utoipa_config)
.configure(routes::internal::utoipa_config);
}
+9 -5
View File
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
use validator::Validate;
/// A project returned from the API
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct LegacyProject {
/// Relevant V2 fields- these were removed or modified in V3,
/// and are now part of the dynamic fields system
@@ -253,7 +253,9 @@ impl LegacyProject {
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Copy)]
#[derive(
Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Copy, utoipa::ToSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum LegacySideType {
Required,
@@ -290,7 +292,7 @@ impl LegacySideType {
}
/// A specific version of a project
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct LegacyVersion {
/// Relevant V2 fields- these were removed or modified in V3,
/// and are now part of the dynamic fields system
@@ -368,7 +370,7 @@ impl From<Version> for LegacyVersion {
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)]
pub struct LegacyGalleryItem {
pub url: String,
pub raw_url: String,
@@ -393,7 +395,9 @@ impl LegacyGalleryItem {
}
}
#[derive(Serialize, Deserialize, Validate, Clone, Eq, PartialEq)]
#[derive(
Serialize, Deserialize, Validate, Clone, Eq, PartialEq, utoipa::ToSchema,
)]
pub struct DonationLink {
pub id: String,
pub platform: String,
+13
View File
@@ -124,6 +124,19 @@ bitflags::bitflags! {
bitflags_serde_impl!(Scopes, u64);
impl utoipa::PartialSchema for Scopes {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::Integer)
.format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
utoipa::openapi::KnownFormat::Int64,
)))
.into()
}
}
impl utoipa::ToSchema for Scopes {}
impl Scopes {
// these scopes cannot be specified in a personal access token
pub fn restricted() -> Scopes {
+34
View File
@@ -33,6 +33,23 @@ bitflags::bitflags! {
bitflags_serde_impl!(ProjectPermissions, u64);
impl utoipa::PartialSchema for ProjectPermissions {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
u64::schema()
}
}
impl utoipa::ToSchema for ProjectPermissions {
fn schemas(
schemas: &mut Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)>,
) {
u64::schemas(schemas);
}
}
impl Default for ProjectPermissions {
fn default() -> ProjectPermissions {
ProjectPermissions::empty()
@@ -92,6 +109,23 @@ bitflags::bitflags! {
bitflags_serde_impl!(OrganizationPermissions, u64);
impl utoipa::PartialSchema for OrganizationPermissions {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
u64::schema()
}
}
impl utoipa::ToSchema for OrganizationPermissions {
fn schemas(
schemas: &mut Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)>,
) {
u64::schemas(schemas);
}
}
impl Default for OrganizationPermissions {
fn default() -> OrganizationPermissions {
OrganizationPermissions::NONE
+19 -3
View File
@@ -17,15 +17,15 @@ use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::Arc;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
web::scope("admin")
utoipa_actix_web::scope("/admin")
.service(count_download)
.service(force_reindex),
);
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct DownloadBody {
pub url: String,
pub project_id: ProjectId,
@@ -36,6 +36,14 @@ pub struct DownloadBody {
}
// This is an internal route, cannot be used without key
#[utoipa::path(
patch,
operation_id = "countDownload",
responses(
(status = 204, description = "Download counted successfully"),
(status = 400, description = "Invalid input")
)
)]
#[patch("/_count-download", guard = "admin_key_guard")]
#[allow(clippy::too_many_arguments)]
pub async fn count_download(
@@ -150,6 +158,14 @@ pub async fn count_download(
Ok(HttpResponse::NoContent().body(""))
}
#[utoipa::path(
post,
operation_id = "forceReindex",
responses(
(status = 204, description = "Search index rebuilt successfully"),
(status = 401, description = "Unauthorized")
)
)]
#[post("/_force_reindex", guard = "admin_key_guard")]
pub async fn force_reindex(
pool: web::Data<PgPool>,
+1 -1
View File
@@ -44,7 +44,7 @@ use tracing::warn;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("billing")
web::scope("/billing")
.service(products)
.service(subscriptions)
.service(user_customer)
@@ -37,7 +37,7 @@ pub mod rescan;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("delphi")
web::scope("/delphi")
.service(ingest_report)
.service(_run)
.service(version)
+170 -30
View File
@@ -22,7 +22,7 @@ use crate::util::error::Context;
use crate::util::ext::get_image_ext;
use crate::util::img::upload_image_optimized;
use crate::util::validate::validation_errors_to_string;
use actix_web::web::{Data, Query, ServiceConfig, scope};
use actix_web::web::{Data, Query};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
@@ -43,9 +43,9 @@ use tracing::info;
use validator::Validate;
use zxcvbn::Score;
pub fn config(cfg: &mut ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
scope("auth")
utoipa_actix_web::scope("/auth")
.service(init)
.service(auth_callback)
.service(delete_auth_provider)
@@ -1041,7 +1041,7 @@ impl AuthProvider {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct AuthorizationInit {
pub url: String,
#[serde(default)]
@@ -1051,7 +1051,7 @@ pub struct AuthorizationInit {
/// this will be set to the user's auth token from the frontend.
pub auth_token: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct Authorization {
pub code: String,
pub state: String,
@@ -1059,7 +1059,15 @@ pub struct Authorization {
// Init link takes us to GitHub API and calls back to callback endpoint with a code and state
// http://localhost:8000/auth/init?url=https://modrinth.com
#[get("init")]
#[utoipa::path(
get,
operation_id = "authInit",
responses(
(status = 307, description = "Redirect to OAuth provider"),
(status = 400, description = "Invalid input")
)
)]
#[get("/init")]
pub async fn init(
req: HttpRequest,
Query(info): Query<AuthorizationInit>, // callback url
@@ -1140,7 +1148,15 @@ pub async fn init(
.json(serde_json::json!({ "url": url })))
}
#[get("callback")]
#[utoipa::path(
get,
operation_id = "authCallback",
responses(
(status = 307, description = "Redirect with auth code"),
(status = 401, description = "Authentication failed")
)
)]
#[get("/callback")]
pub async fn auth_callback(
req: HttpRequest,
Query(query): Query<HashMap<String, String>>,
@@ -1336,12 +1352,22 @@ pub async fn auth_callback(
Ok(res?)
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct DeleteAuthProvider {
pub provider: AuthProvider,
}
#[delete("provider")]
#[utoipa::path(
delete,
operation_id = "deleteAuthProvider",
responses(
(status = 204, description = "Auth provider removed"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["USER_AUTH_WRITE"]))
)]
#[delete("/provider")]
pub async fn delete_auth_provider(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1425,7 +1451,7 @@ pub async fn check_sendy_subscription(
Ok(response.trim() == "Subscribed")
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct NewAccount {
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))]
pub username: String,
@@ -1437,7 +1463,15 @@ pub struct NewAccount {
pub sign_up_newsletter: Option<bool>,
}
#[post("create")]
#[utoipa::path(
post,
operation_id = "createAccountPassword",
responses(
(status = 200, description = "Account created"),
(status = 400, description = "Invalid input")
)
)]
#[post("/create")]
pub async fn create_account_with_password(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1566,7 +1600,7 @@ pub async fn create_account_with_password(
Ok(HttpResponse::Ok().json(res))
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct Login {
#[serde(rename = "username")]
pub username_or_email: String,
@@ -1574,7 +1608,15 @@ pub struct Login {
pub challenge: String,
}
#[post("login")]
#[utoipa::path(
post,
operation_id = "loginPassword",
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Invalid credentials")
)
)]
#[post("/login")]
pub async fn login_password(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1639,7 +1681,7 @@ pub async fn login_password(
}
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct Login2FA {
pub code: String,
pub flow: String,
@@ -1724,7 +1766,15 @@ async fn validate_2fa_code(
}
}
#[post("login/2fa")]
#[utoipa::path(
post,
operation_id = "login2fa",
responses(
(status = 200, description = "2FA login successful"),
(status = 401, description = "Invalid credentials")
)
)]
#[post("/login/2fa")]
pub async fn login_2fa(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1773,7 +1823,16 @@ pub async fn login_2fa(
}
}
#[post("2fa/get_secret")]
#[utoipa::path(
post,
operation_id = "begin2faFlow",
responses(
(status = 200, description = "2FA secret generated"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[post("/2fa/get_secret")]
pub async fn begin_2fa_flow(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1812,7 +1871,16 @@ pub async fn begin_2fa_flow(
}
}
#[post("2fa")]
#[utoipa::path(
post,
operation_id = "finish2faFlow",
responses(
(status = 200, description = "2FA enabled"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[post("/2fa")]
pub async fn finish_2fa_flow(
req: HttpRequest,
pool: Data<PgPool>,
@@ -1930,12 +1998,21 @@ pub async fn finish_2fa_flow(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct Remove2FA {
pub code: String,
}
#[delete("2fa")]
#[utoipa::path(
delete,
operation_id = "remove2fa",
responses(
(status = 204, description = "2FA removed"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[delete("/2fa")]
pub async fn remove_2fa(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2016,14 +2093,22 @@ pub async fn remove_2fa(
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ResetPassword {
#[serde(rename = "username")]
pub username_or_email: String,
pub challenge: String,
}
#[post("password/reset")]
#[utoipa::path(
post,
operation_id = "resetPasswordBegin",
responses(
(status = 204, description = "Password reset email sent"),
(status = 400, description = "Invalid input")
)
)]
#[post("/password/reset")]
pub async fn reset_password_begin(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2111,14 +2196,24 @@ pub async fn reset_password_begin(
Ok(HttpResponse::Ok().finish())
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct ChangePassword {
pub flow: Option<String>,
pub old_password: Option<String>,
pub new_password: Option<String>,
}
#[patch("password")]
#[utoipa::path(
patch,
operation_id = "changePassword",
responses(
(status = 204, description = "Password changed"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[patch("/password")]
pub async fn change_password(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2265,13 +2360,23 @@ pub async fn change_password(
Ok(HttpResponse::Ok().finish())
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct SetEmail {
#[validate(email)]
pub email: String,
}
#[patch("email")]
#[utoipa::path(
patch,
operation_id = "setEmail",
responses(
(status = 204, description = "Email set"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[patch("/email")]
pub async fn set_email(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2380,7 +2485,16 @@ pub async fn set_email(
Ok(HttpResponse::Ok().finish())
}
#[post("email/resend_verify")]
#[utoipa::path(
post,
operation_id = "resendVerifyEmail",
responses(
(status = 204, description = "Verification email resent"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[post("/email/resend_verify")]
pub async fn resend_verify_email(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2438,12 +2552,20 @@ pub async fn resend_verify_email(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct VerifyEmail {
pub flow: String,
}
#[post("email/verify")]
#[utoipa::path(
post,
operation_id = "verifyEmail",
responses(
(status = 204, description = "Email verified"),
(status = 400, description = "Invalid input")
)
)]
#[post("/email/verify")]
pub async fn verify_email(
pool: Data<PgPool>,
redis: Data<RedisPool>,
@@ -2498,7 +2620,16 @@ pub async fn verify_email(
}
}
#[post("email/subscribe")]
#[utoipa::path(
post,
operation_id = "subscribeNewsletter",
responses(
(status = 204, description = "Newsletter subscription toggled"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[post("/email/subscribe")]
pub async fn subscribe_newsletter(
req: HttpRequest,
pool: Data<PgPool>,
@@ -2535,7 +2666,16 @@ pub async fn subscribe_newsletter(
Ok(HttpResponse::NoContent().finish())
}
#[get("email/subscribe")]
#[utoipa::path(
get,
operation_id = "getNewsletterSubscriptionStatus",
responses(
(status = 200, description = "Subscription status"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = []))
)]
#[get("/email/subscribe")]
pub async fn get_newsletter_subscription_status(
req: HttpRequest,
pool: Data<PgPool>,
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::routes::ApiError;
use actix_web::{HttpRequest, HttpResponse, post, web};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("gdpr").service(export));
cfg.service(web::scope("/gdpr").service(export));
}
#[post("/export")]
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::routes::ApiError;
use crate::util::guards::medal_key_guard;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("medal").service(verify).service(redeem));
cfg.service(web::scope("/medal").service(verify).service(redeem));
}
#[derive(Deserialize)]
+37 -5
View File
@@ -22,13 +22,45 @@ use crate::util::cors::default_cors;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(
actix_web::web::scope("_internal")
actix_web::web::scope("/_internal")
.wrap(default_cors())
.configure(admin::config)
.configure(|cfg| {
cfg.service(
actix_web::web::scope("/admin")
.service(admin::count_download)
.service(admin::force_reindex),
);
cfg.service(
actix_web::web::scope("/session")
.service(session::list)
.service(session::delete)
.service(session::refresh),
);
cfg.service(
actix_web::web::scope("/auth")
.service(flows::init)
.service(flows::auth_callback)
.service(flows::delete_auth_provider)
.service(flows::create_account_with_password)
.service(flows::login_password)
.service(flows::login_2fa)
.service(flows::begin_2fa_flow)
.service(flows::finish_2fa_flow)
.service(flows::remove_2fa)
.service(flows::reset_password_begin)
.service(flows::change_password)
.service(flows::resend_verify_email)
.service(flows::set_email)
.service(flows::verify_email)
.service(flows::subscribe_newsletter)
.service(flows::get_newsletter_subscription_status),
);
cfg.service(pats::get_pats);
cfg.service(pats::create_pat);
cfg.service(pats::edit_pat);
cfg.service(pats::delete_pat);
})
.configure(oauth_clients::config)
.configure(session::config)
.configure(flows::config)
.configure(pats::config)
.configure(billing::config)
.configure(gdpr::config)
.configure(gotenberg::config)
+47 -7
View File
@@ -22,14 +22,23 @@ use crate::util::validate::validation_errors_to_string;
use serde::Deserialize;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_pats);
cfg.service(create_pat);
cfg.service(edit_pat);
cfg.service(delete_pat);
}
#[get("pat")]
#[utoipa::path(
get,
operation_id = "getPats",
responses(
(status = 200, description = "List of PATs"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["PAT_READ"]))
)]
#[get("/pat")]
pub async fn get_pats(
req: HttpRequest,
pool: Data<PgPool>,
@@ -65,7 +74,7 @@ pub async fn get_pats(
))
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct NewPersonalAccessToken {
pub scopes: Scopes,
#[validate(length(min = 3, max = 255))]
@@ -73,7 +82,17 @@ pub struct NewPersonalAccessToken {
pub expires: DateTime<Utc>,
}
#[post("pat")]
#[utoipa::path(
post,
operation_id = "createPat",
responses(
(status = 200, description = "PAT created"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["PAT_CREATE"]))
)]
#[post("/pat")]
pub async fn create_pat(
req: HttpRequest,
info: web::Json<NewPersonalAccessToken>,
@@ -158,7 +177,7 @@ pub async fn create_pat(
}))
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct ModifyPersonalAccessToken {
pub scopes: Option<Scopes>,
#[validate(length(min = 3, max = 255))]
@@ -166,7 +185,18 @@ pub struct ModifyPersonalAccessToken {
pub expires: Option<DateTime<Utc>>,
}
#[patch("pat/{id}")]
#[utoipa::path(
patch,
operation_id = "editPat",
params(("id" = String, Path, description = "The PAT ID")),
responses(
(status = 204, description = "PAT updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["PAT_WRITE"]))
)]
#[patch("/pat/{id}")]
pub async fn edit_pat(
req: HttpRequest,
id: web::Path<(String,)>,
@@ -263,7 +293,17 @@ pub async fn edit_pat(
Ok(HttpResponse::NoContent().finish())
}
#[delete("pat/{id}")]
#[utoipa::path(
delete,
operation_id = "deletePat",
params(("id" = String, Path, description = "The PAT ID")),
responses(
(status = 204, description = "PAT deleted"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["PAT_DELETE"]))
)]
#[delete("/pat/{id}")]
pub async fn delete_pat(
req: HttpRequest,
id: web::Path<(String,)>,
+33 -6
View File
@@ -11,7 +11,7 @@ use crate::models::sessions::Session;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::http::header::AUTHORIZATION;
use actix_web::web::{Data, ServiceConfig, scope};
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use chrono::{DateTime, Utc};
use rand::distributions::Alphanumeric;
@@ -19,9 +19,9 @@ use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use woothee::parser::Parser;
pub fn config(cfg: &mut ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
scope("session")
utoipa_actix_web::scope("/session")
.service(list)
.service(delete)
.service(refresh),
@@ -133,7 +133,16 @@ pub async fn issue_session(
Ok(session)
}
#[get("list")]
#[utoipa::path(
get,
operation_id = "listSessions",
responses(
(status = 200, description = "List of active sessions"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["SESSION_READ"]))
)]
#[get("/list")]
pub async fn list(
req: HttpRequest,
pool: Data<PgPool>,
@@ -169,7 +178,17 @@ pub async fn list(
Ok(HttpResponse::Ok().json(sessions))
}
#[delete("{id}")]
#[utoipa::path(
delete,
operation_id = "deleteSession",
params(("id" = String, Path, description = "The session ID")),
responses(
(status = 204, description = "Session deleted"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["SESSION_DELETE"]))
)]
#[delete("/{id}")]
pub async fn delete(
info: web::Path<(String,)>,
req: HttpRequest,
@@ -209,7 +228,15 @@ pub async fn delete(
Ok(HttpResponse::NoContent().body(""))
}
#[post("refresh")]
#[utoipa::path(
post,
operation_id = "refreshSession",
responses(
(status = 200, description = "Session refreshed"),
(status = 401, description = "Unauthorized")
)
)]
#[post("/refresh")]
pub async fn refresh(
req: HttpRequest,
pool: Data<PgPool>,
+4 -4
View File
@@ -25,17 +25,17 @@ pub use self::not_found::not_found;
pub fn root_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("maven")
web::scope("/maven")
.wrap(default_cors())
.configure(maven::config),
);
cfg.service(
web::scope("updates")
web::scope("/updates")
.wrap(default_cors())
.configure(updates::config),
);
cfg.service(
web::scope("analytics")
web::scope("/analytics")
.wrap(
Cors::default()
.allowed_origin_fn(|origin, _req_head| {
@@ -59,7 +59,7 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
.configure(analytics::config),
);
cfg.service(
web::scope("api/v1")
web::scope("/api/v1")
.wrap(default_cors())
.wrap_fn(|req, _srv| {
async {
+4 -3
View File
@@ -15,12 +15,13 @@ mod versions;
pub use super::ApiError;
use crate::util::cors::default_cors;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
pub fn utoipa_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
) {
cfg.service(
actix_web::web::scope("v2")
utoipa_actix_web::scope("/v2")
.wrap(default_cors())
.configure(super::internal::admin::config)
// Todo: separate these- they need to also follow v2-v3 conversion
.configure(super::internal::session::config)
.configure(super::internal::flows::config)
.configure(super::internal::pats::config)
+27 -3
View File
@@ -8,8 +8,8 @@ use crate::{database::redis::RedisPool, routes::v2_reroute};
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::Deserialize;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("moderation").service(get_projects));
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(utoipa_actix_web::scope("/moderation").service(get_projects));
}
#[derive(Deserialize)]
@@ -22,7 +22,31 @@ fn default_count() -> u16 {
100
}
#[get("projects")]
/// Get projects in the moderation queue.
#[utoipa::path(
get,
operation_id = "getModerationProjects",
params(
(
"count" = Option<u16>,
Query,
description = "Maximum number of projects to return"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_READ"]))
)]
#[get("/projects")]
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
+134 -9
View File
@@ -10,13 +10,12 @@ use crate::routes::v3;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(notifications_get);
cfg.service(notifications_delete);
cfg.service(notifications_read);
cfg.service(
web::scope("notification")
utoipa_actix_web::scope("/notification")
.service(notification_get)
.service(notification_read)
.service(notification_delete),
@@ -28,7 +27,31 @@ pub struct NotificationIds {
pub ids: String,
}
#[get("notifications")]
/// Get multiple notifications by ID.
#[utoipa::path(
get,
operation_id = "getNotifications",
params(
(
"ids" = String,
Query,
description = "The JSON array of notification IDs"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_READ"]))
)]
#[get("/notifications")]
pub async fn notifications_get(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
@@ -57,7 +80,25 @@ pub async fn notifications_get(
}
}
#[get("{id}")]
/// Get a notification by ID.
#[utoipa::path(
get,
operation_id = "getNotification",
params(("id" = NotificationId, Path, description = "The ID of the notification")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_READ"]))
)]
#[get("/{id}")]
pub async fn notification_get(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
@@ -83,7 +124,25 @@ pub async fn notification_get(
}
}
#[patch("{id}")]
/// Mark a notification as read.
#[utoipa::path(
patch,
operation_id = "readNotification",
params(("id" = NotificationId, Path, description = "The ID of the notification")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_WRITE"]))
)]
#[patch("/{id}")]
pub async fn notification_read(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
@@ -97,7 +156,25 @@ pub async fn notification_read(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}")]
/// Delete a notification by ID.
#[utoipa::path(
delete,
operation_id = "deleteNotification",
params(("id" = NotificationId, Path, description = "The ID of the notification")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_WRITE"]))
)]
#[delete("/{id}")]
pub async fn notification_delete(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
@@ -117,7 +194,31 @@ pub async fn notification_delete(
.or_else(v2_reroute::flatten_404_error)
}
#[patch("notifications")]
/// Mark multiple notifications as read.
#[utoipa::path(
patch,
operation_id = "readNotifications",
params(
(
"ids" = String,
Query,
description = "The JSON array of notification IDs"
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_WRITE"]))
)]
#[patch("/notifications")]
pub async fn notifications_read(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
@@ -137,7 +238,31 @@ pub async fn notifications_read(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("notifications")]
/// Delete multiple notifications by ID.
#[utoipa::path(
delete,
operation_id = "deleteNotifications",
params(
(
"ids" = String,
Query,
description = "The JSON array of notification IDs"
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_WRITE"]))
)]
#[delete("/notifications")]
pub async fn notifications_delete(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
@@ -25,7 +25,7 @@ use validator::Validate;
use super::version_creation::InitialVersionData;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(project_create);
}
@@ -134,6 +134,24 @@ struct ProjectCreateData {
pub organization_id: Option<models::ids::OrganizationId>,
}
/// Create a new project with initial versions.
#[utoipa::path(
post,
operation_id = "createProject",
request_body(
content(("multipart/form-data")),
description = "Multipart payload containing `data` and uploaded files"
),
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_CREATE"]))
)]
#[post("/project")]
pub async fn project_create(
req: HttpRequest,
+375 -28
View File
@@ -21,14 +21,13 @@ use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(project_search);
cfg.service(projects_get);
cfg.service(projects_edit);
cfg.service(random_projects_get);
cfg.service(
web::scope("project")
utoipa_actix_web::scope("/project")
.service(project_get)
.service(project_get_check)
.service(project_delete)
@@ -42,7 +41,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(project_unfollow)
.service(super::teams::team_members_get_project)
.service(
web::scope("{project_id}")
utoipa_actix_web::scope("/{project_id}")
.service(super::versions::version_list)
.service(super::versions::version_project_get)
.service(dependency_list),
@@ -50,7 +49,43 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[get("search")]
/// Search projects.
#[utoipa::path(
get,
operation_id = "searchProjects",
params(
(
"query" = Option<String>,
Query,
description = "The query to search for"
),
(
"facets" = Option<String>,
Query,
description = "Search facets JSON"
),
(
"index" = Option<String>,
Query,
description = "Search index to use"
),
(
"offset" = Option<String>,
Query,
description = "Search result offset"
),
(
"limit" = Option<String>,
Query,
description = "Maximum number of search results"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[get("/search")]
pub async fn project_search(
web::Query(info): web::Query<SearchRequest>,
search_backend: web::Data<dyn SearchBackend>,
@@ -141,13 +176,29 @@ fn parse_facet(facet: &str) -> Option<(String, String, String)> {
None
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct RandomProjects {
#[validate(range(min = 1, max = 100))]
pub count: u32,
}
#[get("projects_random")]
/// Get random projects.
#[utoipa::path(
get,
operation_id = "randomProjects",
params(
(
"count" = u32,
Query,
description = "Number of projects to return"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[get("/projects_random")]
pub async fn random_projects_get(
web::Query(count): web::Query<RandomProjects>,
pool: web::Data<PgPool>,
@@ -174,7 +225,20 @@ pub async fn random_projects_get(
}
}
#[get("projects")]
/// Get multiple projects by ID or slug.
#[utoipa::path(
get,
operation_id = "getProjects",
params(
(
"ids" = String,
Query,
description = "The JSON array of project IDs or slugs"
)
),
responses((status = 200, description = "Expected response to a valid request"))
)]
#[get("/projects")]
pub async fn projects_get(
req: HttpRequest,
web::Query(ids): web::Query<ProjectIds>,
@@ -205,7 +269,20 @@ pub async fn projects_get(
}
}
#[get("{id}")]
/// Get a project by ID or slug.
#[utoipa::path(
get,
operation_id = "getProject",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{id}")]
pub async fn project_get(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -241,7 +318,20 @@ pub async fn project_get(
}
//checks the validity of a project id or slug
#[get("{id}/check")]
/// Check that a project ID or slug exists.
#[utoipa::path(
get,
operation_id = "checkProjectValidity",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{id}/check")]
pub async fn project_get_check(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -253,13 +343,26 @@ pub async fn project_get_check(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize)]
#[derive(Serialize, utoipa::ToSchema)]
struct DependencyInfo {
pub projects: Vec<LegacyProject>,
pub versions: Vec<LegacyVersion>,
}
#[get("dependencies")]
/// Get dependency projects and versions for a project.
#[utoipa::path(
get,
operation_id = "getDependencies",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/dependencies")]
pub async fn dependency_list(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -305,7 +408,7 @@ pub async fn dependency_list(
}
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct EditProject {
#[validate(
length(min = 3, max = 64),
@@ -404,7 +507,26 @@ pub struct EditProject {
pub monetization_status: Option<MonetizationStatus>,
}
#[patch("{id}")]
/// Modify a project.
#[utoipa::path(
patch,
operation_id = "modifyProject",
params(("id" = String, Path, description = "The ID or slug of the project")),
request_body = EditProject,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/{id}")]
#[allow(clippy::too_many_arguments)]
pub async fn project_edit(
req: HttpRequest,
@@ -579,7 +701,7 @@ pub async fn project_edit(
Ok(response)
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct BulkEditProject {
#[validate(length(max = 3))]
pub categories: Option<Vec<String>>,
@@ -642,7 +764,29 @@ pub struct BulkEditProject {
pub discord_url: Option<Option<String>>,
}
#[patch("projects")]
/// Bulk-edit multiple projects.
#[utoipa::path(
patch,
operation_id = "patchProjects",
params(
(
"ids" = String,
Query,
description = "The JSON array of project IDs or slugs"
)
),
request_body = BulkEditProject,
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/projects")]
pub async fn projects_edit(
req: HttpRequest,
web::Query(ids): web::Query<ProjectIds>,
@@ -739,12 +883,40 @@ pub async fn projects_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
/// Change a project's icon.
#[utoipa::path(
patch,
operation_id = "changeProjectIcon",
params(
("id" = String, Path, description = "The ID or slug of the project"),
(
"ext" = String,
Query,
description = "Image extension (png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb)"
)
),
request_body(
content(
("image/png"),
("image/jpeg"),
("image/bmp"),
("image/gif"),
("image/webp"),
("image/svg+xml")
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/{id}/icon")]
#[allow(clippy::too_many_arguments)]
pub async fn project_icon_edit(
web::Query(ext): web::Query<Extension>,
@@ -771,7 +943,22 @@ pub async fn project_icon_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}/icon")]
/// Delete a project's icon.
#[utoipa::path(
delete,
operation_id = "deleteProjectIcon",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[delete("/{id}/icon")]
pub async fn delete_project_icon(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -793,7 +980,7 @@ pub async fn delete_project_icon(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct GalleryCreateQuery {
pub featured: bool,
#[validate(length(min = 1, max = 255))]
@@ -803,7 +990,63 @@ pub struct GalleryCreateQuery {
pub ordering: Option<i64>,
}
#[post("{id}/gallery")]
/// Add a gallery image to a project.
#[utoipa::path(
post,
operation_id = "addGalleryImage",
params(
("id" = String, Path, description = "The ID or slug of the project"),
(
"ext" = String,
Query,
description = "Image extension (png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb)"
),
(
"featured" = bool,
Query,
description = "Whether this image is featured"
),
(
"title" = Option<String>,
Query,
description = "Image title"
),
(
"description" = Option<String>,
Query,
description = "Image description"
),
(
"ordering" = Option<i64>,
Query,
description = "Image ordering"
)
),
request_body(
content(
("image/png"),
("image/jpeg"),
("image/bmp"),
("image/gif"),
("image/webp"),
("image/svg+xml")
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[post("/{id}/gallery")]
#[allow(clippy::too_many_arguments)]
pub async fn add_gallery_item(
web::Query(ext): web::Query<Extension>,
@@ -837,7 +1080,7 @@ pub async fn add_gallery_item(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct GalleryEditQuery {
/// The url of the gallery item to edit
pub url: String,
@@ -859,7 +1102,48 @@ pub struct GalleryEditQuery {
pub ordering: Option<i64>,
}
#[patch("{id}/gallery")]
/// Modify a gallery image.
#[utoipa::path(
patch,
operation_id = "modifyGalleryImage",
params(
("id" = String, Path, description = "The ID or slug of the project"),
("url" = String, Query, description = "URL of the image to edit"),
(
"featured" = Option<bool>,
Query,
description = "Whether this image is featured"
),
(
"title" = Option<Option<String>>,
Query,
description = "Image title"
),
(
"description" = Option<Option<String>>,
Query,
description = "Image description"
),
(
"ordering" = Option<i64>,
Query,
description = "Image ordering"
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/{id}/gallery")]
pub async fn edit_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
@@ -885,12 +1169,30 @@ pub async fn edit_gallery_item(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct GalleryDeleteQuery {
pub url: String,
}
#[delete("{id}/gallery")]
/// Delete a gallery image.
#[utoipa::path(
delete,
operation_id = "deleteGalleryImage",
params(
("id" = String, Path, description = "The ID or slug of the project"),
("url" = String, Query, description = "URL of the image to delete")
),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[delete("/{id}/gallery")]
pub async fn delete_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>,
@@ -912,7 +1214,22 @@ pub async fn delete_gallery_item(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}")]
/// Delete a project by ID or slug.
#[utoipa::path(
delete,
operation_id = "deleteProject",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_DELETE"]))
)]
#[delete("/{id}")]
pub async fn project_delete(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -935,7 +1252,22 @@ pub async fn project_delete(
.or_else(v2_reroute::flatten_404_error)
}
#[post("{id}/follow")]
/// Follow a project.
#[utoipa::path(
post,
operation_id = "followProject",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_WRITE"]))
)]
#[post("/{id}/follow")]
pub async fn project_follow(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -949,7 +1281,22 @@ pub async fn project_follow(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}/follow")]
/// Unfollow a project.
#[utoipa::path(
delete,
operation_id = "unfollowProject",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_WRITE"]))
)]
#[delete("/{id}/follow")]
pub async fn project_unfollow(
req: HttpRequest,
info: web::Path<(String,)>,
+128 -10
View File
@@ -8,7 +8,7 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::Deserialize;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(reports_get);
cfg.service(reports);
cfg.service(report_create);
@@ -17,7 +17,21 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(report_get);
}
#[post("report")]
/// Create a report for a project, version, or user.
#[utoipa::path(
post,
operation_id = "submitReport",
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_CREATE"]))
)]
#[post("/report")]
pub async fn report_create(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -40,7 +54,7 @@ pub async fn report_create(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ReportsRequestOptions {
#[serde(default = "default_count")]
count: u16,
@@ -55,7 +69,31 @@ fn default_all() -> bool {
true
}
#[get("report")]
/// Get open reports for the current user.
#[utoipa::path(
get,
operation_id = "getOpenReports",
params(
(
"count" = Option<u16>,
Query,
description = "Maximum number of reports to return"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_READ"]))
)]
#[get("/report")]
pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -88,12 +126,36 @@ pub async fn reports(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ReportIds {
pub ids: String,
}
#[get("reports")]
/// Get multiple reports by ID.
#[utoipa::path(
get,
operation_id = "getReports",
params(
(
"ids" = String,
Query,
description = "The JSON array of report IDs"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_READ"]))
)]
#[get("/reports")]
pub async fn reports_get(
req: HttpRequest,
web::Query(ids): web::Query<ReportIds>,
@@ -122,7 +184,25 @@ pub async fn reports_get(
}
}
#[get("report/{id}")]
/// Get a report by ID.
#[utoipa::path(
get,
operation_id = "getReport",
params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_READ"]))
)]
#[get("/report/{id}")]
pub async fn report_get(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -145,14 +225,34 @@ pub async fn report_get(
}
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct EditReport {
#[validate(length(max = 65536))]
pub body: Option<String>,
pub closed: Option<bool>,
}
#[patch("report/{id}")]
/// Modify a report.
#[utoipa::path(
patch,
operation_id = "modifyReport",
params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")),
request_body = EditReport,
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_WRITE"]))
)]
#[patch("/report/{id}")]
pub async fn report_edit(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -178,7 +278,25 @@ pub async fn report_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("report/{id}")]
/// Delete a report by ID.
#[utoipa::path(
delete,
operation_id = "deleteReport",
params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["REPORT_DELETE"]))
)]
#[delete("/report/{id}")]
pub async fn report_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
+15 -3
View File
@@ -5,11 +5,11 @@ use crate::routes::{
};
use actix_web::{HttpResponse, get, web};
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_stats);
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct V2Stats {
pub projects: Option<i64>,
pub versions: Option<i64>,
@@ -17,7 +17,19 @@ pub struct V2Stats {
pub files: Option<i64>,
}
#[get("statistics")]
/// Get aggregate instance statistics.
#[utoipa::path(
get,
operation_id = "statistics",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = V2Stats
)
)
)]
#[get("/statistics")]
pub async fn get_stats(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
+142 -18
View File
@@ -12,9 +12,9 @@ use actix_web::{HttpResponse, get, web};
use chrono::{DateTime, Utc};
use itertools::Itertools;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
web::scope("tag")
utoipa_actix_web::scope("/tag")
.service(category_list)
.service(loader_list)
.service(game_version_list)
@@ -27,7 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct CategoryData {
pub icon: String,
pub name: String,
@@ -35,7 +35,19 @@ pub struct CategoryData {
pub header: String,
}
#[get("category")]
/// Get the list of project categories.
#[utoipa::path(
get,
operation_id = "categoryList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<CategoryData>
)
)
)]
#[get("/category")]
pub async fn category_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -62,14 +74,26 @@ pub async fn category_list(
}
}
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct LoaderData {
pub icon: String,
pub name: String,
pub supported_project_types: Vec<String>,
}
#[get("loader")]
/// Get the list of loaders.
#[utoipa::path(
get,
operation_id = "loaderList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<LoaderData>
)
)
)]
#[get("/loader")]
pub async fn loader_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -116,7 +140,7 @@ pub async fn loader_list(
}
}
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct GameVersionQueryData {
pub version: String,
pub version_type: String,
@@ -124,14 +148,38 @@ pub struct GameVersionQueryData {
pub major: bool,
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct GameVersionQuery {
#[serde(rename = "type")]
type_: Option<String>,
major: Option<bool>,
}
#[get("game_version")]
/// Get the list of game versions.
#[utoipa::path(
get,
operation_id = "versionList",
params(
(
"type" = Option<String>,
Query,
description = "Optional game version type filter"
),
(
"major" = Option<bool>,
Query,
description = "Whether to return only major versions"
)
),
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<GameVersionQueryData>
)
)
)]
#[get("/game_version")]
pub async fn game_version_list(
pool: web::Data<PgPool>,
query: web::Query<GameVersionQuery>,
@@ -185,13 +233,25 @@ pub async fn game_version_list(
)
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct License {
pub short: String,
pub name: String,
}
#[get("license")]
/// Get SPDX license identifiers and names.
#[utoipa::path(
get,
operation_id = "licenseList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<License>
)
)
)]
#[get("/license")]
pub async fn license_list() -> HttpResponse {
let response = v3::tags::license_list().await;
@@ -212,13 +272,27 @@ pub async fn license_list() -> HttpResponse {
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct LicenseText {
pub title: String,
pub body: String,
}
#[get("license/{id}")]
/// Get full license text by SPDX ID.
#[utoipa::path(
get,
operation_id = "licenseText",
params(("id" = String, Path, description = "The license ID to get the text for")),
responses(
(
status = 200,
description = "Expected response to a valid request",
body = LicenseText
),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[get("/license/{id}")]
pub async fn license_text(
params: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
@@ -240,7 +314,9 @@ pub async fn license_text(
)
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug)]
#[derive(
serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug, utoipa::ToSchema,
)]
pub struct DonationPlatformQueryData {
// The difference between name and short is removed in v3.
// Now, the 'id' becomes the name, and the 'name' is removed (the frontend uses the id as the name)
@@ -249,7 +325,19 @@ pub struct DonationPlatformQueryData {
pub name: String,
}
#[get("donation_platform")]
/// Get available donation platforms.
#[utoipa::path(
get,
operation_id = "donationPlatformList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<DonationPlatformQueryData>
)
)
)]
#[get("/donation_platform")]
pub async fn donation_platform_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -295,7 +383,19 @@ pub async fn donation_platform_list(
.or_else(v2_reroute::flatten_404_error)
}
#[get("report_type")]
/// Get valid report types.
#[utoipa::path(
get,
operation_id = "reportTypeList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<String>
)
)
)]
#[get("/report_type")]
pub async fn report_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -306,7 +406,19 @@ pub async fn report_type_list(
.or_else(v2_reroute::flatten_404_error)
}
#[get("project_type")]
/// Get valid project types.
#[utoipa::path(
get,
operation_id = "projectTypeList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<String>
)
)
)]
#[get("/project_type")]
pub async fn project_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -317,7 +429,19 @@ pub async fn project_type_list(
.or_else(v2_reroute::flatten_404_error)
}
#[get("side_type")]
/// Get valid side-type values.
#[utoipa::path(
get,
operation_id = "sideTypeList",
responses(
(
status = 200,
description = "Expected response to a valid request",
body = Vec<String>
)
)
)]
#[get("/side_type")]
pub async fn side_type_list() -> Result<HttpResponse, ApiError> {
// Original side types are no longer reflected in the database.
// Therefore, we hardcode and return all the fields that are supported by our v2 conversion logic.
+149 -15
View File
@@ -12,11 +12,10 @@ use ariadne::ids::UserId;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(teams_get);
cfg.service(
web::scope("team")
utoipa_actix_web::scope("/team")
.service(team_members_get)
.service(edit_team_member)
.service(transfer_ownership)
@@ -31,7 +30,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
// also the members of the organization's team if the project is associated with an organization
// (Unlike team_members_get_project, which only returns the members of the project's team)
// They can be differentiated by the "organization_permissions" field being null or not
#[get("{id}/members")]
/// Get a project's team members.
#[utoipa::path(
get,
operation_id = "getProjectTeamMembers",
params(("id" = String, Path, description = "The ID or slug of the project")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{id}/members")]
pub async fn team_members_get_project(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -62,7 +74,15 @@ pub async fn team_members_get_project(
}
// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project)
#[get("{id}/members")]
/// Get a team's members.
#[utoipa::path(
get,
operation_id = "getTeamMembers",
params(("id" = TeamId, Path, description = "The ID of the team")),
responses((status = 200, description = "Expected response to a valid request")),
security(("bearer_auth" = ["PROJECT_READ"]))
)]
#[get("/{id}/members")]
pub async fn team_members_get(
req: HttpRequest,
info: web::Path<(TeamId,)>,
@@ -87,12 +107,19 @@ pub async fn team_members_get(
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct TeamIds {
pub ids: String,
}
#[get("teams")]
/// Get the members of multiple teams.
#[utoipa::path(
get,
operation_id = "getTeams",
params(("ids" = String, Query, description = "The JSON array of team IDs")),
responses((status = 200, description = "Expected response to a valid request"))
)]
#[get("/teams")]
pub async fn teams_get(
req: HttpRequest,
web::Query(ids): web::Query<TeamIds>,
@@ -127,7 +154,25 @@ pub async fn teams_get(
}
}
#[post("{id}/join")]
/// Join a team with a pending invite.
#[utoipa::path(
post,
operation_id = "joinTeam",
params(("id" = TeamId, Path, description = "The ID of the team")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[post("/{id}/join")]
pub async fn join_team(
req: HttpRequest,
info: web::Path<(TeamId,)>,
@@ -149,7 +194,7 @@ fn default_ordering() -> i64 {
0
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct NewTeamMember {
pub user_id: UserId,
#[serde(default = "default_role")]
@@ -165,7 +210,26 @@ pub struct NewTeamMember {
pub ordering: i64,
}
#[post("{id}/members")]
/// Add a member to a team.
#[utoipa::path(
post,
operation_id = "addTeamMember",
params(("id" = TeamId, Path, description = "The ID of the team")),
request_body = NewTeamMember,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[post("/{id}/members")]
pub async fn add_team_member(
req: HttpRequest,
info: web::Path<(TeamId,)>,
@@ -194,7 +258,7 @@ pub async fn add_team_member(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct EditTeamMember {
pub permissions: Option<ProjectPermissions>,
pub organization_permissions: Option<OrganizationPermissions>,
@@ -203,7 +267,33 @@ pub struct EditTeamMember {
pub ordering: Option<i64>,
}
#[patch("{id}/members/{user_id}")]
/// Modify a team member.
#[utoipa::path(
patch,
operation_id = "modifyTeamMember",
params(
("id" = TeamId, Path, description = "The ID of the team"),
(
"user_id" = UserId,
Path,
description = "The ID of the user to modify"
)
),
request_body = EditTeamMember,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/{id}/members/{user_id}")]
pub async fn edit_team_member(
req: HttpRequest,
info: web::Path<(TeamId, UserId)>,
@@ -231,12 +321,31 @@ pub async fn edit_team_member(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct TransferOwnership {
pub user_id: UserId,
}
#[patch("{id}/owner")]
/// Transfer team ownership.
#[utoipa::path(
patch,
operation_id = "transferTeamOwnership",
params(("id" = TeamId, Path, description = "The ID of the team")),
request_body = TransferOwnership,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[patch("/{id}/owner")]
pub async fn transfer_ownership(
req: HttpRequest,
info: web::Path<(TeamId,)>,
@@ -260,7 +369,32 @@ pub async fn transfer_ownership(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}/members/{user_id}")]
/// Remove a member from a team.
#[utoipa::path(
delete,
operation_id = "deleteTeamMember",
params(
("id" = TeamId, Path, description = "The ID of the team"),
(
"user_id" = UserId,
Path,
description = "The ID of the user to remove"
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_WRITE"]))
)]
#[delete("/{id}/members/{user_id}")]
pub async fn remove_team_member(
req: HttpRequest,
info: web::Path<(TeamId, UserId)>,
+71 -9
View File
@@ -11,17 +11,31 @@ use crate::routes::{ApiError, v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use serde::Deserialize;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
web::scope("thread")
utoipa_actix_web::scope("/thread")
.service(thread_get)
.service(thread_send_message),
);
cfg.service(web::scope("message").service(message_delete));
cfg.service(utoipa_actix_web::scope("/message").service(message_delete));
cfg.service(threads_get);
}
#[get("{id}")]
/// Get a thread by ID.
#[utoipa::path(
get,
operation_id = "getThread",
params(("id" = ThreadId, Path, description = "The ID of the thread")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["THREAD_READ"]))
)]
#[get("/{id}")]
pub async fn thread_get(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
@@ -34,12 +48,26 @@ pub async fn thread_get(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ThreadIds {
pub ids: String,
}
#[get("threads")]
/// Get multiple threads by ID.
#[utoipa::path(
get,
operation_id = "getThreads",
params(("ids" = String, Query, description = "The JSON array of thread IDs")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["THREAD_READ"]))
)]
#[get("/threads")]
pub async fn threads_get(
req: HttpRequest,
web::Query(ids): web::Query<ThreadIds>,
@@ -70,12 +98,28 @@ pub async fn threads_get(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct NewThreadMessage {
pub body: MessageBody,
}
#[post("{id}")]
/// Send a message to a thread.
#[utoipa::path(
post,
operation_id = "sendThreadMessage",
params(("id" = ThreadId, Path, description = "The ID of the thread")),
request_body = NewThreadMessage,
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["THREAD_WRITE"]))
)]
#[post("/{id}")]
pub async fn thread_send_message(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
@@ -100,7 +144,25 @@ pub async fn thread_send_message(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}")]
/// Delete a thread message by ID.
#[utoipa::path(
delete,
operation_id = "deleteThreadMessage",
params(("id" = ThreadMessageId, Path, description = "The ID of the message")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["THREAD_WRITE"]))
)]
#[delete("/{id}")]
pub async fn message_delete(
req: HttpRequest,
info: web::Path<(ThreadMessageId,)>,
+181 -16
View File
@@ -14,12 +14,11 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(user_auth_get);
cfg.service(users_get);
cfg.service(
web::scope("user")
utoipa_actix_web::scope("/user")
.service(user_get)
.service(projects_list)
.service(user_delete)
@@ -31,7 +30,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[get("user")]
/// Get the current user from the authorization header.
#[utoipa::path(
get,
operation_id = "getUserFromAuth",
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_READ"]))
)]
#[get("/user")]
pub async fn user_auth_get(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -52,12 +64,19 @@ pub async fn user_auth_get(
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserIds {
pub ids: String,
}
#[get("users")]
/// Get multiple users by ID.
#[utoipa::path(
get,
operation_id = "getUsers",
params(("ids" = String, Query, description = "The JSON array of user IDs")),
responses((status = 200, description = "Expected response to a valid request"))
)]
#[get("/users")]
pub async fn users_get(
web::Query(ids): web::Query<UserIds>,
pool: web::Data<PgPool>,
@@ -82,7 +101,20 @@ pub async fn users_get(
}
}
#[get("{id}")]
/// Get a user by ID or username.
#[utoipa::path(
get,
operation_id = "getUser",
params(("id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{id}")]
pub async fn user_get(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -104,7 +136,20 @@ pub async fn user_get(
}
}
#[get("{user_id}/projects")]
/// Get a user's projects.
#[utoipa::path(
get,
operation_id = "getUserProjects",
params(("user_id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{user_id}/projects")]
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -133,7 +178,7 @@ pub async fn projects_list(
}
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct EditUser {
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_USERNAME))]
pub username: Option<String>,
@@ -156,7 +201,26 @@ pub struct EditUser {
pub allow_friend_requests: Option<bool>,
}
#[patch("{id}")]
/// Modify a user.
#[utoipa::path(
patch,
operation_id = "modifyUser",
params(("id" = String, Path, description = "The ID or username of the user")),
request_body = EditUser,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_WRITE"]))
)]
#[patch("/{id}")]
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -186,12 +250,44 @@ pub async fn user_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
/// Change a user's avatar.
#[utoipa::path(
patch,
operation_id = "changeUserIcon",
params(
("id" = String, Path, description = "The ID or username of the user"),
(
"ext" = String,
Query,
description = "Image extension (png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb)"
)
),
request_body(
content(
("image/png"),
("image/jpeg"),
("image/bmp"),
("image/gif"),
("image/webp"),
("image/svg+xml")
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_WRITE"]))
)]
#[patch("/{id}/icon")]
#[allow(clippy::too_many_arguments)]
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
@@ -218,7 +314,22 @@ pub async fn user_icon_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}/icon")]
/// Remove a user's avatar.
#[utoipa::path(
delete,
operation_id = "deleteUserIcon",
params(("id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 204, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_WRITE"]))
)]
#[delete("/{id}/icon")]
pub async fn user_icon_delete(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -240,7 +351,25 @@ pub async fn user_icon_delete(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}")]
/// Delete a user by ID or username.
#[utoipa::path(
delete,
operation_id = "deleteUser",
params(("id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_DELETE"]))
)]
#[delete("/{id}")]
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -255,7 +384,25 @@ pub async fn user_delete(
.or_else(v2_reroute::flatten_404_error)
}
#[get("{id}/follows")]
/// Get projects followed by a user.
#[utoipa::path(
get,
operation_id = "getFollowedProjects",
params(("id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["USER_READ"]))
)]
#[get("/{id}/follows")]
pub async fn user_follows(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -284,7 +431,25 @@ pub async fn user_follows(
}
}
#[get("{id}/notifications")]
/// Get notifications for a user.
#[utoipa::path(
get,
operation_id = "getUserNotifications",
params(("id" = String, Path, description = "The ID or username of the user")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["NOTIFICATION_READ"]))
)]
#[get("/{id}/notifications")]
pub async fn user_notifications(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -75,7 +75,25 @@ pub struct InitialVersionData {
}
// under `/api/v1/version`
#[post("version")]
/// Create a version on an existing project.
#[utoipa::path(
post,
operation_id = "createVersion",
request_body(
content(("multipart/form-data")),
description = "Multipart payload containing `data` and uploaded files"
),
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["VERSION_CREATE"]))
)]
#[post("/version")]
pub async fn version_create(
req: HttpRequest,
payload: Multipart,
@@ -280,7 +298,29 @@ async fn get_example_version_fields(
}
// under /api/v1/version/{version_id}
#[post("{version_id}/file")]
/// Add files to an existing version.
#[utoipa::path(
post,
operation_id = "addFilesToVersion",
params(("version_id" = VersionId, Path, description = "The ID of the version")),
request_body(
content(("multipart/form-data")),
description = "Multipart payload containing files to upload"
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["VERSION_WRITE"]))
)]
#[post("/{version_id}/file")]
pub async fn upload_file_to_version(
req: HttpRequest,
url_data: web::Path<(VersionId,)>,
+189 -16
View File
@@ -11,9 +11,9 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
web::scope("version_file")
utoipa_actix_web::scope("/version_file")
.service(delete_file)
.service(get_version_from_hash)
.service(download_version)
@@ -22,7 +22,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
cfg.service(
web::scope("version_files")
utoipa_actix_web::scope("/version_files")
.service(get_versions_from_hashes)
.service(update_files)
.service(update_files_many)
@@ -31,7 +31,36 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
/// Get version metadata by file hash.
#[utoipa::path(
get,
operation_id = "versionFromHash",
params(
(
"version_id" = String,
Path,
description = "The hexadecimal file hash"
),
(
"algorithm" = Option<String>,
Query,
description = "Hash algorithm to use (sha1 or sha512)"
),
(
"version_id" = Option<crate::models::ids::VersionId>,
Query,
description = "Optional version ID when hash maps to multiple files"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{version_id}")]
pub async fn get_version_from_hash(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -62,7 +91,36 @@ pub async fn get_version_from_hash(
}
// under /api/v1/version_file/{hash}/download
#[get("{version_id}/download")]
/// Download a file by hash.
#[utoipa::path(
get,
operation_id = "downloadVersionFromHash",
params(
(
"version_id" = String,
Path,
description = "The hexadecimal file hash"
),
(
"algorithm" = Option<String>,
Query,
description = "Hash algorithm to use (sha1 or sha512)"
),
(
"version_id" = Option<crate::models::ids::VersionId>,
Query,
description = "Optional version ID when hash maps to multiple files"
)
),
responses(
(status = 302, description = "Temporary redirect to file URL"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -85,7 +143,41 @@ pub async fn download_version(
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
/// Delete a file by hash.
#[utoipa::path(
delete,
operation_id = "deleteFileFromHash",
params(
(
"version_id" = String,
Path,
description = "The hexadecimal file hash"
),
(
"algorithm" = Option<String>,
Query,
description = "Hash algorithm to use (sha1 or sha512)"
),
(
"version_id" = Option<crate::models::ids::VersionId>,
Query,
description = "Optional version ID to delete from"
)
),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["VERSION_WRITE"]))
)]
#[delete("/{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -107,14 +199,45 @@ pub async fn delete_file(
.or_else(v2_reroute::flatten_404_error)
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateData {
pub loaders: Option<Vec<String>>,
pub game_versions: Option<Vec<String>>,
pub version_types: Option<Vec<VersionType>>,
}
#[post("{version_id}/update")]
/// Get the latest compatible version from a file hash.
#[utoipa::path(
post,
operation_id = "getLatestVersionFromHash",
params(
(
"version_id" = String,
Path,
description = "The hexadecimal file hash"
),
(
"algorithm" = Option<String>,
Query,
description = "Hash algorithm to use (sha1 or sha512)"
),
(
"version_id" = Option<crate::models::ids::VersionId>,
Query,
description = "Optional version ID when hash maps to multiple files"
)
),
request_body = UpdateData,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[post("/{version_id}/update")]
pub async fn get_update_from_hash(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -162,13 +285,23 @@ pub async fn get_update_from_hash(
}
// Requests above with multiple versions below
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct FileHashes {
pub algorithm: Option<String>,
pub hashes: Vec<String>,
}
// under /api/v2/version_files
/// Get versions from file hashes.
#[utoipa::path(
post,
operation_id = "versionsFromHashes",
request_body = FileHashes,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[post("")]
pub async fn get_versions_from_hashes(
req: HttpRequest,
@@ -210,7 +343,17 @@ pub async fn get_versions_from_hashes(
}
}
#[post("project")]
/// Get projects from file hashes.
#[utoipa::path(
post,
operation_id = "projectsFromHashes",
request_body = FileHashes,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[post("/project")]
pub async fn get_projects_from_hashes(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -268,7 +411,7 @@ pub async fn get_projects_from_hashes(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ManyUpdateData {
pub algorithm: Option<String>, // Defaults to calculation based on size of hash
pub hashes: Vec<String>,
@@ -277,7 +420,17 @@ pub struct ManyUpdateData {
pub version_types: Option<Vec<VersionType>>,
}
#[post("update")]
/// Get latest compatible versions for multiple hashes.
#[utoipa::path(
post,
operation_id = "getLatestVersionsFromHashes",
request_body = ManyUpdateData,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[post("/update")]
pub async fn update_files(
pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
@@ -316,7 +469,17 @@ pub async fn update_files(
Ok(HttpResponse::Ok().json(v3_versions))
}
#[post("update_many")]
/// Get all latest compatible versions for multiple hashes.
#[utoipa::path(
post,
operation_id = "getLatestVersionsFromHashesMany",
request_body = ManyUpdateData,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[post("/update_many")]
pub async fn update_files_many(
pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
@@ -358,7 +521,7 @@ pub async fn update_files_many(
Ok(HttpResponse::Ok().json(v3_versions))
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct FileUpdateData {
pub hash: String,
pub loaders: Option<Vec<String>>,
@@ -366,13 +529,23 @@ pub struct FileUpdateData {
pub version_types: Option<Vec<VersionType>>,
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ManyFileUpdateData {
pub algorithm: Option<String>, // Defaults to calculation based on size of hash
pub hashes: Vec<FileUpdateData>,
}
#[post("update_individual")]
/// Get latest versions with per-hash filters.
#[utoipa::path(
post,
operation_id = "getLatestVersionsFromHashesIndividual",
request_body = ManyFileUpdateData,
responses(
(status = 200, description = "Expected response to a valid request"),
(status = 400, description = "Request was invalid, see given error")
)
)]
#[post("/update_individual")]
pub async fn update_individual_files(
req: HttpRequest,
pool: web::Data<PgPool>,
+132 -13
View File
@@ -16,12 +16,11 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::{Deserialize, Serialize};
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(versions_get);
cfg.service(super::version_creation::version_create);
cfg.service(
web::scope("version")
utoipa_actix_web::scope("/version")
.service(version_get)
.service(version_delete)
.service(version_edit)
@@ -29,7 +28,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct VersionListFilters {
pub game_versions: Option<String>,
pub loaders: Option<String>,
@@ -45,7 +44,46 @@ fn default_true() -> bool {
true
}
#[get("version")]
/// List versions for a project.
#[utoipa::path(
get,
operation_id = "getProjectVersions",
params(
(
"project_id" = String,
Path,
description = "The ID or slug of the project"
),
(
"loaders" = Option<String>,
Query,
description = "JSON array of loaders to filter by"
),
(
"game_versions" = Option<String>,
Query,
description = "JSON array of game versions to filter by"
),
(
"featured" = Option<bool>,
Query,
description = "Filter by featured status"
),
(
"include_changelog" = Option<bool>,
Query,
description = "Whether to include changelog fields"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/version")]
pub async fn version_list(
req: HttpRequest,
info: web::Path<(String,)>,
@@ -129,7 +167,31 @@ pub async fn version_list(
}
// Given a project ID/slug and a version slug
#[get("version/{slug}")]
/// Get a project version by ID or version number.
#[utoipa::path(
get,
operation_id = "getVersionFromIdOrNumber",
params(
(
"project_id" = String,
Path,
description = "The ID or slug of the project"
),
(
"slug" = String,
Path,
description = "The version ID or version number"
)
),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/version/{slug}")]
pub async fn version_project_get(
req: HttpRequest,
info: web::Path<(String, String)>,
@@ -157,14 +219,21 @@ pub async fn version_project_get(
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct VersionIds {
pub ids: String,
#[serde(default = "default_true")]
pub include_changelog: bool,
}
#[get("versions")]
/// Get multiple versions by ID.
#[utoipa::path(
get,
operation_id = "getVersions",
params(("ids" = String, Query, description = "The JSON array of version IDs")),
responses((status = 200, description = "Expected response to a valid request"))
)]
#[get("/versions")]
pub async fn versions_get(
req: HttpRequest,
web::Query(ids): web::Query<VersionIds>,
@@ -199,7 +268,20 @@ pub async fn versions_get(
}
}
#[get("{version_id}")]
/// Get a version by ID.
#[utoipa::path(
get,
operation_id = "getVersion",
params(("version_id" = models::ids::VersionId, Path, description = "The ID of the version")),
responses(
(status = 200, description = "Expected response to a valid request"),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
)
)]
#[get("/{version_id}")]
pub async fn version_get(
req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>,
@@ -223,7 +305,7 @@ pub async fn version_get(
}
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct EditVersion {
#[validate(
length(min = 1, max = 64),
@@ -251,14 +333,33 @@ pub struct EditVersion {
pub file_types: Option<Vec<EditVersionFileType>>,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct EditVersionFileType {
pub algorithm: String,
pub hash: String,
pub file_type: Option<FileType>,
}
#[patch("{id}")]
/// Modify an existing version.
#[utoipa::path(
patch,
operation_id = "modifyVersion",
params(("id" = VersionId, Path, description = "The ID of the version")),
request_body = EditVersion,
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["VERSION_WRITE"]))
)]
#[patch("/{id}")]
pub async fn version_edit(
req: HttpRequest,
info: web::Path<(VersionId,)>,
@@ -350,7 +451,25 @@ pub async fn version_edit(
Ok(response)
}
#[delete("{version_id}")]
/// Delete a version by ID.
#[utoipa::path(
delete,
operation_id = "deleteVersion",
params(("version_id" = VersionId, Path, description = "The ID of the version")),
responses(
(status = 204, description = "Expected response to a valid request"),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["VERSION_DELETE"]))
)]
#[delete("/{version_id}")]
pub async fn version_delete(
req: HttpRequest,
info: web::Path<(VersionId,)>,
@@ -83,10 +83,16 @@ pub struct RequestConfig {
pub prioritize_exact_match: bool,
#[serde(default = "default_prioritize_num_matching_fields")]
pub prioritize_num_matching_fields: bool,
#[serde(default = "default_prioritize_token_positions")]
pub prioritize_token_positions: bool,
#[serde(default = "default_drop_tokens_threshold")]
pub drop_tokens_threshold: usize,
#[serde(default)]
pub text_match_type: TextMatchType,
#[serde(default)]
pub bucketing: Bucketing,
#[serde(default = "default_max_candidates")]
pub max_candidates: usize,
}
impl Default for RequestConfig {
@@ -98,32 +104,38 @@ impl Default for RequestConfig {
prioritize_exact_match: default_prioritize_exact_match(),
prioritize_num_matching_fields:
default_prioritize_num_matching_fields(),
prioritize_token_positions: default_prioritize_token_positions(),
drop_tokens_threshold: default_drop_tokens_threshold(),
text_match_type: TextMatchType::default(),
bucketing: Bucketing::default(),
max_candidates: default_max_candidates(),
}
}
}
fn default_query_by() -> Vec<String> {
[
"name",
"indexed_name",
"slug",
"author",
"indexed_author",
"summary",
]
.into_iter()
.map(str::to_string)
.collect()
// [
// "name",
// "indexed_name",
// "slug",
// "author",
// "indexed_author",
// "summary",
// ]
["name", "indexed_name", "slug", "author", "indexed_author"]
.into_iter()
.map(str::to_string)
.collect()
}
fn default_query_by_weights() -> Vec<u8> {
vec![15, 15, 10, 3, 3, 1]
// vec![15, 15, 10, 3, 3, 1]
vec![15, 15, 10, 3, 3]
}
fn default_prefix() -> Vec<bool> {
vec![true, true, true, true, true, true]
// vec![true, true, true, true, true, true]
vec![true, true, true, true, true]
}
const fn default_prioritize_exact_match() -> bool {
@@ -134,6 +146,20 @@ const fn default_prioritize_num_matching_fields() -> bool {
false
}
const fn default_prioritize_token_positions() -> bool {
// true
false
}
const fn default_drop_tokens_threshold() -> usize {
// 0
1
}
const fn default_max_candidates() -> usize {
8
}
impl TypesenseConfig {
pub fn new(meta_namespace: Option<String>) -> Self {
Self {
@@ -696,6 +722,14 @@ impl SearchBackend for Typesense {
.prioritize_num_matching_fields
.to_string(),
),
(
"prioritize_token_positions",
info.typesense_config.prioritize_token_positions.to_string(),
),
(
"drop_tokens_threshold",
info.typesense_config.drop_tokens_threshold.to_string(),
),
(
"text_match_type",
info.typesense_config.text_match_type.as_str().to_string(),
@@ -707,6 +741,10 @@ impl SearchBackend for Typesense {
("group_limit", "1".to_string()),
("facet_by", "project_id".to_string()),
("max_facet_values", "0".to_string()),
(
"max_candidates",
info.typesense_config.max_candidates.to_string(),
),
];
if let Some(query_by_weights) =
Self::query_by_weights(&info.typesense_config)
+1
View File
@@ -55,6 +55,7 @@ pub enum FeatureFlag {
ProjectBackground,
WorldsTab,
WorldsInHome,
ServerRamAsBytesAlwaysOn,
ServersInApp,
ServerProjectQa,
I18nDebug,
+2 -2
View File
@@ -3,6 +3,8 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -392,8 +394,6 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
+7 -2
View File
@@ -906,10 +906,15 @@ a:not(.no-click-animation),
border-spacing: 0;
line-height: 1.5;
border: 0.1rem solid var(--color-button-bg);
border-radius: var(--radius-sm);
border-radius: var(--radius-xl);
th {
font-weight: 600;
background-color: var(--surface-3);
}
tr {
background-color: var(--surface-1-5);
}
td,
@@ -918,7 +923,7 @@ a:not(.no-click-animation),
}
tr:nth-child(2n) {
background-color: var(--color-accent-contrast);
background-color: var(--surface-1);
}
td:not(:last-of-type),
+37
View File
@@ -10,6 +10,43 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-04-15T19:39:48+00:00`,
product: 'hosting',
body: `## Added
- Server stats inside server settings modal, in info card.
- Feature flag to always display RAM as bytes.
## Changed
- Console search highlighting is clearer and more accurate.
- When memory is shown as bytes, the max RAM is now displayed alongside it.
- Consolidated spacing between server and instance pages in the Modrinth App.
- Moved the "Kill server" action into a dropdown under the "Restart" button.
## Fixed
- The support bubble is now available on hosting pages in the Modrinth App.
- Paper and Purpur build versions can be selected when resetting a server in the Modrinth App.
- Server CPU and memory graphs no longer freeze on the last value after a hard crash or out-of-memory kill.`,
},
{
date: `2026-04-15T19:39:48+00:00`,
product: 'web',
body: `## Added
- Publishing checklist added back to project page.
## Fixed
- Fixed version-specific links returning 404.
- Fixed overflow in the project page header on mobile.
- Fixed markdown tables causing the project page to overflow.
- Fixed menu anchoring in the Discover content menu.`,
},
{
date: `2026-04-15T19:39:48+00:00`,
product: 'app',
version: '0.13.1',
body: `## Fixed
- Fixed the sidebar gutter margin on macOS when the scrollbar is set to auto-hide.`,
},
{
date: `2026-04-12T22:12:15+00:00`,
product: 'app',
@@ -1,8 +1,6 @@
<template>
<div
class="flex w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
:style="!fullscreen && componentHeight ? { minHeight: componentHeight + 'px' } : {}"
:class="{ 'h-full': fullscreen }"
class="flex h-full w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
>
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
<div ref="containerRef" class="size-full" />
@@ -93,7 +91,6 @@ const containerRef = ref<HTMLElement | null>(null)
const wrapperRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLElement | null>(null)
const commandInput = ref('')
const componentHeight = ref(0)
const snappedHeight = ref<number | null>(null)
@@ -114,14 +111,10 @@ const {
scrollback: props.scrollback,
onReady: (term) => {
nextTick(() => {
updateComponentHeight()
snapToRows()
})
emit('ready', term)
},
onResize: () => {
updateComponentHeight()
},
})
function writeEmptyState() {
@@ -175,12 +168,21 @@ function handleWindowResize() {
}, 50)
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!terminal.value?.hasSelection()) return
const target = event.target as Node | null
if (target && containerRef.value?.contains(target)) return
terminal.value.clearSelection()
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleWindowResize)
document.removeEventListener('pointerdown', handleDocumentPointerDown)
if (resizeDebounce) clearTimeout(resizeDebounce)
})
@@ -199,20 +201,10 @@ watch(
})
} else {
snappedHeight.value = null
componentHeight.value = 0
}
},
)
function updateComponentHeight() {
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
if (!screen) return
const screenH = screen.offsetHeight
const inputH = inputRef.value?.offsetHeight ?? 0
const borderW = 2
componentHeight.value = screenH + getWrapperMargins() + inputH + borderW
}
const submitCommand = () => {
const cmd = commandInput.value.trim()
if (!cmd) return
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="flex flex-wrap items-start gap-4">
<div class="flex flex-wrap items-start gap-4 max-md:flex-col">
<div class="flex min-w-0 flex-1 gap-4">
<slot name="icon" />
<div class="flex min-w-0 flex-col gap-2 justify-center">
@@ -162,9 +162,8 @@ const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
const triggerRect = triggerRef.value.getBoundingClientRect()
const menuRect = menuRef.value.getBoundingClientRect()
const menuWidth = menuRect.width + 16
const menuHeight = menuRect.height
const menuWidth = menuRef.value.offsetWidth
const menuHeight = menuRef.value.offsetHeight
const margin = 8
let top: number
@@ -8,7 +8,7 @@
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
<ButtonStyled v-if="showStopButton" type="standard" color="red" size="large">
<button
v-tooltip="busyTooltip"
:disabled="!canTakeAction"
@@ -21,9 +21,38 @@
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand" size="large">
<div v-if="showRestartDropdown" class="joined-buttons">
<ButtonStyled type="standard" color="orange" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="orange" size="large">
<OverflowMenu
v-tooltip="busyTooltip"
:disabled="!canTakeAction"
:options="[
{
id: 'kill_server',
action: () => initiateAction('Kill'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else type="standard" color="brand" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<component :is="isRunning || showStopButton ? UpdatedIcon : PlayIcon" />
<PlayIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
@@ -33,10 +62,17 @@
</template>
<script setup lang="ts">
import { LoaderCircleIcon, PlayIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets'
import {
DropdownIcon,
LoaderCircleIcon,
PlayIcon,
SlashIcon,
StopCircleIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { ButtonStyled } from '#ui/components'
import { ButtonStyled, OverflowMenu } from '#ui/components'
import { useServerPowerAction } from './use-server-power-action'
@@ -51,7 +87,6 @@ const props = withDefaults(
const {
isInstalling,
isRunning,
showStopButton,
busyTooltip,
canTakeAction,
@@ -61,4 +96,32 @@ const {
} = useServerPowerAction({
disabled: computed(() => props.disabled),
})
const showRestartDropdown = computed(() => primaryActionText.value === 'Restart')
</script>
<style scoped>
.joined-buttons {
display: flex;
align-items: center;
}
.joined-buttons > :deep(.btn) {
border-radius: 0;
}
.joined-buttons > :deep(.btn:first-child) {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.joined-buttons > :deep(.btn:last-child) {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
margin-left: -1px;
}
.joined-buttons > :deep(.btn:not(:last-child)) {
border-right: none;
}
</style>
@@ -3,10 +3,6 @@
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu :options="menuOptions">
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
@@ -21,7 +17,7 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon, SlashIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -29,8 +25,6 @@ import { ButtonStyled } from '#ui/components'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { injectModrinthServerContext } from '#ui/providers'
import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults(
defineProps<{
disabled?: boolean
@@ -49,21 +43,7 @@ const props = withDefaults(
const router = useRouter()
const { serverId } = injectModrinthServerContext()
const { isInstalling, initiateAction } = useServerPowerAction({
disabled: computed(() => props.disabled),
})
const menuOptions = computed(() => [
...(isInstalling.value
? []
: [
{
id: 'kill',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction('Kill'),
},
]),
{
id: 'allServers',
label: 'All servers',
@@ -63,6 +63,9 @@ const appendGraphData = (dataArray: number[], newValue: number): number[] => {
return updated
}
const STALE_STATS_THRESHOLD_MS = 5000
const STALE_STATS_PUSH_INTERVAL_MS = 1000
const mapPowerStateFromStateEvent = (
data: Archon.Websocket.v0.WSStateEvent,
): Archon.Websocket.v0.PowerState => {
@@ -101,6 +104,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const ramData = ref<number[]>([])
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null
const markBackupCancelled =
options.markBackupCancelled ??
@@ -183,6 +188,43 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
}
const clearStaleStatsTimers = () => {
if (staleStatsTimeoutId) {
clearTimeout(staleStatsTimeoutId)
staleStatsTimeoutId = null
}
if (staleStatsIntervalId) {
clearInterval(staleStatsIntervalId)
staleStatsIntervalId = null
}
}
const pushZeroStats = () => {
if (!shouldProcessEvent()) return
cpuData.value = appendGraphData(cpuData.value, 0)
ramData.value = appendGraphData(ramData.value, 0)
stats.value = {
current: {
...stats.value.current,
cpu_percent: 0,
ram_usage_bytes: 0,
},
past: { ...stats.value.current },
graph: {
cpu: cpuData.value,
ram: ramData.value,
},
}
}
const armStaleStatsWatchdog = () => {
clearStaleStatsTimers()
staleStatsTimeoutId = setTimeout(() => {
pushZeroStats()
staleStatsIntervalId = setInterval(pushZeroStats, STALE_STATS_PUSH_INTERVAL_MS)
}, STALE_STATS_THRESHOLD_MS)
}
const updatePowerState = (
state: Archon.Websocket.v0.PowerState,
details?: { oom_killed?: boolean; exit_code?: number },
@@ -209,6 +251,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
armStaleStatsWatchdog()
updateStats({
cpu_percent: data.cpu_percent,
ram_usage_bytes: data.ram_usage_bytes,
@@ -280,6 +323,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
stopUptimeTicker()
clearStaleStatsTimers()
connectedSocketServerId.value = null
isConnected.value = false
isWsAuthIncorrect.value = false
+11
View File
@@ -193,6 +193,17 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
term.options.disableStdin = true
term.write('\x1b[?25l')
// term.attachCustomKeyEventHandler((e) => {
// if (e.type !== 'keydown') return true
// const mod = e.ctrlKey || e.metaKey
// if (!mod) return true
// const key = e.key.toLowerCase()
// if (key === 'c' || key === 'insert' || key === 'a') {
// return false
// }
// return true
// })
wheelHandler = (e: WheelEvent) => {
e.preventDefault()
}
@@ -60,8 +60,8 @@ export function useServerImage(
const { data: remoteImage, refetch } = useQuery({
queryKey,
queryFn: async (): Promise<string | null | undefined> => {
if (!serverId) return undefined
queryFn: async (): Promise<string | null> => {
if (!serverId) return null
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
@@ -84,21 +84,21 @@ export function useServerImage(
}
} catch (error) {
console.debug('Server image fetch failed:', error)
return undefined
return null
}
if (!includeProjectFallback || !upstream.value?.project_id) return undefined
if (!includeProjectFallback || !upstream.value?.project_id) return null
try {
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
if (!project.icon_url) return undefined
if (!project.icon_url) return null
const response = await fetch(project.icon_url)
if (!response.ok) return undefined
if (!response.ok) return null
const blob = await response.blob()
return await processImageBlob(blob, iconSize)
} catch (error) {
console.debug('Project icon fallback failed:', error)
return undefined
return null
}
},
enabled: isEnabled,
@@ -1,43 +1,162 @@
import type { Terminal } from '@xterm/xterm'
import type { IDecoration, Terminal } from '@xterm/xterm'
import { ref } from 'vue'
import type { LogLevel, LogLine } from '../types'
export type FilterPredicate = (line: LogLine) => boolean
function highlightMatches(text: string, query: string): string {
if (!query) return text
const lower = text.toLowerCase()
let result = ''
let pos = 0
while (pos < text.length) {
const idx = lower.indexOf(query, pos)
if (idx === -1) {
result += text.slice(pos)
break
}
result += text.slice(pos, idx)
result += `\x1b[1;7m${text.slice(idx, idx + query.length)}\x1b[27;22m`
pos = idx + query.length
}
return result
}
export function colorize(line: LogLine, searchQuery?: string): string {
const text = searchQuery ? highlightMatches(line.text, searchQuery) : line.text
export function colorize(line: LogLine, _searchQuery?: string): string {
switch (line.level) {
case 'error':
return `\x1b[31;40m${text}\x1b[K\x1b[0m`
return `\x1b[31;40m${line.text}\x1b[K\x1b[0m`
case 'warn':
return `\x1b[33;40m${text}\x1b[K\x1b[0m`
return `\x1b[33;40m${line.text}\x1b[K\x1b[0m`
case 'debug':
case 'trace':
return `\x1b[90m${text}\x1b[0m`
return `\x1b[90m${line.text}\x1b[0m`
default:
return text
return line.text
}
}
const HIGHLIGHT_BG = '#ffd60a'
const HIGHLIGHT_FG = '#000000'
const terminalDecorations = new WeakMap<Terminal, IDecoration[]>()
const activeQueries = new WeakMap<Terminal, string>()
const highlightVersions = new WeakMap<Terminal, number>()
function getDecorationList(terminal: Terminal): IDecoration[] {
let list = terminalDecorations.get(terminal)
if (!list) {
list = []
terminalDecorations.set(terminal, list)
}
return list
}
function bumpVersion(terminal: Terminal): number {
const next = (highlightVersions.get(terminal) ?? 0) + 1
highlightVersions.set(terminal, next)
return next
}
export function getHighlightVersion(terminal: Terminal): number {
return highlightVersions.get(terminal) ?? 0
}
export function clearSearchHighlights(terminal: Terminal) {
const existing = terminalDecorations.get(terminal)
if (existing) {
for (const d of existing) d.dispose()
existing.length = 0
}
activeQueries.delete(terminal)
bumpVersion(terminal)
}
function walkBackToLogicalStart(terminal: Terminal, row: number): number {
const buffer = terminal.buffer.active
let y = Math.max(0, row)
while (y > 0) {
const line = buffer.getLine(y)
if (!line?.isWrapped) break
y--
}
return y
}
function scanRange(
terminal: Terminal,
query: string,
startRow: number,
endRow: number,
out: IDecoration[],
) {
const buffer = terminal.buffer.active
const cols = terminal.cols
const cursorAbsolute = buffer.baseY + buffer.cursorY
let y = startRow
while (y <= endRow) {
const head = buffer.getLine(y)
if (!head) break
const lineStart = y
let text = head.translateToString(false)
y++
while (y < buffer.length) {
const next = buffer.getLine(y)
if (!next?.isWrapped) break
text += next.translateToString(false)
y++
}
const lower = text.toLowerCase()
let pos = 0
while (true) {
const idx = lower.indexOf(query, pos)
if (idx === -1) break
let remaining = query.length
let rowAbs = lineStart + Math.floor(idx / cols)
let col = idx % cols
while (remaining > 0) {
const amount = Math.min(cols - col, remaining)
const marker = terminal.registerMarker(rowAbs - cursorAbsolute)
if (marker) {
const decoration = terminal.registerDecoration({
marker,
x: col,
width: amount,
layer: 'top',
backgroundColor: HIGHLIGHT_BG,
foregroundColor: HIGHLIGHT_FG,
})
if (decoration) out.push(decoration)
}
remaining -= amount
rowAbs++
col = 0
}
pos = idx + query.length
}
}
}
export function applySearchHighlights(terminal: Terminal, query: string): number {
const trimmed = query.trim().toLowerCase()
const list = getDecorationList(terminal)
for (const d of list) d.dispose()
list.length = 0
const version = bumpVersion(terminal)
if (!trimmed) {
activeQueries.delete(terminal)
return version
}
activeQueries.set(terminal, trimmed)
const endRow = terminal.buffer.active.length - 1
scanRange(terminal, trimmed, 0, endRow, list)
return version
}
export function highlightAppendedRange(terminal: Terminal, fromRow: number, version: number) {
if (getHighlightVersion(terminal) !== version) return
const query = activeQueries.get(terminal)
if (!query) return
const scanFrom = walkBackToLogicalStart(terminal, fromRow)
const list = getDecorationList(terminal)
const survivors: IDecoration[] = []
for (const d of list) {
if (d.marker.line >= scanFrom) {
d.dispose()
} else {
survivors.push(d)
}
}
list.length = 0
list.push(...survivors)
const endRow = terminal.buffer.active.length - 1
if (scanFrom > endRow) return
scanRange(terminal, query, scanFrom, endRow, list)
}
export type ConditionalLevel = 'debug' | 'trace'
export function useConsoleFilters() {
@@ -80,18 +199,23 @@ export function rewriteTerminal(
searchQuery?: string,
callback?: () => void,
) {
clearSearchHighlights(terminal)
terminal.reset()
terminal.write('\x1b[?25l')
const filtered = predicate ? allLines.filter(predicate) : allLines
if (filtered.length === 0) {
if (searchQuery) applySearchHighlights(terminal, searchQuery)
callback?.()
return
}
terminal.write('\x1b[?2026h')
terminal.write(filtered.map((line) => colorize(line, searchQuery)).join('\r\n'), () => {
terminal.write(filtered.map((line) => colorize(line)).join('\r\n'), () => {
terminal.write('\x1b[?2026l')
if (searchQuery) {
applySearchHighlights(terminal, searchQuery)
}
callback?.()
})
}
@@ -1,7 +1,11 @@
export {
applySearchHighlights,
clearSearchHighlights,
colorize,
type ConditionalLevel,
type FilterPredicate,
getHighlightVersion,
highlightAppendedRange,
rewriteTerminal,
useConsoleFilters,
} from './console-filtering'
@@ -111,7 +111,14 @@ import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
import { colorize, rewriteTerminal, useConsoleFilters } from './composables'
import {
clearSearchHighlights,
colorize,
getHighlightVersion,
highlightAppendedRange,
rewriteTerminal,
useConsoleFilters,
} from './composables'
import type { ConditionalLevel } from './composables/console-filtering'
import { injectConsoleManager } from './providers'
import type { LogLevel, LogLine } from './types'
@@ -279,18 +286,21 @@ watch(ctx.logLines, (lines, oldLines) => {
}
const predicate = buildCombinedPredicate()
const query = activeSearchQuery()
const newLines: string[] = []
for (let i = lastWrittenIndex; i < lines.length; i++) {
if (!predicate || predicate(lines[i])) {
newLines.push(colorize(lines[i], query))
newLines.push(colorize(lines[i]))
}
}
if (newLines.length > 0) {
const buffer = term.buffer.active
const onFreshLine = buffer.cursorX === 0
const data = onFreshLine ? newLines.join('\r\n') : '\r\n' + newLines.join('\r\n')
term.write(data)
const fromRow = buffer.baseY + buffer.cursorY
const version = getHighlightVersion(term)
term.write(data, () => {
highlightAppendedRange(term, fromRow, version)
})
}
lastWrittenIndex = lines.length
})
@@ -307,6 +317,8 @@ function handleCommand(cmd: string) {
}
function handleClear() {
const term = terminalRef.value?.terminal
if (term) clearSearchHighlights(term)
terminalRef.value?.reset()
lastWrittenIndex = 0
ctx.onClear?.()
@@ -89,26 +89,36 @@
</div>
<span>{{ prefConfig.description }}</span>
</label>
<Toggle
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="flex-none"
:disabled="!prefConfig.implemented"
/>
<div v-tooltip="getPreferenceTooltip(key)">
<Toggle
:id="`pref-${key}`"
:model-value="getPreferenceValue(key)"
class="flex-none"
:disabled="!prefConfig.implemented || isPreferenceForcedByFeatureFlag(key)"
@update:model-value="(value) => setPreferenceValue(key, !!value)"
/>
</div>
</div>
<!-- Info -->
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2.5 pb-10">
<div class="text-lg m-0 font-semibold text-contrast">Info</div>
<div class="flex flex-col gap-2.5 rounded-xl bg-surface-2 p-4">
<div
v-for="property in infoProperties"
:key="property.name"
class="flex items-center justify-between gap-4"
class="flex items-start justify-between gap-4"
>
<template v-if="property.value !== 'Unknown'">
<span>{{ property.name }}</span>
<CopyCode :text="property.value" />
<span class="mt-1">{{ property.name }}</span>
<CopyCode v-if="property.type === 'copy'" :text="property.value" />
<div
v-else-if="property.type === 'specs'"
class="flex flex-col items-end text-right text-sm leading-5 break-words"
>
<span v-for="line in property.lines" :key="line">{{ line }}</span>
</div>
<span v-else class="text-right text-sm break-words">{{ property.value }}</span>
</template>
</div>
</div>
@@ -127,7 +137,8 @@
</template>
<script setup lang="ts">
import { useQueryClient } from '@tanstack/vue-query'
import type { Labrinth } from '@modrinth/api-client'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
@@ -138,11 +149,13 @@ import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectPageContext,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { server: data, serverId, busyReasons } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
const queryClient = useQueryClient()
const serverName = ref(data.value?.name)
@@ -207,11 +220,118 @@ const userPreferences = useStorage<UserPreferences>(
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
const isRamAsBytesForcedByFeatureFlag = computed(
() => featureFlags?.serverRamAsBytesAlwaysOn?.value ?? false,
)
const isPreferenceForcedByFeatureFlag = (key: string) =>
key === 'ramAsNumber' && isRamAsBytesForcedByFeatureFlag.value
const getPreferenceTooltip = (key: string) =>
isPreferenceForcedByFeatureFlag(key)
? 'Feature flag enabled to always show RAM as bytes.'
: undefined
const getPreferenceValue = (key: string) =>
isPreferenceForcedByFeatureFlag(key) ? true : newUserPreferences.value[key as PreferenceKeys]
const setPreferenceValue = (key: string, value: boolean) => {
if (isPreferenceForcedByFeatureFlag(key)) {
return
}
newUserPreferences.value[key as PreferenceKeys] = value
}
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
})
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
})
const serverSubscription = computed(() =>
subscriptions.value?.find(
(subscription) =>
subscription.metadata?.type === 'pyro' && subscription.metadata.id === serverId,
),
)
const serverProduct = computed(() =>
products.value?.find((product) =>
product.prices.some((price) => price.id === serverSubscription.value?.price_id),
),
)
const formatSpecNumber = (value: number) =>
Number.isInteger(value) ? String(value) : value.toFixed(1)
const getServerSpecs = (product?: Labrinth.Billing.Internal.Product | null) => {
const metadata = product?.metadata
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) {
return null
}
const sharedCpus = formatSpecNumber(metadata.cpu / 2)
const burstCpus = formatSpecNumber(metadata.cpu)
const ramGb = formatSpecNumber(metadata.ram / 1024)
const swapGb = formatSpecNumber(metadata.swap / 1024)
const storageGb = formatSpecNumber(metadata.storage / 1024)
return {
sharedCpus,
burstCpus,
ramGb,
swapGb,
storageGb,
}
}
const serverHostname = computed(() =>
serverSubdomain.value ? `${serverSubdomain.value}.modrinth.gg` : 'Unknown',
)
const serverSpecs = computed(() => getServerSpecs(serverProduct.value))
type InfoProperty =
| {
name: string
value: string
type: 'copy'
}
| {
name: string
value: string
type: 'text'
}
| {
name: string
value: string
type: 'specs'
lines: string[]
}
// Info properties
const infoProperties = [
{ name: 'Server ID', value: serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
]
const infoProperties = computed<InfoProperty[]>(() => [
{ name: 'Server ID', value: serverId ?? 'Unknown', type: 'copy' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown', type: 'copy' },
{ name: 'Hostname', value: serverHostname.value, type: 'copy' },
{
name: 'Server specs',
value: serverSpecs.value ? 'Available' : 'Unknown',
type: 'specs',
lines: serverSpecs.value
? [
`${serverSpecs.value.sharedCpus} Shared CPU${Number(serverSpecs.value.sharedCpus) > 1 ? 's' : ''} (Bursts up to ${serverSpecs.value.burstCpus} CPUs)`,
`${serverSpecs.value.ramGb} GB RAM`,
`${serverSpecs.value.swapGb} GB Swap`,
`${serverSpecs.value.storageGb} GB SSD`,
]
: [],
},
])
// Unsaved changes tracking (API fields + preferences)
const hasUnsavedChanges = computed(
@@ -2,7 +2,7 @@
<div
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-4 md:flex-row"
class="flex select-none flex-col items-center gap-3 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
@@ -31,7 +31,12 @@
</span>
</div>
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
{{ metric.value }}
{{ metric.value
}}<span
v-if="metric.secondary"
class="ml-1 text-sm font-normal stat-drop-shadow text-secondary"
>{{ metric.secondary }}</span
>
</span>
<!-- <div
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
@@ -60,11 +65,12 @@ import { useStorage } from '@vueuse/core'
import { computed, defineAsyncComponent, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { injectModrinthServerContext } from '#ui/providers'
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const { serverId } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
const props = withDefaults(
defineProps<{
@@ -83,6 +89,16 @@ const chartsReady = ref(new Set<number>())
const userPreferences = useStorage(`pyro-server-${serverId || 'unknown'}-preferences`, {
ramAsNumber: false,
})
const isRamAsBytesForcedByFeatureFlag = computed(
() => featureFlags?.serverRamAsBytesAlwaysOn?.value ?? false,
)
const showRamAsBytes = computed(
() =>
props.showMemoryAsBytes ||
isRamAsBytesForcedByFeatureFlag.value ||
userPreferences.value.ramAsNumber,
)
const stats = shallowRef(
props.data?.current || {
@@ -170,6 +186,7 @@ const metrics = computed(() => {
const storageMetric = {
title: 'Storage',
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes ?? 0),
secondary: null as string | null,
icon: FolderOpenIcon,
showGraph: false,
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
@@ -182,6 +199,7 @@ const metrics = computed(() => {
{
title: 'CPU',
value: '0.00%',
secondary: null as string | null,
icon: CpuIcon,
showGraph: true,
chartOptions: cpuChartOptions.value,
@@ -191,6 +209,7 @@ const metrics = computed(() => {
{
title: 'Memory',
value: '0.00%',
secondary: null as string | null,
icon: DatabaseIcon,
showGraph: true,
chartOptions: ramChartOptions.value,
@@ -205,6 +224,7 @@ const metrics = computed(() => {
{
title: 'CPU',
value: `${cpuPercent.value.toFixed(2)}%`,
secondary: null as string | null,
icon: CpuIcon,
showGraph: true,
chartOptions: cpuChartOptions.value,
@@ -213,10 +233,12 @@ const metrics = computed(() => {
},
{
title: 'Memory',
value:
props.showMemoryAsBytes || userPreferences.value.ramAsNumber
? formatBytes(stats.value.ram_usage_bytes ?? 0)
: `${ramPercent.value.toFixed(2)}%`,
value: showRamAsBytes.value
? formatBytes(stats.value.ram_usage_bytes ?? 0)
: `${ramPercent.value.toFixed(2)}%`,
secondary: showRamAsBytes.value
? `/ ${formatBytes(stats.value.ram_total_bytes ?? 0)}`
: (null as string | null),
icon: DatabaseIcon,
showGraph: true,
chartOptions: ramChartOptions.value,
@@ -1,12 +1,12 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<ServerManageStats
:data="!isWsAuthIncorrect ? stats : undefined"
:loading="isWsAuthIncorrect"
/>
<div class="flex min-h-[700px] flex-col gap-4">
<div class="flex min-h-[700px] flex-col gap-2">
<span class="text-2xl font-semibold text-contrast">Console</span>
<ConsolePageLayout />
@@ -107,7 +107,7 @@
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within relative mx-auto pb-12 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-6 px-6 transition-all duration-300"
class="experimental-styles-within relative mx-auto pb-6 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverImage
? `url(${serverImage})`
@@ -1493,7 +1493,11 @@ onMounted(() => {
})
}
if (props.authUser && props.fetchIntercomToken) {
let intercomInitialized = false
const tryInitIntercom = () => {
if (intercomInitialized) return
if (!props.authUser || !props.fetchIntercomToken) return
intercomInitialized = true
props
.fetchIntercomToken()
.then(({ token }) => {
@@ -1504,9 +1508,20 @@ onMounted(() => {
})
})
.catch((error) => {
intercomInitialized = false
console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error)
})
}
tryInitIntercom()
const stopIntercomWatch = watch(
() => props.authUser,
(user) => {
if (user) {
tryInitIntercom()
stopIntercomWatch()
}
},
)
DOMPurify.addHook(
'afterSanitizeAttributes',
@@ -6,6 +6,9 @@ export interface PageContext {
// pages may render sidebar content in #sidebar-teleport-target instead of in the main layout when true
hierarchicalSidebarAvailable: Ref<boolean>
showAds: Ref<boolean>
featureFlags?: {
serverRamAsBytesAlwaysOn?: Ref<boolean>
}
openExternalUrl: (url: string) => void
}
+8
View File
@@ -137,6 +137,9 @@ importers:
ofetch:
specifier: ^1.3.4
version: 1.5.1
overlayscrollbars:
specifier: ^2.15.1
version: 2.15.1
pinia:
specifier: ^3.0.0
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
@@ -7604,6 +7607,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
overlayscrollbars@2.15.1:
resolution: {integrity: sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==}
oxc-minify@0.110.0:
resolution: {integrity: sha512-KWGTzPo83QmGrXC4ml83PM9HDwUPtZFfasiclUvTV4i3/0j7xRRqINVkrL77CbQnoWura3CMxkRofjQKVDuhBw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -17717,6 +17723,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
overlayscrollbars@2.15.1: {}
oxc-minify@0.110.0:
optionalDependencies:
'@oxc-minify/binding-android-arm-eabi': 0.110.0
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
Search projects on api.modrinth.com and import results into the local database
with correct author names.
Modes:
search - Import top N results for a text query
top - Import the top N projects by total downloads (for building a
representative corpus that mirrors prod IDF distributions)
Usage:
python3 scripts/import-projects.py search <query> [limit]
python3 scripts/import-projects.py top [count]
Examples:
python3 scripts/import-projects.py search "sodium" 5
python3 scripts/import-projects.py top 1000
"""
import json
import subprocess
import sys
import time
import urllib.parse
import urllib.request
ADMIN_USER_ID = 103587649610509
DB_CONTAINER = "labrinth-postgres"
DB_USER = "labrinth"
DB_NAME = "labrinth"
API_BASE = "https://api.modrinth.com/v2"
HEADERS = {"User-Agent": "import-projects-script/1.0"}
seen_slugs = set()
author_user_ids = {}
next_user_id = 200_000_000_000_000
def api_get(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def psql(sql):
result = subprocess.run(
[
"podman",
"exec",
DB_CONTAINER,
"psql",
"-U",
DB_USER,
"-d",
DB_NAME,
"-c",
sql,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f" DB error: {result.stderr.strip()}", file=sys.stderr)
return False
return True
def sql_escape(s):
return s.replace("'", "''")
def get_or_create_author_user(author_name):
global next_user_id
if author_name in author_user_ids:
return author_user_ids[author_name]
uid = next_user_id
next_user_id += 1
name_e = sql_escape(author_name)
sql = f"""
INSERT INTO users (id, username, email, created, role)
VALUES ({uid}, '{name_e}', '{name_e}@imported.local', NOW(), 'developer')
ON CONFLICT (id) DO NOTHING;
"""
if psql(sql):
author_user_ids[author_name] = uid
else:
author_user_ids[author_name] = ADMIN_USER_ID
return author_user_ids[author_name]
def import_project(hit, counter):
slug = hit.get("slug", "")
if slug in seen_slugs:
return False
seen_slugs.add(slug)
title = hit.get("title", "")
summary = hit.get("description", "")[:2048]
project_id_api = hit.get("project_id", "")
downloads = hit.get("downloads", 0)
follows = hit.get("follows", 0)
icon_url = hit.get("icon_url") or None
author_name = hit.get("author", "Unknown")
print(f" Fetching: {title}")
try:
project_data = api_get(f"{API_BASE}/project/{project_id_api}")
description = (project_data.get("body") or "")[:65536]
icon_url = project_data.get("icon_url") or icon_url
except Exception:
description = summary
author_id = get_or_create_author_user(author_name)
base = int(time.time() * 1e9) % 900_000_000_000_000 + 100_000_000_000_000
mod_id = base + counter * 5
team_id = base + counter * 5 + 1
member_id = base + counter * 5 + 2
version_id = base + counter * 5 + 3
title_e = sql_escape(title)
summary_e = sql_escape(summary)
description_e = sql_escape(description)
slug_e = sql_escape(slug)
icon_col = f"'{sql_escape(icon_url)}'" if icon_url else "NULL"
print(
f" Importing: {title} (author={author_name}, downloads={downloads}, followers={follows})"
)
sql = f"""
BEGIN;
INSERT INTO teams (id) VALUES ({team_id});
INSERT INTO mods (
id, team_id, name, summary, description,
published, downloads, follows,
status, license, side_types_migration_review_status,
components, monetization_status, slug,
icon_url, raw_icon_url
) VALUES (
{mod_id},
{team_id},
'{title_e}',
'{summary_e}',
'{description_e}',
NOW(),
{downloads},
{follows},
'approved',
'LicenseRef-All-Rights-Reserved',
'reviewed',
'{{}}'::jsonb,
'monetized',
LOWER('{slug_e}'),
{icon_col},
{icon_col}
);
INSERT INTO team_members (
id, team_id, user_id, role, permissions,
accepted, payouts_split, ordering, is_owner
) VALUES (
{member_id},
{team_id},
{author_id},
'Owner',
1275068466,
true,
1.00000000000000000000,
0,
true
);
INSERT INTO versions (
id, mod_id, name, version_number, version_type,
author_id, downloads, changelog, status, components
) VALUES (
{version_id},
{mod_id},
'1.0.0',
'1.0.0',
'release',
{author_id},
{downloads},
'',
'listed',
'{{}}'::jsonb
);
INSERT INTO loaders_versions (loader_id, version_id) VALUES (2, {version_id});
COMMIT;
"""
return psql(sql)
def mode_search(query, limit=5):
encoded_query = urllib.parse.quote(query)
search_url = f"{API_BASE}/search?query={encoded_query}&limit={limit}&facets=[]"
print(f"Searching Modrinth for: {query} (limit: {limit})")
search_data = api_get(search_url)
hits = search_data.get("hits", [])
if not hits:
print("No results found.")
return
imported = 0
for i, hit in enumerate(hits):
if import_project(hit, i):
imported += 1
print(f"Done. Imported {imported} project(s).")
def mode_top(count=1000):
print(f"Fetching top {count} projects by downloads from Modrinth...")
imported = 0
batch_size = 50
counter = 0
for offset in range(0, count, batch_size):
limit = min(batch_size, count - offset)
url = (
f"{API_BASE}/search?limit={limit}&offset={offset}&index=downloads&facets=[]"
)
print(f"\n Batch offset={offset}, limit={limit}")
data = api_get(url)
hits = data.get("hits", [])
if not hits:
break
for hit in hits:
if import_project(hit, counter):
imported += 1
counter += 1
time.sleep(1)
print(f"\nDone. Imported {imported} project(s).")
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} search <query> [limit]")
print(f" {sys.argv[0]} top [count]")
sys.exit(1)
mode = sys.argv[1]
if mode == "search":
if len(sys.argv) < 3:
print("Usage: {sys.argv[0]} search <query> [limit]")
sys.exit(1)
query = sys.argv[2]
limit = int(sys.argv[3]) if len(sys.argv) > 3 else 5
mode_search(query, limit)
elif mode == "top":
count = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
mode_top(count)
else:
print(f"Unknown mode: {mode}. Use 'search' or 'top'.")
sys.exit(1)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
# Changelog Style Guide
## The core rule
**Each bullet describes one user-visible change, written from the user's perspective, in plain language, as a single sentence.**
If you can't explain the change without referencing internal code, components, or refactors, it probably doesn't belong in the changelog.
## Voice and tense
- **Past tense, implied subject.** The section heading (`## Added`, `## Fixed`, `## Changed`) supplies the verb's mood - bullets read as a continuation of it.
- Good: `Fixed a missing gap between the project filter tabs and the project list.`
- Good: `Added support for Java 25.`
- Avoid: `We fixed...`, `This fixes...`, `Fixes...` (present tense), `Will fix...`
- **No first person.** Don't say "we" or "our" inside a bullet. The exception is featured release callouts that link to a blog post (`We've overhauled the Content tab...`).
- **No second person except for direct user actions.** "You" is fine when describing what the user can now do (`Joining a server from the app downloads the required content and launches you directly into the server.`), but don't address the user gratuitously.
## Section/verb agreement
The opening verb must match the section it lives under. Don't put "Fixed X" bullets inside `## Added`.
| Section | Typical opening verbs |
| ------------- | ------------------------------------------------------------------------------- |
| `## Added` | Added, Introduced, New |
| `## Changed` | Refreshed, Redesigned, Moved, Renamed, Updated, Consolidated, Improved, Rebuilt |
| `## Fixed` | Fixed |
| `## Security` | Fixed (security framing) |
In `## Added`, the leading "Added" is often dropped because it's redundant with the heading:
- `- Server stats inside server settings modal, in info card.`
- `- Confirmation modal for resubscribing to a server.`
In `## Fixed`, the leading "Fixed" is **kept** in most entries - it reads more clearly. Be consistent within a single entry.
## What to write about
Describe the **observable behavior**, not the implementation.
- Good: `Server CPU and memory graphs no longer freeze on the last value after a hard crash or out-of-memory kill.`
- Bad: `Refactored the metrics polling hook to clear stale state on socket disconnect.`
- Good: `Historical log files are now fetched in the background when opening the Logs page, so switching between them is instant.`
- Bad: `Moved log file fetching into a background worker.`
If a refactor has no user-visible effect, **don't list it**. Internal cleanup, dependency bumps, and code moves don't belong in the changelog unless they produce a noticeable difference (perf, reliability, consistency).
## Specificity
Be specific enough that a user reading the changelog can recognize the thing you're talking about.
- Vague: `Fixed a bug on the project page.`
- Better: `Fixed project versions table overflowing outside of table. Version tags will now truncate.`
- Vague: `Improved the UI.`
- Better: `Refreshed the server cards UI for consistency.`
Name the page, tab, modal, or feature you're talking about. "The Content tab", "the server panel header", "the Worlds tab", "the project page" - these give the reader a concrete anchor.
## Length
- **One sentence per bullet.** If you need two sentences, you probably have two bullets, or one bullet plus a sub-bullet.
- Aim for under ~25 words. Long bullets are usually a sign that the change is being over-explained or is actually multiple changes.
- Sub-bullets (indented with a tab) are allowed when one change has several facets - see the `## Added` section in the v0.12.0 app release for a good example.
## Punctuation
- **End every bullet with a period.** This is inconsistent in the historical file, but periods are the more common pattern and the one to follow going forward.
- Use sentence case, not Title Case.
- Use straight quotes, not curly quotes (`"foo"` not `"foo"`).
- Use proper code formatting for filenames, flags, and literal strings: `` `.log` ``, `` `Restart` ``.
## Naming things
- Use the public, branded name: **Modrinth App**, **Modrinth Hosting**, **Modrinth** - not "the app", "servers", "Modrinth Servers" (deprecated). Capitalize product names.
- Refer to UI surfaces by the label the user sees: **Content tab**, **Worlds tab**, **Files tab**, **Logs page**, **server panel**, **project page**, **Discover page**.
- Capitalize tab and page names when referring to them by name (`the Content tab`), but not when used generically (`browse content`).
## Don't
- **Don't blame.** Avoid "fixed a regression introduced in v0.12.0" - just describe the fix.
- **Don't reference PRs, issues, or commits.** The changelog is for users, not contributors - the exception is notable third-party contributions, where you should credit the contributor by linking their GitHub profile (e.g. `Added support for Java 25. Thanks to [@username](https://github.com/username)!`). Sharing credit for community contributions is encouraged.
- **Don't reference internal team members or processes.** No "as requested by support", no "per the design review".
- **Don't apologize or editorialize.** Skip "unfortunately", "finally", "long-awaited", "we know this has been a pain point". State the change.
- **Don't use vague intensifiers.** "Significantly improved", "much better", "vastly faster" - quantify if you can, otherwise drop the adverb.
- **Don't list every sub-fix of a bigger change separately.** If you redesigned the server panel header, write one bullet about the redesign rather than six bullets about each moved element.
- **Don't use "issue with" / "issue where" as filler.** `Fixed an issue where buttons were misaligned``Fixed misaligned buttons.`
## Examples - rewriting weak bullets
| Weak | Better |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `Fixed a bug.` | `Fixed project icons becoming extremely bright on hover.` |
| `Various improvements to the server panel.` | Split into specific bullets, or drop entirely. |
| `Refactored the logs page to use a new component.` | `Redesigned the Logs page to match the Modrinth Hosting server panel.` |
| `Fixed an issue where the server address wasn't copyable.` | `Server address in the panel header can now be clicked to copy it to your clipboard.` |
| `Made some changes to the content tab.` | Either drop, or list each user-visible change as its own bullet. |
| `Fixed UX issues.` | Name the specific UX issue. |
## Featured release bullets
When an entry has a linked blog post heading (e.g. `## [Introducing Server Projects](/news/article/...)`), the bullets underneath summarize the *highlights* in 14 lines, then link out. They don't need to be exhaustive - that's what the blog post is for.
## Quick checklist before committing a bullet
1. Would a non-developer user understand it?
2. Does it describe behavior, not implementation?
3. Is the verb in the right tense for its section?
4. Does it name the specific surface (tab/page/modal)?
5. Is it one sentence, ending in a period?
6. Is there a vague word ("issue", "bug", "various", "some") I can replace with something concrete?