Add report + moderation messaging (#567)

* Add report + moderation messaging

* Add system messages

* address review comments

* Remove ds store

* Update messaging

* run prep

---------

Co-authored-by: Geometrically <geometrically@Jais-MacBook-Pro.local>
This commit is contained in:
Geometrically 2023-04-12 17:59:43 -07:00 committed by GitHub
parent 7605df1bd9
commit 8f61e9876f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2005 additions and 2180 deletions

2
.gitignore vendored
View File

@ -1,4 +1,3 @@
# Created by https://www.gitignore.io/api/rust,clion
# Edit at https://www.gitignore.io/?templates=rust,clion
@ -107,3 +106,4 @@ fabric.properties
# End of https://www.gitignore.io/api/rust,clion
.DS_Store

View File

@ -0,0 +1,32 @@
-- Add migration script here
-- Add route for users to see their own reports
CREATE TABLE threads (
id bigint PRIMARY KEY,
-- can be either "report", "project", or "direct_message". direct message is unused for now
thread_type VARCHAR(64) NOT NULL
);
CREATE TABLE threads_messages (
id bigint PRIMARY KEY,
thread_id bigint REFERENCES threads ON UPDATE CASCADE NOT NULL,
-- If this is null, it's a system message
author_id bigint REFERENCES users ON UPDATE CASCADE NULL,
body jsonb NOT NULL,
created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
show_in_mod_inbox BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE threads_members (
thread_id bigint REFERENCES threads ON UPDATE CASCADE NOT NULL,
user_id bigint REFERENCES users ON UPDATE CASCADE NOT NULL,
PRIMARY KEY (thread_id, user_id)
);
ALTER TABLE reports
ADD COLUMN closed boolean NOT NULL DEFAULT FALSE;
ALTER TABLE reports
ADD COLUMN thread_id bigint references threads ON UPDATE CASCADE;
ALTER TABLE mods
ADD COLUMN thread_id bigint references threads ON UPDATE CASCADE;

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,11 @@ pub struct ProjectType {
pub name: String,
}
pub struct SideType {
pub id: SideTypeId,
pub name: String,
}
pub struct Loader {
pub id: LoaderId,
pub loader: String,
@ -46,23 +51,7 @@ pub struct DonationPlatform {
pub name: String,
}
pub struct CategoryBuilder<'a> {
pub name: Option<&'a str>,
pub project_type: Option<&'a ProjectTypeId>,
pub icon: Option<&'a str>,
pub header: Option<&'a str>,
}
impl Category {
pub fn builder() -> CategoryBuilder<'static> {
CategoryBuilder {
name: None,
project_type: None,
icon: None,
header: None,
}
}
pub async fn get_id<'a, E>(
name: &str,
exec: E,
@ -105,26 +94,6 @@ impl Category {
Ok(result.map(|r| CategoryId(r.id)))
}
pub async fn get_name<'a, E>(
id: CategoryId,
exec: E,
) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT category FROM categories
WHERE id = $1
",
id as CategoryId
)
.fetch_one(exec)
.await?;
Ok(result.category)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<Category>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@ -152,118 +121,9 @@ impl Category {
Ok(result)
}
pub async fn remove<'a, E>(
name: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM categories
WHERE category = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> CategoryBuilder<'a> {
/// The name of the category. Must be ASCII alphanumeric or `-`/`_`
pub fn name(
self,
name: &'a str,
) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub fn header(
self,
header: &'a str,
) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
header: Some(header),
..self
})
}
pub fn project_type(
self,
project_type: &'a ProjectTypeId,
) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
project_type: Some(project_type),
..self
})
}
pub fn icon(
self,
icon: &'a str,
) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
icon: Some(icon),
..self
})
}
pub async fn insert<'b, E>(
self,
exec: E,
) -> Result<CategoryId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let id = *self.project_type.ok_or_else(|| {
DatabaseError::Other("No project type specified.".to_string())
})?;
let result = sqlx::query!(
"
INSERT INTO categories (category, project_type, icon, header)
VALUES ($1, $2, $3, $4)
RETURNING id
",
self.name,
id as ProjectTypeId,
self.icon,
self.header
)
.fetch_one(exec)
.await?;
Ok(CategoryId(result.id))
}
}
pub struct LoaderBuilder<'a> {
pub name: Option<&'a str>,
pub icon: Option<&'a str>,
pub supported_project_types: Option<&'a [ProjectTypeId]>,
}
impl Loader {
pub fn builder() -> LoaderBuilder<'static> {
LoaderBuilder {
name: None,
icon: None,
supported_project_types: None,
}
}
pub async fn get_id<'a, E>(
name: &str,
exec: E,
@ -284,26 +144,6 @@ impl Loader {
Ok(result.map(|r| LoaderId(r.id)))
}
pub async fn get_name<'a, E>(
id: LoaderId,
exec: E,
) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT loader FROM loaders
WHERE id = $1
",
id as LoaderId
)
.fetch_one(exec)
.await?;
Ok(result.loader)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<Loader>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@ -337,110 +177,6 @@ impl Loader {
Ok(result)
}
// TODO: remove loaders with projects using them
pub async fn remove<'a, E>(
name: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM loaders
WHERE loader = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> LoaderBuilder<'a> {
/// The name of the loader. Must be ASCII alphanumeric or `-`/`_`
pub fn name(
self,
name: &'a str,
) -> Result<LoaderBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub fn icon(
self,
icon: &'a str,
) -> Result<LoaderBuilder<'a>, DatabaseError> {
Ok(Self {
icon: Some(icon),
..self
})
}
pub fn supported_project_types(
self,
supported_project_types: &'a [ProjectTypeId],
) -> Result<LoaderBuilder<'a>, DatabaseError> {
Ok(Self {
supported_project_types: Some(supported_project_types),
..self
})
}
pub async fn insert(
self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<LoaderId, super::DatabaseError> {
let result = sqlx::query!(
"
INSERT INTO loaders (loader, icon)
VALUES ($1, $2)
ON CONFLICT (loader) DO NOTHING
RETURNING id
",
self.name,
self.icon
)
.fetch_one(&mut *transaction)
.await?;
if let Some(project_types) = self.supported_project_types {
sqlx::query!(
"
DELETE FROM loaders_project_types
WHERE joining_loader_id = $1
",
result.id
)
.execute(&mut *transaction)
.await?;
for project_type in project_types {
sqlx::query!(
"
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id)
VALUES ($1, $2)
",
result.id,
project_type.0,
)
.execute(&mut *transaction)
.await?;
}
}
Ok(LoaderId(result.id))
}
}
#[derive(Default)]
@ -475,26 +211,6 @@ impl GameVersion {
Ok(result.map(|r| GameVersionId(r.id)))
}
pub async fn get_name<'a, E>(
id: GameVersionId,
exec: E,
) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT version FROM game_versions
WHERE id = $1
",
id as GameVersionId
)
.fetch_one(exec)
.await?;
Ok(result.version)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<GameVersion>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@ -595,31 +311,6 @@ impl GameVersion {
Ok(result)
}
pub async fn remove<'a, E>(
name: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM game_versions
WHERE version = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> GameVersionBuilder<'a> {
@ -681,17 +372,7 @@ impl<'a> GameVersionBuilder<'a> {
}
}
#[derive(Default)]
pub struct DonationPlatformBuilder<'a> {
pub short: Option<&'a str>,
pub name: Option<&'a str>,
}
impl DonationPlatform {
pub fn builder() -> DonationPlatformBuilder<'static> {
DonationPlatformBuilder::default()
}
pub async fn get_id<'a, E>(
id: &str,
exec: E,
@ -712,30 +393,6 @@ impl DonationPlatform {
Ok(result.map(|r| DonationPlatformId(r.id)))
}
pub async fn get<'a, E>(
id: DonationPlatformId,
exec: E,
) -> Result<DonationPlatform, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT short, name FROM donation_platforms
WHERE id = $1
",
id as DonationPlatformId
)
.fetch_one(exec)
.await?;
Ok(DonationPlatform {
id,
short: result.short,
name: result.name,
})
}
pub async fn list<'a, E>(
exec: E,
) -> Result<Vec<DonationPlatform>, DatabaseError>
@ -760,89 +417,9 @@ impl DonationPlatform {
Ok(result)
}
pub async fn remove<'a, E>(
short: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM donation_platforms
WHERE short = $1
",
short
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> DonationPlatformBuilder<'a> {
/// The donation platform short name. Spaces must be replaced with '_' for it to be valid
pub fn short(
self,
short: &'a str,
) -> Result<DonationPlatformBuilder<'a>, DatabaseError> {
Ok(Self {
short: Some(short),
..self
})
}
/// The donation platform long name
pub fn name(
self,
name: &'a str,
) -> Result<DonationPlatformBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub async fn insert<'b, E>(
self,
exec: E,
) -> Result<DonationPlatformId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO donation_platforms (short, name)
VALUES ($1, $2)
ON CONFLICT (short) DO NOTHING
RETURNING id
",
self.short,
self.name,
)
.fetch_one(exec)
.await?;
Ok(DonationPlatformId(result.id))
}
}
pub struct ReportTypeBuilder<'a> {
pub name: Option<&'a str>,
}
impl ReportType {
pub fn builder() -> ReportTypeBuilder<'static> {
ReportTypeBuilder { name: None }
}
pub async fn get_id<'a, E>(
name: &str,
exec: E,
@ -863,26 +440,6 @@ impl ReportType {
Ok(result.map(|r| ReportTypeId(r.id)))
}
pub async fn get_name<'a, E>(
id: ReportTypeId,
exec: E,
) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT name FROM report_types
WHERE id = $1
",
id as ReportTypeId
)
.fetch_one(exec)
.await?;
Ok(result.name)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@ -899,74 +456,9 @@ impl ReportType {
Ok(result)
}
pub async fn remove<'a, E>(
name: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM report_types
WHERE name = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> ReportTypeBuilder<'a> {
/// The name of the report type. Must be ASCII alphanumeric or `-`/`_`
pub fn name(
self,
name: &'a str,
) -> Result<ReportTypeBuilder<'a>, DatabaseError> {
Ok(Self { name: Some(name) })
}
pub async fn insert<'b, E>(
self,
exec: E,
) -> Result<ReportTypeId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO report_types (name)
VALUES ($1)
ON CONFLICT (name) DO NOTHING
RETURNING id
",
self.name
)
.fetch_one(exec)
.await?;
Ok(ReportTypeId(result.id))
}
}
pub struct ProjectTypeBuilder<'a> {
pub name: Option<&'a str>,
}
impl ProjectType {
pub fn builder() -> ProjectTypeBuilder<'static> {
ProjectTypeBuilder { name: None }
}
pub async fn get_id<'a, E>(
name: &str,
exec: E,
@ -987,53 +479,6 @@ impl ProjectType {
Ok(result.map(|r| ProjectTypeId(r.id)))
}
pub async fn get_many_id<'a, E>(
names: &[String],
exec: E,
) -> Result<Vec<ProjectType>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let project_types = sqlx::query!(
"
SELECT id, name FROM project_types
WHERE name = ANY($1)
",
names
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|x| ProjectType {
id: ProjectTypeId(x.id),
name: x.name,
}))
})
.try_collect::<Vec<ProjectType>>()
.await?;
Ok(project_types)
}
pub async fn get_name<'a, E>(
id: ProjectTypeId,
exec: E,
) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT name FROM project_types
WHERE id = $1
",
id as ProjectTypeId
)
.fetch_one(exec)
.await?;
Ok(result.name)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@ -1050,62 +495,43 @@ impl ProjectType {
Ok(result)
}
}
// TODO: remove loaders with mods using them
pub async fn remove<'a, E>(
impl SideType {
pub async fn get_id<'a, E>(
name: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
) -> Result<Option<SideTypeId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM project_types
SELECT id FROM side_types
WHERE name = $1
",
name
)
.execute(exec)
.fetch_optional(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> ProjectTypeBuilder<'a> {
/// The name of the project type. Must be ASCII alphanumeric or `-`/`_`
pub fn name(
self,
name: &'a str,
) -> Result<ProjectTypeBuilder<'a>, DatabaseError> {
Ok(Self { name: Some(name) })
Ok(result.map(|r| SideTypeId(r.id)))
}
pub async fn insert<'b, E>(
self,
exec: E,
) -> Result<ProjectTypeId, DatabaseError>
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO project_types (name)
VALUES ($1)
ON CONFLICT (name) DO NOTHING
RETURNING id
",
self.name
SELECT name FROM side_types
"
)
.fetch_one(exec)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.name)) })
.try_collect::<Vec<String>>()
.await?;
Ok(ProjectTypeId(result.id))
Ok(result)
}
}

