You've already forked AstralRinth
forked from didirus/AstralRinth
Collections (#688)
* initial draft; unfinished * images, fixes * fixes * println * revisions * fixes * alternate context setup version * rev * partial revs * rev * clippy ,fmt * fmt/clippy/prepare * fixes * revs
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use crate::database;
|
||||
use crate::database::models::project_item::QueryProject;
|
||||
use crate::database::models::version_item::QueryVersion;
|
||||
use crate::database::models::Collection;
|
||||
use crate::database::{models, Project, Version};
|
||||
use crate::models::users::User;
|
||||
use crate::routes::ApiError;
|
||||
@@ -192,3 +193,76 @@ pub async fn filter_authorized_versions(
|
||||
|
||||
Ok(return_versions)
|
||||
}
|
||||
|
||||
pub async fn is_authorized_collection(
|
||||
collection_data: &Collection,
|
||||
user_option: &Option<User>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut authorized = !collection_data.status.is_hidden();
|
||||
|
||||
if let Some(user) = &user_option {
|
||||
if !authorized && (user.role.is_mod() || user.id == collection_data.user_id.into()) {
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(authorized)
|
||||
}
|
||||
|
||||
pub async fn filter_authorized_collections(
|
||||
collections: Vec<Collection>,
|
||||
user_option: &Option<User>,
|
||||
pool: &web::Data<PgPool>,
|
||||
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
||||
let mut return_collections = Vec::new();
|
||||
let mut check_collections = Vec::new();
|
||||
|
||||
for collection in collections {
|
||||
if !collection.status.is_hidden()
|
||||
|| user_option
|
||||
.as_ref()
|
||||
.map(|x| x.role.is_mod())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return_collections.push(collection.into());
|
||||
} else if user_option.is_some() {
|
||||
check_collections.push(collection);
|
||||
}
|
||||
}
|
||||
|
||||
if !check_collections.is_empty() {
|
||||
if let Some(user) = user_option {
|
||||
let user_id: models::ids::UserId = user.id.into();
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT c.id id, c.user_id user_id FROM collections c
|
||||
WHERE c.user_id = $2 AND c.id = ANY($1)
|
||||
",
|
||||
&check_collections.iter().map(|x| x.id.0).collect::<Vec<_>>(),
|
||||
user_id as database::models::ids::UserId,
|
||||
)
|
||||
.fetch_many(&***pool)
|
||||
.try_for_each(|e| {
|
||||
if let Some(row) = e.right() {
|
||||
check_collections.retain(|x| {
|
||||
let bool = x.id.0 == row.id && x.user_id.0 == row.user_id;
|
||||
|
||||
if bool {
|
||||
return_collections.push(x.clone().into());
|
||||
}
|
||||
|
||||
!bool
|
||||
});
|
||||
}
|
||||
|
||||
futures::future::ready(Ok(()))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(return_collections)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ where
|
||||
get_user_record_from_bearer_token(req, None, executor, redis, session_queue)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
let mut auth_providers = Vec::new();
|
||||
if db_user.github_id.is_some() {
|
||||
auth_providers.push(AuthProvider::GitHub)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod models;
|
||||
mod postgres_database;
|
||||
pub use models::Image;
|
||||
pub use models::Project;
|
||||
pub use models::Version;
|
||||
pub use postgres_database::check_for_migrations;
|
||||
|
||||
263
src/database/models/collection_item.rs
Normal file
263
src/database/models/collection_item.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use super::ids::*;
|
||||
use crate::database::models;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::models::collections::CollectionStatus;
|
||||
use chrono::{DateTime, Utc};
|
||||
use redis::cmd;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const COLLECTIONS_NAMESPACE: &str = "collections";
|
||||
const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CollectionBuilder {
|
||||
pub collection_id: CollectionId,
|
||||
pub user_id: UserId,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: CollectionStatus,
|
||||
pub projects: Vec<ProjectId>,
|
||||
}
|
||||
|
||||
impl CollectionBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<CollectionId, DatabaseError> {
|
||||
let collection_struct = Collection {
|
||||
id: self.collection_id,
|
||||
title: self.title,
|
||||
user_id: self.user_id,
|
||||
description: self.description,
|
||||
created: Utc::now(),
|
||||
updated: Utc::now(),
|
||||
icon_url: None,
|
||||
color: None,
|
||||
status: self.status,
|
||||
projects: self.projects,
|
||||
};
|
||||
collection_struct.insert(&mut *transaction).await?;
|
||||
|
||||
Ok(self.collection_id)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: CollectionId,
|
||||
pub user_id: UserId,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
pub icon_url: Option<String>,
|
||||
pub color: Option<u32>,
|
||||
pub status: CollectionStatus,
|
||||
pub projects: Vec<ProjectId>,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO collections (
|
||||
id, user_id, title, description,
|
||||
created, icon_url, status
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7
|
||||
)
|
||||
",
|
||||
self.id as CollectionId,
|
||||
self.user_id as UserId,
|
||||
&self.title,
|
||||
&self.description,
|
||||
self.created,
|
||||
self.icon_url.as_ref(),
|
||||
self.status.to_string(),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
for project_id in self.projects.iter() {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO collections_mods (collection_id, mod_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
",
|
||||
self.id as CollectionId,
|
||||
*project_id as ProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
id: CollectionId,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Option<()>, DatabaseError> {
|
||||
let collection = Self::get(id, &mut *transaction, redis).await?;
|
||||
|
||||
if let Some(collection) = collection {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM collections_mods
|
||||
WHERE collection_id = $1
|
||||
",
|
||||
id as CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM collections
|
||||
WHERE id = $1
|
||||
",
|
||||
id as CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
models::Collection::clear_cache(collection.id, redis).await?;
|
||||
|
||||
Ok(Some(()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get<'a, 'b, E>(
|
||||
id: CollectionId,
|
||||
executor: E,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Option<Collection>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Collection::get_many(&[id], executor, redis)
|
||||
.await
|
||||
.map(|x| x.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn get_many<'a, E>(
|
||||
collection_ids: &[CollectionId],
|
||||
exec: E,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Vec<Collection>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
|
||||
if collection_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut redis = redis.get().await?;
|
||||
|
||||
let mut found_collections = Vec::new();
|
||||
let mut remaining_collections: Vec<CollectionId> = collection_ids.to_vec();
|
||||
|
||||
if !collection_ids.is_empty() {
|
||||
let collections = cmd("MGET")
|
||||
.arg(
|
||||
collection_ids
|
||||
.iter()
|
||||
.map(|x| format!("{}:{}", COLLECTIONS_NAMESPACE, x.0))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<_, Vec<Option<String>>>(&mut redis)
|
||||
.await?;
|
||||
|
||||
for collection in collections {
|
||||
if let Some(collection) =
|
||||
collection.and_then(|x| serde_json::from_str::<Collection>(&x).ok())
|
||||
{
|
||||
remaining_collections.retain(|x| collection.id.0 != x.0);
|
||||
found_collections.push(collection);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining_collections.is_empty() {
|
||||
let collection_ids_parsed: Vec<i64> =
|
||||
remaining_collections.iter().map(|x| x.0).collect();
|
||||
let db_collections: Vec<Collection> = sqlx::query!(
|
||||
"
|
||||
SELECT c.id id, c.title title, c.description description,
|
||||
c.icon_url icon_url, c.color color, c.created created, c.user_id user_id,
|
||||
c.updated updated, c.status status,
|
||||
ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods
|
||||
FROM collections c
|
||||
LEFT JOIN collections_mods cm ON cm.collection_id = c.id
|
||||
WHERE c.id = ANY($1)
|
||||
GROUP BY c.id;
|
||||
",
|
||||
&collection_ids_parsed,
|
||||
)
|
||||
.fetch_many(exec)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|m| {
|
||||
let id = m.id;
|
||||
|
||||
Collection {
|
||||
id: CollectionId(id),
|
||||
user_id: UserId(m.user_id),
|
||||
title: m.title.clone(),
|
||||
description: m.description.clone(),
|
||||
icon_url: m.icon_url.clone(),
|
||||
color: m.color.map(|x| x as u32),
|
||||
created: m.created,
|
||||
updated: m.updated,
|
||||
status: CollectionStatus::from_str(&m.status),
|
||||
projects: m
|
||||
.mods
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(ProjectId)
|
||||
.collect(),
|
||||
}
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Collection>>()
|
||||
.await?;
|
||||
|
||||
for collection in db_collections {
|
||||
cmd("SET")
|
||||
.arg(format!("{}:{}", COLLECTIONS_NAMESPACE, collection.id.0))
|
||||
.arg(serde_json::to_string(&collection)?)
|
||||
.arg("EX")
|
||||
.arg(DEFAULT_EXPIRY)
|
||||
.query_async::<_, ()>(&mut redis)
|
||||
.await?;
|
||||
|
||||
found_collections.push(collection);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found_collections)
|
||||
}
|
||||
|
||||
pub async fn clear_cache(
|
||||
id: CollectionId,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let mut redis = redis.get().await?;
|
||||
let mut cmd = cmd("DEL");
|
||||
|
||||
cmd.arg(format!("{}:{}", COLLECTIONS_NAMESPACE, id.0));
|
||||
cmd.query_async::<_, ()>(&mut redis).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,13 @@ generate_ids!(
|
||||
"SELECT EXISTS(SELECT 1 FROM teams WHERE id=$1)",
|
||||
TeamId
|
||||
);
|
||||
generate_ids!(
|
||||
pub generate_collection_id,
|
||||
CollectionId,
|
||||
8,
|
||||
"SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)",
|
||||
CollectionId
|
||||
);
|
||||
generate_ids!(
|
||||
pub generate_file_id,
|
||||
FileId,
|
||||
@@ -130,6 +137,14 @@ generate_ids!(
|
||||
SessionId
|
||||
);
|
||||
|
||||
generate_ids!(
|
||||
pub generate_image_id,
|
||||
ImageId,
|
||||
8,
|
||||
"SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)",
|
||||
ImageId
|
||||
);
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Serialize, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct UserId(pub i64);
|
||||
@@ -171,7 +186,11 @@ pub struct LoaderId(pub i32);
|
||||
#[sqlx(transparent)]
|
||||
pub struct CategoryId(pub i32);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Type)]
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct CollectionId(pub i64);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct ReportId(pub i64);
|
||||
#[derive(Copy, Clone, Debug, Type)]
|
||||
@@ -196,7 +215,7 @@ pub struct NotificationActionId(pub i32);
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct ThreadId(pub i64);
|
||||
#[derive(Copy, Clone, Debug, Type, Deserialize)]
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct ThreadMessageId(pub i64);
|
||||
|
||||
@@ -204,6 +223,10 @@ pub struct ThreadMessageId(pub i64);
|
||||
#[sqlx(transparent)]
|
||||
pub struct SessionId(pub i64);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct ImageId(pub i64);
|
||||
|
||||
use crate::models::ids;
|
||||
|
||||
impl From<ids::ProjectId> for ProjectId {
|
||||
@@ -246,6 +269,16 @@ impl From<VersionId> for ids::VersionId {
|
||||
ids::VersionId(id.0 as u64)
|
||||
}
|
||||
}
|
||||
impl From<ids::CollectionId> for CollectionId {
|
||||
fn from(id: ids::CollectionId) -> Self {
|
||||
CollectionId(id.0 as i64)
|
||||
}
|
||||
}
|
||||
impl From<CollectionId> for ids::CollectionId {
|
||||
fn from(id: CollectionId) -> Self {
|
||||
ids::CollectionId(id.0 as u64)
|
||||
}
|
||||
}
|
||||
impl From<ids::ReportId> for ReportId {
|
||||
fn from(id: ids::ReportId) -> Self {
|
||||
ReportId(id.0 as i64)
|
||||
@@ -256,6 +289,16 @@ impl From<ReportId> for ids::ReportId {
|
||||
ids::ReportId(id.0 as u64)
|
||||
}
|
||||
}
|
||||
impl From<ImageId> for ids::ImageId {
|
||||
fn from(id: ImageId) -> Self {
|
||||
ids::ImageId(id.0 as u64)
|
||||
}
|
||||
}
|
||||
impl From<ids::ImageId> for ImageId {
|
||||
fn from(id: ids::ImageId) -> Self {
|
||||
ImageId(id.0 as i64)
|
||||
}
|
||||
}
|
||||
impl From<ids::NotificationId> for NotificationId {
|
||||
fn from(id: ids::NotificationId) -> Self {
|
||||
NotificationId(id.0 as i64)
|
||||
|
||||
275
src/database/models/image_item.rs
Normal file
275
src/database/models/image_item.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use super::ids::*;
|
||||
use crate::{database::models::DatabaseError, models::images::ImageContext};
|
||||
use chrono::{DateTime, Utc};
|
||||
use redis::cmd;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const IMAGES_NAMESPACE: &str = "images";
|
||||
const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Image {
|
||||
pub id: ImageId,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
pub created: DateTime<Utc>,
|
||||
pub owner_id: UserId,
|
||||
|
||||
// context it is associated with
|
||||
pub context: String,
|
||||
|
||||
pub project_id: Option<ProjectId>,
|
||||
pub version_id: Option<VersionId>,
|
||||
pub thread_message_id: Option<ThreadMessageId>,
|
||||
pub report_id: Option<ReportId>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO uploaded_images (
|
||||
id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
);
|
||||
",
|
||||
self.id as ImageId,
|
||||
self.url,
|
||||
self.size as i64,
|
||||
self.created,
|
||||
self.owner_id as UserId,
|
||||
self.context,
|
||||
self.project_id.map(|x| x.0),
|
||||
self.version_id.map(|x| x.0),
|
||||
self.thread_message_id.map(|x| x.0),
|
||||
self.report_id.map(|x| x.0),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
id: ImageId,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Option<()>, DatabaseError> {
|
||||
let image = Self::get(id, &mut *transaction, redis).await?;
|
||||
|
||||
if let Some(image) = image {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM uploaded_images
|
||||
WHERE id = $1
|
||||
",
|
||||
id as ImageId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Image::clear_cache(image.id, redis).await?;
|
||||
|
||||
Ok(Some(()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_many_contexted(
|
||||
context: ImageContext,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<Vec<Image>, sqlx::Error> {
|
||||
// Set all of project_id, version_id, thread_message_id, report_id to None
|
||||
// Then set the one that is relevant to Some
|
||||
|
||||
let mut project_id = None;
|
||||
let mut version_id = None;
|
||||
let mut thread_message_id = None;
|
||||
let mut report_id = None;
|
||||
match context {
|
||||
ImageContext::Project {
|
||||
project_id: Some(id),
|
||||
} => {
|
||||
project_id = Some(ProjectId::from(id));
|
||||
}
|
||||
ImageContext::Version {
|
||||
version_id: Some(id),
|
||||
} => {
|
||||
version_id = Some(VersionId::from(id));
|
||||
}
|
||||
ImageContext::ThreadMessage {
|
||||
thread_message_id: Some(id),
|
||||
} => {
|
||||
thread_message_id = Some(ThreadMessageId::from(id));
|
||||
}
|
||||
ImageContext::Report {
|
||||
report_id: Some(id),
|
||||
} => {
|
||||
report_id = Some(ReportId::from(id));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id
|
||||
FROM uploaded_images
|
||||
WHERE context = $1
|
||||
AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL))
|
||||
AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL))
|
||||
AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL))
|
||||
AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL))
|
||||
GROUP BY id
|
||||
",
|
||||
context.context_as_str(),
|
||||
project_id.map(|x| x.0),
|
||||
version_id.map(|x| x.0),
|
||||
thread_message_id.map(|x| x.0),
|
||||
report_id.map(|x| x.0),
|
||||
|
||||
)
|
||||
.fetch_many(transaction)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|row| {
|
||||
let id = ImageId(row.id);
|
||||
|
||||
Image {
|
||||
id,
|
||||
url: row.url,
|
||||
size: row.size as u64,
|
||||
created: row.created,
|
||||
owner_id: UserId(row.owner_id),
|
||||
context: row.context,
|
||||
project_id: row.mod_id.map(ProjectId),
|
||||
version_id: row.version_id.map(VersionId),
|
||||
thread_message_id: row.thread_message_id.map(ThreadMessageId),
|
||||
report_id: row.report_id.map(ReportId),
|
||||
}
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Image>>()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get<'a, 'b, E>(
|
||||
id: ImageId,
|
||||
executor: E,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Option<Image>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Image::get_many(&[id], executor, redis)
|
||||
.await
|
||||
.map(|x| x.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn get_many<'a, E>(
|
||||
image_ids: &[ImageId],
|
||||
exec: E,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<Vec<Image>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
use futures::TryStreamExt;
|
||||
|
||||
if image_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut redis = redis.get().await?;
|
||||
|
||||
let mut found_images = Vec::new();
|
||||
let mut remaining_ids = image_ids.to_vec();
|
||||
|
||||
let image_ids = image_ids.iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
|
||||
if !image_ids.is_empty() {
|
||||
let images = cmd("MGET")
|
||||
.arg(
|
||||
image_ids
|
||||
.iter()
|
||||
.map(|x| format!("{}:{}", IMAGES_NAMESPACE, x))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<_, Vec<Option<String>>>(&mut redis)
|
||||
.await?;
|
||||
|
||||
for image in images {
|
||||
if let Some(image) = image.and_then(|x| serde_json::from_str::<Image>(&x).ok()) {
|
||||
remaining_ids.retain(|x| image.id.0 != x.0);
|
||||
found_images.push(image);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining_ids.is_empty() {
|
||||
let db_images: Vec<Image> = sqlx::query!(
|
||||
"
|
||||
SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id
|
||||
FROM uploaded_images
|
||||
WHERE id = ANY($1)
|
||||
GROUP BY id;
|
||||
",
|
||||
&remaining_ids.iter().map(|x| x.0).collect::<Vec<_>>(),
|
||||
)
|
||||
.fetch_many(exec)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|i| {
|
||||
let id = i.id;
|
||||
|
||||
Image {
|
||||
id: ImageId(id),
|
||||
url: i.url,
|
||||
size: i.size as u64,
|
||||
created: i.created,
|
||||
owner_id: UserId(i.owner_id),
|
||||
context: i.context,
|
||||
project_id: i.mod_id.map(ProjectId),
|
||||
version_id: i.version_id.map(VersionId),
|
||||
thread_message_id: i.thread_message_id.map(ThreadMessageId),
|
||||
report_id: i.report_id.map(ReportId),
|
||||
}
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Image>>()
|
||||
.await?;
|
||||
|
||||
for image in db_images {
|
||||
cmd("SET")
|
||||
.arg(format!("{}:{}", IMAGES_NAMESPACE, image.id.0))
|
||||
.arg(serde_json::to_string(&image)?)
|
||||
.arg("EX")
|
||||
.arg(DEFAULT_EXPIRY)
|
||||
.query_async::<_, ()>(&mut redis)
|
||||
.await?;
|
||||
|
||||
found_images.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found_images)
|
||||
}
|
||||
|
||||
pub async fn clear_cache(
|
||||
id: ImageId,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let mut redis = redis.get().await?;
|
||||
let mut cmd = cmd("DEL");
|
||||
|
||||
cmd.arg(format!("{}:{}", IMAGES_NAMESPACE, id.0));
|
||||
cmd.query_async::<_, ()>(&mut redis).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod categories;
|
||||
pub mod collection_item;
|
||||
pub mod flow_item;
|
||||
pub mod ids;
|
||||
pub mod image_item;
|
||||
pub mod notification_item;
|
||||
pub mod pat_item;
|
||||
pub mod project_item;
|
||||
@@ -13,7 +15,9 @@ pub mod thread_item;
|
||||
pub mod user_item;
|
||||
pub mod version_item;
|
||||
|
||||
pub use collection_item::Collection;
|
||||
pub use ids::*;
|
||||
pub use image_item::Image;
|
||||
pub use project_item::Project;
|
||||
pub use team_item::Team;
|
||||
pub use team_item::TeamMember;
|
||||
|
||||
@@ -546,6 +546,7 @@ impl Project {
|
||||
.flat_map(|x| parse_base62(&x.to_string()).ok())
|
||||
.map(|x| x as i64)
|
||||
.collect();
|
||||
|
||||
let db_projects: Vec<QueryProject> = sqlx::query!(
|
||||
"
|
||||
SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Report {
|
||||
|
||||
pub async fn get<'a, E>(id: ReportId, exec: E) -> Result<Option<QueryReport>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Self::get_many(&[id], exec)
|
||||
.await
|
||||
@@ -70,7 +70,7 @@ impl Report {
|
||||
exec: E,
|
||||
) -> Result<Vec<QueryReport>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ impl ThreadMessage {
|
||||
exec: E,
|
||||
) -> Result<Option<ThreadMessage>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Self::get_many(&[id], exec)
|
||||
.await
|
||||
@@ -224,7 +224,7 @@ impl ThreadMessage {
|
||||
exec: E,
|
||||
) -> Result<Vec<ThreadMessage>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
|
||||
127
src/models/collections.rs
Normal file
127
src/models/collections.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use super::{
|
||||
ids::{Base62Id, ProjectId},
|
||||
users::UserId,
|
||||
};
|
||||
use crate::database;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The ID of a specific collection, encoded as base62 for usage in the API
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct CollectionId(pub u64);
|
||||
|
||||
/// A collection returned from the API
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Collection {
|
||||
/// The ID of the collection, encoded as a base62 string.
|
||||
pub id: CollectionId,
|
||||
/// The person that has ownership of this collection.
|
||||
pub user: UserId,
|
||||
/// The title or name of the collection.
|
||||
pub title: String,
|
||||
/// A short description of the collection.
|
||||
pub description: String,
|
||||
|
||||
/// An icon URL for the collection.
|
||||
pub icon_url: Option<String>,
|
||||
/// Color of the collection.
|
||||
pub color: Option<u32>,
|
||||
|
||||
/// The status of the collectin (eg: whether collection is public or not)
|
||||
pub status: CollectionStatus,
|
||||
|
||||
/// The date at which the collection was first published.
|
||||
pub created: DateTime<Utc>,
|
||||
|
||||
/// The date at which the collection was updated.
|
||||
pub updated: DateTime<Utc>,
|
||||
|
||||
/// A list of ProjectIds that are in this collection.
|
||||
pub projects: Vec<ProjectId>,
|
||||
}
|
||||
|
||||
impl From<database::models::Collection> for Collection {
|
||||
fn from(c: database::models::Collection) -> Self {
|
||||
Self {
|
||||
id: c.id.into(),
|
||||
user: c.user_id.into(),
|
||||
created: c.created,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
updated: c.updated,
|
||||
projects: c.projects.into_iter().map(|x| x.into()).collect(),
|
||||
icon_url: c.icon_url,
|
||||
color: c.color,
|
||||
status: c.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A status decides the visibility of a collection in search, URLs, and the whole site itself.
|
||||
/// Listed - collection is displayed on search, and accessible by URL (for if/when search is implemented for collections)
|
||||
/// Unlisted - collection is not displayed on search, but accessible by URL
|
||||
/// Rejected - collection is disabled
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionStatus {
|
||||
Listed,
|
||||
Unlisted,
|
||||
Rejected,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CollectionStatus {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl CollectionStatus {
|
||||
pub fn from_str(string: &str) -> CollectionStatus {
|
||||
match string {
|
||||
"listed" => CollectionStatus::Listed,
|
||||
"unlisted" => CollectionStatus::Unlisted,
|
||||
"rejected" => CollectionStatus::Rejected,
|
||||
_ => CollectionStatus::Unknown,
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
CollectionStatus::Listed => "listed",
|
||||
CollectionStatus::Unlisted => "unlisted",
|
||||
CollectionStatus::Rejected => "rejected",
|
||||
CollectionStatus::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// Project pages + info cannot be viewed
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Rejected => true,
|
||||
|
||||
CollectionStatus::Listed => false,
|
||||
CollectionStatus::Unlisted => false,
|
||||
CollectionStatus::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_approved(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Listed => true,
|
||||
CollectionStatus::Unlisted => true,
|
||||
CollectionStatus::Rejected => false,
|
||||
CollectionStatus::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_be_requested(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Listed => true,
|
||||
CollectionStatus::Unlisted => true,
|
||||
CollectionStatus::Rejected => false,
|
||||
CollectionStatus::Unknown => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub use super::collections::CollectionId;
|
||||
pub use super::images::ImageId;
|
||||
pub use super::notifications::NotificationId;
|
||||
pub use super::pats::PatId;
|
||||
pub use super::projects::{ProjectId, VersionId};
|
||||
@@ -109,6 +111,7 @@ macro_rules! base62_id_impl {
|
||||
base62_id_impl!(ProjectId, ProjectId);
|
||||
base62_id_impl!(UserId, UserId);
|
||||
base62_id_impl!(VersionId, VersionId);
|
||||
base62_id_impl!(CollectionId, CollectionId);
|
||||
base62_id_impl!(TeamId, TeamId);
|
||||
base62_id_impl!(ReportId, ReportId);
|
||||
base62_id_impl!(NotificationId, NotificationId);
|
||||
@@ -116,6 +119,7 @@ base62_id_impl!(ThreadId, ThreadId);
|
||||
base62_id_impl!(ThreadMessageId, ThreadMessageId);
|
||||
base62_id_impl!(SessionId, SessionId);
|
||||
base62_id_impl!(PatId, PatId);
|
||||
base62_id_impl!(ImageId, ImageId);
|
||||
|
||||
pub mod base62_impl {
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
|
||||
124
src/models/images.rs
Normal file
124
src/models/images.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use super::{
|
||||
ids::{Base62Id, ProjectId, ThreadMessageId, VersionId},
|
||||
pats::Scopes,
|
||||
reports::ReportId,
|
||||
users::UserId,
|
||||
};
|
||||
use crate::database::models::image_item::Image as DBImage;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct ImageId(pub u64);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Image {
|
||||
pub id: ImageId,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
pub created: DateTime<Utc>,
|
||||
pub owner_id: UserId,
|
||||
|
||||
// context it is associated with
|
||||
#[serde(flatten)]
|
||||
pub context: ImageContext,
|
||||
}
|
||||
|
||||
impl From<DBImage> for Image {
|
||||
fn from(x: DBImage) -> Self {
|
||||
let mut context = ImageContext::from_str(&x.context, None);
|
||||
match &mut context {
|
||||
ImageContext::Project { project_id } => {
|
||||
*project_id = x.project_id.map(|x| x.into());
|
||||
}
|
||||
ImageContext::Version { version_id } => {
|
||||
*version_id = x.version_id.map(|x| x.into());
|
||||
}
|
||||
ImageContext::ThreadMessage { thread_message_id } => {
|
||||
*thread_message_id = x.thread_message_id.map(|x| x.into());
|
||||
}
|
||||
ImageContext::Report { report_id } => {
|
||||
*report_id = x.report_id.map(|x| x.into());
|
||||
}
|
||||
ImageContext::Unknown => {}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: x.id.into(),
|
||||
url: x.url,
|
||||
size: x.size,
|
||||
created: x.created,
|
||||
owner_id: x.owner_id.into(),
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "context")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImageContext {
|
||||
Project {
|
||||
project_id: Option<ProjectId>,
|
||||
},
|
||||
Version {
|
||||
// version changelogs
|
||||
version_id: Option<VersionId>,
|
||||
},
|
||||
ThreadMessage {
|
||||
thread_message_id: Option<ThreadMessageId>,
|
||||
},
|
||||
Report {
|
||||
report_id: Option<ReportId>,
|
||||
},
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ImageContext {
|
||||
pub fn context_as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ImageContext::Project { .. } => "project",
|
||||
ImageContext::Version { .. } => "version",
|
||||
ImageContext::ThreadMessage { .. } => "thread_message",
|
||||
ImageContext::Report { .. } => "report",
|
||||
ImageContext::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
pub fn inner_id(&self) -> Option<u64> {
|
||||
match self {
|
||||
ImageContext::Project { project_id } => project_id.map(|x| x.0),
|
||||
ImageContext::Version { version_id } => version_id.map(|x| x.0),
|
||||
ImageContext::ThreadMessage { thread_message_id } => thread_message_id.map(|x| x.0),
|
||||
ImageContext::Report { report_id } => report_id.map(|x| x.0),
|
||||
ImageContext::Unknown => None,
|
||||
}
|
||||
}
|
||||
pub fn relevant_scope(&self) -> Scopes {
|
||||
match self {
|
||||
ImageContext::Project { .. } => Scopes::PROJECT_WRITE,
|
||||
ImageContext::Version { .. } => Scopes::VERSION_WRITE,
|
||||
ImageContext::ThreadMessage { .. } => Scopes::THREAD_WRITE,
|
||||
ImageContext::Report { .. } => Scopes::REPORT_WRITE,
|
||||
ImageContext::Unknown => Scopes::NONE,
|
||||
}
|
||||
}
|
||||
pub fn from_str(context: &str, id: Option<u64>) -> Self {
|
||||
match context {
|
||||
"project" => ImageContext::Project {
|
||||
project_id: id.map(ProjectId),
|
||||
},
|
||||
"version" => ImageContext::Version {
|
||||
version_id: id.map(VersionId),
|
||||
},
|
||||
"thread_message" => ImageContext::ThreadMessage {
|
||||
thread_message_id: id.map(ThreadMessageId),
|
||||
},
|
||||
"report" => ImageContext::Report {
|
||||
report_id: id.map(ReportId),
|
||||
},
|
||||
_ => ImageContext::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod analytics;
|
||||
pub mod collections;
|
||||
pub mod error;
|
||||
pub mod ids;
|
||||
pub mod images;
|
||||
pub mod notifications;
|
||||
pub mod pack;
|
||||
pub mod pats;
|
||||
|
||||
@@ -85,8 +85,17 @@ bitflags::bitflags! {
|
||||
// perform analytics action
|
||||
const PERFORM_ANALYTICS = 1 << 30;
|
||||
|
||||
const ALL = 0b1111111111111111111111111111111;
|
||||
const NOT_RESTRICTED = 0b00000011111111111111100111;
|
||||
// create a collection
|
||||
const COLLECTION_CREATE = 1 << 31;
|
||||
// read a user's collections
|
||||
const COLLECTION_READ = 1 << 32;
|
||||
// write to a collection
|
||||
const COLLECTION_WRITE = 1 << 33;
|
||||
// delete a collection
|
||||
const COLLECTION_DELETE = 1 << 34;
|
||||
|
||||
const ALL = 0b11111111111111111111111111111111111;
|
||||
const NOT_RESTRICTED = 0b111100000011111111111111100111;
|
||||
const NONE = 0b0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::models::ids::{ProjectId, ThreadId, UserId, VersionId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct ReportId(pub u64);
|
||||
|
||||
@@ -35,6 +35,7 @@ bitflags::bitflags! {
|
||||
const DELETE_PROJECT = 1 << 7;
|
||||
const VIEW_ANALYTICS = 1 << 8;
|
||||
const VIEW_PAYOUTS = 1 << 9;
|
||||
|
||||
const ALL = 0b1111111111;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::ids::Base62Id;
|
||||
use super::ids::{Base62Id, ImageId};
|
||||
use crate::models::ids::{ProjectId, ReportId};
|
||||
use crate::models::projects::ProjectStatus;
|
||||
use crate::models::users::{User, UserId};
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct ThreadId(pub u64);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct ThreadMessageId(pub u64);
|
||||
@@ -42,6 +42,8 @@ pub enum MessageBody {
|
||||
#[serde(default)]
|
||||
private: bool,
|
||||
replying_to: Option<ThreadMessageId>,
|
||||
#[serde(default)]
|
||||
associated_images: Vec<ImageId>,
|
||||
},
|
||||
StatusChange {
|
||||
new_status: ProjectStatus,
|
||||
|
||||
533
src/routes/v2/collections.rs
Normal file
533
src/routes/v2/collections.rs
Normal file
@@ -0,0 +1,533 @@
|
||||
use crate::auth::checks::{filter_authorized_collections, is_authorized_collection};
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database;
|
||||
use crate::database::models::{collection_item, generate_collection_id, project_item};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::collections::{Collection, CollectionStatus};
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::ids::{CollectionId, ProjectId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
use super::project_creation::CreateError;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(collections_get);
|
||||
cfg.service(collection_create);
|
||||
cfg.service(
|
||||
web::scope("collection")
|
||||
.service(collection_get)
|
||||
.service(collection_delete)
|
||||
.service(collection_edit)
|
||||
.service(collection_icon_edit)
|
||||
.service(delete_collection_icon),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
pub struct CollectionCreateData {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
/// The title or name of the project.
|
||||
pub title: String,
|
||||
#[validate(length(min = 3, max = 255))]
|
||||
/// A short description of the collection.
|
||||
pub description: String,
|
||||
#[validate(length(max = 32))]
|
||||
#[serde(default = "Vec::new")]
|
||||
/// A list of initial projects to use with the created collection
|
||||
pub projects: Vec<String>,
|
||||
}
|
||||
|
||||
#[post("collection")]
|
||||
pub async fn collection_create(
|
||||
req: HttpRequest,
|
||||
collection_create_data: web::Json<CollectionCreateData>,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<deadpool_redis::Pool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let collection_create_data = collection_create_data.into_inner();
|
||||
|
||||
// The currently logged in user
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**client,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_CREATE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
collection_create_data
|
||||
.validate()
|
||||
.map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?;
|
||||
|
||||
let mut transaction = client.begin().await?;
|
||||
|
||||
let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into();
|
||||
|
||||
let initial_project_ids =
|
||||
project_item::Project::get_many(&collection_create_data.projects, &mut transaction, &redis)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| x.inner.id.into())
|
||||
.collect::<Vec<ProjectId>>();
|
||||
|
||||
let collection_builder_actual = collection_item::CollectionBuilder {
|
||||
collection_id: collection_id.into(),
|
||||
user_id: current_user.id.into(),
|
||||
title: collection_create_data.title,
|
||||
description: collection_create_data.description,
|
||||
status: CollectionStatus::Listed,
|
||||
projects: initial_project_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|x| x.into())
|
||||
.collect(),
|
||||
};
|
||||
let collection_builder = collection_builder_actual.clone();
|
||||
|
||||
let now = Utc::now();
|
||||
collection_builder_actual.insert(&mut transaction).await?;
|
||||
|
||||
let response = crate::models::collections::Collection {
|
||||
id: collection_id,
|
||||
user: collection_builder.user_id.into(),
|
||||
title: collection_builder.title.clone(),
|
||||
description: collection_builder.description.clone(),
|
||||
created: now,
|
||||
updated: now,
|
||||
icon_url: None,
|
||||
color: None,
|
||||
status: collection_builder.status,
|
||||
projects: initial_project_ids,
|
||||
};
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CollectionIds {
|
||||
pub ids: String,
|
||||
}
|
||||
#[get("collections")]
|
||||
pub async fn collections_get(
|
||||
req: HttpRequest,
|
||||
web::Query(ids): web::Query<CollectionIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let ids = serde_json::from_str::<Vec<&str>>(&ids.ids)?;
|
||||
let ids = ids
|
||||
.into_iter()
|
||||
.map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?;
|
||||
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_READ]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(collections))
|
||||
}
|
||||
|
||||
#[get("{id}")]
|
||||
pub async fn collection_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let string = info.into_inner().0;
|
||||
|
||||
let id = database::models::CollectionId(parse_base62(&string)? as i64);
|
||||
let collection_data = database::models::Collection::get(id, &**pool, &redis).await?;
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_READ]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
if let Some(data) = collection_data {
|
||||
if is_authorized_collection(&data, &user_option).await? {
|
||||
return Ok(HttpResponse::Ok().json(Collection::from(data)));
|
||||
}
|
||||
}
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct EditCollection {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
pub title: Option<String>,
|
||||
#[validate(length(min = 3, max = 256))]
|
||||
pub description: Option<String>,
|
||||
pub status: Option<CollectionStatus>,
|
||||
#[validate(length(max = 64))]
|
||||
pub new_projects: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[patch("{id}")]
|
||||
pub async fn collection_edit(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
new_collection: web::Json<EditCollection>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_WRITE]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
new_collection
|
||||
.validate()
|
||||
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
|
||||
|
||||
let string = info.into_inner().0;
|
||||
let id = database::models::CollectionId(parse_base62(&string)? as i64);
|
||||
let result = database::models::Collection::get(id, &**pool, &redis).await?;
|
||||
|
||||
if let Some(collection_item) = result {
|
||||
if !is_authorized_collection(&collection_item, &user_option).await? {
|
||||
return Ok(HttpResponse::Unauthorized().body(""));
|
||||
}
|
||||
|
||||
let id = collection_item.id;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if let Some(title) = &new_collection.title {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE collections
|
||||
SET title = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
title.trim(),
|
||||
id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(description) = &new_collection.description {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE collections
|
||||
SET description = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
description,
|
||||
id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(status) = &new_collection.status {
|
||||
if let Some(user) = user_option {
|
||||
if !(user.role.is_mod()
|
||||
|| collection_item.status.is_approved() && status.can_be_requested())
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to set this status!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE collections
|
||||
SET status = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
status.to_string(),
|
||||
id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_project_ids) = &new_collection.new_projects {
|
||||
// Delete all existing projects
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM collections_mods
|
||||
WHERE collection_id = $1
|
||||
",
|
||||
collection_item.id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
for project_id in new_project_ids {
|
||||
let project = database::models::Project::get(project_id, &**pool, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(format!(
|
||||
"The specified project {project_id} does not exist!"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Insert- don't throw an error if it already exists
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO collections_mods (collection_id, mod_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
",
|
||||
collection_item.id as database::models::ids::CollectionId,
|
||||
project.inner.id as database::models::ids::ProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Extension {
|
||||
pub ext: String,
|
||||
}
|
||||
|
||||
#[patch("{id}/icon")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn collection_icon_edit(
|
||||
web::Query(ext): web::Query<Extension>,
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) {
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_WRITE]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
let string = info.into_inner().0;
|
||||
let id = database::models::CollectionId(parse_base62(&string)? as i64);
|
||||
let collection_item = database::models::Collection::get(id, &**pool, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("The specified collection does not exist!".to_string())
|
||||
})?;
|
||||
|
||||
if !is_authorized_collection(&collection_item, &user_option).await? {
|
||||
return Ok(HttpResponse::Unauthorized().body(""));
|
||||
}
|
||||
|
||||
if let Some(icon) = collection_item.icon_url {
|
||||
let name = icon.split(&format!("{cdn_url}/")).nth(1);
|
||||
|
||||
if let Some(icon_path) = name {
|
||||
file_host.delete_file_version("", icon_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let bytes =
|
||||
read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?;
|
||||
|
||||
let color = crate::util::img::get_color_from_img(&bytes)?;
|
||||
|
||||
let hash = sha1::Sha1::from(&bytes).hexdigest();
|
||||
let collection_id: CollectionId = collection_item.id.into();
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!("data/{}/{}.{}", collection_id, hash, ext.ext),
|
||||
bytes.freeze(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE collections
|
||||
SET icon_url = $1, color = $2
|
||||
WHERE (id = $3)
|
||||
",
|
||||
format!("{}/{}", cdn_url, upload_data.file_name),
|
||||
color.map(|x| x as i32),
|
||||
collection_item.id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(format!(
|
||||
"Invalid format for collection icon: {}",
|
||||
ext.ext
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("{id}/icon")]
|
||||
pub async fn delete_collection_icon(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_WRITE]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
let string = info.into_inner().0;
|
||||
let id = database::models::CollectionId(parse_base62(&string)? as i64);
|
||||
let collection_item = database::models::Collection::get(id, &**pool, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("The specified collection does not exist!".to_string())
|
||||
})?;
|
||||
if !is_authorized_collection(&collection_item, &user_option).await? {
|
||||
return Ok(HttpResponse::Unauthorized().body(""));
|
||||
}
|
||||
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
if let Some(icon) = collection_item.icon_url {
|
||||
let name = icon.split(&format!("{cdn_url}/")).nth(1);
|
||||
|
||||
if let Some(icon_path) = name {
|
||||
file_host.delete_file_version("", icon_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE collections
|
||||
SET icon_url = NULL, color = NULL
|
||||
WHERE (id = $1)
|
||||
",
|
||||
collection_item.id as database::models::ids::CollectionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
pub async fn collection_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_option = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::COLLECTION_DELETE]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
let string = info.into_inner().0;
|
||||
let id = database::models::CollectionId(parse_base62(&string)? as i64);
|
||||
let collection = database::models::Collection::get(id, &**pool, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("The specified collection does not exist!".to_string())
|
||||
})?;
|
||||
if !is_authorized_collection(&collection, &user_option).await? {
|
||||
return Ok(HttpResponse::Unauthorized().body(""));
|
||||
}
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result =
|
||||
database::models::Collection::remove(collection.id, &mut transaction, &redis).await?;
|
||||
database::models::Collection::clear_cache(collection.id, &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
233
src/routes/v2/images.rs
Normal file
233
src/routes/v2/images.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version};
|
||||
use crate::database;
|
||||
use crate::database::models::{project_item, report_item, thread_item, version_item};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::{ThreadMessageId, VersionId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::reports::ReportId;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::v2::threads::is_authorized_thread;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use actix_web::{post, web, HttpRequest, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(images_add);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ImageUpload {
|
||||
pub ext: String,
|
||||
|
||||
// Context must be an allowed context
|
||||
// currently: project, version, thread_message, report
|
||||
pub context: String,
|
||||
|
||||
// Optional context id to associate with
|
||||
pub project_id: Option<String>, // allow slug or id
|
||||
pub version_id: Option<VersionId>,
|
||||
pub thread_message_id: Option<ThreadMessageId>,
|
||||
pub report_id: Option<ReportId>,
|
||||
}
|
||||
|
||||
#[post("image")]
|
||||
pub async fn images_add(
|
||||
req: HttpRequest,
|
||||
web::Query(data): web::Query<ImageUpload>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
mut payload: web::Payload,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) {
|
||||
let mut context = ImageContext::from_str(&data.context, None);
|
||||
|
||||
let scopes = vec![context.relevant_scope()];
|
||||
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes))
|
||||
.await?
|
||||
.1;
|
||||
|
||||
// Attempt to associated a supplied id with the context
|
||||
// If the context cannot be found, or the user is not authorized to upload images for the context, return an error
|
||||
match &mut context {
|
||||
ImageContext::Project { project_id } => {
|
||||
if let Some(id) = data.project_id {
|
||||
let project = project_item::Project::get(&id, &**pool, &redis).await?;
|
||||
if let Some(project) = project {
|
||||
if is_authorized(&project.inner, &Some(user.clone()), &pool).await? {
|
||||
*project_id = Some(project.inner.id.into());
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You are not authorized to upload images for this project"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The project could not be found.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageContext::Version { version_id } => {
|
||||
if let Some(id) = data.version_id {
|
||||
let version = version_item::Version::get(id.into(), &**pool, &redis).await?;
|
||||
if let Some(version) = version {
|
||||
if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await?
|
||||
{
|
||||
*version_id = Some(version.inner.id.into());
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You are not authorized to upload images for this version"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The version could not be found.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageContext::ThreadMessage { thread_message_id } => {
|
||||
if let Some(id) = data.thread_message_id {
|
||||
let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"The thread message could not found.".to_string(),
|
||||
)
|
||||
})?;
|
||||
let thread = thread_item::Thread::get(thread_message.thread_id, &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"The thread associated with the thread message could not be found"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
if is_authorized_thread(&thread, &user, &pool).await? {
|
||||
*thread_message_id = Some(thread_message.id.into());
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You are not authorized to upload images for this thread message"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageContext::Report { report_id } => {
|
||||
if let Some(id) = data.report_id {
|
||||
let report = report_item::Report::get(id.into(), &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("The report could not be found.".to_string())
|
||||
})?;
|
||||
let thread = thread_item::Thread::get(report.thread_id, &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"The thread associated with the report could not be found."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
if is_authorized_thread(&thread, &user, &pool).await? {
|
||||
*report_id = Some(report.id.into());
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You are not authorized to upload images for this report".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageContext::Unknown => {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Context must be one of: project, version, thread_message, report".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Upload the image to the file host
|
||||
let bytes =
|
||||
read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?;
|
||||
|
||||
let hash = sha1::Sha1::from(&bytes).hexdigest();
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!("data/cached_images/{}.{}", hash, data.ext),
|
||||
bytes.freeze(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let db_image: database::models::Image = database::models::Image {
|
||||
id: database::models::generate_image_id(&mut transaction).await?,
|
||||
url: format!("{}/{}", cdn_url, upload_data.file_name),
|
||||
size: upload_data.content_length as u64,
|
||||
created: chrono::Utc::now(),
|
||||
owner_id: database::models::UserId::from(user.id),
|
||||
context: context.context_as_str().to_string(),
|
||||
project_id: if let ImageContext::Project {
|
||||
project_id: Some(id),
|
||||
} = context
|
||||
{
|
||||
Some(database::models::ProjectId::from(id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
version_id: if let ImageContext::Version {
|
||||
version_id: Some(id),
|
||||
} = context
|
||||
{
|
||||
Some(database::models::VersionId::from(id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
thread_message_id: if let ImageContext::ThreadMessage {
|
||||
thread_message_id: Some(id),
|
||||
} = context
|
||||
{
|
||||
Some(database::models::ThreadMessageId::from(id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
report_id: if let ImageContext::Report {
|
||||
report_id: Some(id),
|
||||
} = context
|
||||
{
|
||||
Some(database::models::ReportId::from(id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
// Insert
|
||||
db_image.insert(&mut transaction).await?;
|
||||
|
||||
let image = Image {
|
||||
id: db_image.id.into(),
|
||||
url: db_image.url,
|
||||
size: db_image.size,
|
||||
created: db_image.created,
|
||||
owner_id: db_image.owner_id.into(),
|
||||
context,
|
||||
};
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(image))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"The specified file is not an image!".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod admin;
|
||||
mod collections;
|
||||
mod images;
|
||||
mod analytics_get;
|
||||
mod moderation;
|
||||
mod notifications;
|
||||
@@ -30,6 +32,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(notifications::config)
|
||||
//.configure(pats::config)
|
||||
.configure(project_creation::config)
|
||||
.configure(collections::config)
|
||||
.configure(images::config)
|
||||
.configure(projects::config)
|
||||
.configure(reports::config)
|
||||
.configure(statistics::config)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use super::version_creation::InitialVersionData;
|
||||
use crate::auth::{get_user_from_headers, AuthenticationError};
|
||||
use crate::database::models;
|
||||
use crate::database::models::thread_item::ThreadBuilder;
|
||||
use crate::database::models::{self, image_item};
|
||||
use crate::file_hosting::{FileHost, FileHostingError};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::ImageId;
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::{
|
||||
DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId,
|
||||
@@ -233,6 +235,11 @@ struct ProjectCreateData {
|
||||
#[serde(default = "default_requested_status")]
|
||||
/// The status of the mod to be set once it is approved
|
||||
pub requested_status: ProjectStatus,
|
||||
|
||||
// Associations to uploaded images in body/description
|
||||
#[validate(length(max = 10))]
|
||||
#[serde(default)]
|
||||
pub uploaded_images: Vec<ImageId>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
@@ -466,7 +473,6 @@ async fn project_create_inner(
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
project_create_data = create_data;
|
||||
}
|
||||
|
||||
@@ -512,7 +518,7 @@ async fn project_create_inner(
|
||||
icon_data = Some(
|
||||
process_icon_upload(
|
||||
uploaded_files,
|
||||
project_id,
|
||||
project_id.0,
|
||||
file_extension,
|
||||
file_host,
|
||||
field,
|
||||
@@ -779,6 +785,41 @@ async fn project_create_inner(
|
||||
|
||||
let id = project_builder_actual.insert(&mut *transaction).await?;
|
||||
|
||||
for image_id in project_create_data.uploaded_images {
|
||||
if let Some(db_image) =
|
||||
image_item::Image::get(image_id.into(), &mut *transaction, redis).await?
|
||||
{
|
||||
let image: Image = db_image.into();
|
||||
if !matches!(image.context, ImageContext::Project { .. })
|
||||
|| image.context.inner_id().is_some()
|
||||
{
|
||||
return Err(CreateError::InvalidInput(format!(
|
||||
"Image {} is not unused and in the 'project' context",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE uploaded_images
|
||||
SET mod_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
id as models::ids::ProjectId,
|
||||
image_id.0 as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
image_item::Image::clear_cache(image.id.into(), redis).await?;
|
||||
} else {
|
||||
return Err(CreateError::InvalidInput(format!(
|
||||
"Image {} does not exist",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let thread_id = ThreadBuilder {
|
||||
type_: ThreadType::Project,
|
||||
members: vec![],
|
||||
@@ -935,7 +976,7 @@ async fn create_initial_version(
|
||||
|
||||
async fn process_icon_upload(
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
project_id: ProjectId,
|
||||
id: u64,
|
||||
file_extension: &str,
|
||||
file_host: &dyn FileHost,
|
||||
mut field: Field,
|
||||
@@ -950,7 +991,7 @@ async fn process_icon_upload(
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!("data/{project_id}/{hash}.{file_extension}"),
|
||||
&format!("data/{id}/{hash}.{file_extension}"),
|
||||
data.freeze(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized};
|
||||
use crate::database;
|
||||
use crate::database::models::image_item;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::{
|
||||
@@ -15,6 +17,7 @@ use crate::models::threads::MessageBody;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::{search_for_project, SearchConfig, SearchError};
|
||||
use crate::util::img;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
@@ -1112,6 +1115,18 @@ pub async fn project_edit(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 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> = vec![&new_project.description, &new_project.body]
|
||||
.into_iter()
|
||||
.filter_map(|x| x.as_ref().map(|y| y.as_str()))
|
||||
.collect();
|
||||
|
||||
let context = ImageContext::Project {
|
||||
project_id: Some(id.into()),
|
||||
};
|
||||
|
||||
img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?;
|
||||
database::models::Project::clear_cache(
|
||||
project_item.inner.id,
|
||||
project_item.inner.slug,
|
||||
@@ -2280,6 +2295,24 @@ pub async fn project_delete(
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let context = ImageContext::Project {
|
||||
project_id: Some(project.inner.id.into()),
|
||||
};
|
||||
let uploaded_images =
|
||||
database::models::Image::get_many_contexted(context, &mut transaction).await?;
|
||||
for image in uploaded_images {
|
||||
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM collections_mods
|
||||
WHERE mod_id = $1
|
||||
",
|
||||
project.inner.id as database::models::ids::ProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let result =
|
||||
database::models::Project::remove(project.inner.id, &mut transaction, &redis).await?;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
|
||||
use crate::database;
|
||||
use crate::database::models::image_item;
|
||||
use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder};
|
||||
use crate::models::ids::ImageId;
|
||||
use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::reports::{ItemType, Report};
|
||||
use crate::models::threads::{MessageBody, ThreadType};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::img;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
@@ -22,12 +27,16 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(report_get);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct CreateReport {
|
||||
pub report_type: String,
|
||||
pub item_id: String,
|
||||
pub item_type: ItemType,
|
||||
pub body: String,
|
||||
// Associations to uploaded images
|
||||
#[validate(length(max = 10))]
|
||||
#[serde(default)]
|
||||
pub uploaded_images: Vec<ImageId>,
|
||||
}
|
||||
|
||||
#[post("report")]
|
||||
@@ -147,6 +156,42 @@ pub async fn report_create(
|
||||
}
|
||||
|
||||
report.insert(&mut transaction).await?;
|
||||
|
||||
for image_id in new_report.uploaded_images {
|
||||
if let Some(db_image) =
|
||||
image_item::Image::get(image_id.into(), &mut *transaction, &redis).await?
|
||||
{
|
||||
let image: Image = db_image.into();
|
||||
if !matches!(image.context, ImageContext::Report { .. })
|
||||
|| image.context.inner_id().is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Image {} is not unused and in the 'report' context",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE uploaded_images
|
||||
SET report_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
id.0 as i64,
|
||||
image_id.0 as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
image_item::Image::clear_cache(image.id.into(), &redis).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Image {} could not be found",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let thread_id = ThreadBuilder {
|
||||
type_: ThreadType::Report,
|
||||
members: vec![],
|
||||
@@ -423,6 +468,17 @@ pub async fn report_edit(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// delete any images no longer in the body
|
||||
let checkable_strings: Vec<&str> = vec![&edit_report.body]
|
||||
.into_iter()
|
||||
.filter_map(|x: &Option<String>| x.as_ref().map(|y| y.as_str()))
|
||||
.collect();
|
||||
let image_context = ImageContext::Report {
|
||||
report_id: Some(id.into()),
|
||||
};
|
||||
img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -442,11 +498,20 @@ pub async fn report_delete(
|
||||
check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let result = crate::database::models::report_item::Report::remove_full(
|
||||
info.into_inner().0.into(),
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = info.into_inner().0;
|
||||
let context = ImageContext::Report {
|
||||
report_id: Some(id),
|
||||
};
|
||||
let uploaded_images =
|
||||
database::models::Image::get_many_contexted(context, &mut transaction).await?;
|
||||
for image in uploaded_images {
|
||||
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
|
||||
}
|
||||
|
||||
let result =
|
||||
crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
if result.is_some() {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
|
||||
use crate::database;
|
||||
use crate::database::models::image_item;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::ThreadMessageId;
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::ProjectStatus;
|
||||
@@ -327,6 +332,7 @@ pub async fn thread_send_message(
|
||||
body,
|
||||
replying_to,
|
||||
private,
|
||||
..
|
||||
} = &new_message.body
|
||||
{
|
||||
if body.len() > 65536 {
|
||||
@@ -452,6 +458,46 @@ pub async fn thread_send_message(
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let MessageBody::Text {
|
||||
associated_images, ..
|
||||
} = &new_message.body
|
||||
{
|
||||
for image_id in associated_images {
|
||||
if let Some(db_image) =
|
||||
image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await?
|
||||
{
|
||||
let image: Image = db_image.into();
|
||||
if !matches!(image.context, ImageContext::ThreadMessage { .. })
|
||||
|| image.context.inner_id().is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Image {} is not unused and in the 'thread_message' context",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE uploaded_images
|
||||
SET thread_message_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
thread.id.0,
|
||||
image_id.0 as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
image_item::Image::clear_cache(image.id.into(), &redis).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Image {} does not exist",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -523,6 +569,7 @@ pub async fn message_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@@ -544,6 +591,20 @@ pub async fn message_delete(
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let context = ImageContext::ThreadMessage {
|
||||
thread_message_id: Some(thread.id.into()),
|
||||
};
|
||||
let images = database::Image::get_many_contexted(context, &mut transaction).await?;
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
for image in images {
|
||||
let name = image.url.split(&format!("{cdn_url}/")).nth(1);
|
||||
if let Some(icon_path) = name {
|
||||
file_host.delete_file_version("", icon_path).await?;
|
||||
}
|
||||
database::Image::remove(image.id, &mut transaction, &redis).await?;
|
||||
}
|
||||
|
||||
database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::project_creation::{CreateError, UploadedFile};
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::models;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::version_item::{
|
||||
DependencyBuilder, VersionBuilder, VersionFileBuilder,
|
||||
};
|
||||
use crate::database::models::{self, image_item};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::images::{Image, ImageContext, ImageId};
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::pack::PackFileHash;
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -70,6 +71,10 @@ pub struct InitialVersionData {
|
||||
pub status: VersionStatus,
|
||||
#[serde(default = "HashMap::new")]
|
||||
pub file_types: HashMap<String, Option<FileType>>,
|
||||
// Associations to uploaded images in changelog
|
||||
#[validate(length(max = 10))]
|
||||
#[serde(default)]
|
||||
pub uploaded_images: Vec<ImageId>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@@ -436,6 +441,41 @@ async fn version_create_inner(
|
||||
let project_id = builder.project_id;
|
||||
builder.insert(transaction).await?;
|
||||
|
||||
for image_id in version_data.uploaded_images {
|
||||
if let Some(db_image) =
|
||||
image_item::Image::get(image_id.into(), &mut *transaction, redis).await?
|
||||
{
|
||||
let image: Image = db_image.into();
|
||||
if !matches!(image.context, ImageContext::Report { .. })
|
||||
|| image.context.inner_id().is_some()
|
||||
{
|
||||
return Err(CreateError::InvalidInput(format!(
|
||||
"Image {} is not unused and in the 'version' context",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE uploaded_images
|
||||
SET version_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
version_id.0 as i64,
|
||||
image_id.0 as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
image_item::Image::clear_cache(image.id.into(), redis).await?;
|
||||
} else {
|
||||
return Err(CreateError::InvalidInput(format!(
|
||||
"Image {} does not exist",
|
||||
image_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
models::Project::update_game_versions(project_id, &mut *transaction).await?;
|
||||
models::Project::update_loaders(project_id, &mut *transaction).await?;
|
||||
models::Project::clear_cache(project_id, None, Some(true), redis).await?;
|
||||
|
||||
@@ -3,12 +3,15 @@ use crate::auth::{
|
||||
filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version,
|
||||
};
|
||||
use crate::database;
|
||||
use crate::database::models::image_item;
|
||||
use crate::models;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType};
|
||||
use crate::models::teams::Permissions;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::util::img;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -672,6 +675,17 @@ pub async fn version_edit(
|
||||
}
|
||||
}
|
||||
|
||||
// delete any images no longer in the changelog
|
||||
let checkable_strings: Vec<&str> = vec![&new_version.changelog]
|
||||
.into_iter()
|
||||
.filter_map(|x| x.as_ref().map(|y| y.as_str()))
|
||||
.collect();
|
||||
let context = ImageContext::Version {
|
||||
version_id: Some(version_item.inner.id.into()),
|
||||
};
|
||||
|
||||
img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?;
|
||||
|
||||
database::models::Version::clear_cache(&version_item, &redis).await?;
|
||||
database::models::Project::clear_cache(
|
||||
version_item.inner.project_id,
|
||||
@@ -823,6 +837,14 @@ pub async fn version_delete(
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let context = ImageContext::Version {
|
||||
version_id: Some(version.inner.id.into()),
|
||||
};
|
||||
let uploaded_images =
|
||||
database::models::Image::get_many_contexted(context, &mut transaction).await?;
|
||||
for image in uploaded_images {
|
||||
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
|
||||
}
|
||||
|
||||
let result =
|
||||
database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?;
|
||||
|
||||
@@ -2,6 +2,11 @@ use color_thief::ColorFormat;
|
||||
use image::imageops::FilterType;
|
||||
use image::{EncodableLayout, ImageError};
|
||||
|
||||
use crate::database;
|
||||
use crate::database::models::image_item;
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
|
||||
let image = image::load_from_memory(data)?
|
||||
.resize(256, 256, FilterType::Nearest)
|
||||
@@ -13,3 +18,32 @@ pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
|
||||
// check changes to associated images
|
||||
// if they no longer exist in the String list, delete them
|
||||
// Eg: if description is modified and no longer contains a link to an iamge
|
||||
pub async fn delete_unused_images(
|
||||
context: ImageContext,
|
||||
reference_strings: Vec<&str>,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<(), ApiError> {
|
||||
let uploaded_images = database::models::Image::get_many_contexted(context, transaction).await?;
|
||||
|
||||
for image in uploaded_images {
|
||||
let mut should_delete = true;
|
||||
for reference in &reference_strings {
|
||||
if image.url.contains(reference) {
|
||||
should_delete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if should_delete {
|
||||
image_item::Image::remove(image.id, transaction, redis).await?;
|
||||
image_item::Image::clear_cache(image.id, redis).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user