Compare commits

...
16 Commits
Author SHA1 Message Date
sychicandMichael H. c80de30034 style(labrinth): fix typo 2026-07-31 13:34:26 +02:00
sychicandMichael H. 600a9806f1 style(labrinth): cargo fmt 2026-07-31 13:34:26 +02:00
sychicandMichael H. 6e5e33b1fd feat(labrinth): edit project disclosures endpoint 2026-07-31 13:34:26 +02:00
sychicandMichael H. b9c123031b feat(labrinth): wrap disclosures in struct 2026-07-31 13:34:26 +02:00
sychicandMichael H. 62eb98a997 feat(labrinth): censor user ids if set by moderator 2026-07-31 13:34:26 +02:00
sychicandMichael H. 9e6d64a259 feat(labrinth): project disclosures get endpoint 2026-07-31 13:34:26 +02:00
sychicandMichael H. f3b79b3cfc feat(labrinth): project disclosures database model 2026-07-31 13:34:26 +02:00
sychicandMichael H. a2ca0dda5a feat(labrinth): project disclosures model 2026-07-31 13:34:25 +02:00
Michael H. a53c9c3771 fix: only deploy if internal 2026-07-31 13:21:35 +02:00
Michael H. 919b235621 chore: address action issues 2026-07-31 13:17:26 +02:00
Michael H. 18b0b2848d feat: labrinth argo 2026-07-31 13:10:58 +02:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
ProspectorandGitHub 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
28 changed files with 893 additions and 91 deletions
+21 -11
View File
@@ -34,13 +34,13 @@ runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: |
# Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
branch_name="origin/$PR_BRANCH"
commit_id="$COMMIT_ID"
# Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result
id: passed-checks
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
SECRET: ${{ inputs.secret }}
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
branch: process.env.TARGET_BRANCH,
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
ref: `refs/heads/${process.env.PR_BRANCH}`,
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
+24
View File
@@ -0,0 +1,24 @@
name: Deploy Command
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
on:
repository_dispatch:
types: [deploy-command]
permissions:
contents: read
actions: read
jobs:
deploy:
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
with:
application-set: labrinth
build-workflow: labrinth-build.yml
branch: ${{ github.event.client_payload.pull_request.head.ref }}
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
@@ -1,18 +1,18 @@
name: docker-build
name: Labrinth Build
on:
push:
branches:
- 'main'
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
@@ -69,7 +69,8 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT
fi
docker:
build:
name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -120,42 +121,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
# image manifest only. See:
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Upload Docker context
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
images: ghcr.io/modrinth/labrinth
labels: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
annotations: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
name: labrinth-docker-context
retention-days: 1
path: apps/labrinth/docker-stage
- name: Login to GitHub Packages
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
docker-build:
needs: [skip-if-clean, build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: labrinth
dockerfile-path: apps/labrinth/Dockerfile
artifacts-name: labrinth-docker-context
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
deploy:
needs: [skip-if-clean, docker-build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
with:
application-set: labrinth
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
+20
View File
@@ -0,0 +1,20 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
permissions: {}
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: namespace-profile-tiny-arm64
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
with:
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
issue-type: pull-request
commands: |
deploy
-2
View File
@@ -92,7 +92,6 @@ jobs:
run: corepack enable
- name: Set up caches
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
@@ -100,7 +99,6 @@ jobs:
pnpm
- name: Configure sccache
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
+48 -16
View File
@@ -30,6 +30,29 @@ const normalizeAuthToken = (value: unknown) => {
return ''
}
const getErrorStatus = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object') {
return undefined
}
const typedError = error as { statusCode?: unknown; status?: unknown }
const status = typedError.statusCode ?? typedError.status
return typeof status === 'number' ? status : undefined
}
// only when labrinth actually gives us an auth error
const isAuthFailure = (error: unknown): boolean => {
const status = getErrorStatus(error)
return status === 401 || status === 403
}
const clearAuthCookie = (auth: AuthState, authCookie: { value: string | null }) => {
authCookie.value = null
auth.token = ''
auth.user = null
}
const getQueryString = (value: QueryValue) => {
if (Array.isArray(value)) {
return value[0] ?? null
@@ -90,6 +113,7 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
}
const tokenStr = normalizeAuthToken(authCookie.value)
let shouldRefresh = false
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
@@ -111,12 +135,13 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
},
true,
)) as Labrinth.Users.v2.User
} catch {
/* empty */
} catch (error) {
// only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts)
shouldRefresh = isAuthFailure(error)
}
}
if (!auth.user && auth.token) {
if (!auth.user && auth.token && shouldRefresh) {
try {
const session = (await useBaseFetch(
'session/refresh',
@@ -132,22 +157,29 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
try {
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
},
true,
)) as Labrinth.Users.v2.User
true,
)) as Labrinth.Users.v2.User
} catch (error) {
if (isAuthFailure(error)) {
clearAuthCookie(auth, authCookie)
}
}
} else {
authCookie.value = null
auth.token = ''
clearAuthCookie(auth, authCookie)
}
} catch (error) {
if (isAuthFailure(error)) {
clearAuthCookie(auth, authCookie)
}
} catch {
authCookie.value = null
}
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, support)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tsupport = $5\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tmoderator = $5\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator\n\t\t\tLIMIT 1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tDELETE FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = $2\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, moderator\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disclosure_type!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "moderator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disclosure_type!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "set_by_moderator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, support\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disclosure_type!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "support",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c"
}
+1 -1
View File
@@ -11,7 +11,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
COPY labrinth /labrinth/labrinth
COPY --chmod=0755 labrinth /labrinth/labrinth
COPY migrations /labrinth/migrations
COPY assets /labrinth/assets
@@ -0,0 +1,9 @@
CREATE TABLE project_disclosures (
project_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
type TEXT NOT NULL,
metadata JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
set_by_moderator BOOLEAN NOT NULL,
PRIMARY KEY (project_id, type)
);
+3
View File
@@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
crate::clickhouse::run_migrations()
.await
.wrap_err("failed to run ClickHouse migrations")?;
Ok(())
}
+34 -19
View File
@@ -19,29 +19,44 @@ pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result<clickhouse::Client> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
Ok(connect()?.with_database(database))
}
let client = {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_all_versions()
.build();
let hyper_client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(https_connector);
fn connect() -> clickhouse::error::Result<clickhouse::Client> {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_all_versions()
.build();
let hyper_client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(https_connector);
clickhouse::Client::with_http_client(hyper_client)
.with_url(&ENV.CLICKHOUSE_URL)
.with_user(&ENV.CLICKHOUSE_USER)
.with_password(&ENV.CLICKHOUSE_PASSWORD)
.with_validation(false)
};
Ok(clickhouse::Client::with_http_client(hyper_client)
.with_url(&ENV.CLICKHOUSE_URL)
.with_user(&ENV.CLICKHOUSE_USER)
.with_password(&ENV.CLICKHOUSE_PASSWORD)
.with_validation(false))
}
client
#[cfg(feature = "test")]
pub async fn create_database(database: &str) -> clickhouse::error::Result<()> {
connect()?
.query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
.execute()
.await?;
.await
}
pub async fn run_migrations() -> clickhouse::error::Result<()> {
run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
}
pub async fn run_migrations_on_database(
database: &str,
) -> clickhouse::error::Result<()> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
let client = connect()?;
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let cluster_line = if clickhouse_replicated {
@@ -270,5 +285,5 @@ pub async fn init_client_with_database(
.execute()
.await?;
Ok(client.with_database(database))
Ok(())
}
+2
View File
@@ -30,6 +30,7 @@ pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod products_tax_identifier_item;
pub mod project_disclosure_item;
pub mod project_item;
pub mod report_item;
pub mod session_item;
@@ -53,6 +54,7 @@ pub use image_item::DBImage;
pub use oauth_client_item::DBOAuthClient;
pub use organization_item::DBOrganization;
pub use passkey_item::DBPasskey;
pub use project_disclosure_item::DBProjectDisclosure;
pub use project_item::DBProject;
pub use team_item::DBTeam;
pub use team_item::DBTeamMember;
@@ -0,0 +1,127 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
database::models::{DBProjectId, DBUserId, DatabaseError},
models::v3::disclosures::ProjectDisclosure,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBProjectDisclosure {
pub project_id: DBProjectId,
pub disclosure: ProjectDisclosure,
pub updated_at: DateTime<Utc>,
pub updated_by: DBUserId,
pub set_by_moderator: bool,
}
impl DBProjectDisclosure {
pub async fn upsert(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
let (disclosure_type, metadata) =
self.disclosure.to_parts().map_err(|e| {
DatabaseError::Internal(eyre::Report::new(e).wrap_err(
"failed to serialize project disclosure metadata",
))
})?;
sqlx::query!(
r#"
INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, type) DO UPDATE SET
metadata = $3,
updated_at = now(),
updated_by = $4,
set_by_moderator = $5
"#,
self.project_id as DBProjectId,
disclosure_type,
metadata,
self.updated_by as DBUserId,
self.set_by_moderator,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn get_many_for_project(
project_id: DBProjectId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProjectDisclosure>, DatabaseError> {
let rows = sqlx::query!(
r#"
SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator
FROM project_disclosures
WHERE project_id = $1
ORDER BY updated_at DESC
"#,
project_id as DBProjectId,
)
.fetch_all(exec)
.await?;
rows.into_iter()
.map(|row| {
Ok(DBProjectDisclosure {
project_id: DBProjectId(row.project_id),
disclosure: ProjectDisclosure::from_parts(
&row.disclosure_type,
row.metadata,
)
.map_err(|e| {
DatabaseError::Internal(eyre::Report::new(e).wrap_err(
"failed to deserialize project disclosure metadata",
))
})?,
updated_at: row.updated_at,
updated_by: DBUserId(row.updated_by),
set_by_moderator: row.set_by_moderator,
})
})
.collect()
}
pub async fn any_set_by_moderator(
project_id: DBProjectId,
types: &[String],
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let existing = sqlx::query_scalar!(
r#"
SELECT 1 FROM project_disclosures
WHERE project_id = $1 AND type = ANY($2) AND set_by_moderator
LIMIT 1
"#,
project_id as DBProjectId,
types,
)
.fetch_optional(exec)
.await?;
Ok(existing.is_some())
}
pub async fn remove(
project_id: DBProjectId,
disclosure_type: &str,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
r#"
DELETE FROM project_disclosures
WHERE project_id = $1 AND type = $2
"#,
project_id as DBProjectId,
disclosure_type,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
if !args.no_migrations {
database::check_for_migrations()
labrinth::background_task::run_migrations()
.await
.expect("An error occurred while running migrations.");
}
+1
View File
@@ -6,6 +6,7 @@ pub mod v3;
pub use v3::analytics;
pub use v3::billing;
pub use v3::collections;
pub use v3::disclosures;
pub use v3::ids;
pub use v3::images;
pub use v3::moderation_notes;
+107
View File
@@ -0,0 +1,107 @@
use crate::database::models::DBProjectDisclosure;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use strum::IntoStaticStr;
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)]
#[serde(rename_all = "snake_case", tag = "type")]
#[strum(serialize_all = "snake_case")]
pub enum ProjectDisclosure {
AiContent {
note: Option<String>,
},
Advertisements {
note: Option<String>,
},
EpilepsyTriggers {
note: Option<String>,
},
SystemInteractions {
note: Option<String>,
},
Telemetry {
consent: TelemetryConsent,
data_collected: Vec<String>,
},
DerivativeWork {
sources: Vec<DerivativeSource>,
},
PaidFeatures {
features: Vec<String>,
},
}
impl ProjectDisclosure {
pub fn to_parts(
&self,
) -> Result<(&'static str, serde_json::Value), serde_json::Error> {
let serde_json::Value::Object(mut object) = serde_json::to_value(self)?
else {
return Err(serde::ser::Error::custom(
"project disclosure must serialize to a JSON object",
));
};
object.remove("type");
Ok((self.into(), serde_json::Value::Object(object)))
}
pub fn from_parts(
kind: &str,
metadata: serde_json::Value,
) -> Result<Self, serde_json::Error> {
let serde_json::Value::Object(mut object) = metadata else {
return Err(serde::ser::Error::custom(
"project disclosure metadata must be a JSON object, this should never be reachable",
));
};
object.insert(
"type".to_owned(),
serde_json::Value::String(kind.to_owned()),
);
serde_json::from_value(serde_json::Value::Object(object))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProjectDisclosureData {
#[serde(flatten)]
pub disclosure: ProjectDisclosure,
pub set_by_moderator: bool,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<UserId>,
}
impl ProjectDisclosureData {
pub fn from_db(
value: DBProjectDisclosure,
viewer_is_moderator: bool,
) -> Self {
let updated_by = (!value.set_by_moderator || viewer_is_moderator)
.then_some(value.updated_by.into());
Self {
disclosure: value.disclosure,
set_by_moderator: value.set_by_moderator,
updated_at: value.updated_at,
updated_by,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TelemetryConsent {
OptIn,
OptOut,
AlwaysActive,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DerivativeSource {
pub link: Option<String>,
pub label: String,
pub note: Option<String>,
}
+1
View File
@@ -3,6 +3,7 @@ pub mod analytics;
pub mod analytics_event;
pub mod billing;
pub mod collections;
pub mod disclosures;
pub mod ids;
pub mod images;
pub mod moderation_notes;
+202
View File
@@ -0,0 +1,202 @@
use actix_web::{HttpRequest, get, patch, web};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use xredis::RedisPool;
use crate::auth::checks::is_visible_project;
use crate::auth::get_user_from_headers;
use crate::database::models as db_models;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureData};
use crate::models::pats::Scopes;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::error::Context;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get_project_disclosures)
.service(modify_project_disclosures);
}
#[derive(Serialize, ToSchema)]
pub struct GetProjectDisclosures {
pub disclosures: Vec<ProjectDisclosureData>,
}
#[utoipa::path(
context_path = "/project",
tag = "project_disclosures",
responses((status = OK, body = GetProjectDisclosures))
)]
#[get("/{project_id}/disclosures")]
pub async fn get_project_disclosures(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<GetProjectDisclosures>, ApiError> {
let (string,) = info.into_inner();
let project = db_models::DBProject::get(&string, &***ro_pool, &redis)
.await
.wrap_internal_err("failed to fetch project")?
.ok_or(ApiError::NotFound)?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await
.map(|(_, user)| user)
.ok();
if !is_visible_project(&project.inner, &user_option, &pool, false)
.await
.wrap_internal_err("failed to check project visibility")?
{
return Err(ApiError::NotFound);
}
let viewer_is_moderator =
user_option.is_some_and(|user| user.role.is_mod());
let disclosures = db_models::DBProjectDisclosure::get_many_for_project(
project.inner.id,
&***ro_pool,
)
.await
.wrap_internal_err("failed to fetch project disclosures")?;
Ok(web::Json(GetProjectDisclosures {
disclosures: disclosures
.into_iter()
.map(|disclosure| {
ProjectDisclosureData::from_db(disclosure, viewer_is_moderator)
})
.collect(),
}))
}
#[derive(Deserialize, ToSchema)]
pub struct ModifyProjectDisclosures {
pub set: Vec<ProjectDisclosure>,
pub remove: Vec<String>,
}
#[utoipa::path(
context_path = "/project",
tag = "project_disclosures",
request_body = ModifyProjectDisclosures,
responses((status = NO_CONTENT))
)]
#[patch("/{project_id}/disclosures")]
pub async fn modify_project_disclosures(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<ModifyProjectDisclosures>,
) -> Result<(), ApiError> {
let (string,) = info.into_inner();
let body = body.into_inner();
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?
.1;
let project = db_models::DBProject::get(&string, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch project")?
.ok_or(ApiError::NotFound)?;
let (team_member, organization_team_member) =
db_models::DBTeamMember::get_for_project_permissions(
&project.inner,
user.id.into(),
&**pool,
)
.await
.wrap_internal_err("failed to fetch project permissions")?;
let can_edit_details = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.is_some_and(|perms| perms.contains(ProjectPermissions::EDIT_DETAILS));
if !can_edit_details {
return Err(ApiError::CustomAuthentication(
"you do not have permission to edit this project's disclosures"
.to_string(),
));
}
if !user.role.is_mod() {
let modified_types = body
.set
.iter()
.map(|disclosure| <&'static str>::from(disclosure).to_owned())
.chain(body.remove.iter().cloned())
.collect::<Vec<_>>();
if db_models::DBProjectDisclosure::any_set_by_moderator(
project.inner.id,
&modified_types,
&**pool,
)
.await
.wrap_internal_err("failed to check moderator disclosures")?
{
return Err(ApiError::CustomAuthentication(
"you cannot modify a disclosure set by a moderator".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
for disclosure in body.set {
db_models::DBProjectDisclosure {
project_id: project.inner.id,
disclosure,
updated_at: Utc::now(),
updated_by: user.id.into(),
set_by_moderator: user.role.is_mod(),
}
.upsert(&mut transaction)
.await
.wrap_internal_err("failed to upsert project disclosure")?;
}
for disclosure_type in &body.remove {
db_models::DBProjectDisclosure::remove(
project.inner.id,
disclosure_type,
&mut transaction,
)
.await
.wrap_internal_err("failed to remove project disclosure")?;
}
transaction
.commit()
.await
.wrap_internal_err("failed to commit project disclosure changes")?;
Ok(())
}
+5 -1
View File
@@ -9,6 +9,7 @@ pub mod analytics_get;
pub mod blocked_users;
pub mod collections;
pub mod content;
pub mod disclosures;
pub mod friends;
pub mod images;
pub mod limits;
@@ -44,7 +45,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::scope("/v3/project")
.wrap(default_cors())
.configure(projects::project_config)
.configure(project_creation::config),
.configure(project_creation::config)
.configure(disclosures::config),
);
cfg.service(
web::scope("/v3")
@@ -111,6 +113,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
projects::project_unfollow,
projects::project_get_organization,
projects::dependency_list,
disclosures::get_project_disclosures,
disclosures::modify_project_disclosures,
project_creation::project_create,
project_creation::project_create_with_id,
project_creation::new::create,
+4
View File
@@ -37,6 +37,10 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let search_backend = db.search_backend.clone();
let file_host: Arc<dyn FileHost> = Arc::new(file_hosting::MockHost::new());
let file_host = web::Data::<dyn FileHost>::from(file_host);
clickhouse::create_database(&ENV.CLICKHOUSE_DATABASE)
.await
.unwrap();
clickhouse::run_migrations().await.unwrap();
let mut clickhouse = clickhouse::init_client().await.unwrap();
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
+6
View File
@@ -10,6 +10,12 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-31T06:10:23+00:00`,
product: 'web',
body: `## Fixed
- Fixed randomly getting signed out of Modrinth account due to random non-auth related errors.`,
},
{
date: `2026-07-29T21:32:06+00:00`,
product: 'app',