View File

@ -106,7 +106,22 @@ generate_ids!(
NotificationId
);
#[derive(Copy, Clone, Debug, PartialEq, Eq, Type)]
generate_ids!(
pub generate_thread_id,
ThreadId,
8,
"SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1)",
ThreadId
);
generate_ids!(
pub generate_thread_message_id,
ThreadMessageId,
8,
"SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)",
ThreadMessageId
);
#[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Deserialize)]
#[sqlx(transparent)]
pub struct UserId(pub i64);
@ -169,6 +184,13 @@ pub struct NotificationId(pub i64);
#[sqlx(transparent)]
pub struct NotificationActionId(pub i32);
#[derive(Copy, Clone, Debug, Type, Deserialize)]
#[sqlx(transparent)]
pub struct ThreadId(pub i64);
#[derive(Copy, Clone, Debug, Type, Deserialize)]
#[sqlx(transparent)]
pub struct ThreadMessageId(pub i64);
use crate::models::ids;
impl From<ids::ProjectId> for ProjectId {
@ -231,3 +253,23 @@ impl From<NotificationId> for ids::NotificationId {
ids::NotificationId(id.0 as u64)
}
}
impl From<ids::ThreadId> for ThreadId {
fn from(id: ids::ThreadId) -> Self {
ThreadId(id.0 as i64)
}
}
impl From<ThreadId> for ids::ThreadId {
fn from(id: ThreadId) -> Self {
ids::ThreadId(id.0 as u64)
}
}
impl From<ids::ThreadMessageId> for ThreadMessageId {
fn from(id: ids::ThreadMessageId) -> Self {
ThreadMessageId(id.0 as i64)
}
}
impl From<ThreadMessageId> for ids::ThreadMessageId {
fn from(id: ThreadMessageId) -> Self {
ids::ThreadMessageId(id.0 as u64)
}
}

View File

