use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::SqlitePool;
use tokio::time::interval;
use uuid::Uuid;
use crate::auth::uuid_from_bytes;
use crate::error::Error;
use crate::groups::{self, GroupRole};
use crate::storage;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Destination {
Personal,
Group {
group_id: Uuid,
},
}
impl Destination {
pub fn parse(raw: &str) -> Result<Self, Error> {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("personal") {
return Ok(Self::Personal);
}
if let Some(rest) = trimmed
.strip_prefix("group:")
.or_else(|| trimmed.strip_prefix("Group:"))
{
let group_id = Uuid::parse_str(rest.trim()).map_err(|err| {
Error::BadRequest(format!("invalid group uuid in destination: {err}"))
})?;
return Ok(Self::Group { group_id });
}
Err(Error::BadRequest(format!(
"destination must be `personal` or `group:<uuid>`, got `{trimmed}`"
)))
}
#[must_use]
pub fn render_string(self) -> String {
match self {
Self::Personal => "personal".to_owned(),
Self::Group { group_id } => format!("group:{group_id}"),
}
}
}
pub const MAX_DISPLAY_NAME_LEN: usize = 128;
const fn is_unicode_format(c: char) -> bool {
matches!(
c,
'\u{00AD}'
| '\u{0600}'..='\u{0605}'
| '\u{061C}'
| '\u{06DD}'
| '\u{070F}'
| '\u{0890}'..='\u{0891}'
| '\u{08E2}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{110BD}'
| '\u{110CD}'
| '\u{13430}'..='\u{1343F}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}'
| '\u{E0020}'..='\u{E007F}'
)
}
pub fn sanitise_display_name(raw: &str, field: &str) -> Result<String, Error> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Error::BadRequest(format!("{field} must not be empty")));
}
if trimmed
.chars()
.any(|c| c.is_control() || is_unicode_format(c))
{
return Err(Error::BadRequest(format!(
"{field} must not contain control or formatting characters"
)));
}
if trimmed.chars().count() > MAX_DISPLAY_NAME_LEN {
return Err(Error::BadRequest(format!(
"{field} must be at most {MAX_DISPLAY_NAME_LEN} characters"
)));
}
Ok(trimmed.to_owned())
}
#[must_use]
pub fn is_canonical_hex_color(s: &str) -> bool {
let mut chars = s.chars();
if chars.next() != Some('#') {
return false;
}
let mut count = 0_usize;
for c in chars {
if !c.is_ascii_hexdigit() {
return false;
}
count = count.saturating_add(1);
}
count == 6
}
#[derive(Debug, Clone, Serialize)]
pub struct NotecardView {
pub notecard_id: Uuid,
pub destination: Destination,
pub uploaded_by: Option<Uuid>,
pub uploaded_by_username: Option<String>,
pub uploaded_by_legacy_name: Option<String>,
pub name: String,
pub created_at: DateTime<Utc>,
pub start_region: Option<String>,
pub end_region: Option<String>,
pub waypoint_count: Option<u32>,
pub lower_left_x: Option<u16>,
pub lower_left_y: Option<u16>,
pub upper_right_x: Option<u16>,
pub upper_right_y: Option<u16>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RenderView {
pub render_id: Uuid,
pub destination: Destination,
pub created_by: Option<Uuid>,
pub created_by_username: Option<String>,
pub created_by_legacy_name: Option<String>,
pub notecard_id: Option<Uuid>,
pub notecard_name: Option<String>,
pub kind: String,
pub status: String,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub has_without_route: bool,
pub content_type: Option<String>,
pub lower_left_x: Option<u16>,
pub lower_left_y: Option<u16>,
pub upper_right_x: Option<u16>,
pub upper_right_y: Option<u16>,
pub glw_data_id: Option<Uuid>,
pub glw_data_name: Option<String>,
}
pub async fn assert_can_write(
db: &SqlitePool,
current_user: Uuid,
destination: Destination,
) -> Result<(), Error> {
match destination {
Destination::Personal => Ok(()),
Destination::Group { group_id } => {
groups::require_exists(db, group_id).await?;
let role = groups::lookup_role(db, group_id, current_user).await?;
if role == Some(GroupRole::Owner) {
Ok(())
} else {
Err(Error::Forbidden(format!(
"must be an owner of group {group_id} to save items there"
)))
}
}
}
}
pub async fn assert_can_view(
db: &SqlitePool,
current_user: Uuid,
destination: Destination,
) -> Result<Option<GroupRole>, Error> {
match destination {
Destination::Personal => Ok(None),
Destination::Group { group_id } => {
groups::require_exists(db, group_id).await?;
let role = groups::lookup_role(db, group_id, current_user).await?;
role.map_or_else(
|| {
Err(Error::Forbidden(format!(
"not a member of group {group_id}"
)))
},
|r| Ok(Some(r)),
)
}
}
}
pub fn destination_from_columns(
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
) -> Result<Destination, Error> {
match (owner_user_id, owner_group_id) {
(Some(_), None) => Ok(Destination::Personal),
(None, Some(gid_bytes)) => {
let group_id = uuid_from_bytes(&gid_bytes).ok_or_else(|| {
tracing::error!("bad group uuid blob in destination column");
Error::Database
})?;
Ok(Destination::Group { group_id })
}
_ => {
tracing::error!("saved row had both or neither owner column set");
Err(Error::Database)
}
}
}
pub async fn assert_can_read_notecard(
db: &SqlitePool,
current_user: Uuid,
notecard_id: Uuid,
) -> Result<NotecardRow, Error> {
let row = fetch_notecard_row(db, notecard_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let visible = match destination {
Destination::Personal => {
row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
}
Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
.await?
.is_some(),
};
if visible {
Ok(row)
} else {
Err(Error::NotFound(format!("notecard {notecard_id}")))
}
}
pub async fn assert_can_read_render(
db: &SqlitePool,
current_user: Uuid,
render_id: Uuid,
) -> Result<RenderRow, Error> {
let row = fetch_render_row(db, render_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let visible = match destination {
Destination::Personal => {
row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
}
Destination::Group { group_id } => {
match groups::lookup_role(db, group_id, current_user).await? {
Some(GroupRole::Owner) => true,
Some(GroupRole::Member) => row.status == "done",
None => false,
}
}
};
if visible {
Ok(row)
} else {
Err(Error::NotFound(format!("render {render_id}")))
}
}
pub async fn assert_can_delete_render(
db: &SqlitePool,
current_user: Uuid,
render_id: Uuid,
) -> Result<RenderRow, Error> {
let row = fetch_render_row(db, render_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
match destination {
Destination::Personal => {
let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
if owner == Some(current_user) {
Ok(row)
} else {
Err(Error::Forbidden(format!(
"not allowed to delete render {render_id}"
)))
}
}
Destination::Group { group_id } => {
if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
Ok(row)
} else {
Err(Error::Forbidden(
"must be a group owner to delete a group render".to_owned(),
))
}
}
}
}
pub async fn assert_can_delete_notecard(
db: &SqlitePool,
current_user: Uuid,
notecard_id: Uuid,
) -> Result<NotecardRow, Error> {
let row = fetch_notecard_row(db, notecard_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
match destination {
Destination::Personal => {
let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
if owner == Some(current_user) {
Ok(row)
} else {
Err(Error::Forbidden(format!(
"not allowed to delete notecard {notecard_id}"
)))
}
}
Destination::Group { group_id } => {
if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
Ok(row)
} else {
Err(Error::Forbidden(
"must be a group owner to delete a group notecard".to_owned(),
))
}
}
}
}
#[derive(Debug, Clone)]
pub struct NotecardRow {
pub notecard_id: Uuid,
pub owner_user_id: Option<Vec<u8>>,
pub owner_group_id: Option<Vec<u8>>,
pub uploaded_by: Option<Uuid>,
pub name: String,
pub body: String,
pub created_at: DateTime<Utc>,
pub lower_left_x: Option<u16>,
pub lower_left_y: Option<u16>,
pub upper_right_x: Option<u16>,
pub upper_right_y: Option<u16>,
}
#[derive(Debug, Clone)]
pub struct RenderRow {
pub render_id: Uuid,
pub owner_user_id: Option<Vec<u8>>,
pub owner_group_id: Option<Vec<u8>>,
pub created_by: Option<Uuid>,
pub notecard_id: Option<Uuid>,
pub kind: String,
pub status: String,
pub error_message: Option<String>,
pub settings_json: String,
pub metadata_json: Option<String>,
pub content_type: Option<String>,
pub image_filename: Option<String>,
pub image_without_route_filename: Option<String>,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub lower_left_x: Option<u16>,
pub lower_left_y: Option<u16>,
pub upper_right_x: Option<u16>,
pub upper_right_y: Option<u16>,
pub glw_data_id: Option<Uuid>,
}
type NotecardRowTuple = (
Option<Vec<u8>>,
Option<Vec<u8>>,
Option<Vec<u8>>,
String,
String,
DateTime<Utc>,
Option<i64>,
Option<i64>,
Option<i64>,
Option<i64>,
);
async fn fetch_notecard_row(db: &SqlitePool, notecard_id: Uuid) -> Result<NotecardRow, Error> {
let row: Option<NotecardRowTuple> = sqlx::query_as(
"SELECT owner_user_id, owner_group_id, uploaded_by, name, body, created_at, \
lower_left_x, lower_left_y, upper_right_x, upper_right_y \
FROM saved_notecards WHERE notecard_id = ?1",
)
.bind(notecard_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("notecard fetch failed: {err}");
Error::Database
})?;
let (
owner_user_id,
owner_group_id,
uploaded_by_bytes,
name,
body,
created_at,
lower_left_x,
lower_left_y,
upper_right_x,
upper_right_y,
) = row.ok_or_else(|| Error::NotFound(format!("notecard {notecard_id}")))?;
let uploaded_by = uploaded_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad uploaded_by uuid in saved_notecards");
Error::Database
})
})
.transpose()?;
Ok(NotecardRow {
notecard_id,
owner_user_id,
owner_group_id,
uploaded_by,
name,
body,
created_at,
lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
})
}
#[derive(sqlx::FromRow)]
struct RenderRowDb {
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
created_by: Option<Vec<u8>>,
notecard_id: Option<Vec<u8>>,
kind: String,
status: String,
error_message: Option<String>,
settings_json: String,
metadata_json: Option<String>,
content_type: Option<String>,
image_filename: Option<String>,
image_without_route_filename: Option<String>,
created_at: DateTime<Utc>,
finished_at: Option<DateTime<Utc>>,
lower_left_x: Option<i64>,
lower_left_y: Option<i64>,
upper_right_x: Option<i64>,
upper_right_y: Option<i64>,
glw_data_id: Option<Vec<u8>>,
}
async fn fetch_render_row(db: &SqlitePool, render_id: Uuid) -> Result<RenderRow, Error> {
let row: Option<RenderRowDb> = sqlx::query_as(
"SELECT owner_user_id, owner_group_id, created_by, notecard_id, kind, status, \
error_message, settings_json, metadata_json, content_type, \
image_filename, image_without_route_filename, created_at, finished_at, \
lower_left_x, lower_left_y, upper_right_x, upper_right_y, glw_data_id \
FROM saved_renders WHERE render_id = ?1",
)
.bind(render_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("render fetch failed: {err}");
Error::Database
})?;
let RenderRowDb {
owner_user_id,
owner_group_id,
created_by: created_by_bytes,
notecard_id: notecard_bytes,
kind,
status,
error_message,
settings_json,
metadata_json,
content_type,
image_filename,
image_without_route_filename,
created_at,
finished_at,
lower_left_x,
lower_left_y,
upper_right_x,
upper_right_y,
glw_data_id: glw_data_id_bytes,
} = row.ok_or_else(|| Error::NotFound(format!("render {render_id}")))?;
let glw_data_id = glw_data_id_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad glw_data_id uuid in saved_renders");
Error::Database
})
})
.transpose()?;
let created_by = created_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid in saved_renders");
Error::Database
})
})
.transpose()?;
let notecard_id = notecard_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad notecard_id uuid in saved_renders");
Error::Database
})
})
.transpose()?;
Ok(RenderRow {
render_id,
owner_user_id,
owner_group_id,
created_by,
notecard_id,
kind,
status,
error_message,
settings_json,
metadata_json,
content_type,
image_filename,
image_without_route_filename,
created_at,
finished_at,
lower_left_x: lower_left_x.and_then(|v| u16::try_from(v).ok()),
lower_left_y: lower_left_y.and_then(|v| u16::try_from(v).ok()),
upper_right_x: upper_right_x.and_then(|v| u16::try_from(v).ok()),
upper_right_y: upper_right_y.and_then(|v| u16::try_from(v).ok()),
glw_data_id,
})
}
pub async fn recover_orphaned_in_progress(pool: &SqlitePool) -> Result<u64, Error> {
let now = Utc::now();
let result = sqlx::query(
"UPDATE saved_renders \
SET status = 'failed', finished_at = ?1, \
error_message = 'server restarted before render completed' \
WHERE status = 'in_progress'",
)
.bind(now)
.execute(pool)
.await
.map_err(|err| {
tracing::error!("recover orphaned in_progress renders failed: {err}");
Error::Database
})?;
Ok(result.rows_affected())
}
pub async fn run_orphan_sweeper(
db: SqlitePool,
storage_dir: Arc<Path>,
dirty: Arc<AtomicBool>,
period: Duration,
) {
let mut tick = interval(period);
loop {
tick.tick().await;
if !dirty.swap(false, Ordering::AcqRel) {
tracing::debug!("orphan sweeper: no work flagged, skipping");
continue;
}
match sweep_once(&db, storage_dir.as_ref()).await {
Ok(count) => {
if count > 0 {
tracing::info!("orphan sweeper: removed {count} stale render file(s)");
}
}
Err(err) => {
tracing::warn!("orphan sweeper run failed: {err}; will retry on next tick");
dirty.store(true, Ordering::Release);
}
}
}
}
async fn sweep_once(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
let renders = sweep_renders(db, storage_dir).await?;
let logos = sweep_logos(db, storage_dir).await?;
Ok(renders.saturating_add(logos))
}
async fn sweep_renders(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
let files = storage::list_render_files(storage_dir)?;
let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT render_id FROM saved_renders")
.fetch_all(db)
.await
.map_err(|err| {
tracing::error!("sweeper render id query failed: {err}");
Error::Database
})?;
let live_set: HashSet<Uuid> = live
.into_iter()
.filter_map(|b| uuid_from_bytes(&b))
.collect();
let mut removed = 0_usize;
for filename in files {
let Some(id) = storage::parse_render_id_from_filename(&filename) else {
continue;
};
if live_set.contains(&id) {
continue;
}
if let Err(err) = storage::try_delete_render_file(storage_dir, &filename) {
tracing::warn!("sweeper failed to unlink {filename}: {err}");
continue;
}
removed = removed.saturating_add(1);
}
Ok(removed)
}
async fn sweep_logos(db: &SqlitePool, storage_dir: &Path) -> Result<usize, Error> {
let files = storage::list_logo_files(storage_dir)?;
let live: Vec<Vec<u8>> = sqlx::query_scalar("SELECT logo_id FROM saved_logos")
.fetch_all(db)
.await
.map_err(|err| {
tracing::error!("sweeper logo id query failed: {err}");
Error::Database
})?;
let live_set: HashSet<Uuid> = live
.into_iter()
.filter_map(|b| uuid_from_bytes(&b))
.collect();
let mut removed = 0_usize;
for filename in files {
let Some(id) = storage::parse_logo_id_from_filename(&filename) else {
continue;
};
if live_set.contains(&id) {
continue;
}
if let Err(err) = storage::try_delete_logo_file(storage_dir, &filename) {
tracing::warn!("sweeper failed to unlink {filename}: {err}");
continue;
}
removed = removed.saturating_add(1);
}
Ok(removed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GlwDataSourceKind {
EventId,
EventKey,
PastedJson,
}
impl GlwDataSourceKind {
#[must_use]
pub const fn as_db_str(self) -> &'static str {
match self {
Self::EventId => "event_id",
Self::EventKey => "event_key",
Self::PastedJson => "pasted_json",
}
}
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"event_id" => Some(Self::EventId),
"event_key" => Some(Self::EventKey),
"pasted_json" => Some(Self::PastedJson),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct GlwDataRow {
pub glw_data_id: Uuid,
pub owner_user_id: Option<Vec<u8>>,
pub owner_group_id: Option<Vec<u8>>,
pub created_by: Option<Uuid>,
pub name: String,
pub source_kind: GlwDataSourceKind,
pub source_event_id: Option<u32>,
pub source_event_key: Option<String>,
pub payload_json: String,
pub event_id: Option<u32>,
pub event_key: Option<String>,
pub event_name: Option<String>,
pub fetched_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GlwDataView {
pub glw_data_id: Uuid,
pub destination: Destination,
pub created_by: Option<Uuid>,
pub created_by_username: Option<String>,
pub created_by_legacy_name: Option<String>,
pub name: String,
pub source_kind: GlwDataSourceKind,
pub source_event_id: Option<u32>,
pub source_event_key: Option<String>,
pub event_id: Option<u32>,
pub event_key: Option<String>,
pub event_name: Option<String>,
pub fetched_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
type GlwDataRowTuple = (
Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, Option<i64>, Option<String>, String, Option<i64>, Option<String>, Option<String>, DateTime<Utc>, DateTime<Utc>, );
async fn fetch_glw_data_row(db: &SqlitePool, glw_data_id: Uuid) -> Result<GlwDataRow, Error> {
let row: Option<GlwDataRowTuple> = sqlx::query_as(
"SELECT owner_user_id, owner_group_id, created_by, name, source_kind, \
source_event_id, source_event_key, payload_json, \
event_id, event_key, event_name, fetched_at, created_at \
FROM saved_glw_data WHERE glw_data_id = ?1",
)
.bind(glw_data_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("GLW data fetch failed: {err}");
Error::Database
})?;
let (
owner_user_id,
owner_group_id,
created_by_bytes,
name,
source_kind_str,
source_event_id,
source_event_key,
payload_json,
event_id,
event_key,
event_name,
fetched_at,
created_at,
) = row.ok_or_else(|| Error::NotFound(format!("glw data {glw_data_id}")))?;
let created_by = created_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid in saved_glw_data");
Error::Database
})
})
.transpose()?;
let source_kind = GlwDataSourceKind::from_db_str(&source_kind_str).ok_or_else(|| {
tracing::error!("unrecognised source_kind `{source_kind_str}` in saved_glw_data");
Error::Database
})?;
Ok(GlwDataRow {
glw_data_id,
owner_user_id,
owner_group_id,
created_by,
name,
source_kind,
source_event_id: source_event_id.and_then(|v| u32::try_from(v).ok()),
source_event_key,
payload_json,
event_id: event_id.and_then(|v| u32::try_from(v).ok()),
event_key,
event_name,
fetched_at,
created_at,
})
}
pub async fn assert_can_read_glw_data(
db: &SqlitePool,
current_user: Uuid,
glw_data_id: Uuid,
) -> Result<GlwDataRow, Error> {
let row = fetch_glw_data_row(db, glw_data_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let visible = match destination {
Destination::Personal => {
row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
}
Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
.await?
.is_some(),
};
if visible {
Ok(row)
} else {
Err(Error::NotFound(format!("glw data {glw_data_id}")))
}
}
pub async fn assert_can_delete_glw_data(
db: &SqlitePool,
current_user: Uuid,
glw_data_id: Uuid,
) -> Result<GlwDataRow, Error> {
let row = fetch_glw_data_row(db, glw_data_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
match destination {
Destination::Personal => {
let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
if owner == Some(current_user) {
Ok(row)
} else {
Err(Error::Forbidden(format!(
"not allowed to delete glw data {glw_data_id}"
)))
}
}
Destination::Group { group_id } => {
if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
Ok(row)
} else {
Err(Error::Forbidden(
"must be a group owner to delete group glw data".to_owned(),
))
}
}
}
}
#[derive(Debug, Clone)]
pub struct ThemeRow {
pub theme_id: Uuid,
pub owner_user_id: Option<Vec<u8>>,
pub owner_group_id: Option<Vec<u8>>,
pub created_by: Option<Uuid>,
pub name: String,
pub settings_json: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
type ThemeRowTuple = (
Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, DateTime<Utc>, DateTime<Utc>, );
async fn fetch_theme_row(db: &SqlitePool, theme_id: Uuid) -> Result<ThemeRow, Error> {
let row: Option<ThemeRowTuple> = sqlx::query_as(
"SELECT owner_user_id, owner_group_id, created_by, name, settings_json, \
created_at, updated_at \
FROM themes WHERE theme_id = ?1",
)
.bind(theme_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("theme fetch failed: {err}");
Error::Database
})?;
let (
owner_user_id,
owner_group_id,
created_by_bytes,
name,
settings_json,
created_at,
updated_at,
) = row.ok_or_else(|| Error::NotFound(format!("theme {theme_id}")))?;
let created_by = created_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad created_by uuid in themes");
Error::Database
})
})
.transpose()?;
Ok(ThemeRow {
theme_id,
owner_user_id,
owner_group_id,
created_by,
name,
settings_json,
created_at,
updated_at,
})
}
pub async fn assert_can_read_theme(
db: &SqlitePool,
current_user: Uuid,
theme_id: Uuid,
) -> Result<ThemeRow, Error> {
let row = fetch_theme_row(db, theme_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let visible = match destination {
Destination::Personal => {
row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
}
Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
.await?
.is_some(),
};
if visible {
Ok(row)
} else {
Err(Error::NotFound(format!("theme {theme_id}")))
}
}
pub async fn assert_can_modify_theme(
db: &SqlitePool,
current_user: Uuid,
theme_id: Uuid,
) -> Result<ThemeRow, Error> {
let row = fetch_theme_row(db, theme_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
match destination {
Destination::Personal => {
let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
if owner == Some(current_user) {
Ok(row)
} else {
Err(Error::Forbidden(format!(
"not allowed to modify theme {theme_id}"
)))
}
}
Destination::Group { group_id } => {
if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
Ok(row)
} else {
Err(Error::Forbidden(
"must be a group owner to modify a group theme".to_owned(),
))
}
}
}
}
#[derive(Debug, Clone)]
pub struct LogoRow {
pub logo_id: Uuid,
pub owner_user_id: Option<Vec<u8>>,
pub owner_group_id: Option<Vec<u8>>,
pub uploaded_by: Option<Uuid>,
pub name: String,
pub content_type: String,
pub image_filename: String,
pub width: u32,
pub height: u32,
pub byte_size: u64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LogoView {
pub logo_id: Uuid,
pub destination: Destination,
pub uploaded_by: Option<Uuid>,
pub uploaded_by_username: Option<String>,
pub uploaded_by_legacy_name: Option<String>,
pub name: String,
pub content_type: String,
pub width: u32,
pub height: u32,
pub byte_size: u64,
pub created_at: DateTime<Utc>,
}
type LogoRowTuple = (
Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>, String, String, String, i64, i64, i64, DateTime<Utc>, );
async fn fetch_logo_row(db: &SqlitePool, logo_id: Uuid) -> Result<LogoRow, Error> {
let row: Option<LogoRowTuple> = sqlx::query_as(
"SELECT owner_user_id, owner_group_id, uploaded_by, name, content_type, \
image_filename, width, height, byte_size, created_at \
FROM saved_logos WHERE logo_id = ?1",
)
.bind(logo_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("logo fetch failed: {err}");
Error::Database
})?;
let (
owner_user_id,
owner_group_id,
uploaded_by_bytes,
name,
content_type,
image_filename,
width,
height,
byte_size,
created_at,
) = row.ok_or_else(|| Error::NotFound(format!("logo {logo_id}")))?;
let uploaded_by = uploaded_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad uploaded_by uuid in saved_logos");
Error::Database
})
})
.transpose()?;
Ok(LogoRow {
logo_id,
owner_user_id,
owner_group_id,
uploaded_by,
name,
content_type,
image_filename,
width: u32::try_from(width).unwrap_or(0),
height: u32::try_from(height).unwrap_or(0),
byte_size: u64::try_from(byte_size).unwrap_or(0),
created_at,
})
}
pub async fn assert_can_read_logo(
db: &SqlitePool,
current_user: Uuid,
logo_id: Uuid,
) -> Result<LogoRow, Error> {
let row = fetch_logo_row(db, logo_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let visible = match destination {
Destination::Personal => {
row.owner_user_id.as_deref().and_then(uuid_from_bytes) == Some(current_user)
}
Destination::Group { group_id } => groups::lookup_role(db, group_id, current_user)
.await?
.is_some(),
};
if visible {
Ok(row)
} else {
Err(Error::NotFound(format!("logo {logo_id}")))
}
}
pub async fn assert_can_delete_logo(
db: &SqlitePool,
current_user: Uuid,
logo_id: Uuid,
) -> Result<LogoRow, Error> {
let row = fetch_logo_row(db, logo_id).await?;
let destination =
destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
match destination {
Destination::Personal => {
let owner = row.owner_user_id.as_deref().and_then(uuid_from_bytes);
if owner == Some(current_user) {
Ok(row)
} else {
Err(Error::Forbidden(format!(
"not allowed to delete logo {logo_id}"
)))
}
}
Destination::Group { group_id } => {
if groups::lookup_role(db, group_id, current_user).await? == Some(GroupRole::Owner) {
Ok(row)
} else {
Err(Error::Forbidden(
"must be a group owner to delete a group logo".to_owned(),
))
}
}
}
}