Skip to content
This repository was archived by the owner on Mar 27, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/model/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ argon2.workspace = true
pbkdf2.workspace = true
rand_core.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
time.workspace = true
tracing.workspace = true
Expand Down
19 changes: 19 additions & 0 deletions crates/model/migrations/014_upload_tags.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Adds tags to uploads. Tags are just short strings, and uploads can have multiple tags.

CREATE TABLE tags (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
user TEXT,
team TEXT,
UNIQUE (name, user),
UNIQUE (name, team),
CHECK ((user IS NOT NULL AND team IS NULL) OR (user IS NULL AND team IS NOT NULL)),
FOREIGN KEY (user) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (team) REFERENCES teams (id) ON DELETE CASCADE
);

CREATE TABLE upload_tags (
upload TEXT NOT NULL REFERENCES uploads (id) ON DELETE CASCADE,
tag TEXT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (upload, tag)
);
1 change: 1 addition & 0 deletions crates/model/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod migration;
pub mod password;
pub mod tag;
pub mod team;
pub mod types;
pub mod upload;
Expand Down
125 changes: 125 additions & 0 deletions crates/model/src/tag.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use serde::Serialize;
use sqlx::{FromRow, SqlitePool};

use super::{team::Team, types::Key, upload::UploadOwner, user::User};

#[derive(Debug, FromRow, Serialize)]
pub struct Tag {
pub id: Key<Tag>,
pub name: String,
pub user: Option<Key<User>>,
pub team: Option<Key<Team>>,
}

impl Tag {
pub fn new_for_user(name: String, user: Key<User>) -> Self {
Self {
id: Key::new(),
name,
user: Some(user),
team: None,
}
}

pub fn new_for_team(name: String, team: Key<Team>) -> Self {
Self {
id: Key::new(),
name,
user: None,
team: Some(team),
}
}

pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> {
let result = sqlx::query("INSERT INTO tags (id, name, user, team) VALUES ($1, $2, $3, $4)")
.bind(self.id)
.bind(&self.name)
.bind(self.user)
.bind(self.team)
.execute(pool)
.await?;

if result.rows_affected() == 0 {
return Err(sqlx::Error::RowNotFound);
}

Ok(())
}

pub async fn get_for_user(pool: &SqlitePool, user_id: Key<User>) -> sqlx::Result<Vec<Tag>> {
sqlx::query_as("SELECT * FROM tags WHERE user = $1 ORDER BY name")
.bind(user_id)
.fetch_all(pool)
.await
}

pub async fn get_for_user_by_name(
pool: &SqlitePool,
user_id: Key<User>,
name: &str,
) -> sqlx::Result<Option<Tag>> {
sqlx::query_as("SELECT * FROM tags WHERE user = $1 AND name = $2")
.bind(user_id)
.bind(name)
.fetch_optional(pool)
.await
}

pub async fn get_or_create_for_user(
pool: &SqlitePool,
user_id: Key<User>,
name: &str,
) -> sqlx::Result<Tag> {
if let Some(tag) = Self::get_for_user_by_name(pool, user_id, name).await? {
return Ok(tag);
}

let tag = Self::new_for_user(name.to_string(), user_id);
tag.create(pool).await?;
Ok(tag)
}

pub async fn get_for_team(pool: &SqlitePool, team_id: Key<Team>) -> sqlx::Result<Vec<Tag>> {
sqlx::query_as("SELECT * FROM tags WHERE team = $1 ORDER BY name")
.bind(team_id)
.fetch_all(pool)
.await
}

pub async fn get_for_team_by_name(
pool: &SqlitePool,
team_id: Key<Team>,
name: &str,
) -> sqlx::Result<Option<Tag>> {
sqlx::query_as("SELECT * FROM tags WHERE team = $1 AND name = $2")
.bind(team_id)
.bind(name)
.fetch_optional(pool)
.await
}

pub async fn get_or_create_for_team(
pool: &SqlitePool,
team_id: Key<Team>,
name: &str,
) -> sqlx::Result<Tag> {
if let Some(tag) = Self::get_for_team_by_name(pool, team_id, name).await? {
return Ok(tag);
}

let tag = Self::new_for_team(name.to_string(), team_id);
tag.create(pool).await?;
Ok(tag)
}

pub async fn get_or_create_for_owner(
pool: &SqlitePool,
owner: UploadOwner,
name: &str,
) -> sqlx::Result<Tag> {
match owner {
UploadOwner::User(user_id) => Self::get_or_create_for_user(pool, user_id, name).await,
UploadOwner::Team(team_id) => Self::get_or_create_for_team(pool, team_id, name).await,
}
}
}
77 changes: 74 additions & 3 deletions crates/model/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use time::{Date, OffsetDateTime};

