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:
Wyatt Verchere 2023-09-13 22:22:32 -07:00 committed by GitHub
parent 35cd277fcf
commit 9bd2cb3c7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2579 additions and 24 deletions

View File

@ -0,0 +1,37 @@
CREATE TABLE collections (
id bigint PRIMARY KEY,
title varchar(255) NOT NULL,
description varchar(2048) NOT NULL,
user_id bigint REFERENCES users NOT NULL,
created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
status varchar(64) NOT NULL DEFAULT 'listed',
icon_url varchar(2048) NULL,
color integer NULL
);
CREATE TABLE collections_mods (
collection_id bigint REFERENCES collections NOT NULL,
mod_id bigint REFERENCES mods NOT NULL,
PRIMARY KEY (collection_id, mod_id)
);
CREATE TABLE uploaded_images (
id bigint PRIMARY KEY,
url varchar(2048) NOT NULL,
size integer NOT NULL,
created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
owner_id bigint REFERENCES users NOT NULL,
-- Type of contextual association
context varchar(64) NOT NULL, -- project, version, thread_message, report, etc.
-- Only one of these should be set (based on 'context')
mod_id bigint NULL REFERENCES mods,
version_id bigint NULL REFERENCES versions,
thread_message_id bigint NULL REFERENCES threads_messages,
report_id bigint NULL REFERENCES reports
);

View File

