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 1289cb52869185abe1481dfb6b0c00c0233bf59e. * 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>
This commit is contained in:
@@ -2,7 +2,8 @@ use crate::auth::get_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::env::ENV;
|
||||
use crate::models::analytics::{PageView, Playtime};
|
||||
use crate::models::analytics::{MinecraftServerPlay, PageView, Playtime};
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -16,6 +17,7 @@ use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use tracing::trace;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const FILTERED_HEADERS: &[&str] = &[
|
||||
"authorization",
|
||||
@@ -39,6 +41,13 @@ pub const FILTERED_HEADERS: &[&str] = &[
|
||||
"x-vercel-ip-latitude",
|
||||
"x-vercel-ip-country",
|
||||
];
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(page_view_ingest)
|
||||
.service(playtime_ingest)
|
||||
.service(minecraft_server_play_ingest);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UrlInput {
|
||||
url: String,
|
||||
@@ -46,7 +55,7 @@ pub struct UrlInput {
|
||||
|
||||
//this route should be behind the cloudflare WAF to prevent non-browsers from calling it
|
||||
#[post("view")]
|
||||
pub async fn page_view_ingest(
|
||||
async fn page_view_ingest(
|
||||
req: HttpRequest,
|
||||
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
@@ -167,7 +176,7 @@ pub struct PlaytimeInput {
|
||||
}
|
||||
|
||||
#[post("playtime")]
|
||||
pub async fn playtime_ingest(
|
||||
async fn playtime_ingest(
|
||||
req: HttpRequest,
|
||||
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
@@ -223,3 +232,44 @@ pub async fn playtime_ingest(
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MinecraftJavaServerPlayInput {
|
||||
project_id: ProjectId,
|
||||
minecraft_uuid: Uuid,
|
||||
}
|
||||
|
||||
pub const MINECRAFT_SERVER_PLAYS: &str = "minecraft_server_plays";
|
||||
|
||||
#[post("minecraft-server-play")]
|
||||
async fn minecraft_server_play_ingest(
|
||||
req: HttpRequest,
|
||||
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
play_input: web::Json<MinecraftJavaServerPlayInput>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await
|
||||
.map(|(_, user)| user)
|
||||
.ok();
|
||||
|
||||
let project_id = play_input.project_id;
|
||||
let row = MinecraftServerPlay {
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
user_id: user.map(|u| u.id.0).unwrap_or(0),
|
||||
project_id: project_id.0,
|
||||
minecraft_uuid: play_input.minecraft_uuid,
|
||||
};
|
||||
|
||||
analytics_queue.add_minecraft_server_play(row);
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
|
||||
use serde::Deserialize;
|
||||
@@ -157,6 +158,8 @@ pub async fn force_reindex(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
use crate::search::indexing::index_projects;
|
||||
let redis = redis.get_ref();
|
||||
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
|
||||
index_projects(pool.as_ref().clone(), redis.clone(), &config)
|
||||
.await
|
||||
.wrap_internal_err("failed to index projects")?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod moderation;
|
||||
pub mod mural;
|
||||
pub mod pats;
|
||||
pub mod search;
|
||||
pub mod server_ping;
|
||||
pub mod session;
|
||||
pub mod statuses;
|
||||
|
||||
@@ -61,5 +62,10 @@ pub fn utoipa_config(
|
||||
utoipa_actix_web::scope("/_internal/globals")
|
||||
.wrap(default_cors())
|
||||
.configure(globals::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/server-ping")
|
||||
.wrap(default_cors())
|
||||
.configure(server_ping::config),
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/labrinth/src/routes/internal/server_ping.rs
Normal file
46
apps/labrinth/src/routes/internal/server_ping.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{PgPool, redis::RedisPool},
|
||||
models::pats::Scopes,
|
||||
queue::{server_ping, session::AuthQueue},
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(ping_minecraft_java);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PingRequest {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/minecraft-java")]
|
||||
pub async fn ping_minecraft_java(
|
||||
req: HttpRequest,
|
||||
web::Json(request): web::Json<PingRequest>,
|
||||
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::SESSION_ACCESS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
server_ping::ping_server(&request.address, request.port)
|
||||
.await
|
||||
.wrap_request_err("failed to ping server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::database::models::DelphiReportIssueDetailsId;
|
||||
use crate::env::ENV;
|
||||
use crate::file_hosting::FileHostingError;
|
||||
use crate::routes::analytics::{page_view_ingest, playtime_ingest};
|
||||
use crate::util::cors::default_cors;
|
||||
use actix_cors::Cors;
|
||||
use actix_files::Files;
|
||||
@@ -16,7 +15,7 @@ pub mod v2;
|
||||
pub mod v2_reroute;
|
||||
pub mod v3;
|
||||
|
||||
mod analytics;
|
||||
pub mod analytics;
|
||||
mod index;
|
||||
mod maven;
|
||||
mod not_found;
|
||||
@@ -57,8 +56,7 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
|
||||
])
|
||||
.max_age(3600),
|
||||
)
|
||||
.service(page_view_ingest)
|
||||
.service(playtime_ingest),
|
||||
.configure(analytics::config),
|
||||
);
|
||||
cfg.service(
|
||||
web::scope("api/v1")
|
||||
@@ -216,9 +214,9 @@ impl ApiError {
|
||||
}
|
||||
},
|
||||
description: match self {
|
||||
Self::Internal(e) => format!("{e:#?}"),
|
||||
Self::Request(e) => format!("{e:#?}"),
|
||||
Self::Auth(e) => format!("{e:#?}"),
|
||||
Self::Internal(e) => format!("{e:#}"),
|
||||
Self::Request(e) => format!("{e:#}"),
|
||||
Self::Auth(e) => format!("{e:#}"),
|
||||
_ => self.to_string(),
|
||||
},
|
||||
details: match self {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
// 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
|
||||
@@ -99,7 +100,7 @@ pub async fn project_search(
|
||||
..info
|
||||
};
|
||||
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
let results = search_for_project(&info, &config, &redis).await?;
|
||||
|
||||
let results = LegacySearchResults::from(results);
|
||||
|
||||
@@ -214,7 +215,7 @@ pub async fn project_get(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Convert V2 data to V3 data
|
||||
// Call V3 project creation
|
||||
let response = v3::projects::project_get(
|
||||
let project = match v3::projects::project_get_internal(
|
||||
req,
|
||||
info,
|
||||
pool.clone(),
|
||||
@@ -222,23 +223,21 @@ pub async fn project_get(
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)?;
|
||||
{
|
||||
Ok(resp) => resp.0,
|
||||
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
// Convert response to V2 format
|
||||
match v2_reroute::extract_ok_json::<Project>(response).await {
|
||||
Ok(project) => {
|
||||
let version_item = match project.versions.first() {
|
||||
Some(vid) => {
|
||||
version_item::DBVersion::get((*vid).into(), &**pool, &redis)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let project = LegacyProject::from(project, version_item);
|
||||
Ok(HttpResponse::Ok().json(project))
|
||||
let version_item = match project.versions.first() {
|
||||
Some(vid) => {
|
||||
version_item::DBVersion::get((*vid).into(), &**pool, &redis).await?
|
||||
}
|
||||
Err(response) => Ok(response),
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let project = LegacyProject::from(project, version_item);
|
||||
Ok(HttpResponse::Ok().json(project))
|
||||
}
|
||||
|
||||
//checks the validity of a project id or slug
|
||||
@@ -249,7 +248,7 @@ pub async fn project_get_check(
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns an id only, do not need to convert
|
||||
v3::projects::project_get_check(info, pool, redis)
|
||||
v3::projects::project_get_check_internal(info, pool, redis)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
}
|
||||
@@ -269,7 +268,7 @@ pub async fn dependency_list(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// TODO: tests, probably
|
||||
let response = v3::projects::dependency_list(
|
||||
let response = v3::projects::dependency_list_internal(
|
||||
req,
|
||||
info,
|
||||
pool.clone(),
|
||||
@@ -512,12 +511,16 @@ pub async fn project_edit(
|
||||
moderation_message_body: v2_new_project.moderation_message_body,
|
||||
monetization_status: v2_new_project.monetization_status,
|
||||
side_types_migration_review_status: None, // Not to be exposed in v2
|
||||
loader_fields: HashMap::new(), // Loader fields are not a thing in v2
|
||||
// None of the below is present in v2
|
||||
loader_fields: HashMap::new(),
|
||||
minecraft_server: None,
|
||||
minecraft_java_server: None,
|
||||
minecraft_bedrock_server: None,
|
||||
};
|
||||
|
||||
// This returns 204 or failure so we don't need to do anything with it
|
||||
let project_id = info.clone().0;
|
||||
let mut response = v3::projects::project_edit(
|
||||
let mut response = v3::projects::project_edit_internal(
|
||||
req.clone(),
|
||||
info,
|
||||
pool.clone(),
|
||||
@@ -754,7 +757,7 @@ pub async fn project_icon_edit(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::project_icon_edit(
|
||||
v3::projects::project_icon_edit_internal(
|
||||
web::Query(v3::projects::Extension { ext: ext.ext }),
|
||||
req,
|
||||
info,
|
||||
@@ -778,7 +781,7 @@ pub async fn delete_project_icon(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::delete_project_icon(
|
||||
v3::projects::delete_project_icon_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
@@ -814,7 +817,7 @@ pub async fn add_gallery_item(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::add_gallery_item(
|
||||
v3::projects::add_gallery_item_internal(
|
||||
web::Query(v3::projects::Extension { ext: ext.ext }),
|
||||
req,
|
||||
web::Query(v3::projects::GalleryCreateQuery {
|
||||
@@ -865,7 +868,7 @@ pub async fn edit_gallery_item(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::edit_gallery_item(
|
||||
v3::projects::edit_gallery_item_internal(
|
||||
req,
|
||||
web::Query(v3::projects::GalleryEditQuery {
|
||||
url: item.url,
|
||||
@@ -897,7 +900,7 @@ pub async fn delete_gallery_item(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::delete_gallery_item(
|
||||
v3::projects::delete_gallery_item_internal(
|
||||
req,
|
||||
web::Query(v3::projects::GalleryDeleteQuery { url: item.url }),
|
||||
pool,
|
||||
@@ -919,7 +922,7 @@ pub async fn project_delete(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::project_delete(
|
||||
v3::projects::project_delete_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
@@ -941,7 +944,7 @@ pub async fn project_follow(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::project_follow(req, info, pool, redis, session_queue)
|
||||
v3::projects::project_follow_internal(req, info, pool, redis, session_queue)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
}
|
||||
@@ -955,7 +958,13 @@ pub async fn project_unfollow(
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
v3::projects::project_unfollow(req, info, pool, redis, session_queue)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
v3::projects::project_unfollow_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub async fn team_members_get_project(
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let response = v3::teams::team_members_get_project(
|
||||
let response = v3::teams::team_members_get_project_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
|
||||
@@ -104,7 +104,7 @@ pub async fn version_list(
|
||||
include_changelog: filters.include_changelog,
|
||||
};
|
||||
|
||||
let response = v3::versions::version_list(
|
||||
let response = v3::versions::version_list_internal(
|
||||
req,
|
||||
info,
|
||||
web::Query(filters),
|
||||
@@ -211,6 +211,7 @@ pub async fn version_get(
|
||||
let response =
|
||||
v3::versions::version_get_helper(req, id, pool, redis, session_queue)
|
||||
.await
|
||||
.map(|b| HttpResponse::Ok().json(b))
|
||||
.or_else(v2_reroute::flatten_404_error)?;
|
||||
// Convert response to V2 format
|
||||
match v2_reroute::extract_ok_json::<Version>(response).await {
|
||||
@@ -277,7 +278,7 @@ pub async fn version_edit(
|
||||
}
|
||||
|
||||
// Get the older version to get info from
|
||||
let old_version = v3::versions::version_get_helper(
|
||||
let old_version = match v3::versions::version_get_helper(
|
||||
req.clone(),
|
||||
(*info).0,
|
||||
pool.clone(),
|
||||
@@ -285,12 +286,19 @@ pub async fn version_edit(
|
||||
session_queue.clone(),
|
||||
)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)?;
|
||||
let old_version =
|
||||
match v2_reroute::extract_ok_json::<Version>(old_version).await {
|
||||
Ok(version) => version,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let old_version = match v2_reroute::extract_ok_json::<Version>(
|
||||
HttpResponse::Ok().json(old_version.0),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(version) => version,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
// If this has 'mrpack_loaders' as a loader field previously, this is a modpack.
|
||||
// Therefore, if we are modifying the 'loader' field in this case,
|
||||
|
||||
@@ -99,7 +99,8 @@ pub async fn collection_create(
|
||||
&mut transaction,
|
||||
&redis,
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch created projects")?
|
||||
.into_iter()
|
||||
.map(|x| x.inner.id.into())
|
||||
.collect::<Vec<ProjectId>>();
|
||||
|
||||
@@ -36,7 +36,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(images::config)
|
||||
.configure(notifications::config)
|
||||
.configure(organizations::config)
|
||||
.configure(project_creation::config)
|
||||
.configure(projects::config)
|
||||
.configure(reports::config)
|
||||
.configure(shared_instance_version_creation::config)
|
||||
@@ -65,6 +64,12 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(payouts::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/project")
|
||||
.wrap(default_cors())
|
||||
.configure(projects::utoipa_config)
|
||||
.configure(project_creation::config),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn hello_world() -> Result<HttpResponse, ApiError> {
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::models::{self, DBUser, image_item};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -43,8 +44,12 @@ use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(project_create).service(project_create_with_id);
|
||||
mod new;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(project_create)
|
||||
.service(project_create_with_id)
|
||||
.configure(new::config);
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -93,6 +98,30 @@ pub enum CreateError {
|
||||
LimitReached,
|
||||
}
|
||||
|
||||
impl From<crate::routes::ApiError> for CreateError {
|
||||
fn from(value: crate::routes::ApiError) -> Self {
|
||||
match value {
|
||||
crate::routes::ApiError::Database(err) => Self::DatabaseError(err),
|
||||
crate::routes::ApiError::SqlxDatabase(err) => {
|
||||
Self::SqlxDatabaseError(err)
|
||||
}
|
||||
crate::routes::ApiError::Authentication(err) => {
|
||||
Self::Unauthorized(err)
|
||||
}
|
||||
crate::routes::ApiError::CustomAuthentication(err) => {
|
||||
Self::CustomAuthenticationError(err)
|
||||
}
|
||||
crate::routes::ApiError::InvalidInput(err)
|
||||
| crate::routes::ApiError::Validation(err) => {
|
||||
Self::InvalidInput(err)
|
||||
}
|
||||
err => Self::DatabaseError(models::DatabaseError::SchemaError(
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for CreateError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
@@ -262,7 +291,8 @@ pub async fn undo_uploads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/project")]
|
||||
#[utoipa::path]
|
||||
#[post("")]
|
||||
pub async fn project_create(
|
||||
req: HttpRequest,
|
||||
payload: Multipart,
|
||||
@@ -327,7 +357,8 @@ pub async fn project_create_internal(
|
||||
/// Allows creating a project with a specific ID.
|
||||
///
|
||||
/// This is a testing endpoint only accessible behind an admin key.
|
||||
#[post("/project/{id}", guard = "admin_key_guard")]
|
||||
#[utoipa::path]
|
||||
#[post("/{id}", guard = "admin_key_guard")]
|
||||
pub async fn project_create_with_id(
|
||||
req: HttpRequest,
|
||||
mut payload: Multipart,
|
||||
@@ -870,6 +901,7 @@ async fn project_create_inner(
|
||||
.collect(),
|
||||
color: icon_data.and_then(|x| x.2),
|
||||
monetization_status: MonetizationStatus::Monetized,
|
||||
components: exp::ProjectSerial::default(),
|
||||
};
|
||||
let project_builder = project_builder_actual.clone();
|
||||
|
||||
@@ -992,6 +1024,7 @@ async fn project_create_inner(
|
||||
side_types_migration_review_status:
|
||||
SideTypesMigrationReviewStatus::Reviewed,
|
||||
fields: HashMap::new(), // Fields instantiate to empty
|
||||
components: exp::ProjectQuery::default(),
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
@@ -1076,6 +1109,7 @@ async fn create_initial_version(
|
||||
version_type: version_data.release_channel.to_string(),
|
||||
requested_status: None,
|
||||
ordering: version_data.ordering,
|
||||
components: exp::VersionSerial::default(),
|
||||
};
|
||||
|
||||
Ok(version)
|
||||
|
||||
337
apps/labrinth/src/routes/v3/project_creation/new.rs
Normal file
337
apps/labrinth/src/routes/v3/project_creation/new.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web};
|
||||
use eyre::eyre;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
self, DBOrganization, DBTeamMember, DBUser,
|
||||
project_item::ProjectBuilder, thread_item::ThreadBuilder,
|
||||
version_item::VersionBuilder,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
exp::{self, ProjectComponentKind, component::ComponentRelationError},
|
||||
ids::ProjectId,
|
||||
pats::Scopes,
|
||||
projects::{
|
||||
MonetizationStatus, ProjectStatus, VersionStatus, VersionType,
|
||||
},
|
||||
teams::{OrganizationPermissions, ProjectPermissions},
|
||||
threads::ThreadType,
|
||||
v3::user_limits::UserLimits,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::{error::Context, validate::validation_errors_to_string},
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(create);
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CreateError {
|
||||
#[error("project limit reached")]
|
||||
LimitReached,
|
||||
#[error("invalid component kinds")]
|
||||
ComponentKinds(ComponentRelationError<ProjectComponentKind>),
|
||||
#[error("failed to validate request: {0}")]
|
||||
Validation(String),
|
||||
#[error("slug collision")]
|
||||
SlugCollision,
|
||||
#[error(transparent)]
|
||||
Api(#[from] ApiError),
|
||||
}
|
||||
|
||||
impl CreateError {
|
||||
pub fn as_api_error(&self) -> crate::models::error::ApiError<'_> {
|
||||
match self {
|
||||
Self::LimitReached => crate::models::error::ApiError {
|
||||
error: "limit_reached",
|
||||
description: self.to_string(),
|
||||
details: None,
|
||||
},
|
||||
Self::ComponentKinds(err) => crate::models::error::ApiError {
|
||||
error: "component_kinds",
|
||||
description: format!("{self}: {err}"),
|
||||
details: Some(
|
||||
serde_json::to_value(err)
|
||||
.expect("should never fail to serialize"),
|
||||
),
|
||||
},
|
||||
Self::Validation(_) => crate::models::error::ApiError {
|
||||
error: "validation",
|
||||
description: self.to_string(),
|
||||
details: None,
|
||||
},
|
||||
Self::SlugCollision => crate::models::error::ApiError {
|
||||
error: "slug_collision",
|
||||
description: self.to_string(),
|
||||
details: None,
|
||||
},
|
||||
Self::Api(err) => err.as_api_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for CreateError {
|
||||
fn status_code(&self) -> actix_http::StatusCode {
|
||||
match self {
|
||||
Self::LimitReached
|
||||
| Self::ComponentKinds(_)
|
||||
| Self::Validation(_)
|
||||
| Self::SlugCollision => StatusCode::BAD_REQUEST,
|
||||
Self::Api(err) => err.status_code(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(self.as_api_error())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct ProjectCreate {
|
||||
pub base: exp::base::Project,
|
||||
#[serde(flatten)]
|
||||
#[validate(nested)]
|
||||
pub components: exp::ProjectEdit,
|
||||
}
|
||||
|
||||
/// Creates a new project with the given components.
|
||||
///
|
||||
/// Components must include `base` ([`exp::base::Project`]), and at least one
|
||||
/// other component.
|
||||
#[utoipa::path]
|
||||
#[put("")]
|
||||
pub async fn create(
|
||||
req: HttpRequest,
|
||||
db: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
web::Json(create): web::Json<ProjectCreate>,
|
||||
) -> Result<web::Json<ProjectId>, CreateError> {
|
||||
// check that the user can make a project
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**db,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_CREATE,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let limits = UserLimits::get_for_projects(&user, &db)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
if limits.current >= limits.max {
|
||||
return Err(CreateError::LimitReached);
|
||||
}
|
||||
|
||||
// check if the given details are valid
|
||||
|
||||
create.validate().map_err(|err| {
|
||||
CreateError::Validation(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
let ProjectCreate { base, components } = create;
|
||||
|
||||
exp::component::kinds_valid(
|
||||
&components.component_kinds(),
|
||||
&exp::PROJECT_COMPONENT_RELATIONS,
|
||||
)
|
||||
.map_err(CreateError::ComponentKinds)?;
|
||||
|
||||
// get component-specific data
|
||||
// use struct destructor syntax, so we get a compile error
|
||||
// if we add a new field and don't add it here
|
||||
let exp::base::Project {
|
||||
name,
|
||||
slug,
|
||||
summary,
|
||||
description,
|
||||
requested_status,
|
||||
organization_id,
|
||||
} = base;
|
||||
|
||||
// check if this won't conflict with an existing project
|
||||
|
||||
let mut txn = db
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
|
||||
let same_slug_record = sqlx::query!(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1
|
||||
)",
|
||||
slug.to_lowercase()
|
||||
)
|
||||
.fetch_one(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to query if slug already exists")?;
|
||||
|
||||
if same_slug_record.exists.unwrap_or(false) {
|
||||
return Err(CreateError::SlugCollision);
|
||||
}
|
||||
|
||||
// create project and supporting records in db
|
||||
|
||||
let team = if let Some(organization_id) = organization_id {
|
||||
let org = DBOrganization::get_id(organization_id.into(), &**db, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to get organization")?
|
||||
.wrap_request_err("invalid organization ID")?;
|
||||
|
||||
let team_member =
|
||||
DBTeamMember::get_from_user_id(org.team_id, user.id.into(), &**db)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to get team member of user for organization",
|
||||
)?;
|
||||
|
||||
let perms = OrganizationPermissions::get_permissions_by_role(
|
||||
&user.role,
|
||||
&team_member,
|
||||
);
|
||||
|
||||
if !perms
|
||||
.is_some_and(|p| p.contains(OrganizationPermissions::ADD_PROJECT))
|
||||
{
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"no permission to create projects in this organization"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
models::team_item::TeamBuilder {
|
||||
members: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
let members = vec![models::team_item::TeamMemberBuilder {
|
||||
user_id: user.id.into(),
|
||||
role: crate::models::teams::DEFAULT_ROLE.to_owned(),
|
||||
is_owner: true,
|
||||
permissions: ProjectPermissions::all(),
|
||||
organization_permissions: None,
|
||||
accepted: true,
|
||||
payouts_split: Decimal::ONE_HUNDRED,
|
||||
ordering: 0,
|
||||
}];
|
||||
|
||||
models::team_item::TeamBuilder { members }
|
||||
};
|
||||
let team_id = team
|
||||
.insert(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert team")?;
|
||||
|
||||
let project_id: ProjectId = models::generate_project_id(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate project ID")?
|
||||
.into();
|
||||
|
||||
// TODO: special casing certain components
|
||||
let mut monetization_status = MonetizationStatus::Monetized;
|
||||
let mut version_builder = None::<VersionBuilder>;
|
||||
|
||||
if components.minecraft_server.is_some() {
|
||||
// servers are not part of the monetization pool;
|
||||
// they generate no payouts for their owners
|
||||
monetization_status = MonetizationStatus::ForceDemonetized;
|
||||
|
||||
// servers may never have a version added, if e.g. they are a vanilla server
|
||||
// but we need at least 1 version on this project for certain features,
|
||||
// like search indexing, to work.
|
||||
// so we generate a synthetic initial version.
|
||||
let version_id = models::generate_version_id(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate project ID")?;
|
||||
version_builder = Some(VersionBuilder {
|
||||
version_id,
|
||||
project_id: project_id.into(),
|
||||
author_id: user.id.into(),
|
||||
name: "__synthetic".into(),
|
||||
version_number: String::new(),
|
||||
changelog: String::new(),
|
||||
files: Vec::new(),
|
||||
dependencies: Vec::new(),
|
||||
loaders: Vec::new(),
|
||||
version_fields: Vec::new(),
|
||||
version_type: VersionType::Release.to_string(),
|
||||
featured: false,
|
||||
status: VersionStatus::Listed,
|
||||
requested_status: None,
|
||||
ordering: None,
|
||||
components: exp::VersionSerial::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let project_builder = ProjectBuilder {
|
||||
project_id: project_id.into(),
|
||||
team_id,
|
||||
organization_id: organization_id.map(From::from),
|
||||
name: name.clone(),
|
||||
summary: summary.clone(),
|
||||
description: description.clone(),
|
||||
icon_url: None,
|
||||
raw_icon_url: None,
|
||||
license_url: None,
|
||||
categories: vec![],
|
||||
additional_categories: vec![],
|
||||
initial_versions: vec![],
|
||||
status: ProjectStatus::Draft,
|
||||
requested_status: Some(requested_status),
|
||||
license: "LicenseRef-Unknown".into(),
|
||||
slug: Some(slug.clone()),
|
||||
link_urls: vec![],
|
||||
gallery_items: vec![],
|
||||
color: None,
|
||||
monetization_status,
|
||||
components: components
|
||||
.create()
|
||||
.wrap_request_err("failed to create components")?,
|
||||
};
|
||||
|
||||
project_builder
|
||||
.insert(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert project")?;
|
||||
|
||||
if let Some(version_builder) = version_builder {
|
||||
version_builder
|
||||
.insert(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert initial version")?;
|
||||
}
|
||||
|
||||
DBUser::clear_project_cache(&[user.id.into()], &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear user project cache")?;
|
||||
|
||||
ThreadBuilder {
|
||||
type_: ThreadType::Project,
|
||||
members: vec![],
|
||||
project_id: Some(project_id.into()),
|
||||
report_id: None,
|
||||
}
|
||||
.insert(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert thread")?;
|
||||
|
||||
// and commit!
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
Ok(web::Json(project_id))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::any::type_name;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,14 +8,13 @@ use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::project_item::{DBGalleryItem, DBModCategory};
|
||||
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||
use crate::database::models::{
|
||||
DBModerationLock, DBTeamMember, ids as db_ids, image_item,
|
||||
DBModerationLock, DBProjectId, DBTeamMember, ids as db_ids, image_item,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{self, models as db_models};
|
||||
use crate::database::{PgPool, PgTransaction};
|
||||
use crate::env::ENV;
|
||||
use crate::file_hosting::{FileHost, FileHostPublicity};
|
||||
use crate::models;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
@@ -25,6 +25,7 @@ use crate::models::projects::{
|
||||
};
|
||||
use crate::models::teams::ProjectPermissions;
|
||||
use crate::models::threads::MessageBody;
|
||||
use crate::models::{self, exp};
|
||||
use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
@@ -36,7 +37,7 @@ use crate::util::img;
|
||||
use crate::util::img::{delete_old_images, upload_image_optimized};
|
||||
use crate::util::routes::read_limited_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use chrono::Utc;
|
||||
use eyre::eyre;
|
||||
use futures::TryStreamExt;
|
||||
@@ -50,38 +51,27 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("projects", web::get().to(projects_get));
|
||||
cfg.route("projects", web::patch().to(projects_edit));
|
||||
cfg.route("projects_random", web::get().to(random_projects_get));
|
||||
}
|
||||
|
||||
cfg.service(
|
||||
web::scope("project")
|
||||
.route("{id}", web::get().to(project_get))
|
||||
.route("{id}/check", web::get().to(project_get_check))
|
||||
.route("{id}", web::delete().to(project_delete))
|
||||
.route("{id}", web::patch().to(project_edit))
|
||||
.route("{id}/icon", web::patch().to(project_icon_edit))
|
||||
.route("{id}/icon", web::delete().to(delete_project_icon))
|
||||
.route("{id}/gallery", web::post().to(add_gallery_item))
|
||||
.route("{id}/gallery", web::patch().to(edit_gallery_item))
|
||||
.route("{id}/gallery", web::delete().to(delete_gallery_item))
|
||||
.route("{id}/follow", web::post().to(project_follow))
|
||||
.route("{id}/follow", web::delete().to(project_unfollow))
|
||||
.route("{id}/organization", web::get().to(project_get_organization))
|
||||
.service(
|
||||
web::scope("{project_id}")
|
||||
.route(
|
||||
"members",
|
||||
web::get().to(super::teams::team_members_get_project),
|
||||
)
|
||||
.route(
|
||||
"version",
|
||||
web::get().to(super::versions::version_list),
|
||||
)
|
||||
.route(
|
||||
"version/{slug}",
|
||||
web::get().to(super::versions::version_project_get),
|
||||
)
|
||||
.route("dependencies", web::get().to(dependency_list)),
|
||||
),
|
||||
);
|
||||
pub fn utoipa_config(
|
||||
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
|
||||
) {
|
||||
cfg.service(project_get)
|
||||
.service(project_get_check)
|
||||
.service(project_delete)
|
||||
.service(project_edit)
|
||||
.service(project_icon_edit)
|
||||
.service(delete_project_icon)
|
||||
.service(add_gallery_item)
|
||||
.service(edit_gallery_item)
|
||||
.service(delete_gallery_item)
|
||||
.service(project_follow)
|
||||
.service(project_unfollow)
|
||||
.service(project_get_organization)
|
||||
.service(super::teams::team_members_get_project)
|
||||
.service(super::versions::version_list)
|
||||
.service(super::versions::version_project_get)
|
||||
.service(dependency_list);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
@@ -165,17 +155,30 @@ pub async fn projects_get(
|
||||
Ok(HttpResponse::Ok().json(projects))
|
||||
}
|
||||
|
||||
pub async fn project_get(
|
||||
#[utoipa::path]
|
||||
#[get("/{id}")]
|
||||
async fn project_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let string = info.into_inner().0;
|
||||
) -> Result<web::Json<Project>, ApiError> {
|
||||
project_get_internal(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
let project_data =
|
||||
db_models::DBProject::get(&string, &**pool, &redis).await?;
|
||||
pub async fn project_get_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<Project>, ApiError> {
|
||||
let (string,) = info.into_inner();
|
||||
|
||||
let project_data = db_models::DBProject::get(&string, &**pool, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch project")?;
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -184,18 +187,20 @@ pub async fn project_get(
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.map(|(_, user)| user)
|
||||
.ok();
|
||||
|
||||
if let Some(data) = project_data
|
||||
&& is_visible_project(&data.inner, &user_option, &pool, false).await?
|
||||
&& is_visible_project(&data.inner, &user_option, &pool, false)
|
||||
.await
|
||||
.wrap_internal_err("failed to check project visibility")?
|
||||
{
|
||||
return Ok(HttpResponse::Ok().json(Project::from(data)));
|
||||
return Ok(web::Json(Project::from(data)));
|
||||
}
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate)]
|
||||
#[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct EditProject {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
@@ -257,15 +262,61 @@ pub struct EditProject {
|
||||
Option<SideTypesMigrationReviewStatus>,
|
||||
#[serde(flatten)]
|
||||
pub loader_fields: HashMap<String, serde_json::Value>,
|
||||
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub minecraft_server: Option<Option<exp::minecraft::ServerProjectEdit>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub minecraft_java_server:
|
||||
Option<Option<exp::minecraft::JavaServerProjectEdit>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub minecraft_bedrock_server:
|
||||
Option<Option<exp::minecraft::BedrockServerProjectEdit>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn project_edit(
|
||||
#[utoipa::path]
|
||||
#[patch("/{id}")]
|
||||
async fn project_edit(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
new_project: web::Json<EditProject>,
|
||||
web::Json(new_project): web::Json<EditProject>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
moderation_queue: web::Data<AutomatedModerationQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
project_edit_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
search_config,
|
||||
web::Json(new_project),
|
||||
redis,
|
||||
session_queue,
|
||||
moderation_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn project_edit_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
web::Json(new_project): web::Json<EditProject>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
moderation_queue: web::Data<AutomatedModerationQueue>,
|
||||
@@ -284,7 +335,7 @@ pub async fn project_edit(
|
||||
ApiError::Validation(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
let Some(project_item) =
|
||||
let Some(mut project_item) =
|
||||
db_models::DBProject::get(&info.into_inner().0, &**pool, &redis)
|
||||
.await?
|
||||
else {
|
||||
@@ -430,6 +481,7 @@ pub async fn project_edit(
|
||||
if status.is_searchable()
|
||||
&& !project_item.inner.webhook_sent
|
||||
&& !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty()
|
||||
&& project_item.inner.components.minecraft_server.is_none()
|
||||
{
|
||||
crate::util::webhook::send_discord_webhook(
|
||||
project_item.inner.id.into(),
|
||||
@@ -939,6 +991,95 @@ pub async fn project_edit(
|
||||
}
|
||||
}
|
||||
|
||||
// components
|
||||
|
||||
async fn update<E: exp::component::ComponentEdit>(
|
||||
_txn: &mut PgTransaction<'_>,
|
||||
_project_id: DBProjectId,
|
||||
edit: Option<Option<E>>,
|
||||
mut component: &mut Option<E::Component>,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(edit) = edit else {
|
||||
// component is not specified in the input JSON - leave alone
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match (&mut component, edit) {
|
||||
(None, None) => {}
|
||||
(Some(_), None) => {
|
||||
// component is `null` in the input JSON - remove component
|
||||
*component = None;
|
||||
}
|
||||
(None, Some(edit)) => {
|
||||
// component is specified in the JSON and is non-null - create new component
|
||||
*component =
|
||||
Some(edit.create().wrap_request_err_with(|| {
|
||||
eyre!(
|
||||
"failed to create `{}` component",
|
||||
type_name::<E::Component>()
|
||||
)
|
||||
})?);
|
||||
}
|
||||
(Some(component), Some(edit)) => {
|
||||
// edit component
|
||||
edit.apply_to(component).await.wrap_internal_err_with(
|
||||
|| {
|
||||
eyre!(
|
||||
"failed to update `{}` component",
|
||||
type_name::<E::Component>()
|
||||
)
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_server,
|
||||
&mut project_item.inner.components.minecraft_server,
|
||||
)
|
||||
.await?;
|
||||
update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_java_server,
|
||||
&mut project_item.inner.components.minecraft_java_server,
|
||||
)
|
||||
.await?;
|
||||
update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_bedrock_server,
|
||||
&mut project_item.inner.components.minecraft_bedrock_server,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let components_serial = project_item.inner.components.clone();
|
||||
|
||||
exp::component::kinds_valid(
|
||||
&components_serial.component_kinds(),
|
||||
&exp::PROJECT_COMPONENT_RELATIONS,
|
||||
)
|
||||
.wrap_request_err("invalid component kinds")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE mods
|
||||
SET components = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
serde_json::to_value(&components_serial)
|
||||
.expect("serialization shouldn't fail"),
|
||||
id as db_ids::DBProjectId,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to update components")?;
|
||||
|
||||
// check new description and body for links to associated images
|
||||
// if they no longer exist in the description or body, delete them
|
||||
let checkable_strings: Vec<&str> =
|
||||
@@ -1040,8 +1181,9 @@ pub async fn edit_project_categories(
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
let results = search_for_project(&info, &config, &redis).await?;
|
||||
|
||||
// TODO: add this back
|
||||
// let results = ReturnSearchResults {
|
||||
@@ -1059,7 +1201,17 @@ pub async fn project_search(
|
||||
}
|
||||
|
||||
//checks the validity of a project id or slug
|
||||
pub async fn project_get_check(
|
||||
#[utoipa::path]
|
||||
#[get("/{id}/check")]
|
||||
async fn project_get_check(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
project_get_check_internal(info, pool, redis).await
|
||||
}
|
||||
|
||||
pub async fn project_get_check_internal(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -1084,12 +1236,24 @@ pub struct DependencyInfo {
|
||||
pub versions: Vec<models::projects::Version>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/{project_id}/dependencies")]
|
||||
pub async fn dependency_list(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
dependency_list_internal(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
pub async fn dependency_list_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let string = info.into_inner().0;
|
||||
|
||||
@@ -1142,7 +1306,11 @@ pub async fn dependency_list(
|
||||
.collect::<Vec<db_models::DBVersionId>>();
|
||||
let (projects_result, versions_result) = futures::future::try_join(
|
||||
database::DBProject::get_many_ids(&project_ids, &**pool, &redis),
|
||||
database::DBVersion::get_many(&dep_version_ids, &**pool, &redis),
|
||||
async {
|
||||
database::DBVersion::get_many(&dep_version_ids, &**pool, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch dependency versions")
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1494,7 +1662,32 @@ pub struct Extension {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn project_icon_edit(
|
||||
#[utoipa::path]
|
||||
#[patch("/{id}/icon")]
|
||||
async fn project_icon_edit(
|
||||
web::Query(ext): web::Query<Extension>,
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
project_icon_edit_internal(
|
||||
web::Query(ext),
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
file_host,
|
||||
payload,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn project_icon_edit_internal(
|
||||
web::Query(ext): web::Query<Extension>,
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
@@ -1609,7 +1802,28 @@ pub async fn project_icon_edit(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
pub async fn delete_project_icon(
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}/icon")]
|
||||
async fn delete_project_icon(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
delete_project_icon_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
file_host,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_project_icon_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1710,7 +1924,34 @@ pub struct GalleryCreateQuery {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[utoipa::path]
|
||||
#[post("/{id}/gallery")]
|
||||
pub async fn add_gallery_item(
|
||||
web::Query(ext): web::Query<Extension>,
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryCreateQuery>,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
add_gallery_item_internal(
|
||||
web::Query(ext),
|
||||
req,
|
||||
web::Query(item),
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
file_host,
|
||||
payload,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn add_gallery_item_internal(
|
||||
web::Query(ext): web::Query<Extension>,
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryCreateQuery>,
|
||||
@@ -1877,7 +2118,26 @@ pub struct GalleryEditQuery {
|
||||
pub ordering: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn edit_gallery_item(
|
||||
#[utoipa::path]
|
||||
#[patch("/{id}/gallery")]
|
||||
async fn edit_gallery_item(
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryEditQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
edit_gallery_item_internal(
|
||||
req,
|
||||
web::Query(item),
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn edit_gallery_item_internal(
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryEditQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -2043,7 +2303,28 @@ pub struct GalleryDeleteQuery {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub async fn delete_gallery_item(
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}/gallery")]
|
||||
async fn delete_gallery_item(
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryDeleteQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
delete_gallery_item_internal(
|
||||
req,
|
||||
web::Query(item),
|
||||
pool,
|
||||
redis,
|
||||
file_host,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_gallery_item_internal(
|
||||
req: HttpRequest,
|
||||
web::Query(item): web::Query<GalleryDeleteQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -2153,7 +2434,28 @@ pub async fn delete_gallery_item(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
pub async fn project_delete(
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}")]
|
||||
async fn project_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
project_delete_internal(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
search_config,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn project_delete_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -2288,7 +2590,19 @@ pub async fn project_delete(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn project_follow(
|
||||
#[utoipa::path]
|
||||
#[post("/{id}/follow")]
|
||||
async fn project_follow(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
project_follow_internal(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
pub async fn project_follow_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -2368,7 +2682,19 @@ pub async fn project_follow(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn project_unfollow(
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}/follow")]
|
||||
async fn project_unfollow(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
project_unfollow_internal(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
pub async fn project_unfollow_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -2444,6 +2770,8 @@ pub async fn project_unfollow(
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/{id}/organization")]
|
||||
pub async fn project_get_organization(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::models::pats::Scopes;
|
||||
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, get, web};
|
||||
use ariadne::ids::UserId;
|
||||
use eyre::eyre;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -40,7 +40,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
// also the members of the organization's team if the project is associated with an organization
|
||||
// (Unlike team_members_get_project, which only returns the members of the project's team)
|
||||
// They can be differentiated by the "organization_permissions" field being null or not
|
||||
pub async fn team_members_get_project(
|
||||
#[utoipa::path]
|
||||
#[get("/{project_id}/members")]
|
||||
async fn team_members_get_project(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
team_members_get_project_internal(req, info, pool, redis, session_queue)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn team_members_get_project_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::database::models::{self, DBOrganization, image_item};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::env::ENV;
|
||||
use crate::file_hosting::{FileHost, FileHostPublicity};
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::{ImageId, ProjectId, VersionId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::notifications::NotificationBody;
|
||||
@@ -325,6 +326,7 @@ async fn version_create_inner(
|
||||
status: version_create_data.status,
|
||||
requested_status: None,
|
||||
ordering: version_create_data.ordering,
|
||||
components: exp::VersionSerial::default(),
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
@@ -474,6 +476,7 @@ async fn version_create_inner(
|
||||
dependencies: version_data.dependencies,
|
||||
loaders: version_data.loaders,
|
||||
fields: version_data.fields,
|
||||
components: exp::VersionQuery::default(),
|
||||
};
|
||||
|
||||
let project_id = builder.project_id;
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::search::indexing::remove_documents;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::img;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, get, web};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -57,6 +57,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
}
|
||||
|
||||
// Given a project ID/slug and a version slug
|
||||
#[utoipa::path]
|
||||
#[get("/{project_id}/version/{slug}")]
|
||||
pub async fn version_project_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String, String)>,
|
||||
@@ -177,7 +179,7 @@ pub async fn version_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<web::Json<models::projects::Version>, ApiError> {
|
||||
let id = info.into_inner().0;
|
||||
version_get_helper(req, id, pool, redis, session_queue).await
|
||||
}
|
||||
@@ -188,7 +190,7 @@ pub async fn version_get_helper(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<web::Json<models::projects::Version>, ApiError> {
|
||||
let version_data =
|
||||
database::models::DBVersion::get(id.into(), &**pool, &redis).await?;
|
||||
|
||||
@@ -206,9 +208,7 @@ pub async fn version_get_helper(
|
||||
if let Some(data) = version_data
|
||||
&& is_visible_version(&data.inner, &user_option, &pool, &redis).await?
|
||||
{
|
||||
return Ok(
|
||||
HttpResponse::Ok().json(models::projects::Version::from(data))
|
||||
);
|
||||
return Ok(web::Json(models::projects::Version::from(data)));
|
||||
}
|
||||
|
||||
Err(ApiError::NotFound)
|
||||
@@ -733,7 +733,28 @@ pub struct VersionListFilters {
|
||||
pub include_changelog: bool,
|
||||
}
|
||||
|
||||
pub async fn version_list(
|
||||
#[utoipa::path]
|
||||
#[get("/{project_id}/version")]
|
||||
async fn version_list(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
web::Query(filters): web::Query<VersionListFilters>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
version_list_internal(
|
||||
req,
|
||||
info,
|
||||
web::Query(filters),
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn version_list_internal(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
web::Query(filters): web::Query<VersionListFilters>,
|
||||
|
||||
Reference in New Issue
Block a user