mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
Compare commits
10
Commits
22b2b7746e
...
8b753a52ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b753a52ad | ||
|
|
a53c9c3771 | ||
|
|
919b235621 | ||
|
|
18b0b2848d | ||
|
|
65a3ac4b34 | ||
|
|
295db3fb7a | ||
|
|
7abe6f8c73 | ||
|
|
c5ce5bc9b3 | ||
|
|
572b8027ff | ||
|
|
0a53211389 |
@@ -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};`)}`);
|
||||
|
||||
@@ -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,19 @@
|
||||
name: docker-build
|
||||
name: Labrinth Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'prod'
|
||||
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 +70,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 +122,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' }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,9 +485,9 @@ async function saveEvent() {
|
||||
const payload = buildEventPayload()
|
||||
|
||||
if (modalMode.value === 'edit' && editingEventId.value !== null) {
|
||||
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
|
||||
await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload)
|
||||
} else {
|
||||
await client.labrinth.analytics_v3.createEvent(payload)
|
||||
await client.labrinth.analytics_internal.createEvent(payload)
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
|
||||
@@ -528,7 +528,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
|
||||
setDeletingEvent(eventId, true)
|
||||
|
||||
try {
|
||||
await client.labrinth.analytics_v3.deleteEvent(eventId)
|
||||
await client.labrinth.analytics_internal.deleteEvent(eventId)
|
||||
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
|
||||
addNotification({
|
||||
title: 'Analytics event deleted',
|
||||
@@ -581,7 +581,7 @@ function commitAnnouncementUrl() {
|
||||
committedAnnouncementUrl.value = form.value.announcementUrl
|
||||
}
|
||||
|
||||
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
|
||||
function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert {
|
||||
const selectedRange = getEventFormDateRange()
|
||||
if (!selectedRange) {
|
||||
throw new Error('Select a valid start and end date')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use actix_web::{HttpRequest, delete, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Update an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Delete an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod affiliate;
|
||||
pub mod analytics_event;
|
||||
pub mod attribution;
|
||||
pub mod billing;
|
||||
pub mod blocked_users;
|
||||
@@ -35,6 +36,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(flows::config)
|
||||
.configure(pats::config)
|
||||
.configure(oauth_clients::config)
|
||||
.service(
|
||||
web::scope("/analytics-event")
|
||||
.configure(analytics_event::config),
|
||||
)
|
||||
.service(web::scope("/moderation").configure(moderation::config))
|
||||
.service(web::scope("/affiliate").configure(affiliate::config))
|
||||
.service(web::scope("/campaign").configure(campaign::config))
|
||||
@@ -50,11 +55,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(medal::config)
|
||||
.configure(mural::config)
|
||||
.configure(statuses::config),
|
||||
)
|
||||
.service(
|
||||
web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(super::v3::analytics_event::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,10 +180,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
medal::redeem,
|
||||
mural::get_bank_details,
|
||||
statuses::ws_init,
|
||||
super::v3::analytics_event::analytics_events_get,
|
||||
super::v3::analytics_event::analytics_event_create,
|
||||
super::v3::analytics_event::analytics_event_edit,
|
||||
super::v3::analytics_event::analytics_event_delete,
|
||||
analytics_event::analytics_event_create,
|
||||
analytics_event::analytics_event_edit,
|
||||
analytics_event::analytics_event_delete,
|
||||
),
|
||||
modifiers(&InternalPathModifier, &SecurityAddon)
|
||||
)]
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use actix_web::{get, web};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
database::{PgPool, models::DBAnalyticsEvent},
|
||||
models::v3::analytics_event::AnalyticsEvent,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(analytics_events_get)
|
||||
.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
cfg.service(analytics_events_get);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List analytics events.
|
||||
/// List analytics events.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = Vec<AnalyticsEvent>))
|
||||
)]
|
||||
#[get("")]
|
||||
#[get("/analytics-event")]
|
||||
pub async fn analytics_events_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -56,158 +30,3 @@ pub async fn analytics_events_get(
|
||||
|
||||
Ok(web::Json(events))
|
||||
}
|
||||
|
||||
/// Create an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Update an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Delete an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/v3")
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_event::config)
|
||||
.configure(limits::config)
|
||||
.configure(collections::config)
|
||||
.configure(images::config)
|
||||
@@ -80,6 +81,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
description = include_str!("../../api_v3_description.md"),
|
||||
),
|
||||
paths(
|
||||
analytics_event::analytics_events_get,
|
||||
analytics_get::fetch_analytics,
|
||||
analytics_get::facets::fetch_facets,
|
||||
analytics_get::old::playtimes_get,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -19,6 +19,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAnalyticsInternalModule } from './labrinth/analytics/internal'
|
||||
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
|
||||
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
@@ -97,6 +98,7 @@ export const MODULE_REGISTRY = {
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_analytics_internal: LabrinthAnalyticsInternalModule,
|
||||
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthAnalyticsInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_analytics_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analytics event.
|
||||
* POST /_internal/analytics-event
|
||||
*/
|
||||
public async createEvent(
|
||||
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an analytics event.
|
||||
* PATCH /_internal/analytics-event/{id}
|
||||
*/
|
||||
public async editEvent(
|
||||
id: Labrinth.Analytics.v3.AnalyticsEventId,
|
||||
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an analytics event.
|
||||
* DELETE /_internal/analytics-event/{id}
|
||||
*/
|
||||
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
|
||||
return this.client.request<void>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,47 +70,4 @@ export class LabrinthAnalyticsV3Module extends AbstractModule {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analytics event.
|
||||
* POST /v3/analytics-event
|
||||
*/
|
||||
public async createEvent(
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an analytics event.
|
||||
* PATCH /v3/analytics-event/{id}
|
||||
*/
|
||||
public async editEvent(
|
||||
id: Labrinth.Analytics.v3.AnalyticsEventId,
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an analytics event.
|
||||
* DELETE /v3/analytics-event/{id}
|
||||
*/
|
||||
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
|
||||
return this.client.request<void>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './analytics/internal'
|
||||
export * from './analytics/v3'
|
||||
export * from './attribution/internal'
|
||||
export * from './auth/internal'
|
||||
|
||||
@@ -463,6 +463,16 @@ export namespace Labrinth {
|
||||
}
|
||||
|
||||
export namespace Analytics {
|
||||
export namespace Internal {
|
||||
export type AnalyticsEventUpsert = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: v3.AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
starts: string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v3 {
|
||||
export type AnalyticsEventId = number
|
||||
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
|
||||
@@ -476,14 +486,6 @@ export namespace Labrinth {
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type AnalyticsEventUpsert = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type FetchRequest = {
|
||||
time_range: TimeRange
|
||||
return_metrics: ReturnMetrics
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user