Compare commits

...
Author SHA1 Message Date
aecsocket 707c3e8b80 Squashed Elasticsearch impl 2026-03-05 17:11:16 +00:00
NitrrineandGitHub 9d5d34fde8 feat: backport servers tab to legacy navbar (#5471)
* feat: backport servers tab to legacy navbar

* style: make gap between projects types tighter
2026-03-04 22:51:58 +00:00
Prospector 2d5c26896f fix blog date + app changelog 2026-03-04 14:41:52 -08:00
ea3bb334a8 Fix muralpay pix payouts in backend (#5463)
* Fix fields for pix payout

* Fix muralpay pix backend

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-03-04 13:10:31 +00:00
024e079a7d chore: the blog (#5453)
* feat: start on blogpost

* feat: images for post

* fix: grammar + prerender news + changelog

* feat: add discovery in app vid

* fix lint

* rename new blog md to match title

* fix assets directories

* remove left over compiled files

* update thumbnail

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-03-04 02:18:08 +01:00
aecsocketandGitHub d902b281f7 comment out impl (#5468) 2026-03-04 01:03:20 +01:00
Truman GaoandGitHub c4a0008708 fix: invalid args path (#5467)
* fix port in server address

* fix: invalid args for path

* fix page loading

* hide labels
2026-03-04 01:03:12 +01:00
Truman GaoandGitHub 835f80ee50 fix port in server address (#5466) 2026-03-04 00:20:52 +01:00
155f4091a6 Tweak search sorting (#5464)
* Tweak search sorting

* Tweak search sorting

* fix ping impl

* remove port field, add server regions

* fix compile

* fix tests

* update frontend banner upload size limit

* feat: use server project region instead of country

* remove java and bedrock port in frontend

* add helper text

* allow filtering by if server is online

* add server status online offline filter

* use region in instance

* pre-collapse status in app discovery

* pnpm prepr

* remove server discovery flag

* add servers into mobile nav tabs

* parse port from address if present

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-03-03 23:20:48 +01:00
Truman GaoandGitHub e1ee9c364b disable update modpack for server project (#5465) 2026-03-03 20:24:53 +00:00
0029a22569 fix: app cache and other issues (#5460)
* fixes

* #[serde(untagged)] my BEHATED (still kinda broken)

* remove unused hasContent ref

* clean up code in fetch instance

* ping 3 times for average latency

* fix: pinging to be more accurate

TCP_NODELAY — Set on the TCP stream right after connect, preventing Nagle's algorithm from buffering the small ping packet (could save up to ~40ms)

Instant over Utc::now() — Switched to monotonic std::time::Instant for timing, which is more precise and designed for measuring elapsed time (still using chrono just for the ping magic value)

* delete useFetch util and just use native fetch

* rename worlds until functions for more clarity

* fix lint

* fix cache.rs logic

* make backend ping use both impls

* Add optional timeout to server ping

* fix gallery appearing in nav with no items

* remove EU countries and add EU option for server country

* add uk to europe

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
2026-03-03 18:41:12 +00:00
211ec20970 fix: remove tax compliance env var (#5445)
* fix: remove tax compliance env var

* improve tax compliance year logic

* bit more tests

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
2026-03-03 17:45:28 +00:00
aecsocketandGitHub 34997bada5 Rescan tech review reports when a new version of Delphi is ran (#5433)
* Delphi rescan when version changes

* Fix inserting duplicate reports when rescanning

* upsert report issue details instead of deleting

* fix up rescan stuff
2026-03-03 17:44:30 +00:00
aecsocketandGitHub 63daac917b Fix Mural API response schema (#5454)
* Fix Mural API response schema

* Fix clippy

* fix compile

* fix compile
2026-03-03 17:17:44 +00:00
51ceb9d851 feat: linked server instances (#5221)
* ping queue with tests

* mc ping server info + timeout

* sqlx prepare

* tombi fmt

* tombi fmt

* allow querying server ping data

* fix shear

* wip: resolve comments with pings

* Switch to Redis for server pings

* tombi fmt

* fix compile error

* clear cache on project ping, add server store link

* Schema changes

* Improve server messages for app pinging

* synthetic server project version for search indexing

* wip: clean up server ping, background tasks

* fix migration to sync with main, propagate background task errors

* wip: server modpack content query, components in search

* wip: massive component query refactor

* fix more defaults stuff

* sqlx

* fix serde deser flatten

* fix search indexing not showing fields

* remove leftover prompt

* fix import

* add diff detection for version dependencies without version_id/project_id

* move servers tab to end

* hide app nav tabs if only one tab

* fix undefined property

* on click link for server side bar info

* show recommended & supported versions for vanilla

* fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance

* use large play button instance

* show update success instead of launching right into the game

* add global installing server project state

* add comment

* small change: open discover to modpack

* implement ping server projects for latency in app

* add projectV3 to nag context for moderation package

* fix play server project button when instance is launched

* add ping to project header

* wip: server verified plays

* server verified plays compiling

* queue up server plays in batches

* report server plays improved in frontend

* fixes to tracking server joins

* fix: server project detection to do loose null check

* fix server projects showing license

* fix empty server info card

* fix server projects links title

* Fix backend impl for server player count analytics

* fix: allow for links to be set to empty

* hook up server recent plays

* cargo sqlx prepare

* add project sidebar stories

* feat: update project sidebar server info card to new design

* update server project header and project card

* feat: add hide label for project cards

* feat: add tags sidebar card

* small fix to keep color consistent

* fix: remove required content tab from server project page

* many small fixes

* handle locking server instance content

* fix hiding modal after saving server compatibility version

* copy content card item and table from content tab update branch

* fix nav tabs active tag

* fix switching between server instance vs regular instance persisted invalid state

* fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs

* hook up backend searchfor frontend in websiet

* fix: server project card tags

* hook up search v3 in app backend for app frontend

* Don't return missing components in project query

* Add game versions to server filters

* move reporting server joins to backend

* send account UUID along with server play analytics

* update java server ping schema

* feat: implement use server search for search sorting and filter facets

* pnpm prepr

* fix game version filter facet

* fix: allow java and bedrock addresses to be deleted

* feat: hook up languages

* Default deserialize `ProjectSerial`

* feat: show server project tags

* small fix on languages multi select

* also default java server content

* fix: update compatibility modal not closing after successful upload

* remove play button in website discovery for servers

* reenable fence in app backend

* update online/offline tag

* add online status indicator pulsing

* revert pulsing

* disable link for custom modpack project and show tooltip

* change modpack to modded type

* update ip address entire button to be clickable

* polish server info card styles

* make offline tag red and properly hook up online tag

* move server related settings into own tab

* fix setting project compatibility resets unsaved changes

* fix javaServerPatchaData wiping content field

* updates to compatibility card, add download button and display supported versions better

* fix unsaved changes popup for tags

* remove console.log

* fix incorrect project type in projects in dashboard

* fix: savable.ts to reset currentValues to data() after save

* upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery

* fix error handling and helper text copy

* ensure gallery banners are filtered in app backend gallery display

* add grouped filters for search

* add query params for server search

* feat: deep linking to open server project page then open install to play

* fix search in app frontend

* fix: server project showing offline

* fix: profile create error app backend

Here's what was happening and the fix:

Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123).

The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter.

Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before.

* pass undefined instead of unknown for modpack content modal

* fix: wrong way to determine offline status

* delete required content page placeholder

* fix: redirect running function instead of passing function

* add in wiki page

* fix diffs which have unknown project/filename

* pnpm prepr

* feat: add handling for "stop" instance state for server project card and page play button

* fix updating modpack shouldn't launch right into game

* small fix on external icon

* fix refresh search causing infinite rerender i.e. maximum call stack size exceeded

watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs.

ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295.

Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading).

* don't require auth token for logging server play

* fetch latest server player count from redis instead of search doc

* remove components. in search facet

* Category and search sort fixes

* add logging for refreshSearch in browse.vue

* fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs

* fix: server refresh search reactivity

* fix: type errors

* conquer the type errors in Browse.vue

* update search input background

* fix tags location

* slight change to color

* feat: add linked to modpack project for regular modpack instances

* feat: installation tab updates

* fix: copy ip missing hover effect

* feat: implement category and countries negative filters

* fix servers tab label in profile page

* implement add server to instance

* feat: implement allow editing server instances

* update installation settings to handle vanilla server instance case

* hide servers tab when installing content to instance

* add sorting for user installed content to be top of list in content

* update categories filters from one group filter card to separate filters cards

* add active scale

* fix offline server showing online

* update language display

* update tooltip

* hide navtabs if theres only one tab

* fix: modpack content name truncate in project card

* feat: add server projects to moderation queue

* update redirect middleware no longer needs projectV3

* update comment

* fix: server tags labels

* feat: add the mf icons finally

* Revert "update redirect middleware no longer needs projectV3"

This reverts commit 1289cb5286.

* fix open in browser

* revert any handling for handling base linked modpack content for content tab

* update instance online players to be client ping

* fix showing modpack/loader version for server instance in installation settings

* server projects are not marked as modpacks

* skip license check for server projects

* feat: add the concept of linked worlds for server instances and keep in sync with server project

* fix: router.push doesn't add history state, use nagivateTo instead

* fix: get server modpack content wrong link

* update some categories to default collapse

* small fixes

* optional languages & bedrock

* move creator below tags

* sort linked worlds to be first

* add red orange and green ping variants

* bring back content tab

* add download button in required content in app

* fix: server info card loading

* fix: brief flash of normal project before server project stuff loads in

* misc fixes

* invalidate project v3

* fix unused imports

* Quick pass for moderation related changes (#5429)

* filter certain nags out from server projects.

* move add-links nag to links.ts

* first few server related nags

* moderation checklist groundwork

* Prevent undefined stage from appearing on servers.

* add projectV3 to shouldShow callback

* Filter buttons by server project type

* fix, revert private use msg, adjust server & link nags

* starting tags + servers msg

* fix no projectV3

* fix: router.push doesn't add history state, use nagivateTo instead

* Tags nag works with servers now

* support servers' v3 exclusive links

* reupload, and status messages + nag tweaks.

* fixes

* Update tags.vue warning for server projects.

* don't suggest adding a bedrock IP

* Tweak phrasing on servers alert msg

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* only show unique tags in project card

* add projectV3 to cache purge

* fix type: add projectV3 to cache purge

* update caching behaviour for installing

* max 3 plays per user

* accept date_modified and date_created for sorting

* add locking environment filter for server instance and update copy

* custom pack button only shows when needed (#5444)

* expose server pinging route to frontend

* feat: add server field validation with pinging on unfocus

* improve pinging logs

* try another pinging crate

* small fixes

* prefill published project id for updating published project

* fix running app bar for mac

* cargo sqlx prepare

* fix app login avatar

* pnpm prepr

* fix download menu for mac

* FIX CI

* fix lint errors

* cargo fmt

* fix toml

* fix more lint

* add server copy

* more lint

* fix any types

* also ping unlisted and private servers

* fix lint

* remove option for showTypeSelector

* fix cannot read user from undefined

* pnpm prepr

* update pinging to make it better

* update copy

* fix login cache issue

* add project select default icon

* fix: minecraft_java_server not redirecting

* pnpm prepr

* fix required content card in project page for custom modpack

* fix app project cards custom modpacks

* update pre-collapsed for app frontend

* don't send server projects to discord webhook

* add lock icon to linked world managed by server project

* pnpm prepr

* make automod msgs on server projects private

* fix pagination for server projects tab

* fix recent plays copy

* fix sync linked world with server project

* pnpm prepr

* add 0.11.0 changelog

* update date

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2026-03-02 23:38:09 +00:00
Calum H.andGitHub 51066c476a devex: fix claude.md (#5439)
* feat: start on agents.md/claude.md

* feat: set up

* feat: api-client claude + skills

* feat: apps/frontend

* feat: skills list

* fix: lint issues
2026-02-27 17:21:35 +00:00
aecsocketandGitHub 47ef7ee42e When deleting a project, retain the thread's project ID (#5418) 2026-02-27 01:03:08 +00:00
XanderandGitHub 6fba33d443 Fix payouts notifications not delivering (#5430)
* fix FK violation when inserting rows into `notifications_deliveries`

* add test for FK violation when inserting into notifications_deliveries

* sqlx prepare

* add migration to prevent stale notifications from being dequeued all at once upon fix

* Revert "add migration to prevent stale notifications from being dequeued all at once upon fix"

This reverts commit 446f398752.
2026-02-26 19:42:25 +00:00
XanderandGitHub 017f6a5afb fix: session refresh works as intended now (#5330)
* fix: session refresh works as intended now

* use code-defined defaults for expires and session_expires

* fix sqlx

* database migration drop defaults

* run fmt

* remove comment in migration

Signed-off-by: Xander <xander@isxander.dev>

---------

Signed-off-by: Xander <xander@isxander.dev>
2026-02-26 17:33:09 +00:00
1ab722411a Ignore old migrations from prettier formatting (#5408)
* Ignore old migrations from prettier formatting

* Simplify ignore syntax

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-26 11:44:59 +00:00
45387e5fb6 Fix fields for pix payout (#5428)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-25 21:30:31 +00:00
e362de45fb Fix library routerview race condition (#5412)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-24 16:52:50 +00:00
29 changed files with 1966 additions and 561 deletions
Generated
+24
View File
@@ -2631,6 +2631,29 @@ dependencies = [
"serde",
]
[[package]]
name = "elasticsearch"
version = "9.1.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bb303aa6e1d28c0c86b6fbfe484fd0fd3f512629aeed1ac4f6b85f81d9834a"
dependencies = [
"base64 0.22.1",
"bytes",
"dyn-clone",
"flate2",
"lazy_static",
"parking_lot",
"percent-encoding",
"reqwest",
"rustc_version",
"serde",
"serde_json",
"serde_with",
"tokio",
"url",
"void",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -4748,6 +4771,7 @@ dependencies = [
"dotenv-build",
"dotenvy",
"either",
"elasticsearch",
"eyre",
"futures",
"futures-util",
+1
View File
@@ -69,6 +69,7 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
elasticsearch = "9.1.0-alpha.1"
encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
+5
View File
@@ -16,9 +16,14 @@ DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
SEARCH_BACKEND=meilisearch
MEILISEARCH_READ_ADDR=http://localhost:7700
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
MEILISEARCH_KEY=modrinth
ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_INDEX_PREFIX=labrinth
ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD=elastic
REDIS_URL=redis://labrinth-redis
REDIS_MIN_CONNECTIONS=0
+13
View File
@@ -16,16 +16,29 @@ DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
SEARCH_BACKEND=meilisearch
# Meilisearch configuration
MEILISEARCH_READ_ADDR=http://localhost:7700
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
# 5 minutes in milliseconds
SEARCH_OPERATION_TIMEOUT=300000
ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_INDEX_PREFIX=labrinth
# # For a sharded Meilisearch setup (sharded-meilisearch docker compose profile)
# MEILISEARCH_READ_ADDR=http://localhost:7710
# MEILISEARCH_WRITE_ADDRS=http://localhost:7700,http://localhost:7701
MEILISEARCH_KEY=modrinth
MEILISEARCH_META_NAMESPACE=
# Elasticsearch configuration
ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_INDEX_PREFIX=labrinth
ELASTICSEARCH_USERNAME=
ELASTICSEARCH_PASSWORD=
REDIS_URL=redis://localhost
REDIS_MIN_CONNECTIONS=0
+4 -3
View File
@@ -25,7 +25,7 @@ async-stripe = { workspace = true, features = [
"billing",
"checkout",
"connect",
"webhook-events",
"webhook-events"
] }
async-trait = { workspace = true }
base64 = { workspace = true }
@@ -43,6 +43,7 @@ deadpool-redis.workspace = true
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
dotenvy = { workspace = true }
either = { workspace = true }
elasticsearch = { workspace = true, features = ["experimental-apis"] }
eyre = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
@@ -85,11 +86,11 @@ reqwest = { workspace = true, features = [
"http2",
"json",
"multipart",
"rustls-tls-webpki-roots",
"rustls-tls-webpki-roots"
] }
rust_decimal = { workspace = true, features = [
"serde-with-float",
"serde-with-str",
"serde-with-str"
] }
rust_iso3166 = { workspace = true }
rust-s3 = { workspace = true }
-4
View File
@@ -20,8 +20,6 @@ use thiserror::Error;
pub enum AuthenticationError {
#[error(transparent)]
Internal(#[from] eyre::Report),
#[error("Environment Error")]
Env(#[from] dotenvy::Error),
#[error("An unknown database error occurred: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("Database Error: {0}")]
@@ -58,7 +56,6 @@ impl actix_web::ResponseError for AuthenticationError {
AuthenticationError::Internal(..) => {
StatusCode::INTERNAL_SERVER_ERROR
}
AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
AuthenticationError::Sqlx(..) => StatusCode::INTERNAL_SERVER_ERROR,
AuthenticationError::Database(..) => {
StatusCode::INTERNAL_SERVER_ERROR
@@ -94,7 +91,6 @@ impl AuthenticationError {
pub fn error_name(&self) -> &'static str {
match self {
AuthenticationError::Internal(..) => "internal_error",
AuthenticationError::Env(..) => "environment_error",
AuthenticationError::Sqlx(..) => "database_error",
AuthenticationError::Database(..) => "database_error",
AuthenticationError::SerDe(..) => "invalid_input",
+11 -12
View File
@@ -1,3 +1,4 @@
use crate::database;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::queue::billing::{index_billing, index_subscriptions};
@@ -7,9 +8,9 @@ use crate::queue::payouts::{
insert_bank_balances_and_webhook, process_affiliate_payouts,
process_payout, remove_payouts_for_refunded_charges,
};
use crate::search::indexing::index_projects;
use crate::search::SearchBackend;
use crate::util::anrok;
use crate::{database, search};
use actix_web::web;
use clap::ValueEnum;
use tracing::{error, info, warn};
@@ -34,18 +35,19 @@ impl BackgroundTask {
pool: PgPool,
ro_pool: PgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
search_backend: web::Data<dyn SearchBackend>,
clickhouse: clickhouse::Client,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
mural_client: muralpay::Client,
) {
) -> eyre::Result<()> {
use BackgroundTask::*;
// TODO: all of these tasks should return `eyre::Result`s
match self {
Migrations => run_migrations().await,
IndexSearch => {
index_search(ro_pool, redis_pool, search_config).await
return index_search(ro_pool, redis_pool, search_backend).await;
}
ReleaseScheduled => release_scheduled(pool).await,
UpdateVersions => update_versions(pool, redis_pool).await,
@@ -77,6 +79,7 @@ impl BackgroundTask {
run_email(email_queue).await;
}
}
Ok(())
}
}
@@ -122,14 +125,10 @@ pub async fn run_migrations() {
pub async fn index_search(
ro_pool: PgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
) {
search_backend: web::Data<dyn SearchBackend>,
) -> eyre::Result<()> {
info!("Indexing local database");
let result = index_projects(ro_pool, redis_pool, &search_config).await;
if let Err(e) = result {
warn!("Local project indexing failed: {:?}", e);
}
info!("Done indexing local database");
search_backend.index_projects(ro_pool, redis_pool).await
}
pub async fn release_scheduled(pool: PgPool) {
+12 -3
View File
@@ -82,6 +82,7 @@ where
}
pub fn init() -> eyre::Result<()> {
dotenvy::dotenv().ok();
EnvVars::from_env()?;
LazyLock::force(&ENV);
Ok(())
@@ -128,9 +129,6 @@ vars! {
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String;
RATE_LIMIT_IGNORE_KEY: String;
DATABASE_URL: String;
MEILISEARCH_READ_ADDR: String;
MEILISEARCH_WRITE_ADDRS: StringCsv;
MEILISEARCH_KEY: String;
REDIS_URL: String;
BIND_ADDR: String;
SELF_ADDR: String;
@@ -142,6 +140,17 @@ vars! {
ALLOWED_CALLBACK_URLS: Json<Vec<String>>;
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>>;
// search
SEARCH_BACKEND: crate::search::SearchBackendKind;
MEILISEARCH_READ_ADDR: String;
MEILISEARCH_WRITE_ADDRS: StringCsv;
MEILISEARCH_KEY: String;
ELASTICSEARCH_URL: String;
ELASTICSEARCH_INDEX_PREFIX: String;
ELASTICSEARCH_USERNAME: String = "";
ELASTICSEARCH_PASSWORD: String = "";
ELASTICSEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64;
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind;
+12 -9
View File
@@ -56,7 +56,7 @@ pub struct LabrinthConfig {
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
pub scheduler: Arc<scheduler::Scheduler>,
pub ip_salt: Pepper,
pub search_config: search::SearchConfig,
pub search_backend: web::Data<dyn search::SearchBackend>,
pub session_queue: web::Data<AuthQueue>,
pub payouts_queue: web::Data<PayoutsQueue>,
pub analytics_queue: Arc<AnalyticsQueue>,
@@ -75,7 +75,7 @@ pub fn app_setup(
pool: PgPool,
ro_pool: ReadOnlyPgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
search_backend: actix_web::web::Data<dyn search::SearchBackend>,
clickhouse: &mut Client,
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
stripe_client: stripe::Client,
@@ -113,19 +113,22 @@ pub fn app_setup(
let local_index_interval =
Duration::from_secs(ENV.LOCAL_INDEX_INTERVAL);
let pool_ref = pool.clone();
let search_config_ref = search_config.clone();
let redis_pool_ref = redis_pool.clone();
let search_backend_ref = search_backend.clone();
scheduler.run(local_index_interval, move || {
let pool_ref = pool_ref.clone();
let redis_pool_ref = redis_pool_ref.clone();
let search_config_ref = search_config_ref.clone();
let search_backend = search_backend_ref.clone();
async move {
background_task::index_search(
if let Err(err) = background_task::index_search(
pool_ref,
redis_pool_ref,
search_config_ref,
search_backend,
)
.await;
.await
{
warn!("Failed to index search: {err:?}");
}
}
});
@@ -269,7 +272,7 @@ pub fn app_setup(
file_host,
scheduler: Arc::new(scheduler),
ip_salt,
search_config,
search_backend,
session_queue,
payouts_queue: web::Data::new(PayoutsQueue::new()),
analytics_queue,
@@ -307,7 +310,7 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.pool.clone()))
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
.app_data(web::Data::new(labrinth_config.file_host.clone()))
.app_data(web::Data::new(labrinth_config.search_config.clone()))
.app_data(labrinth_config.search_backend.clone())
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
.app_data(labrinth_config.session_queue.clone())
.app_data(labrinth_config.payouts_queue.clone())
+18 -16
View File
@@ -17,6 +17,7 @@ use labrinth::utoipa_app_config;
use labrinth::{app_config, env};
use labrinth::{clickhouse, database, file_hosting};
use std::ffi::CStr;
use std::io;
use std::sync::Arc;
use tracing::{Instrument, info, info_span};
use tracing_actix_web::TracingLogger;
@@ -56,7 +57,6 @@ struct Args {
fn main() -> std::io::Result<()> {
color_eyre::install().expect("failed to install `color-eyre`");
dotenvy::dotenv().ok();
modrinth_util::log::init().expect("failed to initialize logging");
env::init().expect("failed to initialize environment variables");
@@ -152,7 +152,8 @@ async fn app() -> std::io::Result<()> {
info!("Initializing clickhouse connection");
let mut clickhouse = clickhouse::init_client().await.unwrap();
let search_config = search::SearchConfig::new(None);
let search_backend =
actix_web::web::Data::from(Arc::from(search::backend(None)));
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
@@ -167,19 +168,20 @@ async fn app() -> std::io::Result<()> {
if let Some(task) = args.run_background_task {
info!("Running task {task:?} and exiting");
task.run(
pool,
ro_pool.into_inner(),
redis_pool,
search_config,
clickhouse,
stripe_client,
anrok_client.clone(),
email_queue,
muralpay,
)
.await;
return Ok(());
return task
.run(
pool,
ro_pool.into_inner(),
redis_pool,
search_backend,
clickhouse,
stripe_client,
anrok_client.clone(),
email_queue,
muralpay,
)
.await
.map_err(io::Error::other);
}
let prometheus = PrometheusMetricsBuilder::new("labrinth")
@@ -206,7 +208,7 @@ async fn app() -> std::io::Result<()> {
pool.clone(),
ro_pool.clone(),
redis_pool.clone(),
search_config.clone(),
search_backend.clone(),
&mut clickhouse,
file_host.clone(),
stripe_client,
+1 -3
View File
@@ -102,8 +102,6 @@ impl Mailer {
#[derive(Error, Debug)]
pub enum MailError {
#[error("Environment Error")]
Env(#[from] dotenvy::Error),
#[error("Mail Error: {0}")]
Mail(#[from] lettre::error::Error),
#[error("Address Parse Error: {0}")]
@@ -136,7 +134,7 @@ impl EmailQueue {
pg,
redis,
mailer: Arc::new(TokioMutex::new(Mailer::Uninitialized)),
identity: templates::MailingIdentity::from_env()?,
identity: templates::MailingIdentity::from_env(),
client: Client::builder()
.user_agent("Modrinth")
.build()
+3 -3
View File
@@ -95,8 +95,8 @@ pub struct MailingIdentity {
}
impl MailingIdentity {
pub fn from_env() -> dotenvy::Result<Self> {
Ok(Self {
pub fn from_env() -> Self {
Self {
from_name: ENV.SMTP_FROM_NAME.clone(),
from_address: ENV.SMTP_FROM_ADDRESS.clone(),
reply_name: if ENV.SMTP_REPLY_TO_NAME.is_empty() {
@@ -109,7 +109,7 @@ impl MailingIdentity {
} else {
Some(ENV.SMTP_REPLY_TO_ADDRESS.clone())
},
})
}
}
}
+6 -4
View File
@@ -7,7 +7,7 @@ use crate::models::pats::Scopes;
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
use crate::search::SearchBackend;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
@@ -152,10 +152,12 @@ pub async fn count_download(
pub async fn force_reindex(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
) -> Result<HttpResponse, ApiError> {
use crate::search::indexing::index_projects;
let redis = redis.get_ref();
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
search_backend
.index_projects(pool.as_ref().clone(), redis.clone())
.await
.map_err(ApiError::Internal)?;
Ok(HttpResponse::NoContent().finish())
}
+15 -105
View File
@@ -1,12 +1,9 @@
use crate::routes::ApiError;
use crate::search::SearchConfig;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpResponse, delete, get, web};
use meilisearch_sdk::tasks::{Task, TasksCancelQuery};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use utoipa::ToSchema;
use crate::{
routes::ApiError,
search::{SearchBackend, TasksCancelFilter},
};
use actix_web::{delete, get, web};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(tasks).service(tasks_cancel);
@@ -15,107 +12,20 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
#[utoipa::path]
#[get("tasks", guard = "admin_key_guard")]
pub async fn tasks(
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, ApiError> {
let client = config.make_batch_client()?;
let tasks = client
.with_all_clients("get_tasks", async |client| {
let tasks = client.get_tasks().await?;
Ok(tasks.results)
})
.await?;
#[derive(Serialize, ToSchema)]
struct MeiliTask<Time> {
uid: u32,
status: &'static str,
duration: Option<Duration>,
enqueued_at: Option<Time>,
}
#[derive(Serialize, ToSchema)]
struct TaskList<Time> {
by_instance: HashMap<String, Vec<MeiliTask<Time>>>,
}
let response = tasks
.into_iter()
.enumerate()
.map(|(idx, instance_tasks)| {
let tasks = instance_tasks
.into_iter()
.filter_map(|task| {
Some(match task {
Task::Enqueued { content } => MeiliTask {
uid: content.uid,
status: "enqueued",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Processing { content } => MeiliTask {
uid: content.uid,
status: "processing",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Failed { content } => MeiliTask {
uid: content.task.uid,
status: "failed",
duration: Some(content.task.duration),
enqueued_at: Some(content.task.enqueued_at),
},
Task::Succeeded { content: _ } => return None,
})
})
.collect();
(idx.to_string(), tasks)
})
.collect::<HashMap<String, Vec<MeiliTask<_>>>>();
Ok(HttpResponse::Ok().json(TaskList {
by_instance: response,
}))
}
#[derive(Deserialize, Serialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
enum TasksCancelFilter {
All,
AllEnqueued,
Indexes { indexes: Vec<String> },
search: web::Data<dyn SearchBackend>,
) -> Result<web::Json<serde_json::Value>, ApiError> {
Ok(web::Json(search.tasks().await.map_err(ApiError::Internal)?))
}
#[utoipa::path]
#[delete("tasks", guard = "admin_key_guard")]
pub async fn tasks_cancel(
config: web::Data<SearchConfig>,
search: web::Data<dyn SearchBackend>,
body: web::Json<TasksCancelFilter>,
) -> Result<HttpResponse, ApiError> {
let client = config.make_batch_client()?;
let all_results = client
.with_all_clients("cancel_tasks", async |client| {
let mut q = TasksCancelQuery::new(client);
match &body.0 {
TasksCancelFilter::All => {}
TasksCancelFilter::Indexes { indexes } => {
q.with_index_uids(indexes.iter().map(|s| s.as_str()));
}
TasksCancelFilter::AllEnqueued => {
q.with_statuses(["enqueued"]);
}
};
let result = client.cancel_tasks_with(&q).await;
Ok(result)
})
.await?;
for r in all_results {
r?;
}
Ok(HttpResponse::Ok().finish())
) -> Result<(), ApiError> {
search
.tasks_cancel(&body)
.await
.map_err(ApiError::Internal)?;
Ok(())
}
-8
View File
@@ -95,8 +95,6 @@ pub enum ApiError {
Auth(eyre::Report),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Environment error")]
Env(#[from] dotenvy::Error),
#[error("Error while uploading file: {0}")]
FileHosting(#[from] FileHostingError),
#[error("database error")]
@@ -119,8 +117,6 @@ pub enum ApiError {
Validation(String),
#[error("Search error: {0}")]
Search(#[from] meilisearch_sdk::errors::Error),
#[error("search indexing error")]
Indexing(#[from] crate::search::indexing::IndexingError),
#[error("Payments error: {0}")]
Payments(String),
#[error("Discord error: {0}")]
@@ -178,7 +174,6 @@ impl ApiError {
Self::Internal(..) => "internal_error",
Self::Request(..) => "request_error",
Self::Auth(..) => "auth_error",
Self::Env(..) => "environment_error",
Self::Database(..) => "database_error",
Self::SqlxDatabase(..) => "database_error",
Self::RedisDatabase(..) => "database_error",
@@ -187,7 +182,6 @@ impl ApiError {
Self::Xml(..) => "xml_error",
Self::Json(..) => "json_error",
Self::Search(..) => "search_error",
Self::Indexing(..) => "indexing_error",
Self::FileHosting(..) => "file_hosting_error",
Self::InvalidInput(..) => "invalid_input",
Self::Validation(..) => "invalid_input",
@@ -243,7 +237,6 @@ impl actix_web::ResponseError for ApiError {
Self::Request(..) => StatusCode::BAD_REQUEST,
Self::Auth(..) => StatusCode::UNAUTHORIZED,
Self::InvalidInput(..) => StatusCode::BAD_REQUEST,
Self::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
@@ -253,7 +246,6 @@ impl actix_web::ResponseError for ApiError {
Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Json(..) => StatusCode::BAD_REQUEST,
Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Validation(..) => StatusCode::BAD_REQUEST,
Self::Payments(..) => StatusCode::FAILED_DEPENDENCY,
+11 -8
View File
@@ -14,7 +14,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::v3::projects::ProjectIds;
use crate::routes::{ApiError, v2_reroute, v3};
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::search::SearchBackend;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -53,8 +53,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
#[get("search")]
pub async fn project_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, SearchError> {
search_backend: web::Data<dyn SearchBackend>,
) -> Result<HttpResponse, ApiError> {
// Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields
// While the backend for this has changed, it doesnt affect much
// in the API calls except that 'versions:x' is now 'game_versions:x'
@@ -99,7 +99,10 @@ pub async fn project_search(
..info
};
let results = search_for_project(&info, &config).await?;
let results = search_backend
.search_for_project(&info)
.await
.map_err(ApiError::Internal)?;
let results = LegacySearchResults::from(results);
@@ -411,7 +414,7 @@ pub async fn project_edit(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
new_project: web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
@@ -521,7 +524,7 @@ pub async fn project_edit(
req.clone(),
info,
pool.clone(),
search_config,
search_backend,
web::Json(new_project),
redis.clone(),
session_queue.clone(),
@@ -915,7 +918,7 @@ pub async fn project_delete(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
@@ -924,7 +927,7 @@ pub async fn project_delete(
info,
pool,
redis,
search_config,
search_backend,
session_queue,
)
.await
+3 -3
View File
@@ -11,7 +11,7 @@ use crate::models::projects::{
use crate::models::v2::projects::LegacyVersion;
use crate::queue::session::AuthQueue;
use crate::routes::{v2_reroute, v3};
use crate::search::SearchConfig;
use crate::search::SearchBackend;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -349,7 +349,7 @@ pub async fn version_delete(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so we don't need to convert the response
v3::versions::version_delete(
@@ -358,7 +358,7 @@ pub async fn version_delete(
pool,
redis,
session_queue,
search_config,
search_backend,
)
.await
.or_else(v2_reroute::flatten_404_error)
@@ -21,7 +21,6 @@ use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::threads::ThreadType;
use crate::models::v3::user_limits::UserLimits;
use crate::queue::session::AuthQueue;
use crate::search::indexing::IndexingError;
use crate::util::guards::admin_key_guard;
use crate::util::img::upload_image_optimized;
use crate::util::routes::read_from_field;
@@ -49,14 +48,10 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
#[derive(Error, Debug)]
pub enum CreateError {
#[error("Environment Error")]
EnvError(#[from] dotenvy::Error),
#[error("An unknown database error occurred")]
SqlxDatabaseError(#[from] sqlx::Error),
#[error("Database Error: {0}")]
DatabaseError(#[from] models::DatabaseError),
#[error("Indexing Error: {0}")]
IndexingError(#[from] IndexingError),
#[error("Error while parsing multipart payload: {0}")]
MultipartError(#[from] actix_multipart::MultipartError),
#[error("Error while parsing JSON: {0}")]
@@ -96,12 +91,10 @@ pub enum CreateError {
impl actix_web::ResponseError for CreateError {
fn status_code(&self) -> StatusCode {
match self {
CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR,
CreateError::SqlxDatabaseError(..) => {
StatusCode::INTERNAL_SERVER_ERROR
}
CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR,
CreateError::FileHostingError(..) => {
StatusCode::INTERNAL_SERVER_ERROR
}
@@ -129,10 +122,8 @@ impl actix_web::ResponseError for CreateError {
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).json(ApiError {
error: match self {
CreateError::EnvError(..) => "environment_error",
CreateError::SqlxDatabaseError(..) => "database_error",
CreateError::DatabaseError(..) => "database_error",
CreateError::IndexingError(..) => "indexing_error",
CreateError::FileHostingError(..) => "file_hosting_error",
CreateError::SerDeError(..) => "invalid_input",
CreateError::MultipartError(..) => "invalid_input",
+30 -28
View File
@@ -29,8 +29,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::delphi;
use crate::search::indexing::remove_documents;
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::search::{SearchBackend, SearchResults};
use crate::util::error::Context;
use crate::util::img;
use crate::util::img::{delete_old_images, upload_image_optimized};
@@ -264,7 +263,7 @@ pub async fn project_edit(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
new_project: web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
@@ -974,16 +973,16 @@ pub async fn project_edit(
project_item.inner.status.is_searchable(),
new_project.status.map(|status| status.is_searchable()),
) {
remove_documents(
&project_item
.versions
.into_iter()
.map(|x| x.into())
.collect::<Vec<_>>(),
&search_config,
)
.await
.wrap_internal_err("failed to remove documents")?;
search_backend
.remove_documents(
&project_item
.versions
.into_iter()
.map(|x| x.into())
.collect::<Vec<_>>(),
)
.await
.wrap_internal_err("failed to remove documents")?;
}
Ok(HttpResponse::NoContent().body(""))
@@ -1039,9 +1038,12 @@ pub async fn edit_project_categories(
pub async fn project_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, SearchError> {
let results = search_for_project(&info, &config).await?;
search_backend: web::Data<dyn SearchBackend>,
) -> Result<web::Json<SearchResults>, ApiError> {
let results = search_backend
.search_for_project(&info)
.await
.map_err(ApiError::Internal)?;
// TODO: add this back
// let results = ReturnSearchResults {
@@ -1055,7 +1057,7 @@ pub async fn project_search(
// total_hits: results.total_hits,
// };
Ok(HttpResponse::Ok().json(results))
Ok(web::Json(results))
}
//checks the validity of a project id or slug
@@ -2158,7 +2160,7 @@ pub async fn project_delete(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let (_, user) = get_user_from_headers(
@@ -2270,16 +2272,16 @@ pub async fn project_delete(
.await
.wrap_internal_err("failed to commit transaction")?;
remove_documents(
&project
.versions
.into_iter()
.map(|x| x.into())
.collect::<Vec<_>>(),
&search_config,
)
.await
.wrap_internal_err("failed to remove project version documents")?;
search_backend
.remove_documents(
&project
.versions
.into_iter()
.map(|x| x.into())
.collect::<Vec<_>>(),
)
.await
.wrap_internal_err("failed to remove project version documents")?;
if result.is_some() {
Ok(())
+4 -5
View File
@@ -26,8 +26,7 @@ use crate::models::projects::{Loader, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi;
use crate::search::SearchConfig;
use crate::search::indexing::remove_documents;
use crate::search::SearchBackend;
use crate::util::error::Context;
use crate::util::img;
use crate::util::validate::validation_errors_to_string;
@@ -894,7 +893,7 @@ pub async fn version_delete(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_config: web::Data<SearchConfig>,
search_backend: web::Data<dyn SearchBackend>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
@@ -1001,10 +1000,10 @@ pub async fn version_delete(
&redis,
)
.await?;
remove_documents(&[version.inner.id.into()], &search_config)
search_backend
.remove_documents(&[version.inner.id.into()])
.await
.wrap_internal_err("failed to remove documents")?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,16 @@
/// This module is used for the indexing from any source.
pub mod local_import;
use std::time::Duration;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::search::{SearchConfig, UploadSearchProject};
use crate::search::UploadSearchProject;
use crate::search::backend::meilisearch::MeilisearchConfig;
use crate::search::indexing::index_local;
use crate::util::error::Context;
use ariadne::ids::base62_impl::to_base62;
use eyre::eyre;
use futures::StreamExt;
use futures::stream::FuturesOrdered;
use local_import::index_local;
use meilisearch_sdk::client::{Client, SwapIndexes};
use meilisearch_sdk::indexes::Index;
use meilisearch_sdk::settings::{PaginationSetting, Settings};
@@ -34,6 +32,8 @@ pub enum IndexingError {
Env(#[from] dotenvy::Error),
#[error("Error while awaiting index creation task")]
Task,
#[error(transparent)]
Other(#[from] eyre::Report),
}
// // The chunk size for adding projects to the indexing database. If the request size
@@ -49,8 +49,8 @@ fn search_operation_timeout() -> std::time::Duration {
pub async fn remove_documents(
ids: &[crate::models::ids::VersionId],
config: &SearchConfig,
) -> eyre::Result<()> {
config: &MeilisearchConfig,
) -> Result<(), IndexingError> {
let mut indexes = get_indexes_for_indexing(config, false, false)
.await
.wrap_err("failed to get current indexes")?;
@@ -106,7 +106,7 @@ pub async fn remove_documents(
pub async fn index_projects(
ro_pool: PgPool,
redis: RedisPool,
config: &SearchConfig,
config: &MeilisearchConfig,
) -> Result<(), IndexingError> {
info!("Indexing projects.");
@@ -188,7 +188,7 @@ pub async fn index_projects(
}
pub async fn swap_index(
config: &SearchConfig,
config: &MeilisearchConfig,
index_name: &str,
) -> Result<(), IndexingError> {
let client = config.make_batch_client()?;
@@ -224,7 +224,7 @@ pub async fn swap_index(
#[instrument(skip(config))]
pub async fn get_indexes_for_indexing(
config: &SearchConfig,
config: &MeilisearchConfig,
next: bool, // Get the 'next' one
update_settings: bool,
) -> Result<Vec<Vec<Index>>, IndexingError> {
@@ -500,7 +500,7 @@ pub async fn add_projects_batch_client(
indices: &[Vec<Index>],
projects: Vec<UploadSearchProject>,
additional_fields: Vec<String>,
config: &SearchConfig,
config: &MeilisearchConfig,
) -> Result<(), IndexingError> {
let client = config.make_batch_client()?;
@@ -0,0 +1,426 @@
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::VersionId;
use crate::models::projects::SearchRequest;
use crate::routes::ApiError;
use crate::search::{
ResultSearchProject, SearchBackend, SearchResults, TasksCancelFilter,
};
use crate::util::error::Context;
use async_trait::async_trait;
use eyre::eyre;
use futures::TryStreamExt;
use futures::stream::FuturesOrdered;
use itertools::Itertools;
use meilisearch_sdk::client::Client;
use meilisearch_sdk::tasks::{Task, TasksCancelQuery};
use serde::Serialize;
use serde_json::Value;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write;
use std::time::Duration;
use tracing::{Instrument, info_span};
pub mod indexing;
#[derive(Debug, Clone)]
pub struct MeilisearchReadClient {
pub client: Client,
}
impl std::ops::Deref for MeilisearchReadClient {
type Target = Client;
fn deref(&self) -> &Self::Target {
&self.client
}
}
pub struct BatchClient {
pub clients: Vec<Client>,
}
impl BatchClient {
pub fn new(clients: Vec<Client>) -> Self {
Self { clients }
}
pub async fn with_all_clients<'a, T, G, Fut>(
&'a self,
task_name: &str,
generator: G,
) -> Result<Vec<T>, indexing::IndexingError>
where
G: Fn(&'a Client) -> Fut,
Fut: Future<Output = Result<T, indexing::IndexingError>> + 'a,
{
let mut tasks = FuturesOrdered::new();
for (idx, client) in self.clients.iter().enumerate() {
tasks.push_back(generator(client).instrument(info_span!(
"client_task",
task.name = task_name,
client.idx = idx,
)));
}
let results = tasks.try_collect::<Vec<T>>().await?;
Ok(results)
}
pub fn across_all<T, F, R>(&self, data: Vec<T>, mut predicate: F) -> Vec<R>
where
F: FnMut(T, &Client) -> R,
{
assert_eq!(
data.len(),
self.clients.len(),
"mismatch between data len and meilisearch client count"
);
self.clients
.iter()
.zip(data)
.map(|(client, item)| predicate(item, client))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct MeilisearchConfig {
pub addresses: Vec<String>,
pub read_lb_address: String,
pub key: String,
pub meta_namespace: String,
}
impl MeilisearchConfig {
pub fn new(meta_namespace: Option<String>) -> Self {
Self {
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
key: ENV.MEILISEARCH_KEY.clone(),
meta_namespace: meta_namespace.unwrap_or_default(),
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
}
}
pub fn make_loadbalanced_read_client(
&self,
) -> Result<MeilisearchReadClient, meilisearch_sdk::errors::Error> {
Ok(MeilisearchReadClient {
client: Client::new(&self.read_lb_address, Some(&self.key))?,
})
}
pub fn make_batch_client(
&self,
) -> Result<BatchClient, meilisearch_sdk::errors::Error> {
Ok(BatchClient::new(
self.addresses
.iter()
.map(|address| {
Client::new(address.as_str(), Some(self.key.as_str()))
})
.collect::<Result<Vec<_>, _>>()?,
))
}
pub fn get_index_name(&self, index: &str, next: bool) -> String {
let alt = if next { "_alt" } else { "" };
format!("{}_{}_{}", self.meta_namespace, index, alt)
}
}
pub struct Meilisearch {
pub config: MeilisearchConfig,
}
impl Meilisearch {
pub fn new(config: MeilisearchConfig) -> Self {
Self { config }
}
fn get_sort_index(
&self,
index: &str,
) -> Result<(String, &'static [&'static str]), ApiError> {
let projects_name = self.config.get_index_name("projects", false);
let projects_filtered_name =
self.config.get_index_name("projects_filtered", false);
Ok(match index {
"relevance" => (projects_name, &["downloads:desc"]),
"downloads" => (projects_filtered_name, &["downloads:desc"]),
"follows" => (projects_name, &["follows:desc"]),
"updated" => (projects_name, &["date_modified:desc"]),
"newest" => (projects_name, &["date_created:desc"]),
_ => {
return Err(ApiError::Request(eyre!(
"invalid index `{index}`"
)));
}
})
}
}
#[async_trait]
impl SearchBackend for Meilisearch {
async fn search_for_project(
&self,
info: &SearchRequest,
) -> eyre::Result<SearchResults> {
let offset: usize = info
.offset
.as_deref()
.unwrap_or("0")
.parse()
.wrap_request_err("invalid offset")?;
let index = info.index.as_deref().unwrap_or("relevance");
let limit = info
.limit
.as_deref()
.unwrap_or("10")
.parse::<usize>()
.wrap_request_err("invalid limit")?
.min(100);
let (index_name, sort_name) = self.get_sort_index(index)?;
let client = self
.config
.make_loadbalanced_read_client()
.wrap_internal_err("failed to make load-balanced read client")?;
let meilisearch_index = client
.get_index(index_name)
.await
.wrap_internal_err("failed to get index")?;
let mut filter_string = String::new();
let hits_per_page = if limit == 0 { 1 } else { limit };
let page = offset / hits_per_page + 1;
let results = {
let mut query = meilisearch_index.search();
query
.with_page(page)
.with_hits_per_page(hits_per_page)
.with_query(info.query.as_deref().unwrap_or_default())
.with_sort(sort_name);
if let Some(new_filters) = info.new_filters.as_deref() {
query.with_filter(new_filters);
} else {
let facets = if let Some(facets) = &info.facets {
let facets =
serde_json::from_str::<Vec<Vec<Value>>>(facets)
.wrap_request_err("failed to parse facets")?;
Some(facets)
} else {
None
};
let filters: Cow<_> =
match (info.filters.as_deref(), info.version.as_deref()) {
(Some(f), Some(v)) => format!("({f}) AND ({v})").into(),
(Some(f), None) => f.into(),
(None, Some(v)) => v.into(),
(None, None) => "".into(),
};
if let Some(facets) = facets {
let facets: Vec<Vec<Vec<String>>> =
facets
.into_iter()
.map(|facets| {
facets
.into_iter()
.map(|facet| {
if facet.is_array() {
serde_json::from_value::<Vec<String>>(facet)
.unwrap_or_default()
} else {
vec![
serde_json::from_value::<String>(facet)
.unwrap_or_default(),
]
}
})
.collect_vec()
})
.collect_vec();
filter_string.push('(');
for (index, facet_outer_list) in facets.iter().enumerate() {
filter_string.push('(');
for (facet_outer_index, facet_inner_list) in
facet_outer_list.iter().enumerate()
{
filter_string.push('(');
for (facet_inner_index, facet) in
facet_inner_list.iter().enumerate()
{
filter_string
.push_str(&facet.replace(':', " = "));
if facet_inner_index
!= (facet_inner_list.len() - 1)
{
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if facet_outer_index != (facet_outer_list.len() - 1)
{
filter_string.push_str(" OR ")
}
}
filter_string.push(')');
if index != (facets.len() - 1) {
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if !filters.is_empty() {
write!(filter_string, " AND ({filters})")
.expect("write should not fail");
}
} else {
filter_string.push_str(&filters);
}
if !filter_string.is_empty() {
query.with_filter(&filter_string);
}
}
query.execute::<ResultSearchProject>().await?
};
Ok(SearchResults {
hits: results.hits.into_iter().map(|r| r.result).collect(),
page: results.page.unwrap_or_default(),
hits_per_page: results.hits_per_page.unwrap_or_default(),
total_hits: results.total_hits.unwrap_or_default(),
})
}
async fn index_projects(
&self,
ro_pool: PgPool,
redis: RedisPool,
) -> eyre::Result<()> {
indexing::index_projects(ro_pool, redis, &self.config).await?;
Ok(())
}
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()> {
indexing::remove_documents(ids, &self.config).await?;
Ok(())
}
async fn tasks(&self) -> eyre::Result<Value> {
let client = self
.config
.make_batch_client()
.wrap_internal_err("failed to make batch client")?;
let tasks = client
.with_all_clients("get_tasks", async |client| {
let tasks = client.get_tasks().await?;
Ok(tasks.results)
})
.await
.wrap_internal_err("failed to get tasks")?;
#[derive(Serialize)]
struct MeiliTask<Time> {
uid: u32,
status: &'static str,
duration: Option<Duration>,
enqueued_at: Option<Time>,
}
#[derive(Serialize)]
struct TaskList<Time> {
by_instance: HashMap<String, Vec<MeiliTask<Time>>>,
}
let response = tasks
.into_iter()
.enumerate()
.map(|(idx, instance_tasks)| {
let tasks = instance_tasks
.into_iter()
.filter_map(|task| {
Some(match task {
Task::Enqueued { content } => MeiliTask {
uid: content.uid,
status: "enqueued",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Processing { content } => MeiliTask {
uid: content.uid,
status: "processing",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Failed { content } => MeiliTask {
uid: content.task.uid,
status: "failed",
duration: Some(content.task.duration),
enqueued_at: Some(content.task.enqueued_at),
},
Task::Succeeded { .. } => return None,
})
})
.collect();
(idx.to_string(), tasks)
})
.collect::<HashMap<String, Vec<MeiliTask<_>>>>();
let response = serde_json::to_value(TaskList {
by_instance: response,
})
.wrap_internal_err("failed to serialize tasks response")?;
Ok(response)
}
async fn tasks_cancel(
&self,
filter: &TasksCancelFilter,
) -> eyre::Result<()> {
let client = self
.config
.make_batch_client()
.wrap_internal_err("failed to make batch client")?;
let all_results = client
.with_all_clients("cancel_tasks", async |client| {
let mut q = TasksCancelQuery::new(client);
match filter {
TasksCancelFilter::All => {}
TasksCancelFilter::Indexes { indexes } => {
q.with_index_uids(indexes.iter().map(|s| s.as_str()));
}
TasksCancelFilter::AllEnqueued => {
q.with_statuses(["enqueued"]);
}
};
let result = client.cancel_tasks_with(&q).await;
Ok(result)
})
.await
.wrap_internal_err("failed to cancel tasks")?;
for r in all_results {
r.wrap_internal_err("failed to cancel tasks")?;
}
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
mod elasticsearch;
mod meilisearch;
pub use elasticsearch::Elasticsearch;
pub use meilisearch::{Meilisearch, MeilisearchConfig};
@@ -5,7 +5,6 @@ use itertools::Itertools;
use std::collections::HashMap;
use tracing::info;
use super::IndexingError;
use crate::database::PgPool;
use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
@@ -19,12 +18,13 @@ use crate::models::projects::from_duplicate_version_fields;
use crate::models::v2::projects::LegacyProject;
use crate::routes::v2_reroute;
use crate::search::UploadSearchProject;
use crate::util::error::Context;
pub async fn index_local(
pool: &PgPool,
cursor: i64,
limit: i64,
) -> Result<(Vec<UploadSearchProject>, i64), IndexingError> {
) -> eyre::Result<(Vec<UploadSearchProject>, i64)> {
info!("Indexing local projects!");
// todo: loaders, project type, game versions
@@ -76,7 +76,8 @@ pub async fn index_local(
}
})
.try_collect::<Vec<PartialProject>>()
.await?;
.await
.wrap_err("failed to fetch projects")?;
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
@@ -438,7 +439,7 @@ struct PartialVersion {
async fn index_versions(
pool: &PgPool,
project_ids: Vec<i64>,
) -> Result<HashMap<DBProjectId, Vec<PartialVersion>>, IndexingError> {
) -> eyre::Result<HashMap<DBProjectId, Vec<PartialVersion>>> {
let versions: HashMap<DBProjectId, Vec<DBVersionId>> = sqlx::query!(
"
SELECT v.id, v.mod_id
@@ -457,7 +458,8 @@ async fn index_versions(
async move { Ok(acc) }
},
)
.await?;
.await
.wrap_err("failed to fetch versions")?;
// Get project types, loaders
#[derive(Default)]
@@ -498,7 +500,8 @@ async fn index_versions(
(version_id, version_loader_data)
})
.try_collect()
.await?;
.await
.wrap_err("failed to fetch loaders and project types")?;
// Get version fields
let version_fields: DashMap<DBVersionId, Vec<QueryVersionField>> =
@@ -530,7 +533,10 @@ async fn index_versions(
async move { Ok(acc) }
},
)
.await?;
.await
.wrap_err("failed to fetch version fields")?;
// Get version fields
// Convert to partial versions
let mut res_versions: HashMap<DBProjectId, Vec<PartialVersion>> =
+62 -302
View File
@@ -1,178 +1,71 @@
use crate::env::ENV;
use crate::database::redis::RedisPool;
use crate::models::ids::VersionId;
use crate::models::projects::SearchRequest;
use crate::{models::error::ApiError, search::indexing::IndexingError};
use actix_web::HttpResponse;
use actix_web::http::StatusCode;
use crate::{database::PgPool, env::ENV};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use futures::stream::FuturesOrdered;
use itertools::Itertools;
use meilisearch_sdk::client::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write;
use std::{collections::HashMap, str::FromStr};
use thiserror::Error;
use tracing::{Instrument, info_span};
use utoipa::ToSchema;
pub mod backend;
pub mod indexing;
#[derive(Error, Debug)]
pub enum SearchError {
#[error("MeiliSearch Error: {0}")]
MeiliSearch(#[from] meilisearch_sdk::errors::Error),
#[error("Error while serializing or deserializing JSON: {0}")]
Serde(#[from] serde_json::Error),
#[error("Error while parsing an integer: {0}")]
IntParsing(#[from] std::num::ParseIntError),
#[error("Error while formatting strings: {0}")]
FormatError(#[from] std::fmt::Error),
#[error("Environment Error")]
Env(#[from] dotenvy::Error),
#[error("Invalid index to sort by: {0}")]
InvalidIndex(String),
#[async_trait]
pub trait SearchBackend: Send + Sync {
async fn search_for_project(
&self,
info: &SearchRequest,
) -> eyre::Result<SearchResults>;
async fn index_projects(
&self,
ro_pool: PgPool,
redis: RedisPool,
) -> eyre::Result<()>;
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()>;
async fn tasks(&self) -> eyre::Result<Value>;
async fn tasks_cancel(
&self,
filter: &TasksCancelFilter,
) -> eyre::Result<()>;
}
impl actix_web::ResponseError for SearchError {
fn status_code(&self) -> StatusCode {
match self {
SearchError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
SearchError::MeiliSearch(..) => StatusCode::BAD_REQUEST,
SearchError::Serde(..) => StatusCode::BAD_REQUEST,
SearchError::IntParsing(..) => StatusCode::BAD_REQUEST,
SearchError::InvalidIndex(..) => StatusCode::BAD_REQUEST,
SearchError::FormatError(..) => StatusCode::BAD_REQUEST,
}
}
#[derive(Deserialize, Serialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TasksCancelFilter {
All,
AllEnqueued,
Indexes { indexes: Vec<String> },
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).json(ApiError {
error: match self {
SearchError::Env(..) => "environment_error",
SearchError::MeiliSearch(..) => "meilisearch_error",
SearchError::Serde(..) => "invalid_input",
SearchError::IntParsing(..) => "invalid_input",
SearchError::InvalidIndex(..) => "invalid_input",
SearchError::FormatError(..) => "invalid_input",
},
description: self.to_string(),
details: None,
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SearchBackendKind {
Meilisearch,
Elasticsearch,
}
#[derive(Debug, Error)]
#[error("invalid search backend kind")]
pub struct InvalidSearchBackendKind;
impl FromStr for SearchBackendKind {
type Err = InvalidSearchBackendKind;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"meilisearch" => SearchBackendKind::Meilisearch,
"elasticsearch" => SearchBackendKind::Elasticsearch,
_ => return Err(InvalidSearchBackendKind),
})
}
}
#[derive(Debug, Clone)]
pub struct MeilisearchReadClient {
pub client: Client,
}
impl std::ops::Deref for MeilisearchReadClient {
type Target = Client;
fn deref(&self) -> &Self::Target {
&self.client
}
}
pub struct BatchClient {
pub clients: Vec<Client>,
}
impl BatchClient {
pub fn new(clients: Vec<Client>) -> Self {
Self { clients }
}
pub async fn with_all_clients<'a, T, G, Fut>(
&'a self,
task_name: &str,
generator: G,
) -> Result<Vec<T>, IndexingError>
where
G: Fn(&'a Client) -> Fut,
Fut: Future<Output = Result<T, IndexingError>> + 'a,
{
let mut tasks = FuturesOrdered::new();
for (idx, client) in self.clients.iter().enumerate() {
tasks.push_back(generator(client).instrument(info_span!(
"client_task",
task.name = task_name,
client.idx = idx,
)));
}
let results = tasks.try_collect::<Vec<T>>().await?;
Ok(results)
}
pub fn across_all<T, F, R>(&self, data: Vec<T>, mut predicate: F) -> Vec<R>
where
F: FnMut(T, &Client) -> R,
{
assert_eq!(
data.len(),
self.clients.len(),
"mismatch between data len and meilisearch client count"
);
self.clients
.iter()
.zip(data)
.map(|(client, item)| predicate(item, client))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct SearchConfig {
pub addresses: Vec<String>,
pub read_lb_address: String,
pub key: String,
pub meta_namespace: String,
}
impl SearchConfig {
// Panics if the environment variables are not set,
// but these are already checked for on startup.
pub fn new(meta_namespace: Option<String>) -> Self {
Self {
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
key: ENV.MEILISEARCH_KEY.clone(),
meta_namespace: meta_namespace.unwrap_or_default(),
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
}
}
pub fn make_loadbalanced_read_client(
&self,
) -> Result<MeilisearchReadClient, meilisearch_sdk::errors::Error> {
Ok(MeilisearchReadClient {
client: Client::new(&self.read_lb_address, Some(&self.key))?,
})
}
pub fn make_batch_client(
&self,
) -> Result<BatchClient, meilisearch_sdk::errors::Error> {
Ok(BatchClient::new(
self.addresses
.iter()
.map(|address| {
Client::new(address.as_str(), Some(self.key.as_str()))
})
.collect::<Result<Vec<_>, _>>()?,
))
}
// Next: true if we want the next index (we are preparing the next swap), false if we want the current index (searching)
pub fn get_index_name(&self, index: &str, next: bool) -> String {
let alt = if next { "_alt" } else { "" };
format!("{}_{}_{}", self.meta_namespace, index, alt)
}
}
/// A project document used for uploading projects to MeiliSearch's indices.
/// This contains some extra data that is not returned by search results.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UploadSearchProject {
pub version_id: String,
@@ -249,147 +142,14 @@ pub struct ResultSearchProject {
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
}
pub fn get_sort_index(
config: &SearchConfig,
index: &str,
) -> Result<(String, [&'static str; 1]), SearchError> {
let projects_name = config.get_index_name("projects", false);
let projects_filtered_name =
config.get_index_name("projects_filtered", false);
Ok(match index {
"relevance" => (projects_name, ["downloads:desc"]),
"downloads" => (projects_filtered_name, ["downloads:desc"]),
"follows" => (projects_name, ["follows:desc"]),
"updated" => (projects_name, ["date_modified:desc"]),
"newest" => (projects_name, ["date_created:desc"]),
i => return Err(SearchError::InvalidIndex(i.to_string())),
})
}
pub async fn search_for_project(
info: &SearchRequest,
config: &SearchConfig,
) -> Result<SearchResults, SearchError> {
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
let index = info.index.as_deref().unwrap_or("relevance");
let limit = info
.limit
.as_deref()
.unwrap_or("10")
.parse::<usize>()?
.min(100);
let sort = get_sort_index(config, index)?;
let client = config.make_loadbalanced_read_client()?;
let meilisearch_index = client.get_index(sort.0).await?;
let mut filter_string = String::new();
// Convert offset and limit to page and hits_per_page
let hits_per_page = if limit == 0 { 1 } else { limit };
let page = offset / hits_per_page + 1;
let results = {
let mut query = meilisearch_index.search();
query
.with_page(page)
.with_hits_per_page(hits_per_page)
.with_query(info.query.as_deref().unwrap_or_default())
.with_sort(&sort.1);
if let Some(new_filters) = info.new_filters.as_deref() {
query.with_filter(new_filters);
} else {
let facets = if let Some(facets) = &info.facets {
Some(serde_json::from_str::<Vec<Vec<Value>>>(facets)?)
} else {
None
};
let filters: Cow<_> =
match (info.filters.as_deref(), info.version.as_deref()) {
(Some(f), Some(v)) => format!("({f}) AND ({v})").into(),
(Some(f), None) => f.into(),
(None, Some(v)) => v.into(),
(None, None) => "".into(),
};
if let Some(facets) = facets {
// Search can now *optionally* have a third inner array: So Vec(AND)<Vec(OR)<Vec(AND)< _ >>>
// For every inner facet, we will check if it can be deserialized into a Vec<&str>, and do so.
// If not, we will assume it is a single facet and wrap it in a Vec.
let facets: Vec<Vec<Vec<String>>> = facets
.into_iter()
.map(|facets| {
facets
.into_iter()
.map(|facet| {
if facet.is_array() {
serde_json::from_value::<Vec<String>>(facet)
.unwrap_or_default()
} else {
vec![
serde_json::from_value::<String>(facet)
.unwrap_or_default(),
]
}
})
.collect_vec()
})
.collect_vec();
filter_string.push('(');
for (index, facet_outer_list) in facets.iter().enumerate() {
filter_string.push('(');
for (facet_outer_index, facet_inner_list) in
facet_outer_list.iter().enumerate()
{
filter_string.push('(');
for (facet_inner_index, facet) in
facet_inner_list.iter().enumerate()
{
filter_string.push_str(&facet.replace(':', " = "));
if facet_inner_index != (facet_inner_list.len() - 1)
{
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if facet_outer_index != (facet_outer_list.len() - 1) {
filter_string.push_str(" OR ")
}
}
filter_string.push(')');
if index != (facets.len() - 1) {
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if !filters.is_empty() {
write!(filter_string, " AND ({filters})")?;
}
} else {
filter_string.push_str(&filters);
}
if !filter_string.is_empty() {
query.with_filter(&filter_string);
}
pub fn backend(meta_namespace: Option<String>) -> Box<dyn SearchBackend> {
match ENV.SEARCH_BACKEND {
SearchBackendKind::Meilisearch => {
let config = backend::MeilisearchConfig::new(meta_namespace);
Box::new(backend::Meilisearch::new(config))
}
query.execute::<ResultSearchProject>().await?
};
Ok(SearchResults {
hits: results.hits.into_iter().map(|r| r.result).collect(),
page: results.page.unwrap_or_default(),
hits_per_page: results.hits_per_page.unwrap_or_default(),
total_hits: results.total_hits.unwrap_or_default(),
})
SearchBackendKind::Elasticsearch => {
Box::new(backend::Elasticsearch::new(meta_namespace).unwrap())
}
}
}
+9 -7
View File
@@ -2,8 +2,9 @@ use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::database::{MIGRATOR, ReadOnlyPgPool};
use crate::env::ENV;
use crate::search;
use crate::search::{self, SearchBackend};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use std::time::Duration;
use url::Url;
@@ -42,8 +43,8 @@ pub struct TemporaryDatabase {
pub pool: PgPool,
pub ro_pool: ReadOnlyPgPool,
pub redis_pool: RedisPool,
pub search_config: crate::search::SearchConfig,
pub database_name: String,
pub search_backend: Arc<dyn SearchBackend>,
}
impl TemporaryDatabase {
@@ -91,15 +92,14 @@ impl TemporaryDatabase {
// Gets new Redis pool
let redis_pool = RedisPool::new(temp_database_name.clone());
// Create new meilisearch config
let search_config =
search::SearchConfig::new(Some(temp_database_name.clone()));
// Create search backend
let search_backend = search::backend(Some(temp_database_name.clone()));
Self {
pool,
ro_pool,
database_name: temp_database_name,
redis_pool,
search_config,
search_backend: Arc::from(search_backend),
}
}
@@ -193,7 +193,9 @@ impl TemporaryDatabase {
ro_pool: ReadOnlyPgPool::from(pool.clone()),
database_name: TEMPLATE_DATABASE_NAME.to_string(),
redis_pool: RedisPool::new(name.clone()),
search_config: search::SearchConfig::new(Some(name)),
search_backend: Arc::from(search::backend(Some(
name.clone(),
))),
};
let setup_api =
TestEnvironment::<ApiV3>::build_setup_api(&db).await;
+2 -3
View File
@@ -23,7 +23,6 @@ pub mod search;
// If making a test, you should probably use environment::TestEnvironment::build() (which calls this)
pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
println!("Setting up labrinth config");
dotenvy::dotenv().ok();
env::init().expect("failed to initialize environment variables");
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
@@ -31,7 +30,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let pool = db.pool.clone();
let ro_pool = db.ro_pool.clone();
let redis_pool = db.redis_pool.clone();
let search_config = db.search_config.clone();
let search_backend = db.search_backend.clone();
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
Arc::new(file_hosting::MockHost::new());
let mut clickhouse = clickhouse::init_client().await.unwrap();
@@ -48,7 +47,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
pool.clone(),
ro_pool.clone(),
redis_pool.clone(),
search_config,
search_backend.into(),
&mut clickhouse,
file_host.clone(),
stripe_client,
+173 -8
View File
@@ -37,6 +37,167 @@ services:
interval: 3s
timeout: 5s
retries: 3
elasticsearch-certs:
image: elasticsearch:9.3.0
container_name: labrinth-elasticsearch-certs
user: '0'
networks:
- elasticsearch-mesh
restart: 'no'
volumes:
- elasticsearch-certs:/usr/share/elasticsearch/config/certs
command: |
bash -c '
set -euo pipefail
if [ ! -s config/certs/ca/ca.crt ] || [ ! -s config/certs/elasticsearch0/elasticsearch0.crt ] || [ ! -s config/certs/elasticsearch1/elasticsearch1.crt ] || [ ! -s config/certs/elasticsearch2/elasticsearch2.crt ]; then
rm -rf config/certs/*
printf "%s\n" \
"instances:" \
" - name: elasticsearch0" \
" dns:" \
" - elasticsearch0" \
" - localhost" \
" ip:" \
" - 127.0.0.1" \
" - name: elasticsearch1" \
" dns:" \
" - elasticsearch1" \
" - localhost" \
" ip:" \
" - 127.0.0.1" \
" - name: elasticsearch2" \
" dns:" \
" - elasticsearch2" \
" - localhost" \
" ip:" \
" - 127.0.0.1" \
> config/certs/instances.yml
bin/elasticsearch-certutil ca --silent --pem --out config/certs/ca.zip
unzip config/certs/ca.zip -d config/certs
bin/elasticsearch-certutil cert --silent --pem --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key --out config/certs/certs.zip
unzip config/certs/certs.zip -d config/certs
fi
chown -R 1000:0 config/certs
find config/certs -type d -exec chmod 750 {} \;
find config/certs -type f -exec chmod 640 {} \;
echo "Set up certificates"
'
elasticsearch0:
image: elasticsearch:9.3.0
container_name: labrinth-elasticsearch0
networks:
- elasticsearch-mesh
restart: on-failure
depends_on:
elasticsearch-certs:
condition: service_completed_successfully
ports:
- '127.0.0.1:9200:9200'
volumes:
- elasticsearch0-data:/usr/share/elasticsearch/data
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
environment:
- logger.level=WARN
- node.name=elasticsearch0
- cluster.name=labrinth
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
- discovery.seed_hosts=elasticsearch1,elasticsearch2
- bootstrap.memory_lock=false
# auth
- xpack.security.enabled=true
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.security.transport.ssl.key=certs/elasticsearch0/elasticsearch0.key
- xpack.security.transport.ssl.certificate=certs/elasticsearch0/elasticsearch0.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- ELASTIC_USERNAME=elastic
- ELASTIC_PASSWORD=elastic
mem_limit: 1g
healthcheck:
test:
[
'CMD-SHELL',
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
]
interval: 10s
timeout: 5s
retries: 10
elasticsearch1:
image: elasticsearch:9.3.0
container_name: labrinth-elasticsearch1
networks:
- elasticsearch-mesh
restart: on-failure
depends_on:
elasticsearch-certs:
condition: service_completed_successfully
volumes:
- elasticsearch1-data:/usr/share/elasticsearch/data
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
environment:
- logger.level=WARN
- node.name=elasticsearch1
- cluster.name=labrinth
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
- discovery.seed_hosts=elasticsearch0,elasticsearch2
- bootstrap.memory_lock=false
# auth
- xpack.security.enabled=true
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.security.transport.ssl.key=certs/elasticsearch1/elasticsearch1.key
- xpack.security.transport.ssl.certificate=certs/elasticsearch1/elasticsearch1.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- ELASTIC_USERNAME=elastic
- ELASTIC_PASSWORD=elastic
mem_limit: 1g
healthcheck:
test:
[
'CMD-SHELL',
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
]
interval: 10s
timeout: 5s
retries: 10
elasticsearch2:
image: elasticsearch:9.3.0
container_name: labrinth-elasticsearch2
networks:
- elasticsearch-mesh
restart: on-failure
depends_on:
elasticsearch-certs:
condition: service_completed_successfully
volumes:
- elasticsearch2-data:/usr/share/elasticsearch/data
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
environment:
- logger.level=WARN
- node.name=elasticsearch2
- cluster.name=labrinth
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
- discovery.seed_hosts=elasticsearch0,elasticsearch1
- bootstrap.memory_lock=false
# auth
- xpack.security.enabled=true
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.security.transport.ssl.key=certs/elasticsearch2/elasticsearch2.key
- xpack.security.transport.ssl.certificate=certs/elasticsearch2/elasticsearch2.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- ELASTIC_USERNAME=elastic
- ELASTIC_PASSWORD=elastic
mem_limit: 1g
healthcheck:
test:
[
'CMD-SHELL',
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:alpine
container_name: labrinth-redis
@@ -89,8 +250,6 @@ services:
ports:
- '127.0.0.1:13000:3000'
extra_hosts:
# Gotenberg must send a message on a webhook to our backend,
# so it must have access to our local network
- 'host.docker.internal:host-gateway'
labrinth:
profiles:
@@ -109,6 +268,12 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
elasticsearch0:
condition: service_healthy
elasticsearch1:
condition: service_healthy
elasticsearch2:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
@@ -136,14 +301,9 @@ services:
timeout: 5s
retries: 3
volumes:
# Labrinth deposits version files here;
# Delphi reads them from here
- /tmp/modrinth:/tmp/modrinth:ro,z
extra_hosts:
# Delphi must send a message on a webhook to our backend,
# so it must have access to our local network
- 'host.docker.internal:host-gateway'
# Sharded Meilisearch
meilisearch1:
profiles:
@@ -166,7 +326,6 @@ services:
interval: 3s
timeout: 5s
retries: 3
nginx-meilisearch-lb:
profiles:
- sharded-meilisearch
@@ -186,9 +345,15 @@ services:
networks:
meilisearch-mesh:
driver: bridge
elasticsearch-mesh:
driver: bridge
volumes:
meilisearch-data:
meilisearch1-data:
elasticsearch0-data:
elasticsearch1-data:
elasticsearch2-data:
elasticsearch-certs:
db-data:
redis-data:
labrinth-cdn-data: