mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966e885acd | ||
|
|
4e7a2ee3c4 | ||
|
|
6b21d2a6ea | ||
|
|
e7134af800 | ||
|
|
9dd69dd9bd | ||
|
|
971b1ebf70 | ||
|
|
3e1d0cba2e | ||
|
|
5c282103a6 | ||
|
|
5e737d677d | ||
|
|
2253ada248 | ||
|
|
543cb42210 | ||
|
|
86c6fb8239 | ||
|
|
14e0abb273 | ||
|
|
df653d3e43 | ||
|
|
4aeb6cf9ff | ||
|
|
cb154e205b |
@@ -76,6 +76,8 @@ jobs:
|
||||
|
||||
- name: Deploy Storybook preview
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
Generated
+55
@@ -1795,6 +1795,25 @@ dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cached-projection"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"cached-projection-derive",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cached-projection-derive"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -2133,6 +2152,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.5"
|
||||
@@ -3259,6 +3287,18 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
@@ -5412,6 +5452,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"cached-projection",
|
||||
"censor",
|
||||
"chrono",
|
||||
"clap 4.5.48",
|
||||
@@ -5440,6 +5481,7 @@ dependencies = [
|
||||
"json-patch 4.1.0",
|
||||
"labrinth",
|
||||
"lettre",
|
||||
"lz4_flex",
|
||||
"meilisearch-sdk",
|
||||
"modrinth-content-management",
|
||||
"modrinth-util",
|
||||
@@ -5447,6 +5489,7 @@ dependencies = [
|
||||
"murmur2",
|
||||
"paste",
|
||||
"path-util",
|
||||
"postcard",
|
||||
"prometheus",
|
||||
"quick-xml 0.38.3",
|
||||
"rand 0.8.5",
|
||||
@@ -7492,6 +7535,18 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
|
||||
+10
@@ -7,6 +7,8 @@ members = [
|
||||
"apps/labrinth",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/cached-projection",
|
||||
"packages/cached-projection-derive",
|
||||
"packages/daedalus",
|
||||
"packages/labrinth-derive",
|
||||
"packages/modrinth-content-management",
|
||||
@@ -54,6 +56,7 @@ bitflags = "2.9.4"
|
||||
bon = "3.9.3"
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
cached-projection = { path = "packages/cached-projection" }
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
@@ -118,6 +121,12 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
||||
"tokio1",
|
||||
"tokio1-rustls",
|
||||
] }
|
||||
lz4_flex = { version = "0.11.5", default-features = false, features = [
|
||||
"checked-decode",
|
||||
"safe-decode",
|
||||
"safe-encode",
|
||||
"std",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
||||
@@ -135,6 +144,7 @@ paste = "1.0.15"
|
||||
path-util = { path = "packages/path-util" }
|
||||
phf = { version = "0.13.1", features = ["macros"] }
|
||||
png = "0.18.0"
|
||||
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
|
||||
proc-macro2 = { version = "1.0" }
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
<p class="m-0 break-words font-semibold text-contrast">
|
||||
{{ trace.project_name }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 break-all text-sm text-secondary">
|
||||
Project {{ trace.project_slug ?? trace.project_id }} / Version
|
||||
{{ trace.version_number }} / File {{ trace.file_name }}
|
||||
<p class="m-0 mt-1 flex flex-wrap items-center gap-1 text-sm text-secondary">
|
||||
<span class="break-all">{{ trace.version_number }}</span>
|
||||
<ChevronRightIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="break-all">{{ decodeTracePath(trace.file_name) }}</span>
|
||||
<template v-if="trace.jar">
|
||||
<ChevronRightIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="break-all">{{ decodeTracePath(trace.jar) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-secondary">Local</span>
|
||||
<Badge :type="trace.local_status" />
|
||||
<span class="text-sm text-secondary">Effective</span>
|
||||
<Badge :type="trace.effective_status" />
|
||||
<template v-if="trace.local_status !== 'pending'">
|
||||
<span class="text-sm text-secondary">Local</span>
|
||||
<Badge :type="trace.local_status" />
|
||||
</template>
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="localTraceLink">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
@@ -23,31 +28,12 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Issue</span>
|
||||
{{ trace.issue_type }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Severity</span>
|
||||
{{ trace.severity }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Path</span>
|
||||
{{ trace.file_path }}
|
||||
</p>
|
||||
<p v-if="trace.jar" class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">JAR</span>
|
||||
{{ trace.jar }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { ChevronRightIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -60,4 +46,12 @@ const localTraceLink = computed(
|
||||
props.trace.detail_id,
|
||||
)}`,
|
||||
)
|
||||
|
||||
function decodeTracePath(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,20 +42,34 @@
|
||||
:key="trace.detail_key"
|
||||
class="universal-card flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
||||
Trace
|
||||
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
||||
</h2>
|
||||
<span
|
||||
v-if="getLatestLocalTrace(trace)"
|
||||
class="rounded-full border border-solid px-2.5 py-1 text-sm font-medium capitalize"
|
||||
:class="getSeverityBadgeColor(getLatestLocalTrace(trace)?.severity)"
|
||||
>
|
||||
{{ getLatestLocalTrace(trace)?.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getLatestLocalTrace(trace)" class="mt-1 flex flex-wrap gap-x-3 text-sm">
|
||||
<p class="m-0 break-all text-secondary">
|
||||
<span class="font-semibold text-contrast">Issue</span>
|
||||
{{ getLatestLocalTrace(trace)?.issue_type }}
|
||||
</p>
|
||||
<p class="m-0 break-all text-secondary">
|
||||
<span class="font-semibold text-contrast">Path</span>
|
||||
{{ decodeTracePath(getLatestLocalTrace(trace)?.file_path ?? '') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{ formatTraceCount(trace.local_trace_count) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<div class="flex shrink-0 flex-nowrap items-center gap-2">
|
||||
<Badge :type="trace.verdict" />
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
@@ -70,15 +84,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
|
||||
class="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
Showing first {{ getPreviewLocalTraces(trace).length }} of
|
||||
{{ getVisibleLocalTraceTotal(trace) }} local traces
|
||||
Showing {{ getPreviewLocalTraces(trace).length }} of
|
||||
{{ formatTraceCount(getVisibleLocalTraceTotal(trace)) }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
|
||||
>
|
||||
<NuxtLink :to="getGlobalTraceLink(trace)">
|
||||
<ListIcon aria-hidden="true" />
|
||||
View all
|
||||
@@ -129,7 +142,7 @@ const isLoading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 20
|
||||
const localTracePreviewLimit = 10
|
||||
const localTracePreviewLimit = 3
|
||||
const total = ref(0)
|
||||
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
||||
const removingTraceKeys = reactive<Set<string>>(new Set())
|
||||
@@ -148,6 +161,34 @@ function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDe
|
||||
return trace.local_traces.slice(0, localTracePreviewLimit)
|
||||
}
|
||||
|
||||
function getLatestLocalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return trace.local_traces.at(-1)
|
||||
}
|
||||
|
||||
function decodeTracePath(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverityBadgeColor(
|
||||
severity: Labrinth.TechReview.Internal.DelphiSeverity | undefined,
|
||||
): string {
|
||||
switch (severity) {
|
||||
case 'severe':
|
||||
return 'border-red/60 bg-highlight-red text-red'
|
||||
case 'high':
|
||||
return 'border-orange/60 bg-highlight-orange text-orange'
|
||||
case 'medium':
|
||||
return 'border-green/60 bg-highlight-green text-green'
|
||||
case 'low':
|
||||
default:
|
||||
return 'border-blue/60 bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return Math.max(trace.local_trace_count, trace.local_traces.length)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
BanIcon,
|
||||
BugIcon,
|
||||
CheckCheckIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
ShieldCheckIcon,
|
||||
SpinnerIcon,
|
||||
TimerIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
||||
@@ -36,6 +34,7 @@ import {
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
Toggle,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
@@ -247,6 +246,42 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
return props.item.reports.flatMap((report) => report.issues.flatMap((issue) => issue.details))
|
||||
}
|
||||
|
||||
const hideGloballyPassed = ref(true)
|
||||
|
||||
function isDetailGloballyPassed(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) === 'safe'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe'
|
||||
}
|
||||
|
||||
function isDetailGloballyResolved(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) !== 'pending'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe' || detail.global_status === 'unsafe'
|
||||
}
|
||||
|
||||
const globallyPassedSelectedFileCount = computed(() => {
|
||||
if (!selectedFile.value) return 0
|
||||
|
||||
return selectedFile.value.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyPassed).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const globallyResolvedSelectedFileCount = computed(() => {
|
||||
if (!selectedFile.value) return 0
|
||||
|
||||
return selectedFile.value.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyResolved).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
@@ -397,10 +432,7 @@ watch(
|
||||
allFiles.value = [...reports].sort((a, b) => {
|
||||
const aComplete = getFileMarkedCount(a) === getFileDetailCount(a)
|
||||
const bComplete = getFileMarkedCount(b) === getFileDetailCount(b)
|
||||
if (aComplete !== bComplete) return aComplete ? 1 : -1
|
||||
const aSeverity = getFileHighestSeverity(a)
|
||||
const bSeverity = getFileHighestSeverity(b)
|
||||
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
|
||||
return aComplete === bComplete ? 0 : aComplete ? 1 : -1
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -615,6 +647,18 @@ const remainingUnmarkedCount = computed(() => {
|
||||
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
||||
})
|
||||
|
||||
function getSelectedFileFlags(): ClassGroup['flags'] {
|
||||
if (!selectedFile.value) return []
|
||||
|
||||
return selectedFile.value.issues.flatMap((issue) =>
|
||||
issue.details.map((detail) => ({
|
||||
issueId: issue.id,
|
||||
issueType: issue.issue_type,
|
||||
detail,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
|
||||
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||
}
|
||||
@@ -629,6 +673,74 @@ function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||
|
||||
const isBatchUpdating = ref(false)
|
||||
|
||||
function getRemainingGlobalDetailCount(flags: ClassGroup['flags']): number {
|
||||
return new Set(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => flag.detail.key),
|
||||
).size
|
||||
}
|
||||
|
||||
async function batchMarkRemainingGlobally(flags: ClassGroup['flags'], verdict: 'safe' | 'unsafe') {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailsByKey = new Map(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => [flag.detail.key, flag.detail]),
|
||||
)
|
||||
const details = [...detailsByKey.values()]
|
||||
|
||||
if (details.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails(
|
||||
details.map((detail) => ({ detail_key: detail.key, verdict })),
|
||||
)
|
||||
|
||||
applyDecisionToRelatedDetails(
|
||||
details.map((detail) => detail.id),
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Globally marked ${details.length} trace keys as ${verdict}`,
|
||||
text: `All remaining eligible traces have been globally marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
if (
|
||||
selectedFile.value &&
|
||||
getFileMarkedCount(selectedFile.value) === getFileDetailCount(selectedFile.value)
|
||||
) {
|
||||
backToFileList()
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update global traces:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global batch update failed',
|
||||
text: 'An error occurred while globally updating traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
if (!selectedFile.value || isBatchUpdating.value) return
|
||||
|
||||
@@ -913,7 +1025,7 @@ interface JarGroup {
|
||||
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
|
||||
if (!jar) return []
|
||||
const segments = jar
|
||||
.split('#')
|
||||
.split(/[/#]/)
|
||||
.map((s) => decodeURIComponent(s.trim()))
|
||||
.filter((s) => s.length > 0)
|
||||
// Skip the first segment if it matches the current file tab (it's already shown in the file list)
|
||||
@@ -934,6 +1046,10 @@ const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
|
||||
for (const issue of selectedFile.value.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (hideGloballyPassed.value && isDetailGloballyPassed(detail)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
|
||||
if (!classMap.has(classKey)) {
|
||||
classMap.set(classKey, {
|
||||
@@ -959,20 +1075,11 @@ const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
classGroup.flags.sort((a, b) => {
|
||||
const aPreReviewed = isPreReviewed(a.detail.id, a.detail.status)
|
||||
const bPreReviewed = isPreReviewed(b.detail.id, b.detail.status)
|
||||
|
||||
if (aPreReviewed !== bPreReviewed) {
|
||||
return aPreReviewed ? 1 : -1
|
||||
}
|
||||
|
||||
return (severityOrder[b.detail.severity] ?? 0) - (severityOrder[a.detail.severity] ?? 0)
|
||||
return aPreReviewed === bPreReviewed ? 0 : aPreReviewed ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(classMap.values()).sort((a, b) => {
|
||||
const aSeverity = getHighestSeverityInClass(a.flags)
|
||||
const bSeverity = getHighestSeverityInClass(b.flags)
|
||||
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
|
||||
})
|
||||
return Array.from(classMap.values())
|
||||
})
|
||||
|
||||
const groupedByJar = computed<JarGroup[]>(() => {
|
||||
@@ -994,11 +1101,7 @@ const groupedByJar = computed<JarGroup[]>(() => {
|
||||
return Array.from(jarMap.values()).sort((a, b) => {
|
||||
const aRoot = isRootJarGroup(a)
|
||||
const bRoot = isRootJarGroup(b)
|
||||
if (aRoot !== bRoot) return aRoot ? -1 : 1
|
||||
|
||||
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
|
||||
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
|
||||
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
|
||||
return aRoot === bRoot ? 0 : aRoot ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1589,21 +1692,73 @@ function copyId() {
|
||||
|
||||
<template v-else-if="currentTab === 'File' && selectedFile">
|
||||
<div
|
||||
v-if="remainingUnmarkedCount > 0"
|
||||
class="flex gap-2 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
|
||||
v-if="getFileDetailCount(selectedFile) > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
|
||||
>
|
||||
<ButtonStyled color="brand" :disabled="isBatchUpdating">
|
||||
<button @click="batchMarkRemaining('safe')">
|
||||
<CheckCircleIcon class="size-5" />
|
||||
Remaining safe ({{ remainingUnmarkedCount }})
|
||||
<div
|
||||
v-if="remainingUnmarkedCount > 0"
|
||||
class="detail-verdict-buttons"
|
||||
role="group"
|
||||
aria-label="Remaining issue actions"
|
||||
>
|
||||
<span class="remaining-verdict-label"
|
||||
>Remaining issues ({{ remainingUnmarkedCount }})</span
|
||||
>
|
||||
<button
|
||||
v-tooltip="'Remaining globally safe'"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
aria-label="Remaining globally safe"
|
||||
:disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getSelectedFileFlags()) === 0
|
||||
"
|
||||
@click="batchMarkRemainingGlobally(getSelectedFileFlags(), 'safe')"
|
||||
>
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" :disabled="isBatchUpdating">
|
||||
<button @click="batchMarkRemaining('unsafe')">
|
||||
<TriangleAlertIcon class="size-5" />
|
||||
Remaining malware ({{ remainingUnmarkedCount }})
|
||||
<button
|
||||
v-tooltip="'Remaining safe'"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
aria-label="Remaining safe"
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemaining('safe')"
|
||||
>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<button
|
||||
v-tooltip="'Remaining malware'"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
aria-label="Remaining malware"
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemaining('unsafe')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="'Remaining globally unsafe'"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
aria-label="Remaining globally unsafe"
|
||||
:disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getSelectedFileFlags()) === 0
|
||||
"
|
||||
@click="batchMarkRemainingGlobally(getSelectedFileFlags(), 'unsafe')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<label class="ml-auto flex cursor-pointer items-center gap-3 text-sm">
|
||||
<span class="text-right text-secondary">
|
||||
Hide globally passed
|
||||
<span class="text-tertiary block text-xs">
|
||||
{{ globallyResolvedSelectedFileCount }}/{{ getFileDetailCount(selectedFile) }}
|
||||
traces globally resolved
|
||||
</span>
|
||||
</span>
|
||||
<Toggle
|
||||
v-model="hideGloballyPassed"
|
||||
:disabled="globallyPassedSelectedFileCount === 0"
|
||||
small
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="jarGroup in groupedByJar"
|
||||
@@ -1637,25 +1792,55 @@ function copyId() {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
|
||||
<ButtonStyled color="brand" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
|
||||
>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-if="getJarRemainingUnmarkedCount(jarGroup) > 0"
|
||||
class="detail-verdict-buttons"
|
||||
role="group"
|
||||
aria-label="Remaining JAR issue actions"
|
||||
>
|
||||
<span class="remaining-verdict-label">
|
||||
Remaining issues ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</span>
|
||||
<button
|
||||
v-tooltip="'Remaining globally safe'"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
aria-label="Remaining globally safe"
|
||||
:disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
|
||||
"
|
||||
@click="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'safe')"
|
||||
>
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="'Remaining safe'"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
aria-label="Remaining safe"
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
|
||||
>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="'Remaining malware'"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
aria-label="Remaining malware"
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="'Remaining globally unsafe'"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
aria-label="Remaining globally unsafe"
|
||||
:disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
|
||||
"
|
||||
@click="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'unsafe')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1968,6 +2153,14 @@ pre {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.remaining-verdict-label {
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.detail-verdict-button {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
|
||||
@@ -20,20 +20,34 @@
|
||||
heading="Failed to load global detail trace"
|
||||
/>
|
||||
<article v-else-if="trace" class="universal-card flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
||||
Trace
|
||||
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
||||
</h2>
|
||||
<span
|
||||
v-if="latestLocalTrace"
|
||||
class="rounded-full border border-solid px-2.5 py-1 text-sm font-medium capitalize"
|
||||
:class="getSeverityBadgeColor(latestLocalTrace.severity)"
|
||||
>
|
||||
{{ latestLocalTrace.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="latestLocalTrace" class="mt-1 flex flex-wrap gap-x-3 text-sm">
|
||||
<p class="m-0 break-all text-secondary">
|
||||
<span class="font-semibold text-contrast">Issue</span>
|
||||
{{ latestLocalTrace.issue_type }}
|
||||
</p>
|
||||
<p class="m-0 break-all text-secondary">
|
||||
<span class="font-semibold text-contrast">Path</span>
|
||||
{{ decodeTracePath(latestLocalTrace.file_path) }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<div class="flex shrink-0 flex-nowrap items-center gap-2">
|
||||
<Badge :type="trace.verdict" />
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isRemoving" @click="removeGlobalTrace">
|
||||
@@ -45,11 +59,18 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trace.local_trace_count > localTracePageSize"
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-0 border-t border-solid border-divider pt-3"
|
||||
>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
<p v-if="isLoading" class="m-0 text-sm text-secondary">Loading page...</p>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
||||
<span v-if="isLoading"> · Loading page...</span>
|
||||
</p>
|
||||
<Pagination
|
||||
v-if="trace.local_trace_count > localTracePageSize"
|
||||
:page="currentPage"
|
||||
:count="pageCount"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="trace.local_traces.length > 0" class="flex flex-col gap-2">
|
||||
@@ -114,6 +135,29 @@ const pageStart = computed(() =>
|
||||
const pageEnd = computed(() =>
|
||||
Math.min(currentPage.value * localTracePageSize, trace.value?.local_trace_count ?? 0),
|
||||
)
|
||||
const latestLocalTrace = computed(() => trace.value?.local_traces.at(-1))
|
||||
|
||||
function decodeTracePath(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeverity): string {
|
||||
switch (severity) {
|
||||
case 'severe':
|
||||
return 'border-red/60 bg-highlight-red text-red'
|
||||
case 'high':
|
||||
return 'border-orange/60 bg-highlight-orange text-orange'
|
||||
case 'medium':
|
||||
return 'border-green/60 bg-highlight-green text-green'
|
||||
case 'low':
|
||||
default:
|
||||
return 'border-blue/60 bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTracePage(afterDetailId: string | null) {
|
||||
return await client.labrinth.tech_review_internal.getGlobalIssueDetail({
|
||||
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions)\n VALUES ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM versions WHERE mod_id = ANY($1::bigint[])",
|
||||
"query": "SELECT id FROM versions WHERE mod_id = ANY($1::bigint[]) ORDER BY id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "50d087248322373b2d5fc40e1c7b8a855b55d907d7fba0af0738c37366cb93e0"
|
||||
"hash": "147e143aaaff82c71b89daddcc8805a0874d1c5ad8469dd83133ac771b86ea4e"
|
||||
}
|
||||
Generated
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, title, owner_id, public, current_version_id\n FROM shared_instances\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "owner_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "public",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "current_version_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e"
|
||||
}
|
||||
Generated
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "shared_instance_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "sha512",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tDELETE FROM shared_instance_invited_users\n\t\t\t\tWHERE invited_user_id = $1\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a2a1962875e5164d3d20802886db93631d57be2fe82bc2986dffd878ef1174d"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM shared_instance_versions\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE shared_instances SET current_version_id = $1 WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.local_status AS \"local_status?: DelphiStatus\",\n didws.global_status AS \"global_status?: DelphiStatus\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
|
||||
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.local_status AS \"local_status?: DelphiStatus\",\n didws.global_status AS \"global_status?: DelphiStatus\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ORDER BY didws.issue_id, didws.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -117,5 +117,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9070b1b6a5b1e93eb1fd1838c522fa6084ad6a5c86fafbfe8448ea7cc86f38ba"
|
||||
"hash": "63a9a89443566baa880ddbe05f741e2a83b50d89f5954ab8d951a090e702de73"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE shared_instances\n SET public = $1\n WHERE id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599"
|
||||
}
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO shared_instances (id, title, owner_id, current_version_id)\n VALUES ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id AS \"file_id: DBFileId\",\n version_id AS \"version_id: DBVersionId\",\n url,\n filename,\n size\n FROM files\n WHERE version_id = ANY($1::bigint[])\n ",
|
||||
"query": "\n SELECT\n id AS \"file_id: DBFileId\",\n version_id AS \"version_id: DBVersionId\",\n url,\n filename,\n size\n FROM files\n WHERE version_id = ANY($1::bigint[])\n ORDER BY version_id, id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,5 +42,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8e38571ffae8bf246973d328c890a45b87c0f71d53cf46f390c4b286677b2b31"
|
||||
"hash": "72012eb0655d9130a7c7894f7682015c170b2fb325aaef554f08aac34457a3fa"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE shared_instances\n SET owner_id = $1\n WHERE owner_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM shared_instance_versions WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id AS \"report_id!: DelphiReportId\",\n file_id AS \"file_id!: DBFileId\",\n created,\n severity AS \"severity!: DelphiSeverity\"\n FROM delphi_reports\n WHERE file_id = ANY($1::bigint[])\n ",
|
||||
"query": "\n SELECT\n id AS \"report_id!: DelphiReportId\",\n file_id AS \"file_id!: DBFileId\",\n created,\n severity AS \"severity!: DelphiSeverity\"\n FROM delphi_reports\n WHERE file_id = ANY($1::bigint[])\n ORDER BY file_id, created, id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -48,5 +48,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"
|
||||
"hash": "8d37c8c2633b7d601c7ba74d63608ff40af3b6b56f43540ec3798ef30e5c0b9d"
|
||||
}
|
||||
Generated
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n -- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the \"as\"\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n WHERE owner_id = $1\n UNION\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n JOIN shared_instance_users ON id = shared_instance_id\n WHERE user_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "public!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "owner_id!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "current_version_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE shared_instances\n SET title = $1\n WHERE id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f"
|
||||
}
|
||||
Generated
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT shared_instance_id, user_id, permissions\n FROM shared_instance_users\n WHERE shared_instance_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "shared_instance_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798"
|
||||
}
|
||||
Generated
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "shared_instance_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "sha512",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731"
|
||||
}
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT permissions\n FROM shared_instance_users\n WHERE shared_instance_id = $1 AND user_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "permissions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id AS \"id: DelphiReportIssueId\",\n report_id AS \"report_id: DelphiReportId\",\n issue_type\n FROM delphi_report_issues\n WHERE report_id = ANY($1::bigint[])\n ",
|
||||
"query": "\n SELECT\n id AS \"id: DelphiReportIssueId\",\n report_id AS \"report_id: DelphiReportId\",\n issue_type\n FROM delphi_report_issues\n WHERE report_id = ANY($1::bigint[])\n ORDER BY report_id, id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -30,5 +30,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "632b79f51dd39a75ebff838ca4beab463f84aa32246288cc251881ec41d207f5"
|
||||
"hash": "c69a7a5296941a52f15c3c073e65e8eff6516004d0636357f146047789f8f8c1"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM shared_instances\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM shared_instances WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4"
|
||||
}
|
||||
Generated
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tDELETE FROM shared_instance_users\n\t\t\t\tWHERE user_id = $1\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d926d015cece307da567ac44b84d3e7416723ff95fcd83587f113621a9887420"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE shared_instances\n SET current_version_id = (\n SELECT id FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n LIMIT 1\n )\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6"
|
||||
}
|
||||
@@ -33,6 +33,7 @@ aws-sdk-s3 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
cached-projection = { workspace = true }
|
||||
censor = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
@@ -73,6 +74,7 @@ image = { workspace = true, features = [
|
||||
itertools = { workspace = true }
|
||||
json-patch = { workspace = true }
|
||||
lettre = { workspace = true }
|
||||
lz4_flex = { workspace = true }
|
||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||
modrinth-content-management = { workspace = true }
|
||||
modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] }
|
||||
@@ -80,6 +82,7 @@ muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
|
||||
murmur2 = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
prometheus = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS shared_instance_versions CASCADE;
|
||||
DROP TABLE IF EXISTS shared_instance_invite_links CASCADE;
|
||||
DROP TABLE IF EXISTS shared_instance_invited_users CASCADE;
|
||||
DROP TABLE IF EXISTS shared_instance_users CASCADE;
|
||||
DROP TABLE IF EXISTS shared_instances CASCADE;
|
||||
@@ -1,3 +1,4 @@
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use sqlx::types::Json;
|
||||
@@ -11,10 +12,10 @@ use crate::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events";
|
||||
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v1";
|
||||
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBAnalyticsEvent {
|
||||
pub id: DBAnalyticsEventId,
|
||||
pub meta: AnalyticsEventMeta,
|
||||
@@ -88,7 +89,7 @@ impl DBAnalyticsEvent {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
if let Some(events) = redis
|
||||
.get_deserialized_from_json(
|
||||
.get_deserialized(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
)
|
||||
@@ -119,7 +120,7 @@ impl DBAnalyticsEvent {
|
||||
.await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
&events,
|
||||
|
||||
@@ -4,17 +4,18 @@ use crate::database::redis::RedisPool;
|
||||
|
||||
use super::DatabaseError;
|
||||
use super::ids::*;
|
||||
use cached_projection::CachedProjection;
|
||||
use futures::TryStreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TAGS_NAMESPACE: &str = "tags";
|
||||
const TAGS_NAMESPACE: &str = "tags:v1";
|
||||
|
||||
pub struct ProjectType {
|
||||
pub id: ProjectTypeId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
pub struct Category {
|
||||
pub id: CategoryId,
|
||||
pub category: String,
|
||||
@@ -28,7 +29,7 @@ pub struct ReportType {
|
||||
pub report_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
pub struct LinkPlatform {
|
||||
pub id: LinkPlatformId,
|
||||
pub name: String,
|
||||
@@ -96,9 +97,8 @@ impl Category {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res: Option<Vec<Category>> = redis
|
||||
.get_deserialized_from_json(TAGS_NAMESPACE, "category")
|
||||
.await?;
|
||||
let res: Option<Vec<Category>> =
|
||||
redis.get_deserialized(TAGS_NAMESPACE, "category").await?;
|
||||
|
||||
if let Some(res) = res {
|
||||
return Ok(res);
|
||||
@@ -127,7 +127,7 @@ impl Category {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(TAGS_NAMESPACE, "category", &result, None)
|
||||
.set_serialized(TAGS_NAMESPACE, "category", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
@@ -166,7 +166,7 @@ impl LinkPlatform {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res: Option<Vec<LinkPlatform>> = redis
|
||||
.get_deserialized_from_json(TAGS_NAMESPACE, "link_platform")
|
||||
.get_deserialized(TAGS_NAMESPACE, "link_platform")
|
||||
.await?;
|
||||
|
||||
if let Some(res) = res {
|
||||
@@ -191,12 +191,7 @@ impl LinkPlatform {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
TAGS_NAMESPACE,
|
||||
"link_platform",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(TAGS_NAMESPACE, "link_platform", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
@@ -235,7 +230,7 @@ impl ReportType {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res: Option<Vec<String>> = redis
|
||||
.get_deserialized_from_json(TAGS_NAMESPACE, "report_type")
|
||||
.get_deserialized(TAGS_NAMESPACE, "report_type")
|
||||
.await?;
|
||||
|
||||
if let Some(res) = res {
|
||||
@@ -256,12 +251,7 @@ impl ReportType {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
TAGS_NAMESPACE,
|
||||
"report_type",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(TAGS_NAMESPACE, "report_type", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
@@ -300,7 +290,7 @@ impl ProjectType {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res: Option<Vec<String>> = redis
|
||||
.get_deserialized_from_json(TAGS_NAMESPACE, "project_type")
|
||||
.get_deserialized(TAGS_NAMESPACE, "project_type")
|
||||
.await?;
|
||||
|
||||
if let Some(res) = res {
|
||||
@@ -321,12 +311,7 @@ impl ProjectType {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
TAGS_NAMESPACE,
|
||||
"project_type",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(TAGS_NAMESPACE, "project_type", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
|
||||
@@ -3,12 +3,13 @@ use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgTransaction, models};
|
||||
use crate::models::collections::CollectionStatus;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const COLLECTIONS_NAMESPACE: &str = "collections";
|
||||
const COLLECTIONS_NAMESPACE: &str = "collections:v1";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CollectionBuilder {
|
||||
@@ -43,7 +44,7 @@ impl CollectionBuilder {
|
||||
Ok(self.collection_id)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBCollection {
|
||||
pub id: DBCollectionId,
|
||||
pub user_id: DBUserId,
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::{auth::AuthProvider, routes::internal::flows::TempUser};
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::Duration;
|
||||
use rand::Rng;
|
||||
use rand::distributions::Alphanumeric;
|
||||
@@ -13,10 +14,9 @@ use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration};
|
||||
|
||||
const FLOWS_NAMESPACE: &str = "flows";
|
||||
const FLOWS_NAMESPACE: &str = "flows:v2";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[derive(Deserialize, Serialize, CachedProjection)]
|
||||
pub enum DBFlow {
|
||||
OAuth {
|
||||
user_id: Option<DBUserId>,
|
||||
@@ -61,9 +61,11 @@ pub enum DBFlow {
|
||||
},
|
||||
RegisterPasskey {
|
||||
user_id: DBUserId,
|
||||
#[cached_projection(wrap)]
|
||||
state: PasskeyRegistration,
|
||||
},
|
||||
AuthenticatePasskey {
|
||||
#[cached_projection(wrap)]
|
||||
state: DiscoverableAuthentication,
|
||||
},
|
||||
}
|
||||
@@ -78,10 +80,10 @@ impl DBFlow {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
FLOWS_NAMESPACE,
|
||||
&state,
|
||||
&self,
|
||||
self,
|
||||
Some(expires.num_seconds()),
|
||||
)
|
||||
.await?;
|
||||
@@ -109,7 +111,7 @@ impl DBFlow {
|
||||
) -> Result<Option<DBFlow>, DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis.get_deserialized_from_json(FLOWS_NAMESPACE, id).await
|
||||
redis.get_deserialized(FLOWS_NAMESPACE, id).await
|
||||
}
|
||||
|
||||
/// Gets the flow and removes it from the cache, but only removes if the flow was present and the predicate returned true
|
||||
|
||||
@@ -5,12 +5,12 @@ use crate::models::ids::{
|
||||
ChargeId, CollectionId, FileId, ImageId, NotificationId,
|
||||
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
|
||||
OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
|
||||
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
|
||||
UserSubscriptionId, VersionId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId,
|
||||
ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
use cached_projection::CachedProjection;
|
||||
use censor::Censor;
|
||||
use paste::paste;
|
||||
use rand::SeedableRng;
|
||||
@@ -96,7 +96,7 @@ macro_rules! generate_bulk_ids {
|
||||
|
||||
macro_rules! impl_db_id_interface {
|
||||
($id_struct:ident, $db_id_struct:ident, $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => {
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, utoipa::ToSchema)]
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, utoipa::ToSchema, CachedProjection)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct $db_id_struct(pub i64);
|
||||
|
||||
@@ -155,6 +155,7 @@ macro_rules! id_type {
|
||||
PartialEq,
|
||||
Hash,
|
||||
utoipa::ToSchema,
|
||||
CachedProjection,
|
||||
)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct $name(pub $type);
|
||||
@@ -238,14 +239,6 @@ db_id_interface!(
|
||||
SessionId,
|
||||
generator: generate_session_id @ "sessions",
|
||||
);
|
||||
db_id_interface!(
|
||||
SharedInstanceId,
|
||||
generator: generate_shared_instance_id @ "shared_instances",
|
||||
);
|
||||
db_id_interface!(
|
||||
SharedInstanceVersionId,
|
||||
generator: generate_shared_instance_version_id @ "shared_instance_versions",
|
||||
);
|
||||
db_id_interface!(
|
||||
TeamId,
|
||||
generator: generate_team_id @ "teams",
|
||||
|
||||
@@ -2,13 +2,14 @@ use super::ids::*;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::{database::models::DatabaseError, models::images::ImageContext};
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const IMAGES_NAMESPACE: &str = "images";
|
||||
const IMAGES_NAMESPACE: &str = "images:v1";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBImage {
|
||||
pub id: DBImageId,
|
||||
pub url: String,
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::DatabaseError;
|
||||
use super::ids::*;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::database::redis::RedisPool;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
@@ -12,15 +13,16 @@ use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const GAMES_LIST_NAMESPACE: &str = "games";
|
||||
const LOADER_ID: &str = "loader_id";
|
||||
const LOADERS_LIST_NAMESPACE: &str = "loaders";
|
||||
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields";
|
||||
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all";
|
||||
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums";
|
||||
pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = "loader_field_enum_values";
|
||||
const GAMES_LIST_NAMESPACE: &str = "games:v1";
|
||||
const LOADER_ID: &str = "loader_id:v1";
|
||||
const LOADERS_LIST_NAMESPACE: &str = "loaders:v2";
|
||||
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v1";
|
||||
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v1";
|
||||
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v1";
|
||||
pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str =
|
||||
"loader_field_enum_values:v2";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, CachedProjection)]
|
||||
pub struct Game {
|
||||
pub id: GameId,
|
||||
pub slug: String,
|
||||
@@ -54,7 +56,7 @@ impl Game {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
let cached_games: Option<Vec<Game>> = redis
|
||||
.get_deserialized_from_json(GAMES_LIST_NAMESPACE, "games")
|
||||
.get_deserialized(GAMES_LIST_NAMESPACE, "games")
|
||||
.await?;
|
||||
if let Some(cached_games) = cached_games {
|
||||
return Ok(cached_games);
|
||||
@@ -80,25 +82,21 @@ impl Game {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
GAMES_LIST_NAMESPACE,
|
||||
"games",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(GAMES_LIST_NAMESPACE, "games", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, CachedProjection)]
|
||||
pub struct Loader {
|
||||
pub id: LoaderId,
|
||||
pub loader: String,
|
||||
pub icon: String,
|
||||
pub supported_project_types: Vec<String>,
|
||||
pub supported_games: Vec<String>, // slugs
|
||||
#[cached_projection(wrap)]
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ impl Loader {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
let cached_id: Option<i32> =
|
||||
redis.get_deserialized_from_json(LOADER_ID, name).await?;
|
||||
redis.get_deserialized(LOADER_ID, name).await?;
|
||||
if let Some(cached_id) = cached_id {
|
||||
return Ok(Some(LoaderId(cached_id)));
|
||||
}
|
||||
@@ -134,7 +132,7 @@ impl Loader {
|
||||
if let Some(result) = result {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.set_serialized_to_json(LOADER_ID, name, &result.0, None)
|
||||
.set_serialized(LOADER_ID, name, &result.0, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -151,7 +149,7 @@ impl Loader {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
let cached_loaders: Option<Vec<Loader>> = redis
|
||||
.get_deserialized_from_json(LOADERS_LIST_NAMESPACE, "all")
|
||||
.get_deserialized(LOADERS_LIST_NAMESPACE, "all")
|
||||
.await?;
|
||||
if let Some(cached_loaders) = cached_loaders {
|
||||
return Ok(cached_loaders);
|
||||
@@ -193,19 +191,14 @@ impl Loader {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
LOADERS_LIST_NAMESPACE,
|
||||
"all",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(LOADERS_LIST_NAMESPACE, "all", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, CachedProjection)]
|
||||
pub struct LoaderField {
|
||||
pub id: LoaderFieldId,
|
||||
pub field: String,
|
||||
@@ -274,7 +267,7 @@ impl LoaderFieldType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, CachedProjection)]
|
||||
pub struct LoaderFieldEnum {
|
||||
pub id: LoaderFieldEnumId,
|
||||
pub enum_name: String,
|
||||
@@ -282,14 +275,18 @@ pub struct LoaderFieldEnum {
|
||||
pub hidable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Clone, Serialize, Deserialize, Debug, PartialEq, Eq, CachedProjection,
|
||||
)]
|
||||
pub struct LoaderFieldEnumValue {
|
||||
pub id: LoaderFieldEnumValueId,
|
||||
pub enum_id: LoaderFieldEnumId,
|
||||
pub value: String,
|
||||
pub ordering: Option<i32>,
|
||||
pub created: DateTime<Utc>,
|
||||
|
||||
#[serde(flatten)]
|
||||
#[cached_projection(wrap)]
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
@@ -303,22 +300,33 @@ impl std::hash::Hash for LoaderFieldEnumValue {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(
|
||||
Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash, CachedProjection,
|
||||
)]
|
||||
pub struct VersionField {
|
||||
pub version_id: DBVersionId,
|
||||
pub field_id: LoaderFieldId,
|
||||
pub field_name: String,
|
||||
#[cached_projection(nested)]
|
||||
pub value: VersionFieldValue,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(
|
||||
Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash, CachedProjection,
|
||||
)]
|
||||
pub enum VersionFieldValue {
|
||||
Integer(i32),
|
||||
Text(String),
|
||||
Enum(LoaderFieldEnumId, LoaderFieldEnumValue),
|
||||
Enum(
|
||||
LoaderFieldEnumId,
|
||||
#[cached_projection(nested)] LoaderFieldEnumValue,
|
||||
),
|
||||
Boolean(bool),
|
||||
ArrayInteger(Vec<i32>),
|
||||
ArrayText(Vec<String>),
|
||||
ArrayEnum(LoaderFieldEnumId, Vec<LoaderFieldEnumValue>),
|
||||
ArrayEnum(
|
||||
LoaderFieldEnumId,
|
||||
#[cached_projection(nested)] Vec<LoaderFieldEnumValue>,
|
||||
),
|
||||
ArrayBoolean(Vec<bool>),
|
||||
}
|
||||
|
||||
@@ -470,10 +478,9 @@ impl LoaderField {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let cached_fields: Option<Vec<LoaderField>> =
|
||||
redis.get(LOADER_FIELDS_NAMESPACE_ALL, "").await?.and_then(
|
||||
|x| serde_json::from_str::<Vec<LoaderField>>(&x).ok(),
|
||||
);
|
||||
let cached_fields: Option<Vec<LoaderField>> = redis
|
||||
.get_deserialized(LOADER_FIELDS_NAMESPACE_ALL, "")
|
||||
.await?;
|
||||
|
||||
if let Some(cached_fields) = cached_fields {
|
||||
return Ok(cached_fields);
|
||||
@@ -506,12 +513,7 @@ impl LoaderField {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
LOADER_FIELDS_NAMESPACE_ALL,
|
||||
"",
|
||||
&result,
|
||||
None,
|
||||
)
|
||||
.set_serialized(LOADER_FIELDS_NAMESPACE_ALL, "", &result, None)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
@@ -530,10 +532,7 @@ impl LoaderFieldEnum {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let cached_enum = redis
|
||||
.get_deserialized_from_json(
|
||||
LOADER_FIELD_ENUMS_ID_NAMESPACE,
|
||||
enum_name,
|
||||
)
|
||||
.get_deserialized(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name)
|
||||
.await?;
|
||||
if let Some(cached_enum) = cached_enum {
|
||||
return Ok(cached_enum);
|
||||
@@ -561,7 +560,7 @@ impl LoaderFieldEnum {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
LOADER_FIELD_ENUMS_ID_NAMESPACE,
|
||||
enum_name,
|
||||
&result,
|
||||
|
||||
@@ -32,7 +32,6 @@ pub mod products_tax_identifier_item;
|
||||
pub mod project_item;
|
||||
pub mod report_item;
|
||||
pub mod session_item;
|
||||
pub mod shared_instance_item;
|
||||
pub mod team_item;
|
||||
pub mod thread_item;
|
||||
pub mod user_item;
|
||||
@@ -77,6 +76,8 @@ pub enum DatabaseError {
|
||||
RedisPool(#[from] deadpool_redis::PoolError),
|
||||
#[error("Error while serializing with the cache: {0}")]
|
||||
SerdeCacheError(#[from] serde_json::Error),
|
||||
#[error("error while encoding or decoding the cache: {0}")]
|
||||
PostcardCacheError(#[from] postcard::Error),
|
||||
#[error("Schema error: {0}")]
|
||||
SchemaError(String),
|
||||
#[error(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -7,11 +8,11 @@ use crate::database::redis::RedisPool;
|
||||
|
||||
use super::{DBOrganizationId, DBUserId, DatabaseError};
|
||||
|
||||
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users";
|
||||
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v1";
|
||||
const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str =
|
||||
"moderation_notes_organizations";
|
||||
"moderation_notes_organizations:v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBModerationNote {
|
||||
pub user_id: Option<DBUserId>,
|
||||
pub organization_id: Option<DBOrganizationId>,
|
||||
@@ -40,7 +41,7 @@ impl DBModerationNote {
|
||||
let cached = {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.get_many_deserialized_from_json::<Self>(
|
||||
.get_many_deserialized::<Self>(
|
||||
MODERATION_NOTES_USERS_NAMESPACE,
|
||||
&ids,
|
||||
)
|
||||
@@ -87,7 +88,7 @@ impl DBModerationNote {
|
||||
|
||||
if let Some(user_id) = note.user_id {
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
MODERATION_NOTES_USERS_NAMESPACE,
|
||||
user_id.0,
|
||||
¬e,
|
||||
@@ -130,7 +131,7 @@ impl DBModerationNote {
|
||||
let cached = {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.get_many_deserialized_from_json::<Self>(
|
||||
.get_many_deserialized::<Self>(
|
||||
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
|
||||
&ids,
|
||||
)
|
||||
@@ -177,7 +178,7 @@ impl DBModerationNote {
|
||||
|
||||
if let Some(organization_id) = note.organization_id {
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
|
||||
organization_id.0,
|
||||
¬e,
|
||||
|
||||
@@ -5,20 +5,22 @@ use crate::models::notifications::{
|
||||
NotificationBody, NotificationChannel, NotificationDeliveryStatus,
|
||||
NotificationType,
|
||||
};
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::TryStreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications";
|
||||
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v2";
|
||||
|
||||
pub struct NotificationBuilder {
|
||||
pub body: NotificationBody,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBNotification {
|
||||
pub id: DBNotificationId,
|
||||
pub user_id: DBUserId,
|
||||
#[cached_projection(nested)]
|
||||
pub body: NotificationBody,
|
||||
pub read: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
@@ -435,7 +437,7 @@ impl DBNotification {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let cached_notifications: Option<Vec<DBNotification>> = redis
|
||||
.get_deserialized_from_json(
|
||||
.get_deserialized(
|
||||
USER_NOTIFICATIONS_NAMESPACE,
|
||||
&user_id.0.to_string(),
|
||||
)
|
||||
@@ -493,7 +495,7 @@ impl DBNotification {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
USER_NOTIFICATIONS_NAMESPACE,
|
||||
user_id.0,
|
||||
&db_notifications,
|
||||
|
||||
@@ -2,17 +2,19 @@ use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::v3::notifications::{NotificationChannel, NotificationType};
|
||||
use crate::routes::ApiError;
|
||||
use cached_projection::CachedProjection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TEMPLATES_NAMESPACE: &str = "notifications_templates";
|
||||
const TEMPLATES_HTML_DATA_NAMESPACE: &str = "notifications_templates_html_data";
|
||||
const TEMPLATES_NAMESPACE: &str = "notifications_templates:v1";
|
||||
const TEMPLATES_HTML_DATA_NAMESPACE: &str =
|
||||
"notifications_templates_html_data:v1";
|
||||
const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str =
|
||||
"notifications_templates_dynamic_html";
|
||||
"notifications_templates_dynamic_html:v1";
|
||||
|
||||
const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
|
||||
const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct NotificationTemplate {
|
||||
pub id: i64,
|
||||
pub channel: NotificationChannel,
|
||||
@@ -56,10 +58,7 @@ impl NotificationTemplate {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let maybe_cached_templates = redis
|
||||
.get_deserialized_from_json(
|
||||
TEMPLATES_NAMESPACE,
|
||||
channel.as_str(),
|
||||
)
|
||||
.get_deserialized(TEMPLATES_NAMESPACE, channel.as_str())
|
||||
.await?;
|
||||
|
||||
if let Some(cached) = maybe_cached_templates {
|
||||
@@ -82,7 +81,7 @@ impl NotificationTemplate {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
TEMPLATES_NAMESPACE,
|
||||
channel.as_str(),
|
||||
&templates,
|
||||
@@ -99,7 +98,7 @@ impl NotificationTemplate {
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.get_deserialized_from_json(
|
||||
.get_deserialized(
|
||||
TEMPLATES_HTML_DATA_NAMESPACE,
|
||||
&self.id.to_string(),
|
||||
)
|
||||
@@ -113,7 +112,7 @@ impl NotificationTemplate {
|
||||
) -> Result<(), DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
TEMPLATES_HTML_DATA_NAMESPACE,
|
||||
&self.id.to_string(),
|
||||
&data,
|
||||
@@ -131,17 +130,14 @@ pub async fn get_or_set_cached_dynamic_html<F>(
|
||||
where
|
||||
F: Future<Output = Result<String, ApiError>>,
|
||||
{
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CachedProjection)]
|
||||
struct HtmlBody {
|
||||
html: String,
|
||||
}
|
||||
|
||||
let mut redis_conn = redis.connect().await?;
|
||||
if let Some(body) = redis_conn
|
||||
.get_deserialized_from_json::<HtmlBody>(
|
||||
TEMPLATES_DYNAMIC_HTML_NAMESPACE,
|
||||
key,
|
||||
)
|
||||
.get_deserialized::<HtmlBody>(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key)
|
||||
.await?
|
||||
{
|
||||
return Ok(body.html);
|
||||
@@ -153,7 +149,7 @@ where
|
||||
let mut redis_conn = redis.connect().await?;
|
||||
|
||||
redis_conn
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
TEMPLATES_DYNAMIC_HTML_NAMESPACE,
|
||||
key,
|
||||
&cached,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::v3::notifications::NotificationType;
|
||||
use cached_projection::CachedProjection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types";
|
||||
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v1";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
pub struct NotificationTypeItem {
|
||||
pub name: NotificationType,
|
||||
pub delivery_priority: i32,
|
||||
@@ -43,7 +44,7 @@ impl NotificationTypeItem {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let cached_types = redis
|
||||
.get_deserialized_from_json(NOTIFICATION_TYPES_NAMESPACE, "all")
|
||||
.get_deserialized(NOTIFICATION_TYPES_NAMESPACE, "all")
|
||||
.await?;
|
||||
|
||||
if let Some(types) = cached_types {
|
||||
@@ -63,12 +64,7 @@ impl NotificationTypeItem {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
NOTIFICATION_TYPES_NAMESPACE,
|
||||
"all",
|
||||
&types,
|
||||
None,
|
||||
)
|
||||
.set_serialized(NOTIFICATION_TYPES_NAMESPACE, "all", &types, None)
|
||||
.await?;
|
||||
|
||||
Ok(types)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::database::PgTransaction;
|
||||
use crate::database::redis::RedisPool;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use cached_projection::CachedProjection;
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
use std::fmt::{Debug, Display};
|
||||
@@ -9,10 +10,10 @@ use std::hash::Hash;
|
||||
use super::{DBTeamMember, ids::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ORGANIZATIONS_NAMESPACE: &str = "organizations";
|
||||
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles";
|
||||
const ORGANIZATIONS_NAMESPACE: &str = "organizations:v1";
|
||||
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v1";
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, CachedProjection)]
|
||||
/// An organization of users who together control one or more projects and organizations.
|
||||
pub struct DBOrganization {
|
||||
/// The id of the organization
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
@@ -11,11 +12,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::hash::Hash;
|
||||
|
||||
const PATS_NAMESPACE: &str = "pats";
|
||||
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens";
|
||||
const PATS_USERS_NAMESPACE: &str = "pats_users";
|
||||
const PATS_NAMESPACE: &str = "pats:v1";
|
||||
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v1";
|
||||
const PATS_USERS_NAMESPACE: &str = "pats_users:v1";
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, CachedProjection)]
|
||||
pub struct DBPersonalAccessToken {
|
||||
pub id: DBPatId,
|
||||
pub name: String,
|
||||
@@ -161,7 +162,7 @@ impl DBPersonalAccessToken {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res = redis
|
||||
.get_deserialized_from_json::<Vec<i64>>(
|
||||
.get_deserialized::<Vec<i64>>(
|
||||
PATS_USERS_NAMESPACE,
|
||||
&user_id.0.to_string(),
|
||||
)
|
||||
@@ -189,12 +190,7 @@ impl DBPersonalAccessToken {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set(
|
||||
PATS_USERS_NAMESPACE,
|
||||
&user_id.0.to_string(),
|
||||
&serde_json::to_string(&db_pats)?,
|
||||
None,
|
||||
)
|
||||
.set_serialized(PATS_USERS_NAMESPACE, user_id.0, &db_pats, None)
|
||||
.await?;
|
||||
Ok(db_pats)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ use crate::database::models::{
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::billing::{Price, ProductMetadata};
|
||||
use cached_projection::CachedProjection;
|
||||
use dashmap::DashMap;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryFrom;
|
||||
use std::convert::TryInto;
|
||||
|
||||
const PRODUCTS_NAMESPACE: &str = "products";
|
||||
const PRODUCTS_NAMESPACE: &str = "products:v2";
|
||||
|
||||
pub struct DBProduct {
|
||||
pub id: DBProductId,
|
||||
@@ -131,13 +132,15 @@ impl DBProduct {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, CachedProjection)]
|
||||
pub struct QueryProductWithPrices {
|
||||
pub id: DBProductId,
|
||||
#[cached_projection(nested)]
|
||||
pub metadata: ProductMetadata,
|
||||
pub unitary: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub name: Option<String>,
|
||||
#[cached_projection(nested)]
|
||||
pub prices: Vec<DBProductPrice>,
|
||||
}
|
||||
|
||||
@@ -153,9 +156,8 @@ impl QueryProductWithPrices {
|
||||
{
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res: Option<Vec<QueryProductWithPrices>> = redis
|
||||
.get_deserialized_from_json(PRODUCTS_NAMESPACE, "all")
|
||||
.await?;
|
||||
let res: Option<Vec<QueryProductWithPrices>> =
|
||||
redis.get_deserialized(PRODUCTS_NAMESPACE, "all").await?;
|
||||
|
||||
if let Some(res) = res {
|
||||
return Ok(res);
|
||||
@@ -196,7 +198,7 @@ impl QueryProductWithPrices {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
|
||||
.set_serialized(PRODUCTS_NAMESPACE, "all", &products, None)
|
||||
.await?;
|
||||
|
||||
Ok(products)
|
||||
@@ -243,10 +245,11 @@ impl QueryProductWithPrices {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, CachedProjection)]
|
||||
pub struct DBProductPrice {
|
||||
pub id: DBProductPriceId,
|
||||
pub product_id: DBProductId,
|
||||
#[cached_projection(nested)]
|
||||
pub prices: Price,
|
||||
pub currency_code: String,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::models::projects::{
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use futures::TryStreamExt;
|
||||
@@ -23,9 +24,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::hash::Hash;
|
||||
|
||||
pub const PROJECTS_NAMESPACE: &str = "projects";
|
||||
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs";
|
||||
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies";
|
||||
pub const PROJECTS_NAMESPACE: &str = "projects:v2";
|
||||
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v1";
|
||||
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v1";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LinkUrl {
|
||||
@@ -276,7 +277,7 @@ impl ProjectBuilder {
|
||||
Ok(self.project_id)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct DBProject {
|
||||
pub id: DBProjectId,
|
||||
pub team_id: DBTeamId,
|
||||
@@ -304,6 +305,7 @@ pub struct DBProject {
|
||||
pub monetization_status: MonetizationStatus,
|
||||
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
|
||||
pub loaders: Vec<String>,
|
||||
#[cached_projection(nested)]
|
||||
pub components: exp::ProjectSerial,
|
||||
}
|
||||
|
||||
@@ -976,7 +978,7 @@ impl DBProject {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let dependencies = redis
|
||||
.get_deserialized_from_json::<Dependencies>(
|
||||
.get_deserialized::<Dependencies>(
|
||||
PROJECTS_DEPENDENCIES_NAMESPACE,
|
||||
&id.0.to_string(),
|
||||
)
|
||||
@@ -1014,7 +1016,7 @@ impl DBProject {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
PROJECTS_DEPENDENCIES_NAMESPACE,
|
||||
id.0,
|
||||
&dependencies,
|
||||
@@ -1050,8 +1052,9 @@ impl DBProject {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct ProjectQueryResult {
|
||||
#[cached_projection(nested)]
|
||||
pub inner: DBProject,
|
||||
pub categories: Vec<String>,
|
||||
pub additional_categories: Vec<String>,
|
||||
@@ -1061,7 +1064,9 @@ pub struct ProjectQueryResult {
|
||||
pub urls: Vec<LinkUrl>,
|
||||
pub gallery_items: Vec<DBGalleryItem>,
|
||||
pub thread_id: DBThreadId,
|
||||
#[cached_projection(nested)]
|
||||
pub aggregate_version_fields: Vec<VersionField>,
|
||||
#[serde(flatten)]
|
||||
#[cached_projection(nested)]
|
||||
pub components: exp::ProjectQuery,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::database::PgTransaction;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures_util::TryStreamExt;
|
||||
@@ -10,9 +11,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::hash::Hash;
|
||||
|
||||
const SESSIONS_NAMESPACE: &str = "sessions";
|
||||
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids";
|
||||
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users";
|
||||
const SESSIONS_NAMESPACE: &str = "sessions:v1";
|
||||
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v1";
|
||||
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v1";
|
||||
|
||||
pub struct SessionBuilder {
|
||||
pub session: String,
|
||||
@@ -74,7 +75,7 @@ impl SessionBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, CachedProjection)]
|
||||
pub struct DBSession {
|
||||
pub id: DBSessionId,
|
||||
pub session: String,
|
||||
@@ -226,7 +227,7 @@ impl DBSession {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let res = redis
|
||||
.get_deserialized_from_json::<Vec<i64>>(
|
||||
.get_deserialized::<Vec<i64>>(
|
||||
SESSIONS_USERS_NAMESPACE,
|
||||
&user_id.0.to_string(),
|
||||
)
|
||||
@@ -255,7 +256,7 @@ impl DBSession {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
SESSIONS_USERS_NAMESPACE,
|
||||
user_id.0,
|
||||
&db_sessions,
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
use crate::database::PgTransaction;
|
||||
use crate::database::models::{
|
||||
DBSharedInstanceId, DBSharedInstanceVersionId, DBUserId,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::shared_instances::SharedInstanceUserPermissions;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
//region shared_instances
|
||||
pub struct DBSharedInstance {
|
||||
pub id: DBSharedInstanceId,
|
||||
pub title: String,
|
||||
pub owner_id: DBUserId,
|
||||
pub public: bool,
|
||||
pub current_version_id: Option<DBSharedInstanceVersionId>,
|
||||
}
|
||||
|
||||
struct SharedInstanceQueryResult {
|
||||
id: i64,
|
||||
title: String,
|
||||
owner_id: i64,
|
||||
public: bool,
|
||||
current_version_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<SharedInstanceQueryResult> for DBSharedInstance {
|
||||
fn from(val: SharedInstanceQueryResult) -> Self {
|
||||
DBSharedInstance {
|
||||
id: DBSharedInstanceId(val.id),
|
||||
title: val.title,
|
||||
owner_id: DBUserId(val.owner_id),
|
||||
public: val.public,
|
||||
current_version_id: val
|
||||
.current_version_id
|
||||
.map(DBSharedInstanceVersionId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DBSharedInstance {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO shared_instances (id, title, owner_id, current_version_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
",
|
||||
self.id as DBSharedInstanceId,
|
||||
self.title,
|
||||
self.owner_id as DBUserId,
|
||||
self.current_version_id.map(|x| x.0),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
id: DBSharedInstanceId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
let result = sqlx::query_as!(
|
||||
SharedInstanceQueryResult,
|
||||
"
|
||||
SELECT id, title, owner_id, public, current_version_id
|
||||
FROM shared_instances
|
||||
WHERE id = $1
|
||||
",
|
||||
id.0,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(Into::into))
|
||||
}
|
||||
|
||||
pub async fn list_for_user(
|
||||
user: DBUserId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
let results = sqlx::query_as!(
|
||||
SharedInstanceQueryResult,
|
||||
r#"
|
||||
-- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the "as"
|
||||
SELECT
|
||||
id as "id!",
|
||||
title as "title!",
|
||||
public as "public!",
|
||||
owner_id as "owner_id!",
|
||||
current_version_id
|
||||
FROM shared_instances
|
||||
WHERE owner_id = $1
|
||||
UNION
|
||||
SELECT
|
||||
id as "id!",
|
||||
title as "title!",
|
||||
public as "public!",
|
||||
owner_id as "owner_id!",
|
||||
current_version_id
|
||||
FROM shared_instances
|
||||
JOIN shared_instance_users ON id = shared_instance_id
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
user.0,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(results.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region shared_instance_users
|
||||
const USERS_NAMESPACE: &str = "shared_instance_users";
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct DBSharedInstanceUser {
|
||||
pub user_id: DBUserId,
|
||||
pub shared_instance_id: DBSharedInstanceId,
|
||||
pub permissions: SharedInstanceUserPermissions,
|
||||
}
|
||||
|
||||
impl DBSharedInstanceUser {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions)
|
||||
VALUES ($1, $2, $3)
|
||||
",
|
||||
self.user_id as DBUserId,
|
||||
self.shared_instance_id as DBSharedInstanceId,
|
||||
self.permissions.bits() as i64,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_permissions(
|
||||
instance_id: DBSharedInstanceId,
|
||||
user_id: DBUserId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<SharedInstanceUserPermissions>, super::DatabaseError>
|
||||
{
|
||||
let permissions = sqlx::query!(
|
||||
"
|
||||
SELECT permissions
|
||||
FROM shared_instance_users
|
||||
WHERE shared_instance_id = $1 AND user_id = $2
|
||||
",
|
||||
instance_id as DBSharedInstanceId,
|
||||
user_id as DBUserId,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
.map(|x| {
|
||||
SharedInstanceUserPermissions::from_bits(x.permissions as u64)
|
||||
.unwrap_or(SharedInstanceUserPermissions::empty())
|
||||
});
|
||||
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
pub async fn get_from_instance(
|
||||
instance_id: DBSharedInstanceId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBSharedInstanceUser>, super::DatabaseError> {
|
||||
Self::get_from_instance_many(&[instance_id], exec, redis).await
|
||||
}
|
||||
|
||||
pub async fn get_from_instance_many(
|
||||
instance_ids: &[DBSharedInstanceId],
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBSharedInstanceUser>, super::DatabaseError> {
|
||||
if instance_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let users = redis
|
||||
.get_cached_keys(
|
||||
USERS_NAMESPACE,
|
||||
&instance_ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
async |user_ids| {
|
||||
let users = sqlx::query!(
|
||||
"
|
||||
SELECT shared_instance_id, user_id, permissions
|
||||
FROM shared_instance_users
|
||||
WHERE shared_instance_id = ANY($1)
|
||||
",
|
||||
&user_ids
|
||||
)
|
||||
.fetch(exec)
|
||||
.try_fold(DashMap::new(), |acc: DashMap<_, Vec<_>>, m| {
|
||||
acc.entry(m.shared_instance_id).or_default().push(
|
||||
DBSharedInstanceUser {
|
||||
user_id: DBUserId(m.user_id),
|
||||
shared_instance_id: DBSharedInstanceId(
|
||||
m.shared_instance_id,
|
||||
),
|
||||
permissions:
|
||||
SharedInstanceUserPermissions::from_bits(
|
||||
m.permissions as u64,
|
||||
)
|
||||
.unwrap_or(
|
||||
SharedInstanceUserPermissions::empty(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
async move { Ok(acc) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(users)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(users.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
pub async fn clear_cache(
|
||||
instance_id: DBSharedInstanceId,
|
||||
redis: &RedisPool,
|
||||
) -> Result<(), super::DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis.delete(USERS_NAMESPACE, instance_id.0).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region shared_instance_versions
|
||||
pub struct DBSharedInstanceVersion {
|
||||
pub id: DBSharedInstanceVersionId,
|
||||
pub shared_instance_id: DBSharedInstanceId,
|
||||
pub size: u64,
|
||||
pub sha512: Vec<u8>,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
struct SharedInstanceVersionQueryResult {
|
||||
id: i64,
|
||||
shared_instance_id: i64,
|
||||
size: i64,
|
||||
sha512: Vec<u8>,
|
||||
created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<SharedInstanceVersionQueryResult> for DBSharedInstanceVersion {
|
||||
fn from(val: SharedInstanceVersionQueryResult) -> Self {
|
||||
DBSharedInstanceVersion {
|
||||
id: DBSharedInstanceVersionId(val.id),
|
||||
shared_instance_id: DBSharedInstanceId(val.shared_instance_id),
|
||||
size: val.size as u64,
|
||||
sha512: val.sha512,
|
||||
created: val.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DBSharedInstanceVersion {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
",
|
||||
self.id as DBSharedInstanceVersionId,
|
||||
self.shared_instance_id as DBSharedInstanceId,
|
||||
self.size as i64,
|
||||
self.sha512,
|
||||
self.created,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
id: DBSharedInstanceVersionId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
let result = sqlx::query_as!(
|
||||
SharedInstanceVersionQueryResult,
|
||||
"
|
||||
SELECT id, shared_instance_id, size, sha512, created
|
||||
FROM shared_instance_versions
|
||||
WHERE id = $1
|
||||
",
|
||||
id as DBSharedInstanceVersionId,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(Into::into))
|
||||
}
|
||||
|
||||
pub async fn get_for_instance(
|
||||
instance_id: DBSharedInstanceId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
let results = sqlx::query_as!(
|
||||
SharedInstanceVersionQueryResult,
|
||||
"
|
||||
SELECT id, shared_instance_id, size, sha512, created
|
||||
FROM shared_instance_versions
|
||||
WHERE shared_instance_id = $1
|
||||
ORDER BY created DESC
|
||||
",
|
||||
instance_id as DBSharedInstanceId,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(results.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
@@ -3,13 +3,14 @@ use crate::{
|
||||
database::{PgTransaction, redis::RedisPool},
|
||||
models::teams::{OrganizationPermissions, ProjectPermissions},
|
||||
};
|
||||
use cached_projection::CachedProjection;
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TEAMS_NAMESPACE: &str = "teams";
|
||||
const TEAMS_NAMESPACE: &str = "teams:v2";
|
||||
|
||||
pub struct TeamBuilder {
|
||||
pub members: Vec<TeamMemberBuilder>,
|
||||
@@ -164,7 +165,7 @@ impl DBTeam {
|
||||
}
|
||||
|
||||
/// A member of a team
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, CachedProjection)]
|
||||
pub struct DBTeamMember {
|
||||
pub id: DBTeamMemberId,
|
||||
pub team_id: DBTeamId,
|
||||
@@ -183,6 +184,7 @@ pub struct DBTeamMember {
|
||||
pub organization_permissions: Option<OrganizationPermissions>,
|
||||
|
||||
pub accepted: bool,
|
||||
#[cached_projection(wrap)]
|
||||
pub payouts_split: Decimal,
|
||||
pub ordering: i64,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::models::billing::ChargeStatus;
|
||||
use crate::models::users::Badges;
|
||||
use crate::util::error::Context;
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -16,11 +17,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::hash::Hash;
|
||||
|
||||
const USERS_NAMESPACE: &str = "users";
|
||||
const USER_USERNAMES_NAMESPACE: &str = "users_usernames";
|
||||
const USERS_PROJECTS_NAMESPACE: &str = "users_projects";
|
||||
const USERS_NAMESPACE: &str = "users:v1";
|
||||
const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v1";
|
||||
const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v1";
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, CachedProjection)]
|
||||
pub struct DBUser {
|
||||
pub id: DBUserId,
|
||||
|
||||
@@ -391,7 +392,7 @@ impl DBUser {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
let cached_projects = redis
|
||||
.get_deserialized_from_json::<Vec<DBProjectId>>(
|
||||
.get_deserialized::<Vec<DBProjectId>>(
|
||||
USERS_PROJECTS_NAMESPACE,
|
||||
&user_id.0.to_string(),
|
||||
)
|
||||
@@ -419,7 +420,7 @@ impl DBUser {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
USERS_PROJECTS_NAMESPACE,
|
||||
user_id.0,
|
||||
&db_projects,
|
||||
@@ -631,19 +632,6 @@ impl DBUser {
|
||||
.await
|
||||
.wrap_err("failed to update versions author_id")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE shared_instances
|
||||
SET owner_id = $1
|
||||
WHERE owner_id = $2
|
||||
",
|
||||
deleted_user as DBUserId,
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.wrap_err("failed to update shared_instances owner_id")?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let notifications: Vec<i64> = sqlx::query!(
|
||||
"
|
||||
@@ -1009,28 +997,6 @@ impl DBUser {
|
||||
.await
|
||||
.wrap_err("failed to delete oauth_client_authorizations")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM shared_instance_users
|
||||
WHERE user_id = $1
|
||||
",
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.wrap_err("failed to delete shared_instance_users")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM shared_instance_invited_users
|
||||
WHERE invited_user_id = $1
|
||||
",
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.wrap_err("failed to delete shared_instance_invited_users")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users_redeemals
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::models::exp;
|
||||
use crate::models::projects::{FileType, VersionStatus};
|
||||
use crate::queue::file_scan::scan_file;
|
||||
use crate::routes::internal::delphi::DelphiRunParameters;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use futures::TryStreamExt;
|
||||
@@ -22,8 +23,8 @@ use std::collections::HashMap;
|
||||
use std::iter;
|
||||
use tracing::error;
|
||||
|
||||
pub const VERSIONS_NAMESPACE: &str = "versions";
|
||||
const VERSION_FILES_NAMESPACE: &str = "versions_files";
|
||||
pub const VERSIONS_NAMESPACE: &str = "versions:v2";
|
||||
const VERSION_FILES_NAMESPACE: &str = "versions_files:v1";
|
||||
|
||||
pub async fn cleanup_unused_attribution_files_and_groups(
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
@@ -375,7 +376,7 @@ impl DBLoaderVersion {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[derive(Clone, Deserialize, Serialize, CachedProjection)]
|
||||
pub struct DBVersion {
|
||||
pub id: DBVersionId,
|
||||
pub project_id: DBProjectId,
|
||||
@@ -390,6 +391,7 @@ pub struct DBVersion {
|
||||
pub status: VersionStatus,
|
||||
pub requested_status: Option<VersionStatus>,
|
||||
pub ordering: Option<i32>,
|
||||
#[cached_projection(nested)]
|
||||
pub components: exp::VersionSerial,
|
||||
}
|
||||
|
||||
@@ -1090,27 +1092,32 @@ impl DBVersion {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[derive(Clone, Deserialize, Serialize, CachedProjection)]
|
||||
pub struct VersionQueryResult {
|
||||
#[cached_projection(nested)]
|
||||
pub inner: DBVersion,
|
||||
|
||||
pub files: Vec<FileQueryResult>,
|
||||
#[cached_projection(nested)]
|
||||
pub version_fields: Vec<VersionField>,
|
||||
pub loaders: Vec<String>,
|
||||
pub project_types: Vec<String>,
|
||||
pub games: Vec<String>,
|
||||
#[cached_projection(nested)]
|
||||
pub dependencies: Vec<DependencyQueryResult>,
|
||||
#[serde(flatten)]
|
||||
#[cached_projection(nested)]
|
||||
pub components: exp::VersionQuery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, CachedProjection)]
|
||||
pub struct DependencyQueryResult {
|
||||
pub id: i32,
|
||||
pub project_id: Option<DBProjectId>,
|
||||
pub version_id: Option<DBVersionId>,
|
||||
pub file_name: Option<String>,
|
||||
pub dependency_type: String,
|
||||
#[cached_projection(nested)]
|
||||
pub attribution: Option<crate::models::projects::DependencyAttribution>,
|
||||
}
|
||||
|
||||
@@ -1125,7 +1132,7 @@ pub struct FileQueryResult {
|
||||
pub file_type: Option<FileType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[derive(Clone, Deserialize, Serialize, CachedProjection)]
|
||||
pub struct DBFile {
|
||||
pub id: DBFileId,
|
||||
pub version_id: DBVersionId,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::env::ENV;
|
||||
|
||||
use super::models::DatabaseError;
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use deadpool_redis::{Config, Runtime};
|
||||
@@ -12,12 +13,15 @@ use prometheus::{IntGauge, Registry};
|
||||
use redis::ToRedisArgs;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::future::Future;
|
||||
use std::hash::Hash;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tracing::{Instrument, info, info_span};
|
||||
use util::{cmd, redis_pipe};
|
||||
|
||||
@@ -41,8 +45,121 @@ const MGET_CHUNK_SIZE: usize = 32;
|
||||
// BytesMut peak capacity that builds up under steady load.
|
||||
const REDIS_MAX_CONN_AGE: Duration = Duration::from_secs(120);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
Raw = 0,
|
||||
Lz4 = 1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncodingFormat {
|
||||
Json,
|
||||
Postcard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("invalid redis codec")]
|
||||
pub struct InvalidCodec;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("invalid redis encoding format")]
|
||||
pub struct InvalidEncodingFormat;
|
||||
|
||||
impl TryFrom<u8> for Codec {
|
||||
type Error = InvalidCodec;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Raw),
|
||||
1 => Ok(Self::Lz4),
|
||||
_ => Err(InvalidCodec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Codec {
|
||||
type Err = InvalidCodec;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"lz4" => Ok(Self::Lz4),
|
||||
_ => Err(InvalidCodec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for EncodingFormat {
|
||||
type Err = InvalidEncodingFormat;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"json" => Ok(Self::Json),
|
||||
"postcard" => Ok(Self::Postcard),
|
||||
_ => Err(InvalidEncodingFormat),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_value<T>(value: &T) -> Result<Vec<u8>, DatabaseError>
|
||||
where
|
||||
T: CachedProjection + Serialize,
|
||||
{
|
||||
let mut value = match ENV.REDIS_ENCODING_FORMAT {
|
||||
EncodingFormat::Json => serde_json::to_vec(value)?,
|
||||
EncodingFormat::Postcard => {
|
||||
postcard::to_allocvec(&value.project_ref())?
|
||||
}
|
||||
};
|
||||
|
||||
if ENV.REDIS_COMPRESSION_LEVEL > 0
|
||||
&& ENV.REDIS_COMPRESSION_ALGORITHM == Codec::Lz4
|
||||
&& value.len() >= ENV.REDIS_COMPRESSION_THRESHOLD_BYTES
|
||||
{
|
||||
let compressed = lz4_flex::block::compress_prepend_size(&value);
|
||||
let savings_ratio = value.len().saturating_sub(compressed.len()) as f64
|
||||
/ value.len().max(1) as f64
|
||||
* 100.0;
|
||||
|
||||
if savings_ratio >= ENV.REDIS_COMPRESSION_MIN_SAVINGS_RATIO {
|
||||
let mut encoded = Vec::with_capacity(compressed.len() + 1);
|
||||
encoded.push(Codec::Lz4 as u8);
|
||||
encoded.extend(compressed);
|
||||
return Ok(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
let mut encoded = Vec::with_capacity(value.len() + 1);
|
||||
encoded.push(Codec::Raw as u8);
|
||||
encoded.append(&mut value);
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn decode_value<T>(value: &[u8]) -> Option<T>
|
||||
where
|
||||
T: CachedProjection + DeserializeOwned,
|
||||
{
|
||||
let (codec, value) = value.split_first()?;
|
||||
let value = match Codec::try_from(*codec).ok()? {
|
||||
Codec::Raw => Cow::Borrowed(value),
|
||||
Codec::Lz4 => {
|
||||
Cow::Owned(lz4_flex::block::decompress_size_prepended(value).ok()?)
|
||||
}
|
||||
};
|
||||
|
||||
match ENV.REDIS_ENCODING_FORMAT {
|
||||
EncodingFormat::Json => serde_json::from_slice(&value).ok(),
|
||||
EncodingFormat::Postcard => {
|
||||
let projected =
|
||||
postcard::from_bytes::<T::ProjectedType>(&value).ok()?;
|
||||
Some(T::from_projected(projected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_expiries(namespace: &str) -> (i64, i64) {
|
||||
match namespace {
|
||||
// Namespaces may embed a version suffix like `:v1`, so split it out.
|
||||
match namespace.split_once(':').map(|t| t.0).unwrap_or(namespace) {
|
||||
"versions" | "versions_files" => {
|
||||
(VERSION_DEFAULT_EXPIRY, VERSION_ACTUAL_EXPIRY)
|
||||
}
|
||||
@@ -188,7 +305,7 @@ impl RedisPool {
|
||||
where
|
||||
F: FnOnce(Vec<K>) -> Fut,
|
||||
Fut: Future<Output = Result<DashMap<K, T>, DatabaseError>>,
|
||||
T: Serialize + DeserializeOwned,
|
||||
T: CachedProjection + Serialize + DeserializeOwned,
|
||||
K: Display
|
||||
+ Hash
|
||||
+ Eq
|
||||
@@ -216,7 +333,7 @@ impl RedisPool {
|
||||
where
|
||||
F: FnOnce(Vec<K>) -> Fut,
|
||||
Fut: Future<Output = Result<DashMap<K, T>, DatabaseError>>,
|
||||
T: Serialize + DeserializeOwned,
|
||||
T: CachedProjection + Serialize + DeserializeOwned,
|
||||
K: Display
|
||||
+ Hash
|
||||
+ Eq
|
||||
@@ -254,7 +371,7 @@ impl RedisPool {
|
||||
where
|
||||
F: FnOnce(Vec<I>) -> Fut,
|
||||
Fut: Future<Output = Result<DashMap<K, (Option<S>, T)>, DatabaseError>>,
|
||||
T: Serialize + DeserializeOwned,
|
||||
T: CachedProjection + Serialize + DeserializeOwned,
|
||||
I: Display + Hash + Eq + PartialEq + Clone + Debug,
|
||||
K: Display
|
||||
+ Hash
|
||||
@@ -291,7 +408,7 @@ impl RedisPool {
|
||||
where
|
||||
F: FnOnce(Vec<I>) -> Fut,
|
||||
Fut: Future<Output = Result<DashMap<K, (Option<S>, T)>, DatabaseError>>,
|
||||
T: Serialize + DeserializeOwned,
|
||||
T: CachedProjection + Serialize + DeserializeOwned,
|
||||
I: Display + Hash + Eq + PartialEq + Clone + Debug,
|
||||
K: Display
|
||||
+ Hash
|
||||
@@ -367,12 +484,11 @@ impl RedisPool {
|
||||
for chunk in args.chunks(MGET_CHUNK_SIZE) {
|
||||
let part = cmd("MGET")
|
||||
.arg(chunk)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.query_async::<Vec<Option<Vec<u8>>>>(&mut connection)
|
||||
.await?;
|
||||
cached_values.extend(part.into_iter().filter_map(|x| {
|
||||
x.and_then(|val| {
|
||||
serde_json::from_str::<RedisValue<T, K, S>>(&val)
|
||||
.ok()
|
||||
decode_value::<RedisValue<T, K, S>>(&val)
|
||||
})
|
||||
.map(|val| (val.key.clone(), val))
|
||||
}));
|
||||
@@ -492,7 +608,7 @@ impl RedisPool {
|
||||
"{}_{namespace}:{key}",
|
||||
self.meta_namespace
|
||||
),
|
||||
serde_json::to_string(&value)?,
|
||||
encode_value(&value)?,
|
||||
default_expiry as u64,
|
||||
);
|
||||
pipe_cmds += 1;
|
||||
@@ -624,47 +740,39 @@ impl<'a> Drop for LockSentinel<'a> {
|
||||
|
||||
impl RedisConnection {
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn set(
|
||||
pub async fn set<D>(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
id: &str,
|
||||
data: &str,
|
||||
data: D,
|
||||
expiry: Option<i64>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
) -> Result<(), DatabaseError>
|
||||
where
|
||||
D: ToRedisArgs + Send + Sync + Debug,
|
||||
{
|
||||
let mut cmd = cmd("SET");
|
||||
redis_args(
|
||||
&mut cmd,
|
||||
vec![
|
||||
format!("{}_{}:{}", self.meta_namespace, namespace, id),
|
||||
data.to_string(),
|
||||
"EX".to_string(),
|
||||
expiry.unwrap_or(DEFAULT_EXPIRY).to_string(),
|
||||
]
|
||||
.as_slice(),
|
||||
);
|
||||
cmd.arg(format!("{}_{}:{}", self.meta_namespace, namespace, id))
|
||||
.arg(data)
|
||||
.arg("EX")
|
||||
.arg(expiry.unwrap_or(DEFAULT_EXPIRY));
|
||||
redis_execute::<()>(&mut cmd, &mut self.connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, id, data))]
|
||||
pub async fn set_serialized_to_json<Id, D>(
|
||||
pub async fn set_serialized<Id, D>(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
id: Id,
|
||||
data: D,
|
||||
data: &D,
|
||||
expiry: Option<i64>,
|
||||
) -> Result<(), DatabaseError>
|
||||
where
|
||||
Id: Display,
|
||||
D: serde::Serialize,
|
||||
D: CachedProjection + serde::Serialize,
|
||||
{
|
||||
self.set(
|
||||
namespace,
|
||||
&id.to_string(),
|
||||
&serde_json::to_string(&data)?,
|
||||
expiry,
|
||||
)
|
||||
.await
|
||||
self.set(namespace, &id.to_string(), encode_value(data)?, expiry)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
@@ -688,7 +796,7 @@ impl RedisConnection {
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
ids: &[String],
|
||||
) -> Result<Vec<Option<String>>, DatabaseError> {
|
||||
) -> Result<Vec<Option<Vec<u8>>>, DatabaseError> {
|
||||
let mut cmd = cmd("MGET");
|
||||
redis_args(
|
||||
&mut cmd,
|
||||
@@ -702,35 +810,40 @@ impl RedisConnection {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn get_deserialized_from_json<R>(
|
||||
pub async fn get_deserialized<R>(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<R>, DatabaseError>
|
||||
where
|
||||
R: for<'a> serde::Deserialize<'a>,
|
||||
R: CachedProjection + DeserializeOwned,
|
||||
{
|
||||
Ok(self
|
||||
.get(namespace, id)
|
||||
.await?
|
||||
.and_then(|x| serde_json::from_str(&x).ok()))
|
||||
let mut cmd = cmd("GET");
|
||||
redis_args(
|
||||
&mut cmd,
|
||||
vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)]
|
||||
.as_slice(),
|
||||
);
|
||||
let value: Option<Vec<u8>> =
|
||||
redis_execute(&mut cmd, &mut self.connection).await?;
|
||||
Ok(value.and_then(|value| decode_value(&value)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn get_many_deserialized_from_json<R>(
|
||||
pub async fn get_many_deserialized<R>(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
ids: &[String],
|
||||
) -> Result<Vec<Option<R>>, DatabaseError>
|
||||
where
|
||||
R: for<'a> serde::Deserialize<'a>,
|
||||
R: CachedProjection + DeserializeOwned,
|
||||
{
|
||||
Ok(self
|
||||
.get_many(namespace, ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| x.and_then(|val| serde_json::from_str::<R>(&val).ok()))
|
||||
.collect::<Vec<_>>())
|
||||
.map(|value| value.and_then(|value| decode_value::<R>(&value)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, id))]
|
||||
@@ -799,7 +912,7 @@ impl RedisConnection {
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
timeout: Option<f64>,
|
||||
) -> Result<Option<[String; 2]>, DatabaseError> {
|
||||
) -> Result<Option<[Vec<u8>; 2]>, DatabaseError> {
|
||||
let key = format!("{}_{namespace}:{key}", self.meta_namespace);
|
||||
// a timeout of 0 is infinite
|
||||
let timeout = timeout.unwrap_or(0.0);
|
||||
@@ -826,15 +939,21 @@ impl RedisConnection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(CachedProjection, Serialize, Deserialize)]
|
||||
pub struct RedisValue<T, K, S> {
|
||||
key: K,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alias: Option<S>,
|
||||
iat: i64,
|
||||
#[cached_projection(nested)]
|
||||
val: T,
|
||||
}
|
||||
|
||||
impl<T, K, S> RedisValue<T, K, S> {
|
||||
pub fn value(&self) -> &T {
|
||||
&self.val
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redis_args(cmd: &mut util::InstrumentedCmd, args: &[String]) {
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
|
||||
@@ -289,6 +289,11 @@ vars! {
|
||||
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
|
||||
REDIS_MAX_CONNECTIONS: u32 = 10000u32;
|
||||
REDIS_MIN_CONNECTIONS: usize = 0usize;
|
||||
REDIS_ENCODING_FORMAT: crate::database::redis::EncodingFormat = crate::database::redis::EncodingFormat::Postcard;
|
||||
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
|
||||
REDIS_COMPRESSION_ALGORITHM: crate::database::redis::Codec = crate::database::redis::Codec::Lz4;
|
||||
REDIS_COMPRESSION_THRESHOLD_BYTES: usize = 1024usize;
|
||||
REDIS_COMPRESSION_MIN_SAVINGS_RATIO: f64 = 12.5f64;
|
||||
|
||||
SEARCH_OPERATION_TIMEOUT: u64 = 300000u64;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -109,11 +110,11 @@ pub enum Language {
|
||||
}
|
||||
|
||||
component::define! {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct ModProject {}
|
||||
|
||||
/// Listing for a Minecraft server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct ServerProject {
|
||||
#[base(serde(default))]
|
||||
#[edit(serde(
|
||||
@@ -160,11 +161,11 @@ component::define! {
|
||||
}
|
||||
|
||||
/// Version of a Minecraft Java server listing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct JavaServerVersion {}
|
||||
|
||||
/// Listing for a Minecraft Bedrock server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct BedrockServerProject {
|
||||
#[base()]
|
||||
#[edit(serde(default))]
|
||||
@@ -200,13 +201,22 @@ impl ProjectComponent for BedrockServerProject {
|
||||
}
|
||||
|
||||
/// Listing for a Minecraft Java server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Validate,
|
||||
utoipa::ToSchema,
|
||||
CachedProjection,
|
||||
)]
|
||||
pub struct JavaServerProject {
|
||||
/// Address (IP or domain name) of the Java server, excluding port.
|
||||
#[validate(length(max = 255))]
|
||||
pub address: String,
|
||||
/// What game content this server is using.
|
||||
#[serde(default)]
|
||||
#[cached_projection(nested)]
|
||||
pub content: ServerContent,
|
||||
}
|
||||
|
||||
@@ -219,9 +229,12 @@ pub struct JavaServerProjectEdit {
|
||||
pub content: Option<ServerContent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, CachedProjection,
|
||||
)]
|
||||
pub struct JavaServerProjectQuery {
|
||||
pub address: String,
|
||||
#[cached_projection(nested)]
|
||||
pub content: ServerContentQuery,
|
||||
pub ping: Option<JavaServerPing>,
|
||||
pub verified_plays_2w: Option<u64>,
|
||||
@@ -324,7 +337,9 @@ impl ComponentEdit for JavaServerProjectEdit {
|
||||
}
|
||||
|
||||
/// What game content a [`JavaServerProject`] is using.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, CachedProjection,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ServerContent {
|
||||
/// Server runs modded content with a modpack found on the Modrinth platform.
|
||||
@@ -346,7 +361,9 @@ pub enum ServerContent {
|
||||
}
|
||||
|
||||
/// What game content a [`JavaServerProject`] is using.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, CachedProjection,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ServerContentQuery {
|
||||
/// Server runs modded content with a modpack found on the Modrinth platform.
|
||||
@@ -399,7 +416,9 @@ pub enum ServerRegion {
|
||||
}
|
||||
|
||||
/// Recorded ping attempt that Labrinth made to a Minecraft Java server project.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, CachedProjection,
|
||||
)]
|
||||
pub struct JavaServerPing {
|
||||
/// When the ping was performed.
|
||||
pub when: DateTime<Utc>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use cached_projection::CachedProjection;
|
||||
use eyre::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -52,11 +53,12 @@ macro_rules! define_project_components {
|
||||
|
||||
impl ComponentKind for ProjectComponentKind {}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, CachedProjection)]
|
||||
pub struct ProjectSerial {
|
||||
$(
|
||||
#[validate(nested)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cached_projection(nested)]
|
||||
pub $field_name: Option<$ty>,
|
||||
)*
|
||||
}
|
||||
@@ -113,10 +115,11 @@ macro_rules! define_project_components {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct ProjectQuery {
|
||||
$(
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cached_projection(nested)]
|
||||
pub $field_name: Option<Query<$ty>>,
|
||||
)*
|
||||
}
|
||||
@@ -258,7 +261,7 @@ pub async fn fetch_query_context(
|
||||
HashMap::new()
|
||||
} else {
|
||||
redis
|
||||
.get_many_deserialized_from_json::<minecraft::JavaServerPing>(
|
||||
.get_many_deserialized::<minecraft::JavaServerPing>(
|
||||
server_ping::REDIS_NAMESPACE,
|
||||
&minecraft_java_server_pings
|
||||
.iter()
|
||||
@@ -281,7 +284,7 @@ pub async fn fetch_query_context(
|
||||
HashMap::new()
|
||||
} else {
|
||||
redis
|
||||
.get_many_deserialized_from_json::<MinecraftServerAnalytics>(
|
||||
.get_many_deserialized::<MinecraftServerAnalytics>(
|
||||
MINECRAFT_SERVER_ANALYTICS,
|
||||
&minecraft_server_analytics
|
||||
.iter()
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::models::exp::{
|
||||
minecraft,
|
||||
};
|
||||
|
||||
use cached_projection::CachedProjection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use validator::Validate;
|
||||
@@ -43,9 +44,10 @@ macro_rules! define_version_components {
|
||||
)*
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct VersionSerial {
|
||||
$(
|
||||
#[cached_projection(nested)]
|
||||
pub $field_name: Option<$ty>,
|
||||
)*
|
||||
}
|
||||
@@ -71,9 +73,10 @@ macro_rules! define_version_components {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema, CachedProjection)]
|
||||
pub struct VersionQuery {
|
||||
$(
|
||||
#[cached_projection(nested)]
|
||||
pub $field_name: Option<Query<$ty>>,
|
||||
)*
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ pub use v3::payouts;
|
||||
pub use v3::projects;
|
||||
pub use v3::reports;
|
||||
pub use v3::sessions;
|
||||
pub use v3::shared_instances;
|
||||
pub use v3::teams;
|
||||
pub use v3::threads;
|
||||
pub use v3::users;
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::models::ids::{
|
||||
ChargeId, ProductId, ProductPriceId, UserSubscriptionId,
|
||||
};
|
||||
use ariadne::ids::UserId;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -14,7 +15,7 @@ pub struct Product {
|
||||
pub unitary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ProductMetadata {
|
||||
Midas,
|
||||
@@ -55,7 +56,7 @@ pub struct ProductPrice {
|
||||
pub currency_code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, CachedProjection)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum Price {
|
||||
OneTime {
|
||||
|
||||
@@ -19,8 +19,6 @@ base62_id!(ProductPriceId);
|
||||
base62_id!(ProjectId);
|
||||
base62_id!(ReportId);
|
||||
base62_id!(SessionId);
|
||||
base62_id!(SharedInstanceId);
|
||||
base62_id!(SharedInstanceVersionId);
|
||||
base62_id!(TeamId);
|
||||
base62_id!(TeamMemberId);
|
||||
base62_id!(ThreadId);
|
||||
|
||||
@@ -15,7 +15,6 @@ pub mod payouts;
|
||||
pub mod projects;
|
||||
pub mod reports;
|
||||
pub mod sessions;
|
||||
pub mod shared_instances;
|
||||
pub mod teams;
|
||||
pub mod threads;
|
||||
pub mod user_limits;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::models::ids::{
|
||||
use crate::models::projects::ProjectStatus;
|
||||
use crate::routes::ApiError;
|
||||
use ariadne::ids::UserId;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -151,7 +152,7 @@ impl NotificationType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, CachedProjection)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum NotificationBody {
|
||||
ProjectUpdate {
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::models::ids::{
|
||||
};
|
||||
use crate::routes::{FileHash, HashAlgorithm};
|
||||
use ariadne::ids::UserId;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Utc};
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -676,7 +677,14 @@ pub struct FlameProject {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
utoipa::ToSchema,
|
||||
CachedProjection,
|
||||
)]
|
||||
#[serde(untagged)]
|
||||
pub enum AttributionLicense {
|
||||
@@ -685,11 +693,19 @@ pub enum AttributionLicense {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
utoipa::ToSchema,
|
||||
CachedProjection,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttributionResolutionKind {
|
||||
License {
|
||||
#[cached_projection(nested)]
|
||||
license: AttributionLicense,
|
||||
link_to_work: Url,
|
||||
},
|
||||
@@ -697,6 +713,7 @@ pub enum AttributionResolutionKind {
|
||||
link_to_work: Url,
|
||||
},
|
||||
MyProject {
|
||||
#[cached_projection(nested)]
|
||||
license: AttributionLicense,
|
||||
},
|
||||
SpecialPermissions {
|
||||
@@ -1004,12 +1021,20 @@ pub struct Dependency {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
Debug,
|
||||
PartialEq,
|
||||
Eq,
|
||||
utoipa::ToSchema,
|
||||
CachedProjection,
|
||||
)]
|
||||
pub struct DependencyAttribution {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flame_project: Option<FlameProject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cached_projection(nested)]
|
||||
pub resolution: Option<AttributionResolutionKind>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
use crate::bitflags_serde_impl;
|
||||
use crate::database::models::shared_instance_item::{
|
||||
DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion,
|
||||
};
|
||||
use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId};
|
||||
use ariadne::ids::UserId;
|
||||
use bitflags::bitflags;
|
||||
use chrono::{DateTime, Utc};
|
||||
use hex::ToHex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedInstance {
|
||||
pub id: SharedInstanceId,
|
||||
pub title: String,
|
||||
pub owner: UserId,
|
||||
pub public: bool,
|
||||
pub current_version: Option<SharedInstanceVersion>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub additional_users: Option<Vec<SharedInstanceUser>>,
|
||||
}
|
||||
|
||||
impl SharedInstance {
|
||||
pub fn from_db(
|
||||
instance: DBSharedInstance,
|
||||
users: Option<Vec<DBSharedInstanceUser>>,
|
||||
current_version: Option<DBSharedInstanceVersion>,
|
||||
) -> Self {
|
||||
SharedInstance {
|
||||
id: instance.id.into(),
|
||||
title: instance.title,
|
||||
owner: instance.owner_id.into(),
|
||||
public: instance.public,
|
||||
current_version: current_version.map(Into::into),
|
||||
additional_users: users
|
||||
.map(|x| x.into_iter().map(Into::into).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct SharedInstanceVersion {
|
||||
pub id: SharedInstanceVersionId,
|
||||
pub shared_instance: SharedInstanceId,
|
||||
pub size: u64,
|
||||
pub sha512: String,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<DBSharedInstanceVersion> for SharedInstanceVersion {
|
||||
fn from(value: DBSharedInstanceVersion) -> Self {
|
||||
let version_id = value.id.into();
|
||||
let shared_instance_id = value.shared_instance_id.into();
|
||||
SharedInstanceVersion {
|
||||
id: version_id,
|
||||
shared_instance: shared_instance_id,
|
||||
size: value.size,
|
||||
sha512: value.sha512.encode_hex(),
|
||||
created: value.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct SharedInstanceUserPermissions: u64 {
|
||||
const EDIT = 1 << 0;
|
||||
const DELETE = 1 << 1;
|
||||
const UPLOAD_VERSION = 1 << 2;
|
||||
const DELETE_VERSION = 1 << 3;
|
||||
}
|
||||
}
|
||||
|
||||
bitflags_serde_impl!(SharedInstanceUserPermissions, u64);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedInstanceUser {
|
||||
pub user: UserId,
|
||||
pub permissions: SharedInstanceUserPermissions,
|
||||
}
|
||||
|
||||
impl From<DBSharedInstanceUser> for SharedInstanceUser {
|
||||
fn from(user: DBSharedInstanceUser) -> Self {
|
||||
SharedInstanceUser {
|
||||
user: user.user_id.into(),
|
||||
permissions: user.permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cached_projection::CachedProjection;
|
||||
use const_format::formatcp;
|
||||
use eyre::{Result, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -13,9 +14,9 @@ use crate::{
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics";
|
||||
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CachedProjection)]
|
||||
pub struct MinecraftServerAnalytics {
|
||||
pub verified_plays_2w: u64,
|
||||
pub verified_plays_4w: u64,
|
||||
@@ -117,10 +118,10 @@ pub async fn cache_analytics(
|
||||
|
||||
debug!("Caching analytics for {project_id}: {analytics:?}");
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
MINECRAFT_SERVER_ANALYTICS,
|
||||
project_id.to_string(),
|
||||
analytics,
|
||||
&analytics,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -13,9 +13,9 @@ use tracing::trace;
|
||||
|
||||
pub mod cache;
|
||||
|
||||
const DOWNLOADS_NAMESPACE: &str = "downloads";
|
||||
const VIEWS_NAMESPACE: &str = "views";
|
||||
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays";
|
||||
const DOWNLOADS_NAMESPACE: &str = "downloads:v1";
|
||||
const VIEWS_NAMESPACE: &str = "views:v1";
|
||||
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v1";
|
||||
const MINECRAFT_SERVER_PLAYS_EXPIRY: u64 = 86_400; // 24 hours
|
||||
const MINECRAFT_SERVER_PLAYS_LIMIT: u32 = 5;
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ pub struct ServerPingQueue {
|
||||
pub incremental_search_queue: IncrementalSearchQueue,
|
||||
}
|
||||
|
||||
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping";
|
||||
pub const REDIS_FAILURE_NAMESPACE: &str = "minecraft_java_server_ping_failures";
|
||||
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v1";
|
||||
pub const REDIS_FAILURE_NAMESPACE: &str =
|
||||
"minecraft_java_server_ping_failures:v1";
|
||||
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
|
||||
|
||||
impl ServerPingQueue {
|
||||
@@ -133,12 +134,7 @@ impl ServerPingQueue {
|
||||
// ping succeeded; immediately update its online status in redis
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
REDIS_NAMESPACE,
|
||||
project_id,
|
||||
ping,
|
||||
None,
|
||||
)
|
||||
.set_serialized(REDIS_NAMESPACE, project_id, ping, None)
|
||||
.await
|
||||
.wrap_err("failed to set redis key")?;
|
||||
updated_project = true;
|
||||
@@ -160,7 +156,7 @@ impl ServerPingQueue {
|
||||
&& count >= ENV.SERVER_PING_MAX_FAIL_COUNT
|
||||
{
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
REDIS_NAMESPACE,
|
||||
project_id,
|
||||
ping,
|
||||
@@ -254,7 +250,7 @@ impl ServerPingQueue {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let all_server_last_pings = redis
|
||||
.get_many_deserialized_from_json::<exp::minecraft::JavaServerPing>(
|
||||
.get_many_deserialized::<exp::minecraft::JavaServerPing>(
|
||||
REDIS_NAMESPACE,
|
||||
&all_project_ids,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use actix_web::{HttpRequest, get, post, web};
|
||||
use base64::Engine;
|
||||
use cached_projection::CachedProjection;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use eyre::eyre;
|
||||
use hmac::{Hmac, Mac};
|
||||
@@ -60,15 +61,19 @@ struct TiltifyMeta {
|
||||
subscription_source_type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Clone, Debug, Serialize, Deserialize, utoipa::ToSchema, CachedProjection,
|
||||
)]
|
||||
pub struct CampaignInfo {
|
||||
#[cached_projection(wrap)]
|
||||
total_donations_usd: Decimal,
|
||||
#[cached_projection(wrap)]
|
||||
target_usd: Decimal,
|
||||
num_donators: usize,
|
||||
cached_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info";
|
||||
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v2";
|
||||
const CAMPAIGN_INFO_CACHE_STALE_SECONDS: i64 = 15 * 60;
|
||||
const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60;
|
||||
|
||||
@@ -326,7 +331,7 @@ pub async fn pride_26(
|
||||
.wrap_internal_err("connecting to redis")?;
|
||||
|
||||
let cached = redis_connection
|
||||
.get_deserialized_from_json::<CampaignInfo>(
|
||||
.get_deserialized::<CampaignInfo>(
|
||||
CAMPAIGN_INFO_CACHE_NAMESPACE,
|
||||
campaign_id,
|
||||
)
|
||||
@@ -382,7 +387,7 @@ pub async fn pride_26(
|
||||
};
|
||||
|
||||
redis_connection
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
CAMPAIGN_INFO_CACHE_NAMESPACE,
|
||||
campaign_id,
|
||||
&campaign_info,
|
||||
|
||||
@@ -427,7 +427,7 @@ async fn broadcast_notifications(
|
||||
redis,
|
||||
RedisFriendsMessage::Notification {
|
||||
to_user,
|
||||
notification,
|
||||
notification_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -2126,7 +2126,7 @@ async fn validate_2fa_code(
|
||||
)
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
const TOTP_NAMESPACE: &str = "used_totp";
|
||||
const TOTP_NAMESPACE: &str = "used_totp:v1";
|
||||
let mut conn = redis.connect().await?;
|
||||
|
||||
// Check if TOTP has already been used
|
||||
|
||||
@@ -70,11 +70,11 @@ pub async fn success_callback(
|
||||
|
||||
let body = base64::engine::general_purpose::STANDARD.encode(&body);
|
||||
|
||||
let redis_msg = serde_json::to_string(&Ok::<
|
||||
let redis_msg = postcard::to_allocvec(&Ok::<
|
||||
GotenbergDocument,
|
||||
GotenbergError,
|
||||
>(GotenbergDocument { body }))
|
||||
.wrap_internal_err("failed to serialize document to JSON")?;
|
||||
.wrap_internal_err("failed to serialize Redis document response")?;
|
||||
|
||||
redis
|
||||
.lpush(
|
||||
@@ -139,11 +139,11 @@ pub async fn error_callback(
|
||||
.await
|
||||
.wrap_internal_err("failed to get Redis connection")?;
|
||||
|
||||
let redis_msg = serde_json::to_string(&Err::<
|
||||
let redis_msg = postcard::to_allocvec(&Err::<
|
||||
GotenbergDocument,
|
||||
GotenbergError,
|
||||
>(error_body))
|
||||
.wrap_internal_err("failed to serialize error to JSON")?;
|
||||
.wrap_internal_err("failed to serialize Redis error response")?;
|
||||
|
||||
redis
|
||||
.lpush(
|
||||
|
||||
@@ -443,7 +443,7 @@ async fn fetch_project_reports(
|
||||
}
|
||||
|
||||
let version_id_rows = sqlx::query!(
|
||||
"SELECT id FROM versions WHERE mod_id = ANY($1::bigint[])",
|
||||
"SELECT id FROM versions WHERE mod_id = ANY($1::bigint[]) ORDER BY id",
|
||||
&project_ids.iter().map(|id| id.0).collect::<Vec<_>>()
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@@ -469,6 +469,7 @@ async fn fetch_project_reports(
|
||||
size
|
||||
FROM files
|
||||
WHERE version_id = ANY($1::bigint[])
|
||||
ORDER BY version_id, id
|
||||
"#,
|
||||
&version_ids.iter().map(|id| id.0).collect::<Vec<_>>()
|
||||
)
|
||||
@@ -485,6 +486,7 @@ async fn fetch_project_reports(
|
||||
severity AS "severity!: DelphiSeverity"
|
||||
FROM delphi_reports
|
||||
WHERE file_id = ANY($1::bigint[])
|
||||
ORDER BY file_id, created, id
|
||||
"#,
|
||||
&file_rows.iter().map(|f| f.file_id.0).collect::<Vec<_>>()
|
||||
)
|
||||
@@ -500,6 +502,7 @@ async fn fetch_project_reports(
|
||||
issue_type
|
||||
FROM delphi_report_issues
|
||||
WHERE report_id = ANY($1::bigint[])
|
||||
ORDER BY report_id, id
|
||||
"#,
|
||||
&report_rows
|
||||
.iter()
|
||||
@@ -528,6 +531,7 @@ async fn fetch_project_reports(
|
||||
didws.status AS "status!: DelphiStatus"
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
WHERE didws.issue_id = ANY($1::bigint[])
|
||||
ORDER BY didws.issue_id, didws.id
|
||||
"#,
|
||||
&issue_ids.iter().map(|i| i.0).collect::<Vec<_>>()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::queue::session::AuthQueue;
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use async_trait::async_trait;
|
||||
use cached_projection::CachedProjection;
|
||||
use modrinth_content_management::{
|
||||
ContentMetadataProvider, Error as ResolveError, ResolveContentPlan,
|
||||
ResolveContentRequest,
|
||||
@@ -22,8 +23,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve";
|
||||
const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat";
|
||||
const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve:v2";
|
||||
const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat:v1";
|
||||
const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1";
|
||||
const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24;
|
||||
|
||||
@@ -90,9 +91,10 @@ struct ResolveContentTrace {
|
||||
project_versions: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, CachedProjection)]
|
||||
struct CachedResolveContentPlan {
|
||||
trace: ResolveContentTrace,
|
||||
#[cached_projection(wrap)]
|
||||
plan: ResolveContentPlan,
|
||||
}
|
||||
|
||||
@@ -340,7 +342,7 @@ async fn get_cached_resolve_content_plan(
|
||||
};
|
||||
|
||||
match redis
|
||||
.get_deserialized_from_json(CONTENT_RESOLVE_CACHE_NAMESPACE, cache_key)
|
||||
.get_deserialized(CONTENT_RESOLVE_CACHE_NAMESPACE, cache_key)
|
||||
.await
|
||||
{
|
||||
Ok(cached) => cached,
|
||||
@@ -368,7 +370,7 @@ async fn set_cached_resolve_content_plan(
|
||||
};
|
||||
|
||||
if let Err(error) = redis
|
||||
.set_serialized_to_json(
|
||||
.set_serialized(
|
||||
CONTENT_RESOLVE_CACHE_NAMESPACE,
|
||||
cache_key,
|
||||
cached,
|
||||
|
||||
@@ -17,8 +17,6 @@ pub mod payouts;
|
||||
pub mod project_creation;
|
||||
pub mod projects;
|
||||
pub mod reports;
|
||||
pub mod shared_instance_version_creation;
|
||||
pub mod shared_instances;
|
||||
pub mod statistics;
|
||||
pub mod tags;
|
||||
pub mod teams;
|
||||
@@ -60,8 +58,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(payouts::tremendous_webhook)
|
||||
.configure(projects::config)
|
||||
.configure(reports::config)
|
||||
.configure(shared_instance_version_creation::config)
|
||||
.configure(shared_instances::config)
|
||||
.configure(statistics::config)
|
||||
.configure(tags::config)
|
||||
.configure(teams::config)
|
||||
@@ -175,16 +171,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
reports::report_get_route,
|
||||
reports::report_edit_route,
|
||||
reports::report_delete_route,
|
||||
shared_instance_version_creation::shared_instance_version_create,
|
||||
shared_instances::shared_instance_create,
|
||||
shared_instances::shared_instance_list,
|
||||
shared_instances::shared_instance_get,
|
||||
shared_instances::shared_instance_edit,
|
||||
shared_instances::shared_instance_delete,
|
||||
shared_instances::shared_instance_version_list,
|
||||
shared_instances::shared_instance_version_get,
|
||||
shared_instances::shared_instance_version_delete,
|
||||
shared_instances::shared_instance_version_download,
|
||||
statistics::get_stats_route,
|
||||
tags::games_list_route,
|
||||
tags::category_list_route,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::models::shared_instance_item::{
|
||||
DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion,
|
||||
};
|
||||
use crate::database::models::{
|
||||
DBSharedInstanceId, DBSharedInstanceVersionId,
|
||||
generate_shared_instance_version_id,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgPool, PgTransaction};
|
||||
use crate::file_hosting::{FileHost, FileHostPublicity};
|
||||
use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::shared_instances::{
|
||||
SharedInstanceUserPermissions, SharedInstanceVersion,
|
||||
};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::v3::project_creation::UploadedFile;
|
||||
use crate::util::ext::MRPACK_MIME_TYPE;
|
||||
use actix_web::http::header::ContentLength;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{HttpRequest, HttpResponse, post, web};
|
||||
use bytes::BytesMut;
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use hex::FromHex;
|
||||
|
||||
const MAX_FILE_SIZE: usize = 500 * 1024 * 1024;
|
||||
const MAX_FILE_SIZE_TEXT: &str = "500 MB";
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(shared_instance_version_create);
|
||||
}
|
||||
|
||||
/// Create a shared instance version.
|
||||
#[utoipa::path(
|
||||
tag = "versions",
|
||||
post,
|
||||
params(
|
||||
("id" = SharedInstanceId, Path, description = "The ID of the shared instance")
|
||||
),
|
||||
request_body(content = Vec<u8>, content_type = "application/octet-stream"),
|
||||
responses(
|
||||
(status = 201, description = "Expected response to a valid request", body = SharedInstanceVersion),
|
||||
(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" = ["SHARED_INSTANCE_VERSION_CREATE"]))
|
||||
)]
|
||||
#[post("/shared-instance/{id}/version")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn shared_instance_version_create(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
payload: web::Payload,
|
||||
web::Header(ContentLength(content_length)): web::Header<ContentLength>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if content_length > MAX_FILE_SIZE {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"File size exceeds the maximum limit of {MAX_FILE_SIZE_TEXT}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let mut uploaded_files = vec![];
|
||||
|
||||
let result = shared_instance_version_create_inner(
|
||||
req,
|
||||
&pool,
|
||||
payload,
|
||||
content_length,
|
||||
&redis,
|
||||
&**file_host,
|
||||
info.into_inner().0.into(),
|
||||
&session_queue,
|
||||
&mut transaction,
|
||||
&mut uploaded_files,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&**file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
if let Err(e) = rollback_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
} else {
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn shared_instance_version_create_inner(
|
||||
req: HttpRequest,
|
||||
pool: &PgPool,
|
||||
mut payload: web::Payload,
|
||||
content_length: usize,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
instance_id: DBSharedInstanceId,
|
||||
session_queue: &AuthQueue,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
Scopes::SHARED_INSTANCE_VERSION_CREATE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let Some(instance) = DBSharedInstance::get(instance_id, pool).await? else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
if !user.role.is_mod() && instance.owner_id != user.id.into() {
|
||||
let permissions = DBSharedInstanceUser::get_user_permissions(
|
||||
instance_id,
|
||||
user.id.into(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(permissions) = permissions {
|
||||
if !permissions
|
||||
.contains(SharedInstanceUserPermissions::UPLOAD_VERSION)
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to upload a version for this shared instance.".to_string()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
let version_id =
|
||||
generate_shared_instance_version_id(&mut *transaction).await?;
|
||||
|
||||
let mut file_data = BytesMut::new();
|
||||
while let Some(chunk) = payload.next().await {
|
||||
let chunk = chunk.map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Unable to parse bytes in payload sent!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if file_data.len() + chunk.len() <= MAX_FILE_SIZE {
|
||||
file_data.extend_from_slice(&chunk);
|
||||
} else {
|
||||
file_data
|
||||
.extend_from_slice(&chunk[..MAX_FILE_SIZE - file_data.len()]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let file_data = file_data.freeze();
|
||||
let file_path = format!(
|
||||
"shared_instance/{}.mrpack",
|
||||
SharedInstanceVersionId::from(version_id),
|
||||
);
|
||||
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
MRPACK_MIME_TYPE,
|
||||
&file_path,
|
||||
FileHostPublicity::Private,
|
||||
file_data,
|
||||
)
|
||||
.await?;
|
||||
|
||||
uploaded_files.push(UploadedFile {
|
||||
name: file_path,
|
||||
publicity: upload_data.file_publicity,
|
||||
});
|
||||
|
||||
let sha512 = Vec::<u8>::from_hex(upload_data.content_sha512).unwrap();
|
||||
|
||||
let new_version = DBSharedInstanceVersion {
|
||||
id: version_id,
|
||||
shared_instance_id: instance_id,
|
||||
size: content_length as u64,
|
||||
sha512,
|
||||
created: Utc::now(),
|
||||
};
|
||||
new_version.insert(transaction).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE shared_instances SET current_version_id = $1 WHERE id = $2",
|
||||
new_version.id as DBSharedInstanceVersionId,
|
||||
instance_id as DBSharedInstanceId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let version: SharedInstanceVersion = new_version.into();
|
||||
Ok(HttpResponse::Created().json(version))
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::auth::validate::get_maybe_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::shared_instance_item::{
|
||||
DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion,
|
||||
};
|
||||
use crate::database::models::{
|
||||
DBSharedInstanceId, DBSharedInstanceVersionId, generate_shared_instance_id,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::shared_instances::{
|
||||
SharedInstance, SharedInstanceUserPermissions, SharedInstanceVersion,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::routes::read_typed_from_payload;
|
||||
use actix_web::web::{Data, Redirect};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use futures_util::future::try_join_all;
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(shared_instance_create)
|
||||
.service(shared_instance_list)
|
||||
.service(shared_instance_get)
|
||||
.service(shared_instance_edit)
|
||||
.service(shared_instance_delete)
|
||||
.service(shared_instance_version_list)
|
||||
.service(shared_instance_version_get)
|
||||
.service(shared_instance_version_delete)
|
||||
.service(shared_instance_version_download);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct CreateSharedInstance {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "shared instances",
|
||||
request_body = CreateSharedInstance,
|
||||
responses((status = OK))
|
||||
)]
|
||||
#[post("/shared-instance")]
|
||||
pub async fn shared_instance_create(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
mut body: web::Payload,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let new_instance: CreateSharedInstance =
|
||||
read_typed_from_payload(&mut body).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_CREATE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let id = generate_shared_instance_id(&mut transaction).await?;
|
||||
|
||||
let instance = DBSharedInstance {
|
||||
id,
|
||||
title: new_instance.title,
|
||||
owner_id: user.id.into(),
|
||||
public: new_instance.public,
|
||||
current_version_id: None,
|
||||
};
|
||||
instance.insert(&mut transaction).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Created().json(SharedInstance {
|
||||
id: id.into(),
|
||||
title: instance.title,
|
||||
owner: user.id,
|
||||
public: instance.public,
|
||||
current_version: None,
|
||||
additional_users: Some(vec![]),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = OK)))]
|
||||
#[get("/shared-instance")]
|
||||
pub async fn shared_instance_list(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_READ,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
// TODO: Something for moderators to be able to see all instances?
|
||||
let instances =
|
||||
DBSharedInstance::list_for_user(user.id.into(), &**pool).await?;
|
||||
let instances = try_join_all(instances.into_iter().map(
|
||||
async |instance| -> Result<SharedInstance, ApiError> {
|
||||
let version = if let Some(version_id) = instance.current_version_id
|
||||
{
|
||||
DBSharedInstanceVersion::get(version_id, &**pool).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let instance_id = instance.id;
|
||||
Ok(SharedInstance::from_db(
|
||||
instance,
|
||||
Some(
|
||||
DBSharedInstanceUser::get_from_instance(
|
||||
instance_id,
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
version,
|
||||
))
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(instances))
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = OK)))]
|
||||
#[get("/shared-instance/{id}")]
|
||||
pub async fn shared_instance_get(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner().0.into();
|
||||
|
||||
let user = get_maybe_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_READ,
|
||||
)
|
||||
.await?
|
||||
.map(|(_, user)| user);
|
||||
|
||||
let shared_instance = DBSharedInstance::get(id, &**pool).await?;
|
||||
|
||||
if let Some(shared_instance) = shared_instance {
|
||||
let users =
|
||||
DBSharedInstanceUser::get_from_instance(id, &**pool, &redis)
|
||||
.await?;
|
||||
|
||||
let privately_accessible = user.is_some_and(|user| {
|
||||
can_access_instance_privately(&shared_instance, &users, &user)
|
||||
});
|
||||
if !shared_instance.public && !privately_accessible {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
let current_version =
|
||||
if let Some(version_id) = shared_instance.current_version_id {
|
||||
DBSharedInstanceVersion::get(version_id, &**pool).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let shared_instance = SharedInstance::from_db(
|
||||
shared_instance,
|
||||
privately_accessible.then_some(users),
|
||||
current_version,
|
||||
);
|
||||
|
||||
Ok(HttpResponse::Ok().json(shared_instance))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fn can_access_instance_privately(
|
||||
instance: &DBSharedInstance,
|
||||
users: &[DBSharedInstanceUser],
|
||||
user: &User,
|
||||
) -> bool {
|
||||
user.role.is_mod()
|
||||
|| instance.owner_id == user.id.into()
|
||||
|| users.iter().any(|x| x.user_id == user.id.into())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct EditSharedInstance {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
pub title: Option<String>,
|
||||
pub public: Option<bool>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "shared instances",
|
||||
request_body = EditSharedInstance,
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[patch("/shared-instance/{id}")]
|
||||
pub async fn shared_instance_edit(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
mut body: web::Payload,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner().0.into();
|
||||
let edit_instance: EditSharedInstance =
|
||||
read_typed_from_payload(&mut body).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let Some(instance) = DBSharedInstance::get(id, &**pool).await? else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
|
||||
if !user.role.is_mod() && instance.owner_id != user.id.into() {
|
||||
let permissions = DBSharedInstanceUser::get_user_permissions(
|
||||
id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(permissions) = permissions {
|
||||
if !permissions.contains(SharedInstanceUserPermissions::EDIT) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to edit this shared instance."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(title) = edit_instance.title {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE shared_instances
|
||||
SET title = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
title,
|
||||
id as DBSharedInstanceId,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(public) = edit_instance.public {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE shared_instances
|
||||
SET public = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
public,
|
||||
id as DBSharedInstanceId,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = NO_CONTENT)))]
|
||||
#[delete("/shared-instance/{id}")]
|
||||
pub async fn shared_instance_delete(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id: DBSharedInstanceId = info.into_inner().0.into();
|
||||
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_DELETE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let Some(instance) = DBSharedInstance::get(id, &**pool).await? else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
|
||||
if !user.role.is_mod() && instance.owner_id != user.id.into() {
|
||||
let permissions = DBSharedInstanceUser::get_user_permissions(
|
||||
id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(permissions) = permissions {
|
||||
if !permissions.contains(SharedInstanceUserPermissions::DELETE) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to delete this shared instance.".to_string()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM shared_instances
|
||||
WHERE id = $1
|
||||
",
|
||||
id as DBSharedInstanceId,
|
||||
)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
DBSharedInstanceUser::clear_cache(id, &redis).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = OK)))]
|
||||
#[get("/shared-instance/{id}/version")]
|
||||
pub async fn shared_instance_version_list(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner().0.into();
|
||||
|
||||
let user = get_maybe_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_READ,
|
||||
)
|
||||
.await?
|
||||
.map(|(_, user)| user);
|
||||
|
||||
let shared_instance = DBSharedInstance::get(id, &**pool).await?;
|
||||
|
||||
if let Some(shared_instance) = shared_instance {
|
||||
if !can_access_instance_as_maybe_user(
|
||||
&pool,
|
||||
&redis,
|
||||
&shared_instance,
|
||||
user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
let versions =
|
||||
DBSharedInstanceVersion::get_for_instance(id, &**pool).await?;
|
||||
let versions = versions
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<SharedInstanceVersion>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(versions))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = OK)))]
|
||||
#[get("/shared-instance-version/{id}")]
|
||||
pub async fn shared_instance_version_get(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceVersionId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let version_id = info.into_inner().0.into();
|
||||
|
||||
let user = get_maybe_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_READ,
|
||||
)
|
||||
.await?
|
||||
.map(|(_, user)| user);
|
||||
|
||||
let version = DBSharedInstanceVersion::get(version_id, &**pool).await?;
|
||||
|
||||
if let Some(version) = version {
|
||||
let instance =
|
||||
DBSharedInstance::get(version.shared_instance_id, &**pool).await?;
|
||||
if let Some(instance) = instance {
|
||||
if !can_access_instance_as_maybe_user(
|
||||
&pool, &redis, &instance, user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
let version: SharedInstanceVersion = version.into();
|
||||
Ok(HttpResponse::Ok().json(version))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn can_access_instance_as_maybe_user(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
instance: &DBSharedInstance,
|
||||
user: Option<User>,
|
||||
) -> Result<bool, ApiError> {
|
||||
if instance.public {
|
||||
return Ok(true);
|
||||
}
|
||||
let users =
|
||||
DBSharedInstanceUser::get_from_instance(instance.id, pool, redis)
|
||||
.await?;
|
||||
Ok(user.is_some_and(|user| {
|
||||
can_access_instance_privately(instance, &users, &user)
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = NO_CONTENT)))]
|
||||
#[delete("/shared-instance-version/{id}")]
|
||||
pub async fn shared_instance_version_delete(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
info: web::Path<(SharedInstanceVersionId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let version_id = info.into_inner().0.into();
|
||||
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_VERSION_DELETE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let shared_instance_version =
|
||||
DBSharedInstanceVersion::get(version_id, &**pool).await?;
|
||||
|
||||
if let Some(shared_instance_version) = shared_instance_version {
|
||||
let shared_instance = DBSharedInstance::get(
|
||||
shared_instance_version.shared_instance_id,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(shared_instance) = shared_instance {
|
||||
if !user.role.is_mod() && shared_instance.owner_id != user.id.into()
|
||||
{
|
||||
let permissions = DBSharedInstanceUser::get_user_permissions(
|
||||
shared_instance.id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(permissions) = permissions {
|
||||
if !permissions
|
||||
.contains(SharedInstanceUserPermissions::DELETE)
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to delete this shared instance version.".to_string()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
delete_instance_version(shared_instance.id, version_id, &pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_instance_version(
|
||||
instance_id: DBSharedInstanceId,
|
||||
version_id: DBSharedInstanceVersionId,
|
||||
pool: &PgPool,
|
||||
) -> Result<(), ApiError> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM shared_instance_versions
|
||||
WHERE id = $1
|
||||
",
|
||||
version_id as DBSharedInstanceVersionId,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE shared_instances
|
||||
SET current_version_id = (
|
||||
SELECT id FROM shared_instance_versions
|
||||
WHERE shared_instance_id = $1
|
||||
ORDER BY created DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE id = $1
|
||||
",
|
||||
instance_id as DBSharedInstanceId,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(tag = "shared instances", responses((status = TEMPORARY_REDIRECT)))]
|
||||
#[get("/shared-instance-version/{id}/download")]
|
||||
pub async fn shared_instance_version_download(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
info: web::Path<(SharedInstanceVersionId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<Redirect, ApiError> {
|
||||
let version_id = info.into_inner().0.into();
|
||||
|
||||
let user = get_maybe_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SHARED_INSTANCE_VERSION_READ,
|
||||
)
|
||||
.await?
|
||||
.map(|(_, user)| user);
|
||||
|
||||
let version = DBSharedInstanceVersion::get(version_id, &**pool).await?;
|
||||
|
||||
if let Some(version) = version {
|
||||
let instance =
|
||||
DBSharedInstance::get(version.shared_instance_id, &**pool).await?;
|
||||
if let Some(instance) = instance {
|
||||
if !can_access_instance_as_maybe_user(
|
||||
&pool, &redis, &instance, user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
let file_name = format!(
|
||||
"shared_instance/{}.mrpack",
|
||||
SharedInstanceVersionId::from(version_id)
|
||||
);
|
||||
let url =
|
||||
file_host.get_url_for_private_file(&file_name, 180).await?;
|
||||
|
||||
Ok(Redirect::to(url).see_other())
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ async fn hydrate_search_results(
|
||||
} else {
|
||||
let mut redis = redis_pool.connect().await?;
|
||||
let ping_results = redis
|
||||
.get_many_deserialized_from_json::<JavaServerPing>(
|
||||
.get_many_deserialized::<JavaServerPing>(
|
||||
server_ping::REDIS_NAMESPACE,
|
||||
&project_ids
|
||||
.iter()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::notification_item::DBNotification;
|
||||
use crate::models::ids::NotificationId;
|
||||
use crate::models::notifications::Notification;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use crate::routes::internal::statuses::{
|
||||
@@ -13,10 +15,9 @@ use redis::{RedisWrite, ToRedisArgs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
pub const FRIENDS_CHANNEL_NAME: &str = "friends";
|
||||
pub const FRIENDS_CHANNEL_NAME: &str = "friends:v1";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RedisFriendsMessage {
|
||||
StatusUpdate {
|
||||
status: UserStatus,
|
||||
@@ -30,7 +31,7 @@ pub enum RedisFriendsMessage {
|
||||
},
|
||||
Notification {
|
||||
to_user: UserId,
|
||||
notification: Notification,
|
||||
notification_id: NotificationId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ impl ToRedisArgs for RedisFriendsMessage {
|
||||
where
|
||||
W: ?Sized + RedisWrite,
|
||||
{
|
||||
out.write_arg(&serde_json::to_vec(&self).unwrap())
|
||||
out.write_arg(&postcard::to_allocvec(&self).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@ pub async fn handle_pubsub(
|
||||
if message.get_channel_name() != FRIENDS_CHANNEL_NAME {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::from_slice(message.get_payload_bytes());
|
||||
let payload = postcard::from_bytes(message.get_payload_bytes());
|
||||
|
||||
let pool = pool.clone();
|
||||
let sockets = sockets.clone();
|
||||
@@ -94,14 +95,18 @@ pub async fn handle_pubsub(
|
||||
|
||||
Ok(RedisFriendsMessage::Notification {
|
||||
to_user,
|
||||
notification,
|
||||
notification_id,
|
||||
}) => {
|
||||
let _ = send_notification_to_user(
|
||||
&sockets,
|
||||
to_user,
|
||||
¬ification,
|
||||
)
|
||||
.await;
|
||||
if let Ok(Some(notification)) =
|
||||
DBNotification::get(notification_id.into(), &pool).await
|
||||
{
|
||||
let _ = send_notification_to_user(
|
||||
&sockets,
|
||||
to_user,
|
||||
&Notification::from(notification),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {}
|
||||
|
||||
@@ -17,10 +17,10 @@ pub async fn get_user_status(
|
||||
|
||||
if let Ok(mut conn) = redis.pool.get().await
|
||||
&& let Ok(mut statuses) =
|
||||
conn.sscan::<_, String>(get_field_name(user)).await
|
||||
&& let Some(status_json) = statuses.next_item().await
|
||||
conn.sscan::<_, Vec<u8>>(get_field_name(user)).await
|
||||
&& let Some(status) = statuses.next_item().await
|
||||
{
|
||||
return serde_json::from_str::<UserStatus>(&status_json).ok();
|
||||
return postcard::from_bytes::<UserStatus>(&status).ok();
|
||||
}
|
||||
|
||||
None
|
||||
@@ -40,11 +40,11 @@ pub async fn replace_user_status(
|
||||
let mut pipe = redis::pipe();
|
||||
pipe.atomic();
|
||||
if let Some(status) = old_status {
|
||||
pipe.srem(&field_name, serde_json::to_string(&status).unwrap())
|
||||
pipe.srem(&field_name, postcard::to_allocvec(status).unwrap())
|
||||
.ignore();
|
||||
}
|
||||
if let Some(status) = new_status {
|
||||
pipe.sadd(&field_name, serde_json::to_string(&status).unwrap())
|
||||
pipe.sadd(&field_name, postcard::to_allocvec(status).unwrap())
|
||||
.ignore();
|
||||
pipe.expire(&field_name, EXPIRY_TIME_SECONDS).ignore();
|
||||
}
|
||||
@@ -65,5 +65,5 @@ pub async fn push_back_user_expiry(
|
||||
}
|
||||
|
||||
fn get_field_name(user: UserId) -> String {
|
||||
format!("user_status:{user}")
|
||||
format!("user_status:v1:{user}")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName =
|
||||
HeaderName::from_static("modrinth-generated-pdf-type");
|
||||
pub const MODRINTH_PAYMENT_ID: HeaderName =
|
||||
HeaderName::from_static("modrinth-payment-id");
|
||||
pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements";
|
||||
pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements:v1";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PaymentStatement {
|
||||
@@ -203,7 +203,7 @@ impl GotenbergClient {
|
||||
.wrap_internal_err("failed to get document over Redis")?
|
||||
.wrap_internal_err("no document was returned from Redis")?;
|
||||
|
||||
let document = serde_json::from_str::<
|
||||
let document = postcard::from_bytes::<
|
||||
Result<GotenbergDocument, GotenbergError>,
|
||||
>(&document)
|
||||
.wrap_internal_err("failed to deserialize Redis document response")?
|
||||
|
||||
@@ -11,7 +11,7 @@ use chrono::Utc;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
const RATE_LIMIT_NAMESPACE: &str = "rate_limit";
|
||||
const RATE_LIMIT_NAMESPACE: &str = "rate_limit:v1";
|
||||
const RATE_LIMIT_EXPIRY: i64 = 300; // 5 minutes
|
||||
const MINUTE_IN_NANOS: i64 = 60_000_000_000;
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@ use common::environment::{
|
||||
use common::permissions::{PermissionsTest, PermissionsTestContext};
|
||||
use futures::StreamExt;
|
||||
use hex::ToHex;
|
||||
use labrinth::database::models::DBProjectId;
|
||||
use labrinth::database::models::project_item::{
|
||||
PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE,
|
||||
PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE, ProjectQueryResult,
|
||||
};
|
||||
use labrinth::database::redis::RedisValue;
|
||||
use labrinth::models::ids::ProjectId;
|
||||
use labrinth::models::teams::ProjectPermissions;
|
||||
use labrinth::util::actix::{MultipartSegment, MultipartSegmentData};
|
||||
@@ -67,19 +69,21 @@ async fn test_get_project() {
|
||||
Some(parse_base62(alpha_project_id).unwrap() as i64)
|
||||
);
|
||||
|
||||
let cached_project = redis_pool
|
||||
.get(
|
||||
let cached_project: RedisValue<
|
||||
ProjectQueryResult,
|
||||
DBProjectId,
|
||||
String,
|
||||
> = redis_pool
|
||||
.get_deserialized(
|
||||
PROJECTS_NAMESPACE,
|
||||
&parse_base62(alpha_project_id).unwrap().to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let cached_project: serde_json::Value =
|
||||
serde_json::from_str(&cached_project).unwrap();
|
||||
assert_eq!(
|
||||
cached_project["val"]["inner"]["slug"],
|
||||
json!(alpha_project_slug)
|
||||
cached_project.value().inner.slug.as_ref(),
|
||||
Some(alpha_project_slug)
|
||||
);
|
||||
|
||||
// Make the request again, this time it should be cached
|
||||
|
||||
@@ -14,7 +14,11 @@ use common::asserts::assert_common_version_ids;
|
||||
use common::database::USER_USER_PAT;
|
||||
use common::environment::{with_test_environment, with_test_environment_all};
|
||||
use futures::StreamExt;
|
||||
use labrinth::database::models::version_item::VERSIONS_NAMESPACE;
|
||||
use labrinth::database::models::DBVersionId;
|
||||
use labrinth::database::models::version_item::{
|
||||
VERSIONS_NAMESPACE, VersionQueryResult,
|
||||
};
|
||||
use labrinth::database::redis::RedisValue;
|
||||
use labrinth::models::ids::VersionId;
|
||||
use labrinth::models::projects::{
|
||||
Dependency, DependencyType, VersionStatus, VersionType,
|
||||
@@ -47,18 +51,20 @@ async fn test_get_version() {
|
||||
assert_eq!(&version.id.to_string(), alpha_version_id);
|
||||
|
||||
let mut redis_conn = test_env.db.redis_pool.connect().await.unwrap();
|
||||
let cached_project = redis_conn
|
||||
.get(
|
||||
let cached_version: RedisValue<
|
||||
VersionQueryResult,
|
||||
DBVersionId,
|
||||
String,
|
||||
> = redis_conn
|
||||
.get_deserialized(
|
||||
VERSIONS_NAMESPACE,
|
||||
&parse_base62(alpha_version_id).unwrap().to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let cached_project: serde_json::Value =
|
||||
serde_json::from_str(&cached_project).unwrap();
|
||||
assert_eq!(
|
||||
cached_project["val"]["inner"]["project_id"],
|
||||
cached_version.value().inner.project_id.0,
|
||||
json!(parse_base62(alpha_project_id).unwrap())
|
||||
);
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@ async fn read_singleplayer_world_maybe_locked(
|
||||
.to_string();
|
||||
let last_played = data.get::<_, i64>("LastPlayed").unwrap_or(0);
|
||||
let game_type = data.get::<_, i32>("GameType").unwrap_or(0);
|
||||
let hardcore = data.get::<_, i8>("hardcore").unwrap_or(0) != 0;
|
||||
let hardcore = read_hardcore(data);
|
||||
|
||||
let icon = if tokio::fs::try_exists(world_path.join("icon.png"))
|
||||
.await
|
||||
@@ -438,6 +438,14 @@ async fn read_singleplayer_world_maybe_locked(
|
||||
})
|
||||
}
|
||||
|
||||
fn read_hardcore(data: &NbtCompound) -> bool {
|
||||
data.get::<_, &NbtCompound>("difficulty_settings")
|
||||
.and_then(|settings| settings.get::<_, i8>("hardcore"))
|
||||
.or_else(|_| data.get::<_, i8>("hardcore"))
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
|
||||
async fn get_server_worlds_in_instance(
|
||||
instance_id: &str,
|
||||
instance_dir: &Path,
|
||||
|
||||
@@ -711,7 +711,11 @@ pub(crate) async fn remove_project(
|
||||
)
|
||||
.await?;
|
||||
|
||||
io::remove_file(base.join(project_path)).await?;
|
||||
match io::remove_file(base.join(project_path)).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
|
||||
if let Some(file) = file {
|
||||
content_rows::remove_content_entries_for_file(
|
||||
|
||||
@@ -10,6 +10,14 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-07-16T19:36:45+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.12',
|
||||
body: `## Fixed
|
||||
- Fixed issue with some modpacks failing to install due to file mismatches.
|
||||
- Fixed hardcore worlds generated in 26.1+ not being recognised as hardcore in the Jump back in and Worlds tab.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-14T22:14:08+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "cached-projection-derive"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true, features = ["full"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,765 @@
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::{
|
||||
Attribute, Data, DataEnum, DataStruct, DeriveInput, Field, Fields,
|
||||
GenericParam, Generics, Lifetime, LifetimeParam, Meta, Token, Type,
|
||||
parse_macro_input, parse_quote,
|
||||
};
|
||||
|
||||
#[proc_macro_derive(CachedProjection, attributes(cached_projection))]
|
||||
pub fn cached_projection(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
derive_cached_projection(&input)
|
||||
.unwrap_or_else(syn::Error::into_compile_error)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Projection {
|
||||
Ordinary,
|
||||
Nested,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
fn derive_cached_projection(input: &DeriveInput) -> syn::Result<TokenStream2> {
|
||||
let name = &input.ident;
|
||||
let projected_name = format_ident!("{name}ProjectedType");
|
||||
let projected_ref_name = format_ident!("{name}ProjectedTypeRef");
|
||||
let visibility = &input.vis;
|
||||
let lifetime = Lifetime::new("'__cached_projection", name.span());
|
||||
|
||||
let fields = all_fields(&input.data);
|
||||
let ref_lifetime = (!fields.is_empty()).then_some(&lifetime);
|
||||
let projections = fields
|
||||
.iter()
|
||||
.map(|field| field_projection(field))
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
|
||||
let mut implementation_generics = input.generics.clone();
|
||||
add_projection_bounds(
|
||||
&mut implementation_generics,
|
||||
fields.iter().zip(&projections),
|
||||
);
|
||||
let (impl_generics, _, where_clause) =
|
||||
implementation_generics.split_for_impl();
|
||||
let (_, type_generics, _) = input.generics.split_for_impl();
|
||||
|
||||
let mut ref_generics = implementation_generics.clone();
|
||||
if ref_lifetime.is_some() {
|
||||
ref_generics.params.insert(
|
||||
0,
|
||||
GenericParam::Lifetime(LifetimeParam::new(lifetime.clone())),
|
||||
);
|
||||
add_outlives_bounds(&mut ref_generics, fields.iter(), &lifetime);
|
||||
}
|
||||
let ref_type_arguments = generic_arguments(&input.generics, ref_lifetime);
|
||||
|
||||
let owned_definition = projected_definition(
|
||||
&input.data,
|
||||
&projected_name,
|
||||
visibility,
|
||||
&implementation_generics,
|
||||
None,
|
||||
)?;
|
||||
let ref_definition = projected_definition(
|
||||
&input.data,
|
||||
&projected_ref_name,
|
||||
visibility,
|
||||
&ref_generics,
|
||||
ref_lifetime,
|
||||
)?;
|
||||
|
||||
let project_ref_body =
|
||||
project_ref_body(&input.data, &projected_ref_name, &projections)?;
|
||||
let into_projected_body =
|
||||
into_projected_body(&input.data, &projected_name, &projections)?;
|
||||
let from_projected_body =
|
||||
from_projected_body(&input.data, &projected_name, &projections)?;
|
||||
|
||||
Ok(quote! {
|
||||
#owned_definition
|
||||
#ref_definition
|
||||
|
||||
impl #impl_generics ::cached_projection::CachedProjection for #name #type_generics #where_clause {
|
||||
type ProjectedType = #projected_name #type_generics;
|
||||
type ProjectedTypeRef<#lifetime> = #projected_ref_name #ref_type_arguments
|
||||
where
|
||||
Self: #lifetime;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
#project_ref_body
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
#into_projected_body
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
#from_projected_body
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn all_fields(data: &Data) -> Vec<&Field> {
|
||||
match data {
|
||||
Data::Struct(data) => data.fields.iter().collect(),
|
||||
Data::Enum(data) => data
|
||||
.variants
|
||||
.iter()
|
||||
.flat_map(|variant| variant.fields.iter())
|
||||
.collect(),
|
||||
Data::Union(data) => data.fields.named.iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_projection(field: &Field) -> syn::Result<Projection> {
|
||||
let mut nested = false;
|
||||
let mut wrap = false;
|
||||
|
||||
for attribute in &field.attrs {
|
||||
if !attribute.path().is_ident("cached_projection") {
|
||||
continue;
|
||||
}
|
||||
|
||||
attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("nested") {
|
||||
nested = true;
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("wrap") {
|
||||
wrap = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported cached projection option"))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
if nested && wrap {
|
||||
return Err(syn::Error::new_spanned(
|
||||
field,
|
||||
"`nested` cannot be combined with `wrap`",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(if nested {
|
||||
Projection::Nested
|
||||
} else if wrap {
|
||||
Projection::Wrap
|
||||
} else {
|
||||
Projection::Ordinary
|
||||
})
|
||||
}
|
||||
|
||||
fn add_projection_bounds<'a>(
|
||||
generics: &mut Generics,
|
||||
fields: impl Iterator<Item = (&'a &'a Field, &'a Projection)>,
|
||||
) {
|
||||
let where_clause = generics.make_where_clause();
|
||||
for (field, projection) in fields {
|
||||
let ty = &field.ty;
|
||||
let predicate = match projection {
|
||||
Projection::Nested => {
|
||||
parse_quote!(#ty: ::cached_projection::CachedProjection)
|
||||
}
|
||||
Projection::Ordinary | Projection::Wrap => parse_quote!(
|
||||
#ty: ::serde::Serialize + ::serde::de::DeserializeOwned
|
||||
),
|
||||
};
|
||||
where_clause.predicates.push(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_outlives_bounds<'a>(
|
||||
generics: &mut Generics,
|
||||
fields: impl Iterator<Item = &'a &'a Field>,
|
||||
lifetime: &Lifetime,
|
||||
) {
|
||||
let where_clause = generics.make_where_clause();
|
||||
for field in fields {
|
||||
let ty = &field.ty;
|
||||
where_clause.predicates.push(parse_quote!(#ty: #lifetime));
|
||||
}
|
||||
}
|
||||
|
||||
fn generic_arguments(
|
||||
generics: &Generics,
|
||||
lifetime: Option<&Lifetime>,
|
||||
) -> TokenStream2 {
|
||||
let mut arguments = Vec::new();
|
||||
if let Some(lifetime) = lifetime {
|
||||
arguments.push(quote!(#lifetime));
|
||||
}
|
||||
arguments.extend(generics.params.iter().map(|parameter| match parameter {
|
||||
GenericParam::Lifetime(parameter) => {
|
||||
let lifetime = ¶meter.lifetime;
|
||||
quote!(#lifetime)
|
||||
}
|
||||
GenericParam::Type(parameter) => {
|
||||
let ident = ¶meter.ident;
|
||||
quote!(#ident)
|
||||
}
|
||||
GenericParam::Const(parameter) => {
|
||||
let ident = ¶meter.ident;
|
||||
quote!(#ident)
|
||||
}
|
||||
}));
|
||||
|
||||
if arguments.is_empty() {
|
||||
quote!()
|
||||
} else {
|
||||
quote!(<#(#arguments),*>)
|
||||
}
|
||||
}
|
||||
|
||||
fn projected_definition(
|
||||
data: &Data,
|
||||
name: &syn::Ident,
|
||||
visibility: &syn::Visibility,
|
||||
generics: &Generics,
|
||||
lifetime: Option<&Lifetime>,
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let derive = if lifetime.is_some() {
|
||||
quote!(#[derive(::serde::Serialize)])
|
||||
} else {
|
||||
quote!(#[derive(::serde::Serialize, ::serde::Deserialize)])
|
||||
};
|
||||
let serde_bound = serde_bound_attribute(data, lifetime);
|
||||
|
||||
match data {
|
||||
Data::Struct(data) => {
|
||||
let fields = projected_fields(&data.fields, lifetime)?;
|
||||
let where_clause = &generics.where_clause;
|
||||
let declaration = match &data.fields {
|
||||
Fields::Named(_) => quote!({ #(#fields),* }),
|
||||
Fields::Unnamed(_) => {
|
||||
quote!(( #(#fields),* ) #where_clause;)
|
||||
}
|
||||
Fields::Unit => quote!(#where_clause;),
|
||||
};
|
||||
let leading_where_clause = match &data.fields {
|
||||
Fields::Named(_) => quote!(#where_clause),
|
||||
Fields::Unnamed(_) | Fields::Unit => quote!(),
|
||||
};
|
||||
Ok(quote! {
|
||||
#[doc(hidden)]
|
||||
#derive
|
||||
#serde_bound
|
||||
#visibility struct #name #generics #leading_where_clause #declaration
|
||||
})
|
||||
}
|
||||
Data::Enum(data) => {
|
||||
let where_clause = &generics.where_clause;
|
||||
let variants = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|variant| {
|
||||
let attributes = definition_attributes(&variant.attrs)?;
|
||||
let ident = &variant.ident;
|
||||
let fields = projected_fields(&variant.fields, lifetime)?;
|
||||
let declaration = match &variant.fields {
|
||||
Fields::Named(_) => quote!({ #(#fields),* }),
|
||||
Fields::Unnamed(_) => quote!(( #(#fields),* )),
|
||||
Fields::Unit => quote!(),
|
||||
};
|
||||
Ok(quote!(#(#attributes)* #ident #declaration))
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
Ok(quote! {
|
||||
#[doc(hidden)]
|
||||
#derive
|
||||
#serde_bound
|
||||
#visibility enum #name #generics #where_clause {
|
||||
#(#variants),*
|
||||
}
|
||||
})
|
||||
}
|
||||
Data::Union(data) => Err(syn::Error::new_spanned(
|
||||
data.union_token,
|
||||
"CachedProjection cannot be derived for unions",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_bound_attribute(
|
||||
data: &Data,
|
||||
lifetime: Option<&Lifetime>,
|
||||
) -> TokenStream2 {
|
||||
let projected_types = all_fields(data)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
let projection = field_projection(field)
|
||||
.expect("field projection was already validated");
|
||||
projected_field_type(&field.ty, projection, lifetime)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if projected_types.is_empty() {
|
||||
return quote!();
|
||||
}
|
||||
let serialize_bounds = projected_types
|
||||
.iter()
|
||||
.map(|ty| quote!(#ty: ::serde::Serialize))
|
||||
.collect::<Vec<_>>();
|
||||
let serialize_bounds = syn::LitStr::new(
|
||||
"e!(#(#serialize_bounds),*).to_string(),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
|
||||
if lifetime.is_some() {
|
||||
quote!(#[serde(bound(serialize = #serialize_bounds))])
|
||||
} else {
|
||||
let deserialize_bounds = projected_types
|
||||
.iter()
|
||||
.map(|ty| quote!(#ty: ::serde::de::DeserializeOwned))
|
||||
.collect::<Vec<_>>();
|
||||
let deserialize_bounds = syn::LitStr::new(
|
||||
"e!(#(#deserialize_bounds),*).to_string(),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
quote!(#[serde(bound(
|
||||
serialize = #serialize_bounds,
|
||||
deserialize = #deserialize_bounds,
|
||||
))])
|
||||
}
|
||||
}
|
||||
|
||||
fn projected_fields(
|
||||
fields: &Fields,
|
||||
lifetime: Option<&Lifetime>,
|
||||
) -> syn::Result<Vec<TokenStream2>> {
|
||||
let projected = fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let attributes = definition_attributes(&field.attrs)?;
|
||||
let visibility = &field.vis;
|
||||
let ident = &field.ident;
|
||||
let projection = field_projection(field)?;
|
||||
let ty = projected_field_type(&field.ty, projection, lifetime);
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #visibility #ident: #ty)
|
||||
} else {
|
||||
quote!(#(#attributes)* #visibility #ty)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(projected)
|
||||
}
|
||||
|
||||
fn projected_field_type(
|
||||
ty: &Type,
|
||||
projection: Projection,
|
||||
lifetime: Option<&Lifetime>,
|
||||
) -> TokenStream2 {
|
||||
match (projection, lifetime) {
|
||||
(Projection::Ordinary, None) => quote!(#ty),
|
||||
(Projection::Ordinary, Some(lifetime)) => quote!(&#lifetime #ty),
|
||||
(Projection::Nested, None) => quote!(
|
||||
<#ty as ::cached_projection::CachedProjection>::ProjectedType
|
||||
),
|
||||
(Projection::Nested, Some(lifetime)) => quote!(
|
||||
<#ty as ::cached_projection::CachedProjection>::ProjectedTypeRef<#lifetime>
|
||||
),
|
||||
(Projection::Wrap, None) => quote!(
|
||||
::cached_projection::DeserializeAnyJsonWrapper<#ty>
|
||||
),
|
||||
(Projection::Wrap, Some(lifetime)) => quote!(
|
||||
::cached_projection::DeserializeAnyJsonWrapperRef<#lifetime, #ty>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_attributes(
|
||||
attributes: &[Attribute],
|
||||
) -> syn::Result<Vec<Attribute>> {
|
||||
let mut filtered = Vec::new();
|
||||
for attribute in attributes {
|
||||
if attribute.path().is_ident("cfg") {
|
||||
filtered.push(attribute.clone());
|
||||
} else if attribute.path().is_ident("serde") {
|
||||
if serde_has_skip(attribute)? {
|
||||
filtered.push(parse_quote!(#[serde(skip)]));
|
||||
}
|
||||
} else if attribute.path().is_ident("cfg_attr")
|
||||
&& let Some(attribute) = filter_cfg_attr(attribute, true)? {
|
||||
filtered.push(attribute);
|
||||
}
|
||||
}
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
fn structural_attributes(
|
||||
attributes: &[Attribute],
|
||||
) -> syn::Result<Vec<Attribute>> {
|
||||
let mut filtered = Vec::new();
|
||||
for attribute in attributes {
|
||||
if attribute.path().is_ident("cfg") {
|
||||
filtered.push(attribute.clone());
|
||||
} else if attribute.path().is_ident("cfg_attr")
|
||||
&& let Some(attribute) = filter_cfg_attr(attribute, false)? {
|
||||
filtered.push(attribute);
|
||||
}
|
||||
}
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
fn serde_has_skip(attribute: &Attribute) -> syn::Result<bool> {
|
||||
let Meta::List(list) = &attribute.meta else {
|
||||
return Ok(false);
|
||||
};
|
||||
let items =
|
||||
list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.any(|item| matches!(item, Meta::Path(path) if path.is_ident("skip"))))
|
||||
}
|
||||
|
||||
fn filter_cfg_attr(
|
||||
attribute: &Attribute,
|
||||
include_serde_skip: bool,
|
||||
) -> syn::Result<Option<Attribute>> {
|
||||
let Meta::List(list) = &attribute.meta else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut items = list
|
||||
.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?
|
||||
.into_iter();
|
||||
let Some(condition) = items.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut retained = Vec::new();
|
||||
for item in items {
|
||||
if item.path().is_ident("cfg") {
|
||||
retained.push(item);
|
||||
} else if include_serde_skip && item.path().is_ident("serde") {
|
||||
let attribute: Attribute = parse_quote!(#[#item]);
|
||||
if serde_has_skip(&attribute)? {
|
||||
retained.push(parse_quote!(serde(skip)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if retained.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(parse_quote!(#[cfg_attr(#condition, #(#retained),*)])))
|
||||
}
|
||||
}
|
||||
|
||||
fn project_ref_body(
|
||||
data: &Data,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
) -> syn::Result<TokenStream2> {
|
||||
match data {
|
||||
Data::Struct(data) => struct_projection_body(
|
||||
data,
|
||||
projected_name,
|
||||
projections,
|
||||
Conversion::ProjectRef,
|
||||
),
|
||||
Data::Enum(data) => enum_projection_body(
|
||||
data,
|
||||
projected_name,
|
||||
projections,
|
||||
Conversion::ProjectRef,
|
||||
),
|
||||
Data::Union(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_projected_body(
|
||||
data: &Data,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
) -> syn::Result<TokenStream2> {
|
||||
match data {
|
||||
Data::Struct(data) => struct_projection_body(
|
||||
data,
|
||||
projected_name,
|
||||
projections,
|
||||
Conversion::IntoProjected,
|
||||
),
|
||||
Data::Enum(data) => enum_projection_body(
|
||||
data,
|
||||
projected_name,
|
||||
projections,
|
||||
Conversion::IntoProjected,
|
||||
),
|
||||
Data::Union(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_projected_body(
|
||||
data: &Data,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
) -> syn::Result<TokenStream2> {
|
||||
match data {
|
||||
Data::Struct(data) => {
|
||||
struct_from_projected_body(data, projected_name, projections)
|
||||
}
|
||||
Data::Enum(data) => {
|
||||
enum_from_projected_body(data, projected_name, projections)
|
||||
}
|
||||
Data::Union(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Conversion {
|
||||
ProjectRef,
|
||||
IntoProjected,
|
||||
}
|
||||
|
||||
fn struct_projection_body(
|
||||
data: &DataStruct,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
conversion: Conversion,
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let bindings = field_bindings(&data.fields);
|
||||
let pattern = fields_pattern(&data.fields, &bindings, None)?;
|
||||
let target = parse_quote!(#projected_name);
|
||||
let values = data
|
||||
.fields
|
||||
.iter()
|
||||
.zip(bindings.iter())
|
||||
.zip(projections)
|
||||
.map(|((field, binding), projection)| {
|
||||
let attributes = structural_attributes(&field.attrs)?;
|
||||
let value = conversion_expression(binding, *projection, conversion);
|
||||
let ident = &field.ident;
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #ident: #value)
|
||||
} else {
|
||||
quote!(#(#attributes)* #value)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
let construction = fields_construction(&data.fields, &target, &values);
|
||||
|
||||
Ok(quote! {
|
||||
let #pattern = self;
|
||||
#construction
|
||||
})
|
||||
}
|
||||
|
||||
fn struct_from_projected_body(
|
||||
data: &DataStruct,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let bindings = field_bindings(&data.fields);
|
||||
let source = parse_quote!(#projected_name);
|
||||
let pattern = fields_pattern(&data.fields, &bindings, Some(&source))?;
|
||||
let values = data
|
||||
.fields
|
||||
.iter()
|
||||
.zip(bindings.iter())
|
||||
.zip(projections)
|
||||
.map(|((field, binding), projection)| {
|
||||
let attributes = structural_attributes(&field.attrs)?;
|
||||
let value = from_expression(binding, &field.ty, *projection);
|
||||
let ident = &field.ident;
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #ident: #value)
|
||||
} else {
|
||||
quote!(#(#attributes)* #value)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
let construction =
|
||||
fields_construction(&data.fields, &parse_quote!(Self), &values);
|
||||
|
||||
Ok(quote! {
|
||||
let #pattern = projected;
|
||||
#construction
|
||||
})
|
||||
}
|
||||
|
||||
fn enum_projection_body(
|
||||
data: &DataEnum,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
conversion: Conversion,
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let mut offset = 0;
|
||||
let arms = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|variant| {
|
||||
let count = variant.fields.len();
|
||||
let variant_projections = &projections[offset..offset + count];
|
||||
offset += count;
|
||||
let attributes = structural_attributes(&variant.attrs)?;
|
||||
let bindings = field_bindings(&variant.fields);
|
||||
let variant_ident = &variant.ident;
|
||||
let pattern = fields_pattern(
|
||||
&variant.fields,
|
||||
&bindings,
|
||||
Some(&parse_quote!(Self::#variant_ident)),
|
||||
)?;
|
||||
let values = variant
|
||||
.fields
|
||||
.iter()
|
||||
.zip(bindings.iter())
|
||||
.zip(variant_projections)
|
||||
.map(|((field, binding), projection)| {
|
||||
let attributes = structural_attributes(&field.attrs)?;
|
||||
let value =
|
||||
conversion_expression(binding, *projection, conversion);
|
||||
let ident = &field.ident;
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #ident: #value)
|
||||
} else {
|
||||
quote!(#(#attributes)* #value)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
let target = parse_quote!(#projected_name::#variant_ident);
|
||||
let construction =
|
||||
fields_construction(&variant.fields, &target, &values);
|
||||
Ok(quote!(#(#attributes)* #pattern => #construction))
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(quote!(match self { #(#arms),* }))
|
||||
}
|
||||
|
||||
fn enum_from_projected_body(
|
||||
data: &DataEnum,
|
||||
projected_name: &syn::Ident,
|
||||
projections: &[Projection],
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let mut offset = 0;
|
||||
let arms = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|variant| {
|
||||
let count = variant.fields.len();
|
||||
let variant_projections = &projections[offset..offset + count];
|
||||
offset += count;
|
||||
let attributes = structural_attributes(&variant.attrs)?;
|
||||
let bindings = field_bindings(&variant.fields);
|
||||
let variant_ident = &variant.ident;
|
||||
let source = parse_quote!(#projected_name::#variant_ident);
|
||||
let pattern =
|
||||
fields_pattern(&variant.fields, &bindings, Some(&source))?;
|
||||
let values = variant
|
||||
.fields
|
||||
.iter()
|
||||
.zip(bindings.iter())
|
||||
.zip(variant_projections)
|
||||
.map(|((field, binding), projection)| {
|
||||
let attributes = structural_attributes(&field.attrs)?;
|
||||
let value =
|
||||
from_expression(binding, &field.ty, *projection);
|
||||
let ident = &field.ident;
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #ident: #value)
|
||||
} else {
|
||||
quote!(#(#attributes)* #value)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
let target = parse_quote!(Self::#variant_ident);
|
||||
let construction =
|
||||
fields_construction(&variant.fields, &target, &values);
|
||||
Ok(quote!(#(#attributes)* #pattern => #construction))
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(quote!(match projected { #(#arms),* }))
|
||||
}
|
||||
|
||||
fn field_bindings(fields: &Fields) -> Vec<syn::Ident> {
|
||||
fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
field
|
||||
.ident
|
||||
.clone()
|
||||
.unwrap_or_else(|| format_ident!("field_{index}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fields_pattern(
|
||||
fields: &Fields,
|
||||
bindings: &[syn::Ident],
|
||||
prefix: Option<&syn::Path>,
|
||||
) -> syn::Result<TokenStream2> {
|
||||
let prefix = prefix
|
||||
.map(|prefix| quote!(#prefix))
|
||||
.unwrap_or_else(|| quote!(Self));
|
||||
let entries = fields
|
||||
.iter()
|
||||
.zip(bindings)
|
||||
.map(|(field, binding)| {
|
||||
let attributes = structural_attributes(&field.attrs)?;
|
||||
let ident = &field.ident;
|
||||
Ok(if let Some(ident) = ident {
|
||||
quote!(#(#attributes)* #ident)
|
||||
} else {
|
||||
quote!(#(#attributes)* #binding)
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(match fields {
|
||||
Fields::Named(_) => quote!(#prefix { #(#entries),* }),
|
||||
Fields::Unnamed(_) => quote!(#prefix( #(#entries),* )),
|
||||
Fields::Unit => quote!(#prefix),
|
||||
})
|
||||
}
|
||||
|
||||
fn fields_construction(
|
||||
fields: &Fields,
|
||||
target: &syn::Path,
|
||||
values: &[TokenStream2],
|
||||
) -> TokenStream2 {
|
||||
match fields {
|
||||
Fields::Named(_) => quote!(#target { #(#values),* }),
|
||||
Fields::Unnamed(_) => quote!(#target( #(#values),* )),
|
||||
Fields::Unit => quote!(#target),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_expression(
|
||||
binding: &syn::Ident,
|
||||
projection: Projection,
|
||||
conversion: Conversion,
|
||||
) -> TokenStream2 {
|
||||
match (projection, conversion) {
|
||||
(Projection::Ordinary, _) => quote!(#binding),
|
||||
(Projection::Nested, Conversion::ProjectRef) => quote!(
|
||||
::cached_projection::CachedProjection::project_ref(#binding)
|
||||
),
|
||||
(Projection::Nested, Conversion::IntoProjected) => quote!(
|
||||
::cached_projection::CachedProjection::into_projected(#binding)
|
||||
),
|
||||
(Projection::Wrap, Conversion::ProjectRef) => quote!(
|
||||
::cached_projection::DeserializeAnyJsonWrapperRef(#binding)
|
||||
),
|
||||
(Projection::Wrap, Conversion::IntoProjected) => quote!(
|
||||
::cached_projection::DeserializeAnyJsonWrapper(#binding)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_expression(
|
||||
binding: &syn::Ident,
|
||||
ty: &Type,
|
||||
projection: Projection,
|
||||
) -> TokenStream2 {
|
||||
match projection {
|
||||
Projection::Ordinary => quote!(#binding),
|
||||
Projection::Nested => quote!(
|
||||
<#ty as ::cached_projection::CachedProjection>::from_projected(#binding)
|
||||
),
|
||||
Projection::Wrap => quote!(#binding.0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "cached-projection"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cached-projection-derive = { path = "../cached-projection-derive" }
|
||||
serde = { workspace = true, features = ["derive", "rc"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
postcard = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,429 @@
|
||||
// for macro expansion
|
||||
extern crate self as cached_projection;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::hash::Hash;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cached_projection_derive::CachedProjection;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::{SerializeMap, SerializeSeq};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub trait CachedProjection: Sized {
|
||||
type ProjectedType: Serialize + DeserializeOwned;
|
||||
|
||||
type ProjectedTypeRef<'a>: Serialize
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_>;
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType;
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self;
|
||||
}
|
||||
|
||||
pub struct DeserializeAnyJsonWrapper<T>(pub T);
|
||||
|
||||
impl<T> Serialize for DeserializeAnyJsonWrapper<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serde_json::to_string(&self.0)
|
||||
.map_err(serde::ser::Error::custom)?
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for DeserializeAnyJsonWrapper<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
serde_json::from_str(&value)
|
||||
.map(Self)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeserializeAnyJsonWrapperRef<'a, T: ?Sized>(pub &'a T);
|
||||
|
||||
impl<T> Serialize for DeserializeAnyJsonWrapperRef<'_, T>
|
||||
where
|
||||
T: Serialize + ?Sized,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serde_json::to_string(self.0)
|
||||
.map_err(serde::ser::Error::custom)?
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! identity_projection {
|
||||
($($ty:ty),* $(,)?) => {
|
||||
$(
|
||||
impl CachedProjection for $ty {
|
||||
type ProjectedType = Self;
|
||||
type ProjectedTypeRef<'a> = &'a Self;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
self
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
self
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
projected
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
identity_projection!(
|
||||
(),
|
||||
bool,
|
||||
char,
|
||||
String,
|
||||
i8,
|
||||
i16,
|
||||
i32,
|
||||
i64,
|
||||
i128,
|
||||
isize,
|
||||
u8,
|
||||
u16,
|
||||
u32,
|
||||
u64,
|
||||
u128,
|
||||
usize,
|
||||
f32,
|
||||
f64,
|
||||
);
|
||||
|
||||
impl<T> CachedProjection for Option<T>
|
||||
where
|
||||
T: CachedProjection,
|
||||
{
|
||||
type ProjectedType = Option<T::ProjectedType>;
|
||||
type ProjectedTypeRef<'a>
|
||||
= Option<T::ProjectedTypeRef<'a>>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
self.as_ref().map(CachedProjection::project_ref)
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
self.map(CachedProjection::into_projected)
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
projected.map(CachedProjection::from_projected)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectedSliceRef<'a, T>(&'a [T]);
|
||||
|
||||
impl<T> Serialize for ProjectedSliceRef<'_, T>
|
||||
where
|
||||
T: CachedProjection,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for value in self.0 {
|
||||
sequence.serialize_element(&value.project_ref())?;
|
||||
}
|
||||
sequence.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CachedProjection for Vec<T>
|
||||
where
|
||||
T: CachedProjection,
|
||||
{
|
||||
type ProjectedType = Vec<T::ProjectedType>;
|
||||
type ProjectedTypeRef<'a>
|
||||
= ProjectedSliceRef<'a, T>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
ProjectedSliceRef(self)
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
self.into_iter()
|
||||
.map(CachedProjection::into_projected)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
projected
|
||||
.into_iter()
|
||||
.map(CachedProjection::from_projected)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CachedProjection for Box<T>
|
||||
where
|
||||
T: CachedProjection,
|
||||
{
|
||||
type ProjectedType = T::ProjectedType;
|
||||
type ProjectedTypeRef<'a>
|
||||
= T::ProjectedTypeRef<'a>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
self.as_ref().project_ref()
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
(*self).into_projected()
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
Box::new(T::from_projected(projected))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CachedProjection for Arc<T>
|
||||
where
|
||||
T: CachedProjection + Clone,
|
||||
{
|
||||
type ProjectedType = T::ProjectedType;
|
||||
type ProjectedTypeRef<'a>
|
||||
= T::ProjectedTypeRef<'a>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
self.as_ref().project_ref()
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
Arc::unwrap_or_clone(self).into_projected()
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
Arc::new(T::from_projected(projected))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectedHashMapRef<'a, K, V>(&'a HashMap<K, V>);
|
||||
|
||||
impl<K, V> Serialize for ProjectedHashMapRef<'_, K, V>
|
||||
where
|
||||
K: Serialize,
|
||||
V: CachedProjection,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
||||
for (key, value) in self.0 {
|
||||
map.serialize_entry(key, &value.project_ref())?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> CachedProjection for HashMap<K, V>
|
||||
where
|
||||
K: Serialize + DeserializeOwned + Eq + Hash,
|
||||
V: CachedProjection,
|
||||
{
|
||||
type ProjectedType = HashMap<K, V::ProjectedType>;
|
||||
type ProjectedTypeRef<'a>
|
||||
= ProjectedHashMapRef<'a, K, V>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
ProjectedHashMapRef(self)
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
self.into_iter()
|
||||
.map(|(key, value)| (key, value.into_projected()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
projected
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, V::from_projected(value)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectedBTreeMapRef<'a, K, V>(&'a BTreeMap<K, V>);
|
||||
|
||||
impl<K, V> Serialize for ProjectedBTreeMapRef<'_, K, V>
|
||||
where
|
||||
K: Serialize,
|
||||
V: CachedProjection,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
||||
for (key, value) in self.0 {
|
||||
map.serialize_entry(key, &value.project_ref())?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> CachedProjection for BTreeMap<K, V>
|
||||
where
|
||||
K: Serialize + DeserializeOwned + Ord,
|
||||
V: CachedProjection,
|
||||
{
|
||||
type ProjectedType = BTreeMap<K, V::ProjectedType>;
|
||||
type ProjectedTypeRef<'a>
|
||||
= ProjectedBTreeMapRef<'a, K, V>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
ProjectedBTreeMapRef(self)
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
self.into_iter()
|
||||
.map(|(key, value)| (key, value.into_projected()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
projected
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, V::from_projected(value)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B> CachedProjection for (A, B)
|
||||
where
|
||||
A: CachedProjection,
|
||||
B: CachedProjection,
|
||||
{
|
||||
type ProjectedType = (A::ProjectedType, B::ProjectedType);
|
||||
type ProjectedTypeRef<'a>
|
||||
= (A::ProjectedTypeRef<'a>, B::ProjectedTypeRef<'a>)
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
(self.0.project_ref(), self.1.project_ref())
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
(self.0.into_projected(), self.1.into_projected())
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
(
|
||||
A::from_projected(projected.0),
|
||||
B::from_projected(projected.1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B, C> CachedProjection for (A, B, C)
|
||||
where
|
||||
A: CachedProjection,
|
||||
B: CachedProjection,
|
||||
C: CachedProjection,
|
||||
{
|
||||
type ProjectedType = (A::ProjectedType, B::ProjectedType, C::ProjectedType);
|
||||
type ProjectedTypeRef<'a>
|
||||
= (
|
||||
A::ProjectedTypeRef<'a>,
|
||||
B::ProjectedTypeRef<'a>,
|
||||
C::ProjectedTypeRef<'a>,
|
||||
)
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn project_ref(&self) -> Self::ProjectedTypeRef<'_> {
|
||||
(
|
||||
self.0.project_ref(),
|
||||
self.1.project_ref(),
|
||||
self.2.project_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn into_projected(self) -> Self::ProjectedType {
|
||||
(
|
||||
self.0.into_projected(),
|
||||
self.1.into_projected(),
|
||||
self.2.into_projected(),
|
||||
)
|
||||
}
|
||||
|
||||
fn from_projected(projected: Self::ProjectedType) -> Self {
|
||||
(
|
||||
A::from_projected(projected.0),
|
||||
B::from_projected(projected.1),
|
||||
C::from_projected(projected.2),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CachedProjection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(CachedProjection, Serialize, Deserialize)]
|
||||
struct Child {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(CachedProjection, Serialize, Deserialize)]
|
||||
struct Parent {
|
||||
#[cached_projection(nested)]
|
||||
child: Child,
|
||||
#[cached_projection(wrap)]
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postcard_projection_round_trip() {
|
||||
let value = Parent {
|
||||
child: Child {
|
||||
value: "value".to_owned(),
|
||||
},
|
||||
metadata: serde_json::json!({ "key": [1, 2, 3] }),
|
||||
};
|
||||
|
||||
let bytes = postcard::to_allocvec(&value.project_ref()).unwrap();
|
||||
let projected =
|
||||
postcard::from_bytes::<ParentProjectedType>(&bytes).unwrap();
|
||||
let _value = Parent::from_projected(projected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user