@ -309,6 +309,80 @@
},
"query": "\n SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes\n FROM files f\n INNER JOIN versions v on v.id = f.version_id\n INNER JOIN hashes h on h.file_id = f.id\n WHERE h.algorithm = $1 AND h.hash = ANY($2)\n GROUP BY f.id, v.mod_id, v.date_published\n ORDER BY v.date_published\n "
},
"0b9f174d86badae0d30e34b32130c7cee69926e37db95494ab08f025d19cdb7c": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "title",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "description",
"ordinal": 2,
"type_info": "Varchar"
},
{
"name": "icon_url",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "color",
"ordinal": 4,
"type_info": "Int4"
},
{
"name": "created",
"ordinal": 5,
"type_info": "Timestamptz"
},
{
"name": "user_id",
"ordinal": 6,
"type_info": "Int8"
},
{
"name": "updated",
"ordinal": 7,
"type_info": "Timestamptz"
},
{
"name": "status",
"ordinal": 8,
"type_info": "Varchar"
},
{
"name": "mods",
"ordinal": 9,
"type_info": "Int8Array"
}
],
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
null
],
"parameters": {
"Left": [
"Int8Array"
]
}
},
"query": "\n SELECT c.id id, c.title title, c.description description,\n c.icon_url icon_url, c.color color, c.created created, c.user_id user_id,\n c.updated updated, c.status status,\n ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods\n FROM collections c\n LEFT JOIN collections_mods cm ON cm.collection_id = c.id\n WHERE c.id = ANY($1)\n GROUP BY c.id;\n "
},
"0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58": {
"describe": {
"columns": [
@ -507,6 +581,27 @@
},
"query": "\n UPDATE notifications\n SET read = TRUE\n WHERE id = ANY($1)\n "
},
"15fac93c76e72348b50f526e1acb183521d94be335ad8b9dfeb0398d4a8a2fc4": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Varchar",
"Int4",
"Timestamptz",
"Int8",
"Varchar",
"Int8",
"Int8",
"Int8",
"Int8"
]
}
},
"query": "\n INSERT INTO uploaded_images (\n id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n );\n "
},
"16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6": {
"describe": {
"columns": [],
@ -647,6 +742,18 @@
},
"query": "\n UPDATE users\n SET avatar_url = $1\n WHERE (id = $2)\n "
},
"1abc74fe1da85e031edbc896797991337b57d2c47a8a978f9b9f34b20bf8f410": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n UPDATE collections\n SET icon_url = NULL, color = NULL\n WHERE (id = $1)\n "
},
"1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7": {
"describe": {
"columns": [
@ -668,6 +775,84 @@
},
"query": "\n SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2)\n "
},
"1d6a53187082ad9a57294d9f1c13d66131ccc3d4a0cf59d42346474196ea50f8": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "url",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "size",
"ordinal": 2,
"type_info": "Int4"
},
{
"name": "created",
"ordinal": 3,
"type_info": "Timestamptz"
},
{
"name": "owner_id",
"ordinal": 4,
"type_info": "Int8"
},
{
"name": "context",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "mod_id",
"ordinal": 6,
"type_info": "Int8"
},
{
"name": "version_id",
"ordinal": 7,
"type_info": "Int8"
},
{
"name": "thread_message_id",
"ordinal": 8,
"type_info": "Int8"
},
{
"name": "report_id",
"ordinal": 9,
"type_info": "Int8"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Int8",
"Int8"
]
}
},
"query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE context = $1\n AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL))\n AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL))\n AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL))\n AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL))\n GROUP BY id\n "
},
"1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398": {
"describe": {
"columns": [
@ -926,6 +1111,18 @@
},
"query": "\n UPDATE team_members\n SET ordering = $1\n WHERE (team_id = $2 AND user_id = $3)\n "
},
"2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n DELETE FROM uploaded_images\n WHERE id = $1\n "
},
"2b8dafe9c3df9fd25235a13868e8e7607decfbe96a413cc576919a1fb510f269": {
"describe": {
"columns": [],
@ -1229,6 +1426,26 @@
},
"query": "\n UPDATE mods\n SET follows = follows - 1\n WHERE id = $1\n "
},
"38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)"
},
"3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af": {
"describe": {
"columns": [],
@ -1289,6 +1506,18 @@
},
"query": "\n UPDATE mods_gallery\n SET title = $2\n WHERE id = $1\n "
},
"3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n "
},
"3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc": {
"describe": {
"columns": [
@ -1572,6 +1801,19 @@
},
"query": "\n DELETE FROM mods_donations\n WHERE joining_mod_id = $1\n "
},
"45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n UPDATE uploaded_images\n SET thread_message_id = $1\n WHERE id = $2\n "
},
"4778d2f5994fda2f978fa53e0840c1a9a2582ef0434a5ff7f21706f1dc4edcf4": {
"describe": {
"columns": [],
@ -1618,6 +1860,19 @@
},
"query": "\n SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n LEFT JOIN versions vd ON d.dependency_id = vd.id\n WHERE v.mod_id = $1\n "
},
"489913b3c32631fb329a3259cfe620d65053e2abf425a0d3f1bc01f1cdbdd73d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n "
},
"49813a96f007216072d69468aae705d73d5b85dcdd64a22060009b12d947ed5a": {
"describe": {
"columns": [],
@ -1845,6 +2100,24 @@
},
"query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n "
},
"536f628092168eead27519db013ec8a1510a06f27e699839bac9dc85d16d99c2": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Varchar",
"Varchar",
"Timestamptz",
"Varchar",
"Varchar"
]
}
},
"query": "\n INSERT INTO collections (\n id, user_id, title, description, \n created, icon_url, status\n )\n VALUES (\n $1, $2, $3, $4, \n $5, $6, $7\n )\n "
},
"53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e": {
"describe": {
"columns": [],
@ -1878,6 +2151,33 @@
},
"query": "\n SELECT id FROM mods\n WHERE status = $1 AND queued < NOW() - INTERVAL '40 hours'\n ORDER BY updated ASC\n "
},
"5627b3516fc7c3799154098a663b1586aac11b2dc736810f06630ee5d8a54946": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Int8"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Int8Array",
"Int8"
]
}
},
"query": "\n SELECT c.id id, c.user_id user_id FROM collections c\n WHERE c.user_id = $2 AND c.id = ANY($1)\n "
},
"5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976": {
"describe": {
"columns": [
@ -2099,6 +2399,80 @@
},
"query": "\n UPDATE versions\n SET status = $1\n WHERE (id = $2)\n "
},
"5c7bc2b59e5bcbe50e556cf28fb7a20de645752beef330b6779ec256f33e666a": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "url",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "size",
"ordinal": 2,
"type_info": "Int4"
},
{
"name": "created",
"ordinal": 3,
"type_info": "Timestamptz"
},
{
"name": "owner_id",
"ordinal": 4,
"type_info": "Int8"
},
{
"name": "context",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "mod_id",
"ordinal": 6,
"type_info": "Int8"
},
{
"name": "version_id",
"ordinal": 7,
"type_info": "Int8"
},
{
"name": "thread_message_id",
"ordinal": 8,
"type_info": "Int8"
},
{
"name": "report_id",
"ordinal": 9,
"type_info": "Int8"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
],
"parameters": {
"Left": [
"Int8Array"
]
}
},
"query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE id = ANY($1)\n GROUP BY id;\n "
},
"5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69": {
"describe": {
"columns": [],
@ -2724,6 +3098,26 @@
},
"query": "\n DELETE FROM mod_follows\n WHERE follower_id = $1\n "
},
"6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)"
},
"6fd06767f42be894c7a35c6b61f43407c55de43dc77ed02b39062278f3de81e3": {
"describe": {
"columns": [],
@ -2889,6 +3283,19 @@
},
"query": "\n UPDATE files\n SET file_type = $2\n WHERE (id = $1)\n "
},
"7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n UPDATE uploaded_images\n SET report_id = $1\n WHERE id = $2\n "
},
"76db1c204139e18002e5751c3dcefff79791a1dd852b62d34fcf008151e8945a": {
"describe": {
"columns": [
@ -3451,6 +3858,19 @@
},
"query": "\n SELECT name FROM side_types\n "
},
"86049f204c9eda5241403d22b5f8ffe13b258ddfffb81a1a9ee8602e21c64723": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
}
},
"query": "\n UPDATE collections\n SET status = $1\n WHERE (id = $2)\n "
},
"868c29019bd7e9ad71fb3515ca3489304ade3f6ebe3f77c018a8a521a96fb41f": {
"describe": {
"columns": [
@ -3686,6 +4106,20 @@
},
"query": "\n SELECT t.id, t.thread_type, t.mod_id, t.report_id, t.show_in_mod_inbox,\n ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members,\n JSONB_AGG(DISTINCT jsonb_build_object('id', tmsg.id, 'author_id', tmsg.author_id, 'thread_id', tmsg.thread_id, 'body', tmsg.body, 'created', tmsg.created)) filter (where tmsg.id is not null) messages\n FROM threads t\n LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id\n LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id\n WHERE t.id = ANY($1)\n GROUP BY t.id\n "
},
"9544cea57095a94109be5fef9a4737626a9003d58680943cdbffc7c9ada7877b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Int4",
"Int8"
]
}
},
"query": "\n UPDATE collections\n SET icon_url = $1, color = $2\n WHERE (id = $3)\n "
},
"95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": {
"describe": {
"columns": [],
@ -3828,6 +4262,18 @@
},
"query": "\n INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n "
},
"9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n DELETE FROM collections_mods\n WHERE collection_id = $1\n "
},
"a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af": {
"describe": {
"columns": [],
@ -4248,6 +4694,18 @@
},
"query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n "
},
"b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n DELETE FROM collections\n WHERE id = $1\n "
},
"b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf": {
"describe": {
"columns": [],
@ -4306,6 +4764,19 @@
},
"query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n client_side, server_side, license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, $17, $18,\n LOWER($19), $20, $21, $22\n )\n "
},
"b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
}
},
"query": "\n UPDATE collections\n SET description = $1\n WHERE (id = $2)\n "
},
"b768d9db6c785d6a701324ea746794d33e94121403163a774b6ef775640fd3d3": {
"describe": {
"columns": [
@ -4769,6 +5240,19 @@
},
"query": "\n SELECT id FROM reports\n WHERE id = ANY($1) AND reporter = $2\n "
},
"c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n UPDATE uploaded_images\n SET version_id = $1\n WHERE id = $2\n "
},
"c94faba99d486b11509fff59465b7cc71983551b035e936ce4d9776510afb514": {
"describe": {
"columns": [
@ -4876,6 +5360,19 @@
},
"query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n "
},
"ccce60dc60ca6c4ea1142ab6d0d81bdb1ee9ed97c992695324aec015e0e190bf": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n "
},
"ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c": {
"describe": {
"columns": [
@ -4921,6 +5418,19 @@
},
"query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n "
},
"ce2e7642142f79bdce78ba3316fe402e18ae203cc65fe79f724d37a7076df2dd": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
}
},
"query": "\n UPDATE collections\n SET title = $1\n WHERE (id = $2)\n "
},
"cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6": {
"describe": {
"columns": [],
@ -5754,6 +6264,19 @@
},
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n "
},
"f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
}
},
"query": "\n UPDATE uploaded_images\n SET mod_id = $1\n WHERE id = $2\n "
},
"f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329": {
"describe": {
"columns": [

View File

@ -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)
}

View File

@ -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)

View File

@ -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;

View 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(())
}
}

View File

@ -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)

View 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(())
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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
View 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,
}
}
}

View File

@ -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
View 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,
}
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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);

View File

@ -35,6 +35,7 @@ bitflags::bitflags! {
const DELETE_PROJECT = 1 << 7;
const VIEW_ANALYTICS = 1 << 8;
const VIEW_PAYOUTS = 1 << 9;
const ALL = 0b1111111111;
}
}

View File

@ -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,

View 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
View 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(),
))
}
}

View File

@ -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)

View File

@ -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?;

View File

@ -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?;

View File

@ -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() {

View File

@ -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?;

View File

@ -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?;

View File

@ -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?;

View File

@ -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(())
}