mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 04:56:52 +00:00
wip: delphi trace transform rules
This commit is contained in:
@@ -15,7 +15,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import {
|
||||
FolderIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
ReportIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
@@ -51,6 +58,10 @@ const messages = defineMessages({
|
||||
id: 'moderation.page.global-detail-traces',
|
||||
defaultMessage: 'Global traces',
|
||||
},
|
||||
delphiRulesTitle: {
|
||||
id: 'moderation.page.delphi-rules',
|
||||
defaultMessage: 'Delphi rules',
|
||||
},
|
||||
})
|
||||
|
||||
const moderationLinks = [
|
||||
@@ -71,6 +82,11 @@ const moderationLinks = [
|
||||
href: '/moderation/global-traces',
|
||||
icon: HashIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.delphiRulesTitle),
|
||||
href: '/moderation/technical-review/rules',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const mobileNavOptions = [
|
||||
@@ -79,12 +95,15 @@ const mobileNavOptions = [
|
||||
formatMessage(messages.reportsTitle),
|
||||
formatMessage(messages.externalFilesTitle),
|
||||
formatMessage(messages.globalDetailTracesTitle),
|
||||
formatMessage(messages.delphiRulesTitle),
|
||||
]
|
||||
|
||||
const selectedChip = computed({
|
||||
get() {
|
||||
const path = route.path
|
||||
if (path.startsWith('/moderation/technical-review')) {
|
||||
if (path.startsWith('/moderation/technical-review/rules')) {
|
||||
return formatMessage(messages.delphiRulesTitle)
|
||||
} else if (path.startsWith('/moderation/technical-review')) {
|
||||
return formatMessage(messages.technicalReviewTitle)
|
||||
} else if (path.startsWith('/moderation/reports')) {
|
||||
return formatMessage(messages.reportsTitle)
|
||||
@@ -110,6 +129,8 @@ function navigateToPage(selectedOption: string) {
|
||||
router.push('/moderation/external-projects')
|
||||
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
|
||||
router.push('/moderation/global-traces')
|
||||
} else if (selectedOption === formatMessage(messages.delphiRulesTitle)) {
|
||||
router.push('/moderation/technical-review/rules')
|
||||
} else {
|
||||
router.push('/moderation')
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
ListFilterIcon,
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
@@ -607,6 +609,13 @@ watch(totalPages, (pages) => {
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<ButtonStyled>
|
||||
<NuxtLink to="/moderation/technical-review/rules">
|
||||
<SettingsIcon class="size-5" />
|
||||
Rules
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
|
||||
<Combobox
|
||||
v-model="currentResponseFilter"
|
||||
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<NewModal ref="ruleModal" :header="modalTitle">
|
||||
<form class="flex w-[36rem] max-w-full flex-col gap-3" @submit.prevent="saveRule">
|
||||
<label class="font-semibold text-contrast" for="rule-name">Name</label>
|
||||
<StyledInput
|
||||
id="rule-name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="Known-safe obfuscated bootstrap"
|
||||
/>
|
||||
|
||||
<label class="font-semibold text-contrast" for="rule-priority">Priority</label>
|
||||
<StyledInput id="rule-priority" v-model="form.priority" type="number" placeholder="0" />
|
||||
<p class="m-0 text-sm text-secondary">Higher-priority rules are evaluated first.</p>
|
||||
|
||||
<label class="font-semibold text-contrast" for="rule-expression">CEL expression</label>
|
||||
<StyledInput
|
||||
id="rule-expression"
|
||||
v-model="form.expression"
|
||||
type="text"
|
||||
multiline
|
||||
resize="vertical"
|
||||
class="font-mono"
|
||||
input-class="min-h-64 font-mono"
|
||||
/>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
Return <code>null</code> when the rule does not match, or a map containing
|
||||
<code>severity</code> and/or <code>hidden</code> when it does.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="closeRuleModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit" :disabled="isSaving">
|
||||
{{ isSaving ? 'Saving...' : 'Save rule' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
|
||||
<ConfirmModal
|
||||
ref="deleteModal"
|
||||
:title="`Delete ${ruleToDelete?.name ?? 'rule'}?`"
|
||||
description="This permanently deletes the rule, its revisions, and its materialized effects."
|
||||
:markdown="false"
|
||||
proceed-label="Delete rule"
|
||||
@proceed="deleteRule"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<NuxtLink to="/moderation/technical-review" aria-label="Back to tech review queue">
|
||||
<ArrowLeftIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<div>
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">Delphi rules</h1>
|
||||
<p class="m-0 text-secondary">Transform or hide Delphi issue traces.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="openCreateModal">
|
||||
<PlusIcon />
|
||||
Create rule
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<details class="universal-card text-sm">
|
||||
<summary class="cursor-pointer font-semibold text-contrast">CEL contract and input</summary>
|
||||
<div class="mt-3 flex flex-col gap-2 text-secondary">
|
||||
<p class="m-0">
|
||||
Effects are maps such as
|
||||
<code>{ "severity": "low", "hidden": false }</code>. Severity can be <code>low</code>,
|
||||
<code>medium</code>, <code>high</code>, or <code>severe</code>.
|
||||
</p>
|
||||
<p class="m-0">
|
||||
The <code>input</code> object contains <code>schema_version</code>,
|
||||
<code>trace</code> (<code>key</code>, <code>issue_type</code>, <code>severity</code>,
|
||||
<code>jar</code>, <code>file_path</code>, <code>data</code>),
|
||||
<code>scan.delphi_version</code>, <code>artifact</code> (<code>size</code>,
|
||||
<code>hashes</code>), and stable IDs under <code>scope</code> (<code>project_id</code>,
|
||||
<code>version_id</code>, <code>file_id</code>).
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="isLoading" class="universal-card flex h-32 items-center justify-center">
|
||||
<LoaderCircleIcon class="size-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
<div v-else-if="loadFailed" class="universal-card flex flex-col items-center gap-3 py-8">
|
||||
<p class="m-0 text-secondary">Failed to load Delphi rules.</p>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="loadRules">Try again</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="rules.length === 0"
|
||||
type="no-search-result"
|
||||
heading="No Delphi rules"
|
||||
description="Create a rule to transform matching issue traces."
|
||||
/>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<article v-for="rule in rules" :key="rule.id" class="universal-card flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">{{ rule.name }}</h2>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
Priority {{ rule.priority }} · revision {{ rule.revision_id }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="openEditModal(rule)">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button type="button" @click="openDeleteModal(rule)">
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
class="m-0 overflow-x-auto rounded-lg bg-bg-raised p-3 text-sm"
|
||||
><code>{{ rule.expression }}</code></pre>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ArrowLeftIcon, EditIcon, LoaderCircleIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const DEFAULT_EXPRESSION = `input.trace.issue_type == "OBFUSCATED_NAMES"
|
||||
? {"severity": "low", "hidden": false}
|
||||
: null`
|
||||
|
||||
useHead({ title: 'Delphi rules - Modrinth' })
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ruleModal = useTemplateRef<InstanceType<typeof NewModal>>('ruleModal')
|
||||
const deleteModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteModal')
|
||||
|
||||
const rules = ref<Labrinth.TechReview.Internal.DelphiRule[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const editingRuleId = ref<number | null>(null)
|
||||
const ruleToDelete = ref<Labrinth.TechReview.Internal.DelphiRule | null>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
priority: '0',
|
||||
expression: DEFAULT_EXPRESSION,
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => (editingRuleId.value === null ? 'Create rule' : 'Edit rule'))
|
||||
|
||||
async function loadRules() {
|
||||
isLoading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
rules.value = await client.labrinth.tech_review_internal.getRules()
|
||||
} catch (error) {
|
||||
console.error('Failed to load Delphi rules', error)
|
||||
loadFailed.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingRuleId.value = null
|
||||
form.name = ''
|
||||
form.priority = '0'
|
||||
form.expression = DEFAULT_EXPRESSION
|
||||
ruleModal.value?.show()
|
||||
}
|
||||
|
||||
function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
|
||||
editingRuleId.value = rule.id
|
||||
form.name = rule.name
|
||||
form.priority = String(rule.priority)
|
||||
form.expression = rule.expression
|
||||
ruleModal.value?.show()
|
||||
}
|
||||
|
||||
function closeRuleModal() {
|
||||
ruleModal.value?.hide()
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
if (isSaving.value) return
|
||||
|
||||
const priority = Number(form.priority)
|
||||
if (!form.name.trim() || !form.expression.trim() || !Number.isInteger(priority)) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Invalid rule',
|
||||
text: 'Enter a name, a CEL expression, and an integer priority.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
const payload = {
|
||||
name: form.name,
|
||||
priority,
|
||||
expression: form.expression,
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRuleId.value === null) {
|
||||
await client.labrinth.tech_review_internal.createRule(payload)
|
||||
} else {
|
||||
await client.labrinth.tech_review_internal.updateRule(editingRuleId.value, payload)
|
||||
}
|
||||
closeRuleModal()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Rule saved',
|
||||
text: 'The Delphi rule revision was saved.',
|
||||
})
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Delphi rule', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to save rule',
|
||||
text: 'Check the CEL expression and try again.',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
|
||||
ruleToDelete.value = rule
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteRule() {
|
||||
const rule = ruleToDelete.value
|
||||
if (!rule) return
|
||||
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.deleteRule(rule.id)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Rule deleted',
|
||||
text: `${rule.name} was deleted.`,
|
||||
})
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete Delphi rule', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to delete rule',
|
||||
text: 'The Delphi rule could not be deleted.',
|
||||
})
|
||||
} finally {
|
||||
ruleToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRules)
|
||||
</script>
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tUPDATE delphi_rule_revisions\n\t\tSET active = FALSE\n\t\tWHERE rule_id = $1 AND active\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8"
|
||||
}
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\tpriority,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "priority",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "updated",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421"
|
||||
}
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\tpriority = $3,\n\t\t\tupdated = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "priority",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "updated",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020"
|
||||
}
|
||||
Generated
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tINSERT INTO delphi_rule_revisions (rule_id, expression, created_by)\n\t\tVALUES ($1, $2, $3)\n\t\tRETURNING id, expression, created, created_by\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "expression",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe"
|
||||
}
|
||||
Generated
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tSELECT\n\t\t\tr.id,\n\t\t\tr.name,\n\t\t\tr.priority,\n\t\t\tr.created,\n\t\t\tr.updated,\n\t\t\tr.created_by,\n\t\t\tr.updated_by,\n\t\t\trr.id AS revision_id,\n\t\t\trr.expression,\n\t\t\trr.created AS revision_created,\n\t\t\trr.created_by AS revision_created_by\n\t\tFROM delphi_rules r\n\t\tINNER JOIN delphi_rule_revisions rr\n\t\t\tON rr.rule_id = r.id\n\t\t\tAND rr.active\n\t\tORDER BY r.priority DESC, r.id\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "priority",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "updated",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "revision_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "expression",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "revision_created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "revision_created_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e"
|
||||
}
|
||||
@@ -34,6 +34,7 @@ base64 = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
censor = { workspace = true }
|
||||
cel = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
clickhouse = { workspace = true, features = ["time", "uuid"] }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE delphi_rules (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name TEXT NOT NULL CHECK (BTRIM(name) <> ''),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT REFERENCES users (id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
updated_by BIGINT REFERENCES users (id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE delphi_rule_revisions (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
rule_id BIGINT NOT NULL REFERENCES delphi_rules (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
expression TEXT NOT NULL CHECK (BTRIM(expression) <> ''),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT REFERENCES users (id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX delphi_rule_revisions_active
|
||||
ON delphi_rule_revisions (rule_id)
|
||||
WHERE active;
|
||||
|
||||
CREATE INDEX delphi_rule_revisions_rule_id
|
||||
ON delphi_rule_revisions (rule_id, created DESC);
|
||||
|
||||
CREATE TABLE delphi_rule_effects (
|
||||
rule_revision_id BIGINT NOT NULL REFERENCES delphi_rule_revisions (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
issue_detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
severity delphi_severity,
|
||||
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (rule_revision_id, issue_detail_id),
|
||||
CHECK (severity IS NOT NULL OR hidden)
|
||||
);
|
||||
|
||||
CREATE INDEX delphi_rule_effects_issue_detail_id
|
||||
ON delphi_rule_effects (issue_detail_id);
|
||||
@@ -115,6 +115,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
moderation::tech_review::search_projects,
|
||||
moderation::tech_review::global::search_global_issue_details,
|
||||
moderation::tech_review::global::get_global_issue_detail,
|
||||
moderation::tech_review::rules::get_rules,
|
||||
moderation::tech_review::rules::create_rule,
|
||||
moderation::tech_review::rules::update_rule,
|
||||
moderation::tech_review::rules::delete_rule,
|
||||
moderation::tech_review::get_project_report,
|
||||
moderation::tech_review::submit_report,
|
||||
moderation::tech_review::update_issue_details,
|
||||
|
||||
@@ -44,10 +44,12 @@ use crate::{
|
||||
use eyre::eyre;
|
||||
|
||||
pub mod global;
|
||||
pub mod rules;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(search_projects)
|
||||
.configure(global::config)
|
||||
.configure(rules::config)
|
||||
.service(get_project_report)
|
||||
.service(get_report)
|
||||
.service(get_issue)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
use actix_web::{HttpRequest, delete, get, post, put, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::check_is_moderator_from_headers,
|
||||
database::{PgPool, ReadOnlyPgPool, redis::RedisPool},
|
||||
models::pats::Scopes,
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
const MAX_RULE_NAME_LENGTH: usize = 128;
|
||||
const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_rules)
|
||||
.service(create_rule)
|
||||
.service(update_rule)
|
||||
.service(delete_rule);
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct DelphiRule {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub expression: String,
|
||||
pub revision_id: i64,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
pub revision_created: DateTime<Utc>,
|
||||
pub created_by: Option<i64>,
|
||||
pub updated_by: Option<i64>,
|
||||
pub revision_created_by: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct WriteDelphiRule {
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub expression: String,
|
||||
}
|
||||
|
||||
struct ValidatedRule {
|
||||
name: String,
|
||||
priority: i32,
|
||||
expression: String,
|
||||
}
|
||||
|
||||
impl WriteDelphiRule {
|
||||
fn validate(self) -> Result<ValidatedRule, ApiError> {
|
||||
let name = self.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::Request(eyre!("rule name cannot be empty")));
|
||||
}
|
||||
if name.chars().count() > MAX_RULE_NAME_LENGTH {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let expression = self.expression.trim().to_string();
|
||||
if expression.is_empty() {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"rule expression cannot be empty"
|
||||
)));
|
||||
}
|
||||
if expression.len() > MAX_RULE_EXPRESSION_LENGTH {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
cel::Program::compile(&expression).map_err(|error| {
|
||||
ApiError::Request(eyre!("invalid cel expression: {error}"))
|
||||
})?;
|
||||
|
||||
Ok(ValidatedRule {
|
||||
name,
|
||||
priority: self.priority,
|
||||
expression,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// List the current revision of every Delphi rule.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = Vec<DelphiRule>))
|
||||
)]
|
||||
#[get("/rules")]
|
||||
pub async fn get_rules(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
ro_pool: web::Data<ReadOnlyPgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<Vec<DelphiRule>>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.priority,
|
||||
r.created,
|
||||
r.updated,
|
||||
r.created_by,
|
||||
r.updated_by,
|
||||
rr.id AS revision_id,
|
||||
rr.expression,
|
||||
rr.created AS revision_created,
|
||||
rr.created_by AS revision_created_by
|
||||
FROM delphi_rules r
|
||||
INNER JOIN delphi_rule_revisions rr
|
||||
ON rr.rule_id = r.id
|
||||
AND rr.active
|
||||
ORDER BY r.priority DESC, r.id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&***ro_pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch delphi rules")?;
|
||||
|
||||
Ok(web::Json(
|
||||
rows.into_iter()
|
||||
.map(|row| DelphiRule {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
expression: row.expression,
|
||||
revision_id: row.revision_id,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
revision_created: row.revision_created,
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
revision_created_by: row.revision_created_by,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a Delphi rule and its first revision.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
request_body = WriteDelphiRule,
|
||||
responses((status = OK, body = DelphiRule))
|
||||
)]
|
||||
#[post("/rules")]
|
||||
pub async fn create_rule(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<WriteDelphiRule>,
|
||||
) -> Result<web::Json<DelphiRule>, ApiError> {
|
||||
let user = check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_WRITE,
|
||||
)
|
||||
.await?;
|
||||
let rule = body.into_inner().validate()?;
|
||||
let user_id = user.id.0 as i64;
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin delphi rule transaction")?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO delphi_rules (
|
||||
name,
|
||||
priority,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
RETURNING id, name, priority, created, updated, created_by, updated_by
|
||||
"#,
|
||||
rule.name,
|
||||
rule.priority,
|
||||
user_id,
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to create delphi rule")?;
|
||||
|
||||
let revision = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, expression, created, created_by
|
||||
"#,
|
||||
row.id,
|
||||
rule.expression,
|
||||
user_id,
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to create delphi rule revision")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit delphi rule transaction")?;
|
||||
|
||||
Ok(web::Json(DelphiRule {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
expression: revision.expression,
|
||||
revision_id: revision.id,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
revision_created: revision.created,
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
revision_created_by: revision.created_by,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Replace a Delphi rule and create a new current revision.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
request_body = WriteDelphiRule,
|
||||
responses((status = OK, body = DelphiRule), (status = NOT_FOUND))
|
||||
)]
|
||||
#[put("/rules/{id}")]
|
||||
pub async fn update_rule(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<(i64,)>,
|
||||
body: web::Json<WriteDelphiRule>,
|
||||
) -> Result<web::Json<DelphiRule>, ApiError> {
|
||||
let user = check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_WRITE,
|
||||
)
|
||||
.await?;
|
||||
let (id,) = path.into_inner();
|
||||
let rule = body.into_inner().validate()?;
|
||||
let user_id = user.id.0 as i64;
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin delphi rule transaction")?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
UPDATE delphi_rules
|
||||
SET
|
||||
name = $2,
|
||||
priority = $3,
|
||||
updated = CURRENT_TIMESTAMP,
|
||||
updated_by = $4
|
||||
WHERE id = $1
|
||||
RETURNING id, name, priority, created, updated, created_by, updated_by
|
||||
"#,
|
||||
id,
|
||||
rule.name,
|
||||
rule.priority,
|
||||
user_id,
|
||||
)
|
||||
.fetch_optional(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to update delphi rule")?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE delphi_rule_revisions
|
||||
SET active = FALSE
|
||||
WHERE rule_id = $1 AND active
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to deactivate delphi rule revision")?;
|
||||
|
||||
let revision = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, expression, created, created_by
|
||||
"#,
|
||||
id,
|
||||
rule.expression,
|
||||
user_id,
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to create delphi rule revision")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit delphi rule transaction")?;
|
||||
|
||||
Ok(web::Json(DelphiRule {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
expression: revision.expression,
|
||||
revision_id: revision.id,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
revision_created: revision.created,
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
revision_created_by: revision.created_by,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a Delphi rule and all its revisions and materialized effects.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK), (status = NOT_FOUND))
|
||||
)]
|
||||
#[delete("/rules/{id}")]
|
||||
pub async fn delete_rule(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<(i64,)>,
|
||||
) -> Result<(), ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_WRITE,
|
||||
)
|
||||
.await?;
|
||||
let (id,) = path.into_inner();
|
||||
|
||||
let deleted = sqlx::query!(
|
||||
"DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete delphi rule")?;
|
||||
|
||||
if deleted.is_none() {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user