use super::{
password::StoredPassword,
tag::Tag,
team::{Team, TeamMember},
types::Key,
user::User,
Expand Down Expand Up @@ -78,7 +79,25 @@ pub enum UploadOwnership {
OwnedByTeam(TeamMember),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UploadOwner {
User(Key<User>),
Team(Key<Team>),
}

impl Upload {
pub fn get_owner(&self) -> Option<UploadOwner> {
if let Some(owner_user) = self.owner_user {
return Some(UploadOwner::User(owner_user));
}

if let Some(owner_team) = self.owner_team {
return Some(UploadOwner::Team(owner_team));
}

None
}

pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> {
sqlx::query(
"INSERT INTO uploads (id, slug, filename, size, public,
Expand Down Expand Up @@ -545,6 +564,39 @@ impl Upload {
}
}
}

pub async fn get_tags(&self, pool: &SqlitePool) -> sqlx::Result<Vec<String>> {
sqlx::query_scalar(
"SELECT tags.name FROM upload_tags
LEFT JOIN tags ON tags.id = upload_tags.tag
WHERE upload_tags.upload = $1
ORDER BY tags.name",
)
.bind(self.id)
.fetch_all(pool)
.await
}

pub async fn replace_tags(&self, pool: &SqlitePool, tags: Vec<Key<Tag>>) -> sqlx::Result<()> {
// First, delete all existing tags for this upload.
sqlx::query("DELETE FROM upload_tags WHERE upload = $1")
.bind(self.id)
.execute(pool)
.await?;

// Then, insert the new tags.
sqlx::query(
"INSERT INTO upload_tags (upload, tag) \
SELECT $1, value \
FROM json_each($2);",
)
.bind(self.id)
.bind(serde_json::to_string(&tags).expect("JSON serialization of key array"))
.execute(pool)
.await?;

Ok(())
}
}

#[derive(Debug, FromRow, Serialize)]
Expand Down Expand Up @@ -608,6 +660,7 @@ pub struct UploadList {
pub uploaded_by_id: Option<Key<User>>,
pub uploaded_by_name: Option<String>,
pub uploaded_at: OffsetDateTime,
pub tags: String,
}

impl UploadList {
Expand All @@ -625,15 +678,24 @@ impl UploadList {
uploads.size, uploads.public, uploads.downloads, \
uploads.\"limit\", uploads.remaining, uploads.expiry_date, \
uploads.custom_slug, \
uploads.password is not null as has_password, \
uploads.password IS NOT NULL AS has_password, \
COALESCE(teams.slug, users.username) AS owner_slug, \
uploads.uploaded_by AS uploaded_by_id, \
uploader.name AS uploaded_by_name, \
uploads.uploaded_at \
uploads.uploaded_at, \
tags.tags \
FROM uploads \
LEFT JOIN teams ON uploads.owner_team = teams.id \
LEFT JOIN users ON uploads.owner_user = users.id \
LEFT JOIN users AS uploader ON uploads.uploaded_by = uploader.id \
LEFT JOIN (\
SELECT \
upload_tags.upload, \
GROUP_CONCAT(tags.name, ',' ORDER BY tags.name) AS tags \
FROM upload_tags \
LEFT JOIN tags ON tags.id = upload_tags.tag \
GROUP BY upload_tags.upload \
) AS tags ON uploads.id = tags.upload \
WHERE uploads.owner_user = $1 {} \
ORDER BY {} {} LIMIT {} OFFSET {}",
if let Some(search) = search {
Expand Down Expand Up @@ -669,11 +731,20 @@ impl UploadList {
COALESCE(teams.slug, users.username) AS owner_slug, \
uploads.uploaded_by AS uploaded_by_id, \
uploader.name AS uploaded_by_name, \
uploads.uploaded_at \
uploads.uploaded_at, \
tags.tags \
FROM uploads \
LEFT JOIN teams ON uploads.owner_team = teams.id \
LEFT JOIN users ON uploads.owner_user = users.id \
LEFT JOIN users AS uploader ON uploads.uploaded_by = uploader.id \
LEFT JOIN (\
SELECT \
upload_tags.upload, \
GROUP_CONCAT(tags.name, ',' ORDER BY tags.name) AS tags \
FROM upload_tags \
LEFT JOIN tags ON tags.id = upload_tags.tag \
GROUP BY upload_tags.upload \
) AS tags ON uploads.id = tags.upload \
WHERE uploads.owner_team = $1 {} \
ORDER BY {} {} LIMIT {} OFFSET {}",
if let Some(search) = search {
Expand Down
Loading