@ -1,7 +1,3 @@
#![allow(dead_code)]
// TODO: remove attr once routes are created
use chrono::{DateTime, Utc};
use thiserror::Error;
pub mod categories;
@ -10,6 +6,7 @@ pub mod notification_item;
pub mod project_item;
pub mod report_item;
pub mod team_item;
pub mod thread_item;
pub mod user_item;
pub mod version_item;
@ -17,6 +14,7 @@ pub use ids::*;
pub use project_item::Project;
pub use team_item::Team;
pub use team_item::TeamMember;
pub use thread_item::{Thread, ThreadMessage};
pub use user_item::User;
pub use version_item::Version;
@ -28,82 +26,6 @@ pub enum DatabaseError {
RandomId,
#[error("A database request failed")]
Other(String),
}
impl ids::SideTypeId {
pub async fn get_id<'a, E>(
side: &crate::models::projects::SideType,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM side_types
WHERE name = $1
",
side.as_str()
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ids::SideTypeId(r.id)))
}
}
impl ids::DonationPlatformId {
pub async fn get_id<'a, E>(
id: &str,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM donation_platforms
WHERE short = $1
",
id
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ids::DonationPlatformId(r.id)))
}
}
impl ids::ProjectTypeId {
pub async fn get_id<'a, E>(
project_type: String,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM project_types
WHERE name = $1
",
project_type
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ProjectTypeId(r.id)))
}
}
pub fn convert_postgres_date(input: &str) -> DateTime<Utc> {
let mut result = DateTime::parse_from_str(input, "%Y-%m-%d %T.%f%#z");
if result.is_err() {
result = DateTime::parse_from_str(input, "%Y-%m-%d %T%#z")
}
result
.map(|x| x.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
#[error("Error while parsing JSON: {0}")]
Json(#[from] serde_json::Error),
}

View File

@ -101,6 +101,7 @@ pub struct ProjectBuilder {
pub donation_urls: Vec<DonationUrl>,
pub gallery_items: Vec<GalleryItem>,
pub color: Option<u32>,
pub thread_id: ThreadId,
}
impl ProjectBuilder {
@ -146,6 +147,7 @@ impl ProjectBuilder {
color: self.color,
loaders: vec![],
game_versions: vec![],
thread_id: Some(self.thread_id),
};
project_struct.insert(&mut *transaction).await?;
@ -230,6 +232,7 @@ pub struct Project {
pub color: Option<u32>,
pub loaders: Vec<String>,
pub game_versions: Vec<String>,
pub thread_id: Option<ThreadId>,
}
impl Project {
@ -244,14 +247,14 @@ impl Project {
published, downloads, icon_url, issues_url,
source_url, wiki_url, status, requested_status, discord_url,
client_side, server_side, license_url, license,
slug, project_type, color
slug, project_type, color, thread_id
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, $18,
LOWER($19), $20, $21
LOWER($19), $20, $21, $22
)
",
self.id as ProjectId,
@ -274,7 +277,8 @@ impl Project {
&self.license,
self.slug.as_ref(),
self.project_type as ProjectTypeId,
self.color.map(|x| x as i32)
self.color.map(|x| x as i32),
self.thread_id.map(|x| x.0),
)
.execute(&mut *transaction)
.await?;
@ -313,7 +317,7 @@ impl Project {
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug,
moderation_message, moderation_message_body, flame_anvil_project,
flame_anvil_user, webhook_sent, color, loaders, game_versions
flame_anvil_user, webhook_sent, color, loaders, game_versions, thread_id
FROM mods
WHERE id = ANY($1)
",
@ -359,6 +363,7 @@ impl Project {
loaders: m.loaders,
game_versions: m.game_versions,
queued: m.queued,
thread_id: m.thread_id.map(ThreadId),
}))
})
.try_collect::<Vec<Project>>()
@ -386,6 +391,26 @@ impl Project {
return Ok(None);
};
let thread_id = sqlx::query!(
"
SELECT thread_id FROM mods
WHERE id = $1
",
id as ProjectId
)
.fetch_optional(&mut *transaction)
.await?;
if let Some(thread_id) = thread_id {
if let Some(id) = thread_id.thread_id {
crate::database::models::Thread::remove_full(
ThreadId(id),
transaction,
)
.await?;
}
}
sqlx::query!(
"
DELETE FROM mod_follows
@ -654,7 +679,7 @@ impl Project {
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent, m.color,
m.loaders loaders, m.game_versions game_versions,
m.loaders loaders, m.game_versions game_versions, m.thread_id thread_id,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,
@ -720,6 +745,7 @@ impl Project {
loaders: m.loaders,
game_versions: m.game_versions,
queued: m.queued,
thread_id: m.thread_id.map(ThreadId),
},
project_type: m.project_type_name,
categories: m.categories.unwrap_or_default(),

View File

@ -10,6 +10,8 @@ pub struct Report {
pub body: String,
pub reporter: UserId,
pub created: DateTime<Utc>,
pub closed: bool,
pub thread_id: ThreadId,
}
pub struct QueryReport {
@ -21,6 +23,8 @@ pub struct QueryReport {
pub body: String,
pub reporter: UserId,
pub created: DateTime<Utc>,
pub closed: bool,
pub thread_id: Option<ThreadId>,
}
impl Report {
@ -32,11 +36,11 @@ impl Report {
"
INSERT INTO reports (
id, report_type_id, mod_id, version_id, user_id,
body, reporter
body, reporter, thread_id
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7
$6, $7, $8
)
",
self.id as ReportId,
@ -45,7 +49,8 @@ impl Report {
self.version_id.map(|x| x.0 as i64),
self.user_id.map(|x| x.0 as i64),
self.body,
self.reporter as UserId
self.reporter as UserId,
self.thread_id as ThreadId,
)
.execute(&mut *transaction)
.await?;
@ -78,7 +83,7 @@ impl Report {
report_ids.iter().map(|x| x.0).collect();
let reports = sqlx::query!(
"
SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created
SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, r.thread_id, r.closed
FROM reports r
INNER JOIN report_types rt ON rt.id = r.report_type_id
WHERE r.id = ANY($1)
@ -97,6 +102,8 @@ impl Report {
body: x.body,
reporter: UserId(x.reporter),
created: x.created,
closed: x.closed,
thread_id: x.thread_id.map(ThreadId),
}))
})
.try_collect::<Vec<QueryReport>>()
@ -105,33 +112,50 @@ impl Report {
Ok(reports)
}
pub async fn remove_full<'a, E>(
pub async fn remove_full(
id: ReportId,
exec: E,
) -> Result<Option<()>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
let result = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1)
",
id as ReportId
)
.fetch_one(exec)
.fetch_one(&mut *transaction)
.await?;
if !result.exists.unwrap_or(false) {
return Ok(None);
}
let thread_id = sqlx::query!(
"
SELECT thread_id FROM reports
WHERE id = $1
",
id as ReportId
)
.fetch_optional(&mut *transaction)
.await?;
if let Some(thread_id) = thread_id {
if let Some(id) = thread_id.thread_id {
crate::database::models::Thread::remove_full(
ThreadId(id),
transaction,
)
.await?;
}
}
sqlx::query!(
"
DELETE FROM reports WHERE id = $1
",
id as ReportId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
Ok(Some(()))

View File

@ -104,53 +104,6 @@ pub struct QueryTeamMember {
}
impl TeamMember {
/// Lists the members of a team
pub async fn get_from_team<'a, 'b, E>(
id: TeamId,
executor: E,
) -> Result<Vec<TeamMember>, super::DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use futures::stream::TryStreamExt;
let team_members = sqlx::query!(
"
SELECT id, user_id, role, permissions, accepted, payouts_split, ordering
FROM team_members
WHERE team_id = $1
ORDER BY ordering
",
id as TeamId,
)
.fetch_many(executor)
.try_filter_map(|e| async {
if let Some(m) = e.right() {
Ok(Some(Ok(TeamMember {
id: TeamMemberId(m.id),
team_id: id,
user_id: UserId(m.user_id),
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.unwrap_or_default(),
accepted: m.accepted,
payouts_split: m.payouts_split,
ordering: m.ordering,
})))
} else {
Ok(None)
}
})
.try_collect::<Vec<Result<TeamMember, super::DatabaseError>>>()
.await?;
let team_members = team_members
.into_iter()
.collect::<Result<Vec<TeamMember>, super::DatabaseError>>()?;
Ok(team_members)
}
// Lists the full members of a team
pub async fn get_from_team_full<'a, 'b, E>(
id: TeamId,
@ -232,100 +185,6 @@ impl TeamMember {
Ok(team_members)
}
/// Lists the team members for a user. Does not list pending requests.
pub async fn get_from_user_public<'a, 'b, E>(
id: UserId,
executor: E,
) -> Result<Vec<TeamMember>, super::DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use futures::stream::TryStreamExt;
let team_members = sqlx::query!(
"
SELECT id, team_id, role, permissions, accepted, payouts_split, ordering
FROM team_members
WHERE (user_id = $1 AND accepted = TRUE)
ORDER BY ordering
",
id as UserId,
)
.fetch_many(executor)
.try_filter_map(|e| async {
if let Some(m) = e.right() {
Ok(Some(Ok(TeamMember {
id: TeamMemberId(m.id),
team_id: TeamId(m.team_id),
user_id: id,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.unwrap_or_default(),
accepted: m.accepted,
payouts_split: m.payouts_split,
ordering: m.ordering,
})))
} else {
Ok(None)
}
})
.try_collect::<Vec<Result<TeamMember, super::DatabaseError>>>()
.await?;
let team_members = team_members
.into_iter()
.collect::<Result<Vec<TeamMember>, super::DatabaseError>>()?;
Ok(team_members)
}
/// Lists the team members for a user. Includes pending requests.
pub async fn get_from_user_private<'a, 'b, E>(
id: UserId,
executor: E,
) -> Result<Vec<TeamMember>, super::DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use futures::stream::TryStreamExt;
let team_members = sqlx::query!(
"
SELECT id, team_id, role, permissions, accepted, payouts_split, ordering
FROM team_members
WHERE user_id = $1
ORDER BY ordering
",
id as UserId,
)
.fetch_many(executor)
.try_filter_map(|e| async {
if let Some(m) = e.right() {
Ok(Some(Ok(TeamMember {
id: TeamMemberId(m.id),
team_id: TeamId(m.team_id),
user_id: id,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.unwrap_or_default(),
accepted: m.accepted,
payouts_split: m.payouts_split,
ordering: m.ordering,
})))
} else {
Ok(None)
}
})
.try_collect::<Vec<Result<TeamMember, super::DatabaseError>>>()
.await?;
let team_members = team_members
.into_iter()
.collect::<Result<Vec<TeamMember>, super::DatabaseError>>()?;
Ok(team_members)
}
/// Gets a team member from a user id and team id. Does not return pending members.
pub async fn get_from_user_id<'a, 'b, E>(
id: TeamId,

View File

@ -0,0 +1,267 @@
use super::ids::*;
use crate::database::models::DatabaseError;
use crate::models::threads::{MessageBody, ThreadType};
use chrono::{DateTime, Utc};
use serde::Deserialize;
pub struct ThreadBuilder {
pub type_: ThreadType,
pub members: Vec<UserId>,
}
pub struct Thread {
pub id: ThreadId,
pub type_: ThreadType,
pub messages: Vec<ThreadMessage>,
pub members: Vec<UserId>,
}
pub struct ThreadMessageBuilder {
pub author_id: Option<UserId>,
pub body: MessageBody,
pub thread_id: ThreadId,
pub show_in_mod_inbox: Option<bool>,
}
#[derive(Deserialize)]
pub struct ThreadMessage {
pub id: ThreadMessageId,
pub thread_id: ThreadId,
pub author_id: Option<UserId>,
pub body: MessageBody,
pub created: DateTime<Utc>,
pub show_in_mod_inbox: bool,
}
impl ThreadMessageBuilder {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<ThreadMessageId, DatabaseError> {
let thread_message_id =
generate_thread_message_id(&mut *transaction).await?;
sqlx::query!(
"
INSERT INTO threads_messages (
id, author_id, body, thread_id, show_in_mod_inbox
)
VALUES (
$1, $2, $3, $4, $5
)
",
thread_message_id as ThreadMessageId,
self.author_id.map(|x| x.0),
serde_json::value::to_value(self.body.clone())?,
self.thread_id as ThreadId,
self.show_in_mod_inbox,
)
.execute(&mut *transaction)
.await?;
Ok(thread_message_id)
}
}
impl ThreadBuilder {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<ThreadId, DatabaseError> {
let thread_id = generate_thread_id(&mut *transaction).await?;
sqlx::query!(
"
INSERT INTO threads (
id, thread_type
)
VALUES (
$1, $2
)
",
thread_id as ThreadId,
self.type_.as_str(),
)
.execute(&mut *transaction)
.await?;
for member in &self.members {
sqlx::query!(
"
INSERT INTO threads_members (
thread_id, user_id
)
VALUES (
$1, $2
)
",
thread_id as ThreadId,
*member as UserId,
)
.execute(&mut *transaction)
.await?;
}
Ok(thread_id)
}
}
impl Thread {
pub async fn get<'a, E>(
id: ThreadId,
exec: E,
) -> Result<Option<Thread>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
Self::get_many(&[id], exec)
.await
.map(|x| x.into_iter().next())
}
pub async fn get_many<'a, E>(
thread_ids: &[ThreadId],
exec: E,
) -> Result<Vec<Thread>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let thread_ids_parsed: Vec<i64> =
thread_ids.iter().map(|x| x.0).collect();
let threads = sqlx::query!(
"
SELECT t.id, t.thread_type,
ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members,
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
FROM threads t
LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id
LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id
WHERE t.id = ANY($1)
GROUP BY t.id
",
&thread_ids_parsed
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|x| Thread {
id: ThreadId(x.id),
type_: ThreadType::from_str(&x.thread_type),
messages: serde_json::from_value(
x.messages.unwrap_or_default(),
)
.ok()
.unwrap_or_default(),
members: x.members.unwrap_or_default().into_iter().map(UserId).collect(),
}))
})
.try_collect::<Vec<Thread>>()
.await?;
Ok(threads)
}
pub async fn remove_full(
id: ThreadId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
sqlx::query!(
"
DELETE FROM threads_messages
WHERE thread_id = $1
",
id as ThreadId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM threads_members
WHERE thread_id = $1
",
id as ThreadId
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM threads
WHERE id = $1
",
id as ThreadId,
)
.execute(&mut *transaction)
.await?;
Ok(Some(()))
}
}
impl ThreadMessage {
pub async fn get<'a, E>(
id: ThreadMessageId,
exec: E,
) -> Result<Option<ThreadMessage>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
Self::get_many(&[id], exec)
.await
.map(|x| x.into_iter().next())
}
pub async fn get_many<'a, E>(
message_ids: &[ThreadMessageId],
exec: E,
) -> Result<Vec<ThreadMessage>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let message_ids_parsed: Vec<i64> =
message_ids.iter().map(|x| x.0).collect();
let messages = sqlx::query!(
"
SELECT tm.id, tm.author_id, tm.thread_id, tm.body, tm.created, tm.show_in_mod_inbox
FROM threads_messages tm
WHERE tm.id = ANY($1)
",
&message_ids_parsed
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|x| ThreadMessage {
id: ThreadMessageId(x.id),
thread_id: ThreadId(x.thread_id),
author_id: x.author_id.map(UserId),
body: serde_json::from_value(x.body)
.unwrap_or(MessageBody::Deleted),
created: x.created,
show_in_mod_inbox: x.show_in_mod_inbox,
}))
})
.try_collect::<Vec<ThreadMessage>>()
.await?;
Ok(messages)
}
pub async fn remove_full(
id: ThreadMessageId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
sqlx::query!(
"
DELETE FROM threads_messages
WHERE id = $1
",
id as ThreadMessageId,
)
.execute(&mut *transaction)
.await?;
Ok(Some(()))
}
}

