You've already forked AstralRinth
Implement analytics marker events (#6090)
* Analytics events * prepare * change route prefix * update route return * Add mod launcher analytics * more UA strings * fix ci * caching on analytics events * Return parent modpack versions for playtime queries * sqlx prepare * fmt * dummy fixtures
This commit is contained in:
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tUPDATE analytics_events\n\t\t\tSET meta = $2, starts = $3, ends = $4\n\t\t\tWHERE id = $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Timestamptz",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tINSERT INTO analytics_events (id, meta, starts, ends)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Timestamptz",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM analytics_events WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tDELETE FROM analytics_events\n\t\t\tWHERE id = $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08"
|
||||
}
|
||||
Generated
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tSELECT id, meta AS \"meta: Json<AnalyticsEventMeta>\", starts, ends\n\t\t\tFROM analytics_events\n\t\t\tORDER BY starts DESC\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "meta: Json<AnalyticsEventMeta>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "starts",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "ends",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, mod_id\n FROM versions\n WHERE mod_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "mod_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
- Use `ApiError` as the error type for API routes
|
||||
- Prefer `ApiError::Internal` and `ApiError::Request` over `ApiError::InvalidInput`
|
||||
- The return type of an HTTP route should not be `HttpResponse` if possible; always prefer more specific types
|
||||
- Use `web::Json<T>` for JSON-encoded response
|
||||
- Use `()` for no content
|
||||
- Prefer `ApiError` variants:
|
||||
- `ApiError::Request` instead of `ApiError::InvalidInput`
|
||||
- `ApiError::Auth` instead of `ApiError::CustomAuthentication`
|
||||
- `ApiError::Internal` for database errors, 3rd party service errors, anything else internal
|
||||
- Use `eyre!` to construct a value for `Internal` and `Request` variants
|
||||
- Error messages (both for errors and exceptions) must be formatted as per the Rust API guidelines:
|
||||
- lowercase message
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Dummy ClickHouse download rows for exercising v3 analytics download-source normalization.
|
||||
-- Run this against the analytics ClickHouse database, for example:
|
||||
-- curl -u default:default 'http://localhost:8123/?database=staging_ariadne' --data-binary @apps/labrinth/fixtures/analytics-changes-clickhouse.sql
|
||||
--
|
||||
-- Project 910000000000003 = 4AP3jpvKl
|
||||
|
||||
INSERT INTO downloads FORMAT JSONEachRow
|
||||
{"recorded":"2026-05-13 00:05:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/kyros/1.0.0 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:10:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/theseus/0.8.6 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:15:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Gradle/8.8","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:20:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"MultiMC/5.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:25:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PrismLauncher/6.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:30:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PolyMC/7.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:35:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FeatherLauncher/2.6.12-c Chrome/144.0.7559.236 Electron/40.9.1 Safari/537.36 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:40:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"FeatherMC/Feather Client Rust Launcher/1.0.0 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:45:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Feather/9b6bb39d Safari/537.36","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:50:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PandoraLauncher/1.2.3","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:55:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 AppleWebKit/605.1.15","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
{"recorded":"2026-05-13 00:59:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"curl/8.7.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Dummy analytics data for exercising v3 analytics events and project download analytics.
|
||||
-- IDs are listed as integers, followed by their equivalent base62 representation.
|
||||
|
||||
-- User 103587649610509 = 1XZwx9qL
|
||||
INSERT INTO users (
|
||||
id, username, email, role, badges, balance, email_verified
|
||||
)
|
||||
VALUES (
|
||||
103587649610509, 'Analytics Admin', 'analytics-admin@modrinth.com',
|
||||
'admin', 0, 0, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = EXCLUDED.role;
|
||||
|
||||
INSERT INTO sessions (
|
||||
id, session, user_id, expires, refresh_expires, ip, user_agent
|
||||
)
|
||||
VALUES (
|
||||
103587649610510, 'mra_analytics_admin', 103587649610509,
|
||||
'2030-01-01T00:00:00Z', '2030-01-01T00:00:00Z',
|
||||
'127.0.0.1', 'analytics fixture'
|
||||
)
|
||||
ON CONFLICT (session) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
expires = EXCLUDED.expires,
|
||||
refresh_expires = EXCLUDED.refresh_expires;
|
||||
|
||||
-- Project 910000000000003 = 4AP3jpvKl
|
||||
-- Team 910000000000001 = 4AP3jpvKj
|
||||
-- Thread 910000000000004 = 4AP3jpvKm
|
||||
INSERT INTO teams (id)
|
||||
VALUES (910000000000001)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO team_members (
|
||||
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
|
||||
organization_permissions, is_owner
|
||||
)
|
||||
VALUES (
|
||||
910000000000002, 910000000000001, 103587649610509, 'Owner',
|
||||
1023, TRUE, 100, 0, NULL, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
permissions = EXCLUDED.permissions,
|
||||
accepted = EXCLUDED.accepted,
|
||||
is_owner = EXCLUDED.is_owner;
|
||||
|
||||
INSERT INTO mods (
|
||||
id, team_id, name, summary, downloads, slug, description, follows,
|
||||
license, status, requested_status, monetization_status,
|
||||
side_types_migration_review_status, components
|
||||
)
|
||||
VALUES (
|
||||
910000000000003, 910000000000001, 'Analytics Fixture Project',
|
||||
'Project used by analytics fixture data.', 0, 'analytics-fixture-project',
|
||||
'', 0, 'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
|
||||
'monetized', 'reviewed', '{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
status = EXCLUDED.status,
|
||||
requested_status = EXCLUDED.requested_status,
|
||||
monetization_status = EXCLUDED.monetization_status;
|
||||
|
||||
INSERT INTO threads (id, thread_type, mod_id)
|
||||
VALUES (910000000000004, 'project', 910000000000003)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Analytics events used to test /v3/analytics-event and Redis caching.
|
||||
-- Event 910000000000101 = 4AP3jpvMR
|
||||
-- Event 910000000000102 = 4AP3jpvMS
|
||||
INSERT INTO analytics_events (id, meta, starts, ends)
|
||||
VALUES
|
||||
(
|
||||
910000000000101,
|
||||
'{
|
||||
"title": "Downloads launch",
|
||||
"announcement_url": "https://modrinth.com/news/downloads-launch",
|
||||
"for_metric_kind": ["downloads"]
|
||||
}'::jsonb,
|
||||
'2026-05-13T00:00:00Z',
|
||||
'2026-05-14T00:00:00Z'
|
||||
),
|
||||
(
|
||||
910000000000102,
|
||||
'{
|
||||
"title": "Revenue promo",
|
||||
"announcement_url": "https://modrinth.com/news/revenue-promo",
|
||||
"for_metric_kind": ["revenue"]
|
||||
}'::jsonb,
|
||||
'2026-05-14T00:00:00Z',
|
||||
'2026-05-15T00:00:00Z'
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
meta = EXCLUDED.meta,
|
||||
starts = EXCLUDED.starts,
|
||||
ends = EXCLUDED.ends;
|
||||
@@ -0,0 +1,6 @@
|
||||
create table analytics_events (
|
||||
id bigint primary key,
|
||||
meta jsonb not null,
|
||||
starts timestamptz not null,
|
||||
ends timestamptz not null
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use sqlx::types::Json;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
models::{DBAnalyticsEventId, DatabaseError},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::v3::analytics_event::AnalyticsEventMeta,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events";
|
||||
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DBAnalyticsEvent {
|
||||
pub id: DBAnalyticsEventId,
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl DBAnalyticsEvent {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO analytics_events (id, meta, starts, ends)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
",
|
||||
self.id as DBAnalyticsEventId,
|
||||
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
|
||||
self.starts,
|
||||
self.ends,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE analytics_events
|
||||
SET meta = $2, starts = $3, ends = $4
|
||||
WHERE id = $1
|
||||
",
|
||||
self.id as DBAnalyticsEventId,
|
||||
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
|
||||
self.starts,
|
||||
self.ends,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
id: DBAnalyticsEventId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
DELETE FROM analytics_events
|
||||
WHERE id = $1
|
||||
",
|
||||
id as DBAnalyticsEventId,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBAnalyticsEvent>, DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
if let Some(events) = redis
|
||||
.get_deserialized_from_json(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
let events = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, meta AS "meta: Json<AnalyticsEventMeta>", starts, ends
|
||||
FROM analytics_events
|
||||
ORDER BY starts DESC
|
||||
"#
|
||||
)
|
||||
.fetch(exec)
|
||||
.map(|record| {
|
||||
let record = record?;
|
||||
|
||||
Ok::<_, DatabaseError>(DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId(record.id),
|
||||
meta: record.meta.0,
|
||||
starts: record.starts,
|
||||
ends: record.ends,
|
||||
})
|
||||
})
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
&events,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub async fn clear_cache(redis: &RedisPool) -> Result<(), DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.delete(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId,
|
||||
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
|
||||
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
|
||||
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
|
||||
UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
|
||||
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
|
||||
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
|
||||
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
|
||||
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
|
||||
ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -269,6 +269,10 @@ db_id_interface!(
|
||||
AffiliateCodeId,
|
||||
generator: generate_affiliate_code_id @ "affiliate_codes",
|
||||
);
|
||||
db_id_interface!(
|
||||
AnalyticsEventId,
|
||||
generator: generate_analytics_event_id @ "analytics_events",
|
||||
);
|
||||
|
||||
id_type!(CategoryId as i32);
|
||||
id_type!(GameId as i32);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod affiliate_code_item;
|
||||
pub mod analytics_event_item;
|
||||
pub mod categories;
|
||||
pub mod charge_item;
|
||||
pub mod collection_item;
|
||||
@@ -44,6 +45,7 @@ pub mod users_subscriptions_credits;
|
||||
pub mod version_item;
|
||||
|
||||
pub use affiliate_code_item::DBAffiliateCode;
|
||||
pub use analytics_event_item::DBAnalyticsEvent;
|
||||
pub use collection_item::DBCollection;
|
||||
pub use ids::*;
|
||||
pub use image_item::DBImage;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::ids::AnalyticsEventId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEvent {
|
||||
pub id: AnalyticsEventId,
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventMeta {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub announcement_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub for_metric_kind: HashSet<MetricKind>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetricKind {
|
||||
Views,
|
||||
Downloads,
|
||||
Playtime,
|
||||
Revenue,
|
||||
}
|
||||
|
||||
impl From<crate::database::models::DBAnalyticsEvent> for AnalyticsEvent {
|
||||
fn from(data: crate::database::models::DBAnalyticsEvent) -> Self {
|
||||
Self {
|
||||
id: data.id.into(),
|
||||
meta: data.meta,
|
||||
starts: data.starts,
|
||||
ends: data.ends,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
|
||||
base62_id!(UserSubscriptionId);
|
||||
base62_id!(VersionId);
|
||||
base62_id!(AffiliateCodeId);
|
||||
base62_id!(AnalyticsEventId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod affiliate_code;
|
||||
pub mod analytics;
|
||||
pub mod analytics_event;
|
||||
pub mod billing;
|
||||
pub mod collections;
|
||||
pub mod ids;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(analytics_events_get)
|
||||
.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>,
|
||||
}
|
||||
|
||||
/// Fetches all analytics events.
|
||||
#[utoipa::path(responses((status = OK, body = Vec<AnalyticsEvent>)))]
|
||||
#[get("")]
|
||||
pub async fn analytics_events_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<web::Json<Vec<AnalyticsEvent>>, ApiError> {
|
||||
let events = DBAnalyticsEvent::get_all(&**pool, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch analytics events")?
|
||||
.into_iter()
|
||||
.map(AnalyticsEvent::from)
|
||||
.collect();
|
||||
|
||||
Ok(web::Json(events))
|
||||
}
|
||||
|
||||
/// Creates an analytics event.
|
||||
#[utoipa::path(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()))
|
||||
}
|
||||
|
||||
/// Edits an analytics event.
|
||||
#[utoipa::path(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()))
|
||||
}
|
||||
|
||||
/// Deletes an analytics event.
|
||||
#[utoipa::path(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(())
|
||||
}
|
||||
@@ -9,15 +9,16 @@
|
||||
|
||||
mod old;
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
use std::{num::NonZeroU64, sync::LazyLock};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use eyre::eyre;
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticationError, get_user_from_headers},
|
||||
@@ -167,8 +168,8 @@ pub enum ProjectDownloadsField {
|
||||
VersionId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
||||
SitePath,
|
||||
/// Normalized user agent used to download this project.
|
||||
UserAgent,
|
||||
/// Whether these downloads were monetized or not.
|
||||
Monetized,
|
||||
/// What country these downloads came from.
|
||||
@@ -325,9 +326,9 @@ pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::Domain`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
domain: Option<String>,
|
||||
/// [`ProjectDownloadsField::SitePath`].
|
||||
/// [`ProjectDownloadsField::UserAgent`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
site_path: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version_id: Option<VersionId>,
|
||||
@@ -350,6 +351,56 @@ pub struct ProjectDownloads {
|
||||
downloads: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)]
|
||||
pub enum DownloadSource {
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
Other,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Serialize for DownloadSource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Named(name) => serializer.serialize_str(name),
|
||||
Self::Website => serializer.serialize_str("website"),
|
||||
Self::ModrinthApp => serializer.serialize_str("modrinth_app"),
|
||||
Self::ModrinthHosting => {
|
||||
serializer.serialize_str("modrinth_hosting")
|
||||
}
|
||||
Self::ModrinthMaven => serializer.serialize_str("modrinth_maven"),
|
||||
Self::Other => serializer.serialize_str("other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DownloadSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = String::deserialize(deserializer)?;
|
||||
Ok(match source.as_str() {
|
||||
"website" => Self::Website,
|
||||
"modrinth_app" => Self::ModrinthApp,
|
||||
"modrinth_hosting" => Self::ModrinthHosting,
|
||||
"modrinth_maven" => Self::ModrinthMaven,
|
||||
"other" => Self::Other,
|
||||
_ if !source.is_empty() => Self::Named(source),
|
||||
_ => {
|
||||
return Err(D::Error::custom(
|
||||
"download source cannot be empty",
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ReturnMetrics::project_playtime`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytime {
|
||||
@@ -476,7 +527,7 @@ mod query {
|
||||
pub bucket: u64,
|
||||
pub project_id: DBProjectId,
|
||||
pub domain: String,
|
||||
pub site_path: String,
|
||||
pub user_agent: String,
|
||||
pub version_id: DBVersionId,
|
||||
pub monetized: i8,
|
||||
pub country: String,
|
||||
@@ -489,7 +540,7 @@ mod query {
|
||||
pub const DOWNLOADS: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
@@ -502,7 +553,7 @@ mod query {
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
@@ -517,7 +568,7 @@ mod query {
|
||||
-- not the possibly-zero one,
|
||||
-- by using `downloads.project_id` instead of `project_id`
|
||||
AND downloads.project_id IN {PROJECT_IDS}
|
||||
GROUP BY bucket, project_id, domain, site_path, version_id, monetized, country, reason, game_version, loader"
|
||||
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
@@ -538,23 +589,48 @@ mod query {
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const PARENT_VERSION_IDS: &str = "{parent_version_ids: Array(UInt64)}";
|
||||
const PARENT_VERSION_PROJECT_IDS: &str =
|
||||
"{parent_version_project_ids: Array(UInt64)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
bucket,
|
||||
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
|
||||
version_id,
|
||||
loader,
|
||||
game_version,
|
||||
country,
|
||||
SUM(seconds) AS seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL project id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `playtime.project_id` instead of `project_id`
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
FROM (
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
project_id AS source_project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
transform(parent, {PARENT_VERSION_IDS}, {PARENT_VERSION_PROJECT_IDS}) AS source_project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND parent IN {PARENT_VERSION_IDS}
|
||||
)
|
||||
GROUP BY bucket, project_id, version_id, loader, game_version, country"
|
||||
)
|
||||
};
|
||||
@@ -672,6 +748,27 @@ pub async fn fetch_analytics(
|
||||
let project_ids =
|
||||
filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?;
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let parent_versions = sqlx::query!(
|
||||
"
|
||||
SELECT id, mod_id
|
||||
FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_id_values,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
let parent_version_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBVersionId(version.id))
|
||||
.collect::<Vec<_>>();
|
||||
let parent_version_project_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBProjectId(version.mod_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let affiliate_code_ids =
|
||||
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
|
||||
.await?
|
||||
@@ -684,6 +781,8 @@ pub async fn fetch_analytics(
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
parent_version_ids: &parent_version_ids,
|
||||
parent_version_project_ids: &parent_version_project_ids,
|
||||
affiliate_code_ids: &affiliate_code_ids,
|
||||
};
|
||||
|
||||
@@ -737,7 +836,7 @@ pub async fn fetch_analytics(
|
||||
&[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
("use_site_path", uses(F::SitePath)),
|
||||
("use_user_agent", uses(F::UserAgent)),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
@@ -756,7 +855,11 @@ pub async fn fetch_analytics(
|
||||
source_project: row.project_id.into(),
|
||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||
domain: none_if_empty(row.domain),
|
||||
site_path: none_if_empty(row.site_path),
|
||||
user_agent: if uses(F::UserAgent) {
|
||||
normalize_download_source(&row.user_agent)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
version_id: none_if_zero_version_id(row.version_id),
|
||||
monetized: match row.monetized {
|
||||
0 => Some(false),
|
||||
@@ -840,9 +943,6 @@ pub async fn fetch_analytics(
|
||||
return Err(AuthenticationError::InvalidCredentials.into());
|
||||
}
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
|
||||
let mut rows = sqlx::query!(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
@@ -1016,6 +1116,76 @@ fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
|
||||
if v.0 == 0 { None } else { Some(v.into()) }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DownloadSourcePattern {
|
||||
Named(&'static str),
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
}
|
||||
|
||||
impl DownloadSourcePattern {
|
||||
fn into_source(self) -> DownloadSource {
|
||||
match self {
|
||||
Self::Named(name) => DownloadSource::Named(name.into()),
|
||||
Self::Website => DownloadSource::Website,
|
||||
Self::ModrinthApp => DownloadSource::ModrinthApp,
|
||||
Self::ModrinthHosting => DownloadSource::ModrinthHosting,
|
||||
Self::ModrinthMaven => DownloadSource::ModrinthMaven,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
LazyLock::new(|| {
|
||||
use DownloadSourcePattern as P;
|
||||
|
||||
[
|
||||
(r"^modrinth/kyros/", P::ModrinthHosting),
|
||||
(r"^modrinth/theseus/", P::ModrinthApp),
|
||||
(r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven),
|
||||
(r"^MultiMC/", P::Named("MultiMC")),
|
||||
(r"^PrismLauncher/", P::Named("Prism Launcher")),
|
||||
(r"^PolyMC/", P::Named("PolyMC")),
|
||||
(r"^FCL/", P::Named("FCL")),
|
||||
(r"^PCL2/", P::Named("PCL2")),
|
||||
(r"^HMCL/", P::Named("HMCL")),
|
||||
(r"^Lunar Client Launcher", P::Named("Lunar Client")),
|
||||
(r"^PojavLauncher", P::Named("PojavLauncher")),
|
||||
(r"^ATLauncher/", P::Named("ATLauncher")),
|
||||
(r"FeatherLauncher/", P::Named("Feather Client")),
|
||||
(
|
||||
r"^FeatherMC/Feather Client Rust Launcher/",
|
||||
P::Named("Feather Client"),
|
||||
),
|
||||
(r"Feather/[0-9A-Za-z]+", P::Named("Feather Client")),
|
||||
(r"^PandoraLauncher/", P::Named("Pandora Launcher")),
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, source)| {
|
||||
(
|
||||
Regex::new(pattern)
|
||||
.expect("download source regex should be valid"),
|
||||
source,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn normalize_download_source(user_agent: &str) -> Option<DownloadSource> {
|
||||
DOWNLOAD_SOURCE_PATTERNS.iter().find_map(|(regex, source)| {
|
||||
regex.is_match(user_agent).then(|| source.into_source())
|
||||
})
|
||||
}
|
||||
|
||||
fn condense_country(country: String, count: u64) -> String {
|
||||
// Every country under '50' (view or downloads) should be condensed into 'XX'
|
||||
if count < 50 {
|
||||
@@ -1030,6 +1200,8 @@ struct QueryClickhouseContext<'a> {
|
||||
req: &'a GetRequest,
|
||||
time_slices: &'a mut [TimeSlice],
|
||||
project_ids: &'a [DBProjectId],
|
||||
parent_version_ids: &'a [DBVersionId],
|
||||
parent_version_project_ids: &'a [DBProjectId],
|
||||
affiliate_code_ids: &'a [DBAffiliateCodeId],
|
||||
}
|
||||
|
||||
@@ -1051,6 +1223,8 @@ where
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len())
|
||||
.param("project_ids", cx.project_ids)
|
||||
.param("parent_version_ids", cx.parent_version_ids)
|
||||
.param("parent_version_project_ids", cx.parent_version_project_ids)
|
||||
.param("affiliate_code_ids", cx.affiliate_code_ids);
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
@@ -1170,6 +1344,51 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_download_sources() {
|
||||
let cases = [
|
||||
("MultiMC/5.0", Some(DownloadSource::Named("MultiMC".into()))),
|
||||
(
|
||||
"PrismLauncher/6.1",
|
||||
Some(DownloadSource::Named("Prism Launcher".into())),
|
||||
),
|
||||
(
|
||||
"modrinth/theseus/0.8.6 (support@modrinth.com)",
|
||||
Some(DownloadSource::ModrinthApp),
|
||||
),
|
||||
(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
Some(DownloadSource::Website),
|
||||
),
|
||||
("curl/8.7.1", None),
|
||||
];
|
||||
|
||||
for (user_agent, source) in cases {
|
||||
assert_eq!(normalize_download_source(user_agent), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_source_serializes_as_raw_string() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Named("MultiMC".into()))
|
||||
.unwrap(),
|
||||
json!("MultiMC")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Website).unwrap(),
|
||||
json!("website")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::ModrinthApp).unwrap(),
|
||||
json!("modrinth_app")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Other).unwrap(),
|
||||
json!("other")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_format() {
|
||||
let test_project_1 = ProjectId(123);
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::util::cors::default_cors;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod analytics_event;
|
||||
pub mod analytics_get;
|
||||
pub mod collections;
|
||||
pub mod friends;
|
||||
@@ -59,6 +60,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_get::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_event::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/payout")
|
||||
.wrap(default_cors())
|
||||
|
||||
Reference in New Issue
Block a user