mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d68f3cea4 | ||
|
|
546b117437 | ||
|
|
3d5f29a7a2 | ||
|
|
37b0f7ff98 | ||
|
|
1603796856 | ||
|
|
7c9a9f22d4 | ||
|
|
d38d23dbf3 | ||
|
|
f12bd7b4b8 | ||
|
|
baee34b0b6 |
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,6 +55,7 @@ pub enum FeatureFlag {
|
||||
ProjectBackground,
|
||||
WorldsTab,
|
||||
WorldsInHome,
|
||||
ServerRamAsBytesAlwaysOn,
|
||||
ServersInApp,
|
||||
ServerProjectQa,
|
||||
I18nDebug,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
Executable
+273
@@ -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()
|
||||
@@ -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 1–4 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?
|
||||
Reference in New Issue
Block a user