View File

@ -451,6 +451,28 @@ impl User {
.execute(&mut *transaction)
.await?;
sqlx::query!(
r#"
UPDATE threads_messages
SET body = '{"type": "deleted"}', author_id = $2
WHERE author_id = $1
"#,
id as UserId,
deleted_user as UserId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM threads_members
WHERE user_id = $1
",
id as UserId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM users

View File

@ -568,66 +568,6 @@ impl Version {
Ok(map)
}
pub async fn get<'a, 'b, E>(
id: VersionId,
executor: E,
) -> Result<Option<Self>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
Self::get_many(&[id], executor)
.await
.map(|x| x.into_iter().next())
}
pub async fn get_many<'a, E>(
version_ids: &[VersionId],
exec: E,
) -> Result<Vec<Version>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let version_ids_parsed: Vec<i64> =
version_ids.iter().map(|x| x.0).collect();
let versions = sqlx::query!(
"
SELECT v.id, v.mod_id, v.author_id, v.name, v.version_number,
v.changelog, v.date_published, v.downloads,
v.version_type, v.featured, v.status, v.requested_status
FROM versions v
WHERE v.id = ANY($1)
ORDER BY v.date_published ASC
",
&version_ids_parsed
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|v| Version {
id: VersionId(v.id),
project_id: ProjectId(v.mod_id),
author_id: UserId(v.author_id),
name: v.name,
version_number: v.version_number,
changelog: v.changelog,
changelog_url: None,
date_published: v.date_published,
downloads: v.downloads,
featured: v.featured,
version_type: v.version_type,
status: VersionStatus::from_str(&v.status),
requested_status: v
.requested_status
.map(|x| VersionStatus::from_str(&x)),
}))
})
.try_collect::<Vec<Version>>()
.await?;
Ok(versions)
}
pub async fn get_full<'a, 'b, E>(
id: VersionId,
executor: E,

View File

@ -4,6 +4,8 @@ pub use super::notifications::NotificationId;
pub use super::projects::{ProjectId, VersionId};
pub use super::reports::ReportId;
pub use super::teams::TeamId;
pub use super::threads::ThreadId;
pub use super::threads::ThreadMessageId;
pub use super::users::UserId;
/// Generates a random 64 bit integer that is exactly `n` characters
@ -109,6 +111,8 @@ base62_id_impl!(VersionId, VersionId);
base62_id_impl!(TeamId, TeamId);
base62_id_impl!(ReportId, ReportId);
base62_id_impl!(NotificationId, NotificationId);
base62_id_impl!(ThreadId, ThreadId);
base62_id_impl!(ThreadMessageId, ThreadMessageId);
pub mod base62_impl {
use serde::de::{self, Deserializer, Visitor};

View File

@ -5,4 +5,5 @@ pub mod pack;
pub mod projects;
pub mod reports;
pub mod teams;
pub mod threads;
pub mod users;

View File

@ -3,6 +3,7 @@ use super::teams::TeamId;
use super::users::UserId;
use crate::database::models::project_item::QueryProject;
use crate::database::models::version_item::QueryVersion;
use crate::models::threads::ThreadId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;
@ -56,6 +57,7 @@ pub struct Project {
/// The requested status of this projct
pub requested_status: Option<ProjectStatus>,
/// DEPRECATED: moved to threads system
/// The rejection data of the project
pub moderator_message: Option<ModeratorMessage>,
@ -107,6 +109,9 @@ pub struct Project {
/// The color of the project (picked from icon)
pub color: Option<u32>,
/// The thread of the moderation messages of the project
pub thread_id: Option<ThreadId>,
}
impl From<QueryProject> for Project {
@ -195,6 +200,7 @@ impl From<QueryProject> for Project {
flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(|x| x.into()),
color: m.color,
thread_id: m.thread_id.map(|x| x.into()),
}
}
}

View File

@ -1,5 +1,5 @@
use super::ids::Base62Id;
use crate::models::ids::UserId;
use crate::models::ids::{ThreadId, UserId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -17,6 +17,8 @@ pub struct Report {
pub reporter: UserId,
pub body: String,
pub created: DateTime<Utc>,
pub closed: bool,
pub thread_id: Option<ThreadId>,
}
#[derive(Serialize, Deserialize, Clone)]

80
src/models/threads.rs Normal file
View File

@ -0,0 +1,80 @@
use super::ids::Base62Id;
use crate::models::projects::ProjectStatus;
use crate::models::users::{User, UserId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "Base62Id")]
#[serde(into = "Base62Id")]
pub struct ThreadId(pub u64);
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "Base62Id")]
#[serde(into = "Base62Id")]
pub struct ThreadMessageId(pub u64);
#[derive(Serialize, Deserialize)]
pub struct Thread {
pub id: ThreadId,
#[serde(rename = "type")]
pub type_: ThreadType,
pub messages: Vec<ThreadMessage>,
pub members: Vec<User>,
}
#[derive(Serialize, Deserialize)]
pub struct ThreadMessage {
pub id: ThreadMessageId,
pub author_id: Option<UserId>,
pub body: MessageBody,
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageBody {
Text {
body: String,
},
StatusChange {
new_status: ProjectStatus,
old_status: ProjectStatus,
},
ThreadClosure,
Deleted,
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum ThreadType {
Report,
Project,
DirectMessage,
}
impl std::fmt::Display for ThreadType {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}
impl ThreadType {
// These are constant, so this can remove unneccessary allocations (`to_string`)
pub fn as_str(&self) -> &'static str {
match self {
ThreadType::Report => "report",
ThreadType::Project => "project",
ThreadType::DirectMessage => "direct_message",
}
}
pub fn from_str(string: &str) -> ThreadType {
match string {
"report" => ThreadType::Report,
"project" => ThreadType::Project,
"direct_message" => ThreadType::DirectMessage,
_ => ThreadType::DirectMessage,
}
}
}

View File

@ -9,6 +9,7 @@ mod reports;
mod statistics;
mod tags;
mod teams;
mod threads;
mod users;
mod version_creation;
mod version_file;
@ -30,6 +31,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(statistics::config)
.configure(tags::config)
.configure(teams::config)
.configure(threads::config)
.configure(users::config)
.configure(version_file::config)
.configure(versions::config),

View File

@ -1,11 +1,13 @@
use super::version_creation::InitialVersionData;
use crate::database::models;
use crate::database::models::thread_item::ThreadBuilder;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
use crate::models::projects::{
DonationLink, License, ProjectId, ProjectStatus, SideType, VersionId,
VersionStatus,
};
use crate::models::threads::ThreadType;
use crate::models::users::UserId;
use crate::search::indexing::IndexingError;
use crate::util::auth::{get_user_from_headers, AuthenticationError};
@ -464,8 +466,8 @@ async fn project_create_inner(
project_create_data = create_data;
}
let project_type_id = models::ProjectTypeId::get_id(
project_create_data.project_type.clone(),
let project_type_id = models::categories::ProjectType::get_id(
project_create_data.project_type.as_str(),
&mut *transaction,
)
.await?
@ -698,8 +700,8 @@ async fn project_create_inner(
)));
}
let client_side_id = models::SideTypeId::get_id(
&project_create_data.client_side,
let client_side_id = models::categories::SideType::get_id(
project_create_data.client_side.as_str(),
&mut *transaction,
)
.await?
@ -709,8 +711,8 @@ async fn project_create_inner(
)
})?;
let server_side_id = models::SideTypeId::get_id(
&project_create_data.server_side,
let server_side_id = models::categories::SideType::get_id(
project_create_data.server_side.as_str(),
&mut *transaction,
)
.await?
@ -733,7 +735,7 @@ async fn project_create_inner(
if let Some(urls) = &project_create_data.donation_urls {
for url in urls {
let platform_id = models::DonationPlatformId::get_id(
let platform_id = models::categories::DonationPlatform::get_id(
&url.id,
&mut *transaction,
)
@ -754,6 +756,13 @@ async fn project_create_inner(
}
}
let thread_id = ThreadBuilder {
type_: ThreadType::Project,
members: vec![],
}
.insert(&mut *transaction)
.await?;
let project_builder = models::project_item::ProjectBuilder {
project_id: project_id.into(),
project_type_id,
@ -790,6 +799,7 @@ async fn project_create_inner(
})
.collect(),
color: icon_data.and_then(|x| x.1),
thread_id,
};
let now = Utc::now();
@ -838,6 +848,7 @@ async fn project_create_inner(
flame_anvil_project: None,
flame_anvil_user: None,
color: project_builder.color,
thread_id: Some(project_builder.thread_id.into()),
};
let _project_id = project_builder.insert(&mut *transaction).await?;

View File

@ -1,5 +1,6 @@
use crate::database;
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;
@ -7,6 +8,7 @@ use crate::models::projects::{
DonationLink, Project, ProjectId, ProjectStatus, SearchRequest, SideType,
};
use crate::models::teams::Permissions;
use crate::models::threads::MessageBody;
use crate::routes::ApiError;
use crate::search::{search_for_project, SearchConfig, SearchError};
use crate::util::auth::{
@ -45,10 +47,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(project_unfollow)
.service(project_schedule)
.service(super::teams::team_members_get_project)
.service(web::scope("{project_id}")
.service(super::versions::version_list)
.service(super::versions::version_project_get)
.service(dependency_list)),
.service(
web::scope("{project_id}")
.service(super::versions::version_list)
.service(super::versions::version_project_get)
.service(dependency_list),
),
);
}
@ -160,7 +164,7 @@ pub async fn project_get_check(
) -> Result<HttpResponse, ApiError> {
let slug = info.into_inner().0;
let id_option = models::ids::base62_impl::parse_base62(&slug).ok();
let id_option = parse_base62(&slug).ok();
let id = if let Some(id) = id_option {
let id = sqlx::query!(
@ -315,8 +319,7 @@ pub async fn dependency_list(
}
}
/// A project returned from the API
#[derive(Serialize, Deserialize, Validate)]
#[derive(Deserialize, Validate)]
pub struct EditProject {
#[validate(
length(min = 3, max = 64),
@ -634,6 +637,20 @@ pub async fn project_edit(
.await?;
}
if let Some(thread) = project_item.inner.thread_id {
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::StatusChange {
new_status: *status,
old_status: project_item.inner.status,
},
thread_id: thread,
show_in_mod_inbox: None,
}
.insert(&mut transaction)
.await?;
}
sqlx::query!(
"
UPDATE mods
@ -916,7 +933,7 @@ pub async fn project_edit(
// We are able to unwrap here because the slug is always set
if !slug.eq(&project_item.inner.slug.unwrap_or_default()) {
let results = sqlx::query!(
"
"
SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))
",
slug
@ -953,12 +970,13 @@ pub async fn project_edit(
));
}
let side_type_id = database::models::SideTypeId::get_id(
new_side,
&mut *transaction,
)
.await?
.expect("No database entry found for side type");
let side_type_id =
database::models::categories::SideType::get_id(
new_side.as_str(),
&mut *transaction,
)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
@ -981,12 +999,13 @@ pub async fn project_edit(
));
}
let side_type_id = database::models::SideTypeId::get_id(
new_side,
&mut *transaction,
)
.await?
.expect("No database entry found for side type");
let side_type_id =
database::models::categories::SideType::get_id(
new_side.as_str(),
&mut *transaction,
)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
@ -1054,7 +1073,7 @@ pub async fn project_edit(
for donation in donations {
let platform_id =
database::models::DonationPlatformId::get_id(
database::models::categories::DonationPlatform::get_id(
&donation.id,
&mut *transaction,
)

View File

@ -1,21 +1,28 @@
use crate::database::models::thread_item::{
ThreadBuilder, ThreadMessageBuilder,
};
use crate::models::ids::{
base62_impl::parse_base62, ProjectId, UserId, VersionId,
};
use crate::models::reports::{ItemType, Report};
use crate::models::threads::{MessageBody, ThreadType};
use crate::routes::ApiError;
use crate::util::auth::{
check_is_moderator_from_headers, get_user_from_headers,
};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::StreamExt;
use serde::Deserialize;
use sqlx::PgPool;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(reports);
cfg.service(report_create);
cfg.service(delete_report);
cfg.service(report_edit);
cfg.service(report_delete);
cfg.service(report_get);
}
#[derive(Deserialize)]
@ -60,6 +67,14 @@ pub async fn report_create(
new_report.report_type
))
})?;
let thread_id = ThreadBuilder {
type_: ThreadType::Report,
members: vec![],
}
.insert(&mut transaction)
.await?;
let mut report = crate::database::models::report_item::Report {
id,
report_type_id: report_type,
@ -69,6 +84,8 @@ pub async fn report_create(
body: new_report.body.clone(),
reporter: current_user.id.into(),
created: Utc::now(),
closed: false,
thread_id,
};
match new_report.item_type {
@ -150,44 +167,72 @@ pub async fn report_create(
reporter: current_user.id,
body: new_report.body.clone(),
created: Utc::now(),
closed: false,
thread_id: Some(report.thread_id.into()),
}))
}
#[derive(Deserialize)]
pub struct ResultCount {
pub struct ReportsRequestOptions {
#[serde(default = "default_count")]
count: i16,
#[serde(default = "default_all")]
all: bool,
}
fn default_count() -> i16 {
100
}
fn default_all() -> bool {
true
}
#[get("report")]
pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
count: web::Query<ReportsRequestOptions>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
use futures::stream::TryStreamExt;
let report_ids = sqlx::query!(
"
SELECT id FROM reports
ORDER BY created ASC
LIMIT $1;
",
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right()
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await?;
let report_ids = if user.role.is_mod() && count.all {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE
ORDER BY created ASC
LIMIT $1;
",
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right()
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await?
} else {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE AND reporter = $1
ORDER BY created ASC
LIMIT $2;
",
user.id.0 as i64,
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right()
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await?
};
let query_reports = crate::database::models::report_item::Report::get_many(
&report_ids,
@ -198,47 +243,130 @@ pub async fn reports(
let mut reports = Vec::new();
for x in query_reports {
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Project;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
} else if let Some(user_id) = x.user_id {
item_id = serde_json::to_string::<UserId>(&user_id.into())?;
item_type = ItemType::User;
}
reports.push(Report {
id: x.id.into(),
report_type: x.report_type,
item_id,
item_type,
reporter: x.reporter.into(),
body: x.body,
created: x.created,
})
reports.push(to_report(x)?);
}
Ok(HttpResponse::Ok().json(reports))
}
#[get("report/{id}")]
pub async fn report_get(
req: HttpRequest,
pool: web::Data<PgPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0.into();
let report =
crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.user_id != Some(user.id.into()) {
return Ok(HttpResponse::NotFound().body(""));
}
Ok(HttpResponse::Ok().json(to_report(report)?))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize, Validate)]
pub struct EditReport {
#[validate(length(max = 65536))]
pub body: Option<String>,
pub closed: Option<bool>,
}
#[patch("report/{id}")]
pub async fn report_edit(
req: HttpRequest,
pool: web::Data<PgPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
edit_report: web::Json<EditReport>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0.into();
let report =
crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.user_id != Some(user.id.into()) {
return Ok(HttpResponse::NotFound().body(""));
}
let mut transaction = pool.begin().await?;
if let Some(edit_body) = &edit_report.body {
sqlx::query!(
"
UPDATE reports
SET body = $1
WHERE (id = $2)
",
edit_body,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(edit_closed) = edit_report.closed {
if report.closed && !edit_closed && !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You cannot reopen a report!".to_string(),
));
}
if let Some(thread) = report.thread_id {
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::ThreadClosure,
thread_id: thread,
show_in_mod_inbox: None,
}
.insert(&mut transaction)
.await?;
}
sqlx::query!(
"
UPDATE reports
SET closed = $1
WHERE (id = $2)
",
edit_closed,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[delete("report/{id}")]
pub async fn delete_report(
pub async fn report_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
let mut transaction = pool.begin().await?;
let result = crate::database::models::report_item::Report::remove_full(
info.into_inner().0.into(),
&**pool,
&mut transaction,
)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
@ -246,3 +374,33 @@ pub async fn delete_report(
Ok(HttpResponse::NotFound().body(""))
}
}
fn to_report(
x: crate::database::models::report_item::QueryReport,
) -> Result<Report, ApiError> {
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Project;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
} else if let Some(user_id) = x.user_id {
item_id = serde_json::to_string::<UserId>(&user_id.into())?;
item_type = ItemType::User;
}
Ok(Report {
id: x.id.into(),
report_type: x.report_type,
item_id,
item_type,
reporter: x.reporter.into(),
body: x.body,
created: x.created,
closed: x.closed,
thread_id: x.thread_id.map(|x| x.into()),
})
}

View File

@ -1,10 +1,9 @@
use super::ApiError;
use crate::database::models;
use crate::database::models::categories::{
DonationPlatform, ProjectType, ReportType,
DonationPlatform, ProjectType, ReportType, SideType,
};
use crate::util::auth::check_is_admin_from_headers;
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use actix_web::{get, web, HttpResponse};
use chrono::{DateTime, Utc};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
@ -13,22 +12,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("tag")
.service(category_list)
.service(category_create)
.service(category_delete)
.service(loader_list)
.service(loader_create)
.service(loader_delete)
.service(game_version_list)
.service(game_version_create)
.service(game_version_delete)
.service(license_list)
.service(license_text)
.service(donation_platform_create)
.service(donation_platform_list)
.service(donation_platform_delete)
.service(report_type_create)
.service(report_type_delete)
.service(report_type_list),
.service(report_type_list)
.service(project_type_list)
.service(side_type_list),
);
}
@ -60,62 +51,6 @@ pub async fn category_list(
Ok(HttpResponse::Ok().json(results))
}
#[put("category")]
pub async fn category_create(
req: HttpRequest,
pool: web::Data<PgPool>,
new_category: web::Json<CategoryData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let project_type = crate::database::models::ProjectTypeId::get_id(
new_category.project_type.clone(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"Specified project type does not exist!".to_string(),
)
})?;
let _id = Category::builder()
.name(&new_category.name)?
.project_type(&project_type)?
.icon(&new_category.icon)?
.header(&new_category.header)?
.insert(&**pool)
.await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("category/{name}")]
pub async fn category_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = Category::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct LoaderData {
icon: String,
@ -142,62 +77,6 @@ pub async fn loader_list(
Ok(HttpResponse::Ok().json(results))
}
#[put("loader")]
pub async fn loader_create(
req: HttpRequest,
pool: web::Data<PgPool>,
new_loader: web::Json<LoaderData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let mut transaction = pool.begin().await?;
let project_types = ProjectType::get_many_id(
&new_loader.supported_project_types,
&mut *transaction,
)
.await?;
let _id = Loader::builder()
.name(&new_loader.name)?
.icon(&new_loader.icon)?
.supported_project_types(
&project_types.into_iter().map(|x| x.id).collect::<Vec<_>>(),
)?
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("loader/{name}")]
pub async fn loader_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = Loader::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct GameVersionQueryData {
pub version: String,
@ -238,66 +117,6 @@ pub async fn game_version_list(
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct GameVersionData {
#[serde(rename = "type")]
type_: String,
date: Option<DateTime<Utc>>,
}
#[put("game_version/{name}")]
pub async fn game_version_create(
req: HttpRequest,
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
version_data: web::Json<GameVersionData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
// The version type currently isn't limited, but it should be one of:
// "release", "snapshot", "alpha", "beta", "other"
let mut builder = GameVersion::builder()
.version(&name)?
.version_type(&version_data.type_)?;
if let Some(date) = &version_data.date {
builder = builder.created(date);
}
let _id = builder.insert(&**pool).await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("game_version/{name}")]
pub async fn game_version_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = GameVersion::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct License {
short: String,
@ -372,57 +191,6 @@ pub async fn donation_platform_list(
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct DonationPlatformData {
name: String,
}
#[put("donation_platform/{name}")]
pub async fn donation_platform_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<DonationPlatformData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = DonationPlatform::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("donation_platform/{name}")]
pub async fn donation_platform_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = DonationPlatform::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("report_type")]
pub async fn report_type_list(
pool: web::Data<PgPool>,
@ -431,43 +199,18 @@ pub async fn report_type_list(
Ok(HttpResponse::Ok().json(results))
}
#[put("report_type/{name}")]
pub async fn report_type_create(
req: HttpRequest,
#[get("project_type")]
pub async fn project_type_list(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let _id = ReportType::builder().name(&name)?.insert(&**pool).await?;
Ok(HttpResponse::NoContent().body(""))
let results = ProjectType::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
}
#[delete("report_type/{name}")]
pub async fn report_type_delete(
req: HttpRequest,
#[get("side_type")]
pub async fn side_type_list(
pool: web::Data<PgPool>,
report_type: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = report_type.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = ReportType::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
let results = SideType::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
}

278
src/routes/v2/threads.rs Normal file
View File

@ -0,0 +1,278 @@
use crate::database;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::models::ids::ThreadMessageId;
use crate::models::projects::ProjectStatus;
use crate::models::threads::{
MessageBody, Thread, ThreadId, ThreadMessage, ThreadType,
};
use crate::models::users::User;
use crate::routes::ApiError;
use crate::util::auth::{
check_is_moderator_from_headers, get_user_from_headers,
};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("thread")
.service(moderation_inbox)
.service(thread_get)
.service(thread_send_message),
);
cfg.service(web::scope("message").service(message_delete));
}
pub async fn is_authorized_thread(
thread: &database::models::Thread,
user: &User,
pool: &PgPool,
) -> Result<bool, ApiError> {
if user.role.is_mod() {
return Ok(true);
}
let user_id: database::models::UserId = user.id.into();
Ok(match thread.type_ {
ThreadType::Report => {
let report_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM reports WHERE thread_id = $1 AND reporter = $2)",
thread.id as database::models::ids::ThreadId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
report_exists.unwrap_or(false)
}
ThreadType::Project => {
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE thread_id = $1)",
thread.id as database::models::ids::ThreadId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
project_exists.unwrap_or(false)
}
ThreadType::DirectMessage => thread.members.contains(&user_id),
})
}
#[get("{id}")]
pub async fn thread_get(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0.into();
let thread_data = database::models::Thread::get(string, &**pool).await?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
if let Some(data) = thread_data {
if is_authorized_thread(&data, &user, &pool).await? {
let users: Vec<User> = database::models::User::get_many(
&data
.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>(),
&**pool,
)
.await?
.into_iter()
.map(From::from)
.collect();
let thread_type = data.type_;
return Ok(HttpResponse::Ok().json(Thread {
id: data.id.into(),
type_: thread_type,
messages: data
.messages
.into_iter()
.map(|x| ThreadMessage {
id: x.id.into(),
author_id: if thread_type == ThreadType::Report
&& users
.iter()
.find(|y| x.author_id == Some(y.id.into()))
.map(|x| x.role.is_mod())
.unwrap_or(false)
{
None
} else {
x.author_id.map(|x| x.into())
},
body: x.body,
created: x.created,
})
.collect(),
members: users,
}));
}
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Deserialize)]
pub struct NewThreadMessage {
pub body: MessageBody,
}
#[post("{id}")]
pub async fn thread_send_message(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
pool: web::Data<PgPool>,
new_message: web::Json<NewThreadMessage>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
if let MessageBody::Text { body } = &new_message.body {
if body.len() > 65536 {
return Err(ApiError::InvalidInput(
"Input body is too long!".to_string(),
));
}
}
let string: database::models::ThreadId = info.into_inner().0.into();
let result = database::models::Thread::get(string, &**pool).await?;
if let Some(thread) = result {
if !is_authorized_thread(&thread, &user, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let mod_notif = if thread.type_ == ThreadType::Project {
let status = sqlx::query!(
"SELECT m.status FROM mods m WHERE thread_id = $1",
thread.id as database::models::ids::ThreadId,
)
.fetch_one(&**pool)
.await?;
let status = ProjectStatus::from_str(&status.status);
status == ProjectStatus::Processing && !user.role.is_mod()
} else {
false
};
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: new_message.body.clone(),
thread_id: thread.id,
show_in_mod_inbox: Some(mod_notif),
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("inbox")]
pub async fn moderation_inbox(
req: HttpRequest,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
let messages = sqlx::query!(
"
SELECT tm.id, tm.thread_id, tm.author_id, tm.body, tm.created, m.id project_id FROM threads_messages tm
INNER JOIN mods m ON m.thread_id = tm.thread_id
WHERE tm.show_in_mod_inbox = TRUE
"
)
.fetch_all(&**pool)
.await?
.into_iter()
.map(|x| serde_json::json! ({
"message": ThreadMessage {
id: ThreadMessageId(x.id as u64),
author_id: x.author_id.map(|x| crate::models::users::UserId(x as u64)),
body: serde_json::from_value(x.body).unwrap_or(MessageBody::Deleted),
created: x.created
},
"project_id": crate::models::projects::ProjectId(x.project_id as u64),
}))
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(messages))
}
#[post("{id}/read")]
pub async fn read_message(
req: HttpRequest,
info: web::Path<(ThreadMessageId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE threads_messages
SET show_in_mod_inbox = FALSE
WHERE id = $1
",
id.0 as i64,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("{id}")]
pub async fn message_delete(
req: HttpRequest,
info: web::Path<(ThreadMessageId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let result = database::models::ThreadMessage::get(
info.into_inner().0.into(),
&**pool,
)
.await?;
if let Some(thread) = result {
if !user.role.is_mod() && thread.author_id != Some(user.id.into()) {
return Err(ApiError::CustomAuthentication(
"You cannot delete this message!".to_string(),
));
}
let mut transaction = pool.begin().await?;
database::models::ThreadMessage::remove_full(
thread.id,
&mut transaction,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@ -203,11 +203,8 @@ pub async fn user_edit(
ApiError::Validation(validation_errors_to_string(err, None))
})?;
let id_option = crate::database::models::User::get_id_from_username_or_id(
&info.into_inner().0,
&**pool,
)
.await?;
let id_option =
User::get_id_from_username_or_id(&info.into_inner().0, &**pool).await?;
if let Some(id) = id_option {
let user_id: UserId = id.into();
@ -217,10 +214,7 @@ pub async fn user_edit(
if let Some(username) = &new_user.username {
let existing_user_id_option =
crate::database::models::User::get_id_from_username_or_id(
username, &**pool,
)
.await?;
User::get_id_from_username_or_id(username, &**pool).await?;
if existing_user_id_option
.map(UserId::from)
@ -754,6 +748,8 @@ pub async fn user_payouts_request(
data: web::Json<PayoutData>,
payouts_queue: web::Data<Arc<Mutex<PayoutsQueue>>>,
) -> Result<HttpResponse, ApiError> {
let mut payouts_queue = payouts_queue.lock().await;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
User::get_id_from_username_or_id(&info.into_inner().0, &**pool).await?;
@ -775,8 +771,6 @@ pub async fn user_payouts_request(
return if data.amount < payouts_data.balance {
let mut transaction = pool.begin().await?;
let mut payouts_queue = payouts_queue.lock().await;
let leftover = payouts_queue
.send_payout(PayoutItem {
amount: PayoutAmount {

View File

@ -1,7 +1,7 @@
use super::ApiError;
use crate::database::models::{version_item::QueryVersion, DatabaseError};
use crate::models::ids::VersionId;
use crate::models::projects::{GameVersion, Loader, Version};
use crate::models::projects::{GameVersion, Loader, Project, Version};
use crate::models::teams::Permissions;
use crate::util::auth::get_user_from_headers;
use crate::util::routes::ok_or_not_found;
@ -404,6 +404,65 @@ pub async fn get_versions_from_hashes(
Ok(HttpResponse::Ok().json(response?))
}
#[post("project")]
pub async fn get_projects_from_hashes(
pool: web::Data<PgPool>,
file_data: web::Json<FileHashes>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = file_data
.hashes
.iter()
.map(|x| x.to_lowercase().as_bytes().to_vec())
.collect();
let result = sqlx::query!(
"
SELECT h.hash hash, h.algorithm algorithm, m.id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id AND v.status != ANY($1)
INNER JOIN mods m on v.mod_id = m.id
WHERE h.algorithm = $3 AND h.hash = ANY($2::bytea[]) AND m.status != ANY($4)
",
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
hashes_parsed.as_slice(),
file_data.algorithm,
&*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
)
.fetch_all(&**pool)
.await?;
let project_ids = result
.iter()
.map(|x| database::models::ProjectId(x.project_id))
.collect::<Vec<_>>();
let versions_data =
database::models::Project::get_many_full(&project_ids, &**pool).await?;
let response: Result<HashMap<String, Project>, ApiError> = result
.into_iter()
.filter_map(|row| {
versions_data
.clone()
.into_iter()
.find(|x| x.inner.id.0 == row.project_id)
.map(|v| {
if let Ok(parsed_hash) = String::from_utf8(row.hash) {
Ok((
parsed_hash,
crate::models::projects::Project::from(v),
))
} else {
Err(ApiError::Database(DatabaseError::Other(format!(
"Could not parse hash for version {}",
row.project_id
))))
}
})
})
.collect();
Ok(HttpResponse::Ok().json(response?))
}
#[post("download")]
pub async fn download_files(
pool: web::Data<PgPool>,

View File

@ -117,21 +117,6 @@ where
}
}
pub async fn check_is_admin_from_headers<'a, 'b, E>(
headers: &HeaderMap,
executor: E,
) -> Result<User, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let user = get_user_from_headers(headers, executor).await?;
match user.role {
Role::Admin => Ok(user),
_ => Err(AuthenticationError::InvalidCredentials),
}
}
pub async fn is_authorized(
project_data: &Project,
user_option: &Option<User>,