You've already forked pages
forked from didirus/AstralRinth
Merge tag 'v0.10.27' into beta
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
export const scopeMessages = defineMessages({
|
||||
userReadEmailLabel: {
|
||||
id: 'scopes.userReadEmail.label',
|
||||
|
||||
@@ -41,22 +41,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectEnvironmentSettings: true,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
serverDiscovery: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
// hideModrinthAppPromos: false,
|
||||
// preferredDarkTheme: 'dark',
|
||||
// hideStagingBanner: false,
|
||||
|
||||
// Project display modes
|
||||
// modSearchDisplayMode: 'list',
|
||||
// pluginSearchDisplayMode: 'list',
|
||||
// resourcePackSearchDisplayMode: 'gallery',
|
||||
// modpackSearchDisplayMode: 'list',
|
||||
// shaderSearchDisplayMode: 'gallery',
|
||||
// dataPackSearchDisplayMode: 'list',
|
||||
// userProjectDisplayMode: 'list',
|
||||
// collectionProjectDisplayMode: 'list',
|
||||
disablePrettyProjectUrlRedirects: false,
|
||||
hidePreviewBanner: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
let cachedRateLimitKey = undefined
|
||||
let rateLimitKeyPromise = undefined
|
||||
|
||||
async function getRateLimitKey(config) {
|
||||
if (config.rateLimitKey) return config.rateLimitKey
|
||||
if (cachedRateLimitKey !== undefined) return cachedRateLimitKey
|
||||
|
||||
if (!rateLimitKeyPromise) {
|
||||
rateLimitKeyPromise = (async () => {
|
||||
try {
|
||||
const mod = 'cloudflare:workers'
|
||||
const { env } = await import(/* @vite-ignore */ mod)
|
||||
return await env.RATE_LIMIT_IGNORE_KEY?.get()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
cachedRateLimitKey = await rateLimitKeyPromise
|
||||
return cachedRateLimitKey
|
||||
}
|
||||
|
||||
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
const config = useRuntimeConfig()
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
@@ -7,7 +30,7 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
options.headers['x-ratelimit-key'] = config.rateLimitKey
|
||||
options.headers['x-ratelimit-key'] = await getRateLimitKey(config)
|
||||
}
|
||||
|
||||
if (!skipAuth) {
|
||||
@@ -32,5 +55,8 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
delete options.apiVersion
|
||||
}
|
||||
|
||||
return await $fetch(`${base}${url}`, options)
|
||||
return await $fetch(`${base}${url}`, {
|
||||
timeout: import.meta.server ? 10000 : undefined,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
|
||||
// Metadata
|
||||
lastGenerated?: string
|
||||
apiUrl?: string
|
||||
buildYear: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,9 @@ export const useGeneratedState = () =>
|
||||
muralBankDetails: generatedState.muralBankDetails as
|
||||
| Record<string, { bankNames: string[] }>
|
||||
| undefined,
|
||||
tremendousIdMap: generatedState.tremendousIdMap as
|
||||
| Record<string, { name: string; image_url: string | null }>
|
||||
| undefined,
|
||||
countries: (generatedState.countries ?? []) as ISO3166.Country[],
|
||||
subdivisions: (generatedState.subdivisions ?? {}) as Record<string, ISO3166.Subdivision[]>,
|
||||
|
||||
@@ -121,4 +125,6 @@ export const useGeneratedState = () =>
|
||||
lastGenerated: generatedState.lastGenerated,
|
||||
apiUrl: generatedState.apiUrl,
|
||||
errors: generatedState.errors,
|
||||
|
||||
buildYear: new Date().getFullYear(),
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Cosmetics } from '~/plugins/cosmetics.ts'
|
||||
|
||||
export function useTheme() {
|
||||
return useNuxtApp().$theme
|
||||
}
|
||||
|
||||
export function useCosmetics() {
|
||||
return useNuxtApp().$cosmetics
|
||||
return useNuxtApp().$cosmetics as Ref<Cosmetics>
|
||||
}
|
||||
|
||||
52
apps/frontend/src/composables/queries/project.ts
Normal file
52
apps/frontend/src/composables/queries/project.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AbstractModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 5 // 5 minutes
|
||||
|
||||
export const projectQueryOptions = {
|
||||
v2: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v2', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
v3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v3', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
members: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'members'] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
dependencies: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'dependencies'] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.getDependencies(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
versionsV2: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'versions', 'v2'] as const,
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(projectId, { include_changelog: false }),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
versionsV3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'versions', 'v3'] as const,
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(projectId, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
organization: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'organization'] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
}
|
||||
14
apps/frontend/src/composables/query-client.ts
Normal file
14
apps/frontend/src/composables/query-client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
export function useAppQueryClient(): QueryClient {
|
||||
// In components, use the standard composable
|
||||
if (getCurrentInstance()) {
|
||||
return useQueryClient()
|
||||
}
|
||||
|
||||
// In middleware/server context, use the provided instance
|
||||
const nuxtApp = useNuxtApp()
|
||||
return nuxtApp.$queryClient as QueryClient
|
||||
}
|
||||
@@ -2,15 +2,7 @@ import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import {
|
||||
BackupsModule,
|
||||
ContentModule,
|
||||
FSModule,
|
||||
GeneralModule,
|
||||
NetworkModule,
|
||||
StartupModule,
|
||||
WSModule,
|
||||
} from './modules/index.ts'
|
||||
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
|
||||
import { useServersFetch } from './servers-fetch.ts'
|
||||
|
||||
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
||||
@@ -36,39 +28,16 @@ export class ModrinthServer {
|
||||
|
||||
readonly general: GeneralModule
|
||||
readonly content: ContentModule
|
||||
readonly backups: BackupsModule
|
||||
readonly network: NetworkModule
|
||||
readonly startup: StartupModule
|
||||
readonly ws: WSModule
|
||||
readonly fs: FSModule
|
||||
|
||||
constructor(serverId: string) {
|
||||
this.serverId = serverId
|
||||
|
||||
this.general = new GeneralModule(this)
|
||||
this.content = new ContentModule(this)
|
||||
this.backups = new BackupsModule(this)
|
||||
this.network = new NetworkModule(this)
|
||||
this.startup = new StartupModule(this)
|
||||
this.ws = new WSModule(this)
|
||||
this.fs = new FSModule(this)
|
||||
}
|
||||
|
||||
async createMissingFolders(path: string): Promise<void> {
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1)
|
||||
}
|
||||
const folders = path.split('/')
|
||||
let currentPath = ''
|
||||
|
||||
for (const folder of folders) {
|
||||
currentPath += '/' + folder
|
||||
try {
|
||||
await this.fs.createFileOrFolder(currentPath, 'directory')
|
||||
} catch {
|
||||
// Folder might already exist, ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchConfigFile(fileName: string): Promise<any> {
|
||||
@@ -240,9 +209,7 @@ export class ModrinthServer {
|
||||
},
|
||||
): Promise<void> {
|
||||
const modulesToRefresh =
|
||||
modules.length > 0
|
||||
? modules
|
||||
: (['general', 'content', 'backups', 'network', 'startup', 'ws', 'fs'] as ModuleName[])
|
||||
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined
|
||||
@@ -274,25 +241,16 @@ export class ModrinthServer {
|
||||
case 'content':
|
||||
await this.content.fetch()
|
||||
break
|
||||
case 'backups':
|
||||
await this.backups.fetch()
|
||||
break
|
||||
case 'network':
|
||||
await this.network.fetch()
|
||||
break
|
||||
case 'startup':
|
||||
await this.startup.fetch()
|
||||
break
|
||||
case 'ws':
|
||||
await this.ws.fetch()
|
||||
break
|
||||
case 'fs':
|
||||
await this.fs.fetch()
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode === 404 && ['fs', 'content'].includes(module)) {
|
||||
if (error.statusCode === 404 && module === 'content') {
|
||||
console.debug(`Optional ${module} resource not found:`, error.message)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import type {
|
||||
DirectoryResponse,
|
||||
FilesystemOp,
|
||||
FileUploadQuery,
|
||||
FSQueuedOp,
|
||||
JWTAuth,
|
||||
} from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class FSModule extends ServerModule {
|
||||
auth!: JWTAuth
|
||||
ops: FilesystemOp[] = []
|
||||
queuedOps: FSQueuedOp[] = []
|
||||
opsQueuedForModification: string[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`, {}, 'fs')
|
||||
this.ops = []
|
||||
this.queuedOps = []
|
||||
this.opsQueuedForModification = []
|
||||
}
|
||||
|
||||
private async retryWithAuth<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||
console.debug('Auth failed, refreshing JWT and retrying')
|
||||
await this.fetch() // Refresh auth
|
||||
return await requestFn()
|
||||
}
|
||||
|
||||
const available = await this.server.testNodeReachability()
|
||||
if (!available && !ignoreFailure) {
|
||||
this.server.moduleErrors.general = {
|
||||
error: new ModrinthServerError(
|
||||
'Unable to reach node. FS operation failed and subsequent ping test failed.',
|
||||
500,
|
||||
error as Error,
|
||||
'fs',
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
listDirContents(
|
||||
path: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<DirectoryResponse> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: this.auth,
|
||||
retry: false,
|
||||
})
|
||||
}, ignoreFailure)
|
||||
}
|
||||
|
||||
createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
await useServersFetch(`/create?path=${encodedPath}&type=${type}`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
uploadFile(path: string, file: File): FileUploadQuery {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
const progressSubject = new EventTarget()
|
||||
const abortController = new AbortController()
|
||||
|
||||
const uploadPromise = new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = (e.loaded / e.total) * 100
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: { loaded: e.loaded, total: e.total, progress },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response)
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'))
|
||||
xhr.onabort = () => reject(new Error('Upload cancelled'))
|
||||
|
||||
xhr.open('POST', `https://${this.auth.url}/create?path=${encodedPath}&type=file`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.auth.token}`)
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
|
||||
abortController.signal.addEventListener('abort', () => xhr.abort())
|
||||
})
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (
|
||||
callback: (progress: { loaded: number; total: number; progress: number }) => void,
|
||||
) => {
|
||||
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
|
||||
callback(e.detail)
|
||||
}) as EventListener)
|
||||
},
|
||||
cancel: () => abortController.abort(),
|
||||
} as FileUploadQuery
|
||||
}
|
||||
|
||||
renameFileOrFolder(path: string, name: string): Promise<void> {
|
||||
const pathName = path.split('/').slice(0, -1).join('/') + '/' + name
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/move`, {
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
body: { source: path, destination: pathName },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateFile(path: string, content: string): Promise<void> {
|
||||
const octetStream = new Blob([content], { type: 'application/octet-stream' })
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/update?path=${path}`, {
|
||||
method: 'PUT',
|
||||
contentType: 'application/octet-stream',
|
||||
body: octetStream,
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
moveFileOrFolder(path: string, newPath: string): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
await this.server.createMissingFolders(newPath.substring(0, newPath.lastIndexOf('/')))
|
||||
await useServersFetch(`/move`, {
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
body: { source: path, destination: newPath },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
|
||||
method: 'DELETE',
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||
override: this.auth,
|
||||
})
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
return raw ? fileData : await fileData.text()
|
||||
}
|
||||
return fileData
|
||||
}, ignoreFailure)
|
||||
}
|
||||
|
||||
extractFile(
|
||||
path: string,
|
||||
override = true,
|
||||
dry = false,
|
||||
silentQueue = false,
|
||||
): Promise<{ modpack_name: string | null; conflicting_files: string[] }> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
|
||||
if (!silentQueue) {
|
||||
this.queuedOps.push({ op: 'unarchive', src: path })
|
||||
setTimeout(() => this.removeQueuedOp('unarchive', path), 4000)
|
||||
}
|
||||
|
||||
try {
|
||||
return await useServersFetch(
|
||||
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
|
||||
{
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
'Error extracting file',
|
||||
)
|
||||
} catch (err) {
|
||||
this.removeQueuedOp('unarchive', path)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
modifyOp(id: string, action: 'dismiss' | 'cancel'): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(
|
||||
`/ops/${action}?id=${id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
`Error ${action === 'dismiss' ? 'dismissing' : 'cancelling'} filesystem operation`,
|
||||
)
|
||||
|
||||
this.opsQueuedForModification = this.opsQueuedForModification.filter((x: string) => x !== id)
|
||||
this.ops = this.ops.filter((x: FilesystemOp) => x.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
removeQueuedOp(op: FSQueuedOp['op'], src: string): void {
|
||||
this.queuedOps = this.queuedOps.filter((x: FSQueuedOp) => x.op !== op || x.src !== src)
|
||||
}
|
||||
|
||||
clearQueuedOps(): void {
|
||||
this.queuedOps = []
|
||||
}
|
||||
}
|
||||
@@ -50,19 +50,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const motd = await this.getMotd()
|
||||
if (motd === 'A Minecraft Server') {
|
||||
await this.setMotd(
|
||||
`§b${data.project?.title || data.loader + ' ' + data.mc_version} §f♦ §aModrinth Hosting`,
|
||||
)
|
||||
}
|
||||
data.motd = motd
|
||||
} catch {
|
||||
console.error('[Modrinth Hosting] [General] Failed to fetch MOTD.')
|
||||
data.motd = undefined
|
||||
}
|
||||
|
||||
// Copy data to this module
|
||||
Object.assign(this, data)
|
||||
}
|
||||
@@ -189,23 +176,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async getMotd(): Promise<string | undefined> {
|
||||
try {
|
||||
const props = await this.server.fs.downloadFile('/server.properties', false, true)
|
||||
if (props) {
|
||||
const lines = props.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('motd=')) {
|
||||
return line.slice(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async setMotd(motd: string): Promise<void> {
|
||||
try {
|
||||
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './backups.ts'
|
||||
export * from './base.ts'
|
||||
export * from './content.ts'
|
||||
export * from './fs.ts'
|
||||
export * from './general.ts'
|
||||
export * from './network.ts'
|
||||
export * from './startup.ts'
|
||||
|
||||
Reference in New Issue
Block a user