Commonized networking (#3310)

* Fix not being able to connect to local friends socket

* Start basic work on tunneling protocol and move some code into a common crate

* Commonize message serialization logic

* Serialize Base62Ids as u64 when human-readability is not required

* Move ActiveSockets tuple into struct

* Make CI run when rust-common is updated

CI is currently broken for labrinth, however

* Fix theseus-release.yml to reference itself correctly

* Implement Labrinth side of tunneling

* Implement non-friend part of theseus tunneling

* Implement client-side except for socket loop

* Implement the socket loop

Doesn't work though. Debugging time!

* Fix config.rs

* Fix deadlock in labrinth socket handling

* Update dockerfile

* switch to workspace prepare at root level

* Wait for connection before tunneling in playground

* Move rust-common into labrinth

* Remove rust-common references from Actions

* Revert "Update dockerfile"

This reverts commit 3caad59bb474ce425d0b8928d7cee7ae1a5011bd.

* Fix Docker build

* Rebuild Theseus if common code changes

* Allow multiple connections from the same user

* Fix test building

* Move FriendSocketListening and FriendSocketStoppedListening to non-panicking TODO for now

* Make message_serialization macro take varargs for binary messages

* Improve syntax of message_serialization macro

* Remove the ability to connect to a virtual socket, and disable the ability to listen on one

* Allow the app to compile without running labrinth

* Clippy fix

* Update Rust and Clippy fix again

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Josiah Glosson
2025-02-28 12:52:47 -06:00
committed by GitHub
parent 90def724c2
commit 650ab71a83
72 changed files with 1132 additions and 584 deletions

View File

@@ -74,7 +74,7 @@ pub async fn count_download(
let project_id: crate::database::models::ids::ProjectId =
download_body.project_id.into();
let id_option = crate::models::ids::base62_impl::parse_base62(
let id_option = crate::common::ids::base62_impl::parse_base62(
&download_body.version_name,
)
.ok()

View File

@@ -1,4 +1,5 @@
use crate::auth::{get_user_from_headers, send_email};
use crate::common::ids::base62_impl::{parse_base62, to_base62};
use crate::database::models::charge_item::ChargeItem;
use crate::database::models::{
generate_charge_id, generate_user_subscription_id, product_item,
@@ -10,7 +11,6 @@ use crate::models::billing::{
Product, ProductMetadata, ProductPrice, SubscriptionMetadata,
SubscriptionStatus, UserSubscription,
};
use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::pats::Scopes;
use crate::models::users::Badges;
use crate::queue::session::AuthQueue;

View File

@@ -1,11 +1,11 @@
use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::{get_user_from_headers, AuthProvider, AuthenticationError};
use crate::common::ids::base62_impl::{parse_base62, to_base62};
use crate::common::ids::random_base62_rng;
use crate::database::models::flow_item::Flow;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::ids::random_base62_rng;
use crate::models::pats::Scopes;
use crate::models::users::{Badges, Role};
use crate::queue::session::AuthQueue;

View File

@@ -1,7 +1,7 @@
use super::ApiError;
use crate::common::ids::random_base62;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::ids::random_base62;
use crate::models::projects::ProjectStatus;
use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
use crate::queue::session::AuthQueue;

View File

@@ -1,41 +1,33 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::AuthenticationError;
use crate::common::ids::UserId;
use crate::common::networking::message::{
ClientToServerMessage, ServerToClientMessage,
};
use crate::common::users::UserStatus;
use crate::database::models::friend_item::FriendItem;
use crate::database::redis::RedisPool;
use crate::models::ids::UserId;
use crate::models::pats::Scopes;
use crate::models::users::{User, UserStatus};
use crate::models::users::User;
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
use crate::queue::socket::{
ActiveSocket, ActiveSockets, SocketId, TunnelSocketType,
};
use crate::routes::ApiError;
use actix_web::web::{Data, Payload};
use actix_web::{get, web, HttpRequest, HttpResponse};
use actix_ws::Message;
use chrono::Utc;
use either::Either;
use futures_util::{StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use sqlx::PgPool;
use std::sync::atomic::Ordering;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(ws_init);
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientToServerMessage {
StatusUpdate { profile_name: Option<String> },
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerToClientMessage {
StatusUpdate { status: UserStatus },
UserOffline { id: UserId },
FriendStatuses { statuses: Vec<UserStatus> },
FriendRequest { from: UserId },
FriendRequestRejected { from: UserId },
}
#[derive(Deserialize)]
struct LauncherHeartbeatInit {
code: String,
@@ -71,10 +63,6 @@ pub async fn ws_init(
let user = User::from_full(db_user);
if let Some((_, (_, session))) = db.auth_sockets.remove(&user.id) {
let _ = session.close(None).await;
}
let (res, mut session, msg_stream) = match actix_ws::handle(&req, body) {
Ok(x) => x,
Err(e) => return Ok(e.error_response()),
@@ -94,8 +82,8 @@ pub async fn ws_init(
friends
.iter()
.filter_map(|x| {
db.auth_sockets.get(
&if x.user_id == user.id.into() {
db.get_status(
if x.user_id == user.id.into() {
x.friend_id
} else {
x.user_id
@@ -103,7 +91,6 @@ pub async fn ws_init(
.into(),
)
})
.map(|x| x.value().0.clone())
.collect::<Vec<_>>()
} else {
Vec::new()
@@ -117,7 +104,17 @@ pub async fn ws_init(
)?)
.await;
db.auth_sockets.insert(user.id, (status.clone(), session));
let db = db.clone();
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
db.sockets
.insert(socket_id, ActiveSocket::new(status.clone(), session));
db.sockets_by_user_id
.entry(user.id)
.or_default()
.insert(socket_id);
#[cfg(debug_assertions)]
log::info!("Connection {socket_id} opened by {}", user.id);
broadcast_friends(
user.id,
@@ -133,68 +130,182 @@ pub async fn ws_init(
actix_web::rt::spawn(async move {
// receive messages from websocket
while let Some(msg) = stream.next().await {
match msg {
let message = match msg {
Ok(Message::Text(text)) => {
if let Ok(message) =
serde_json::from_str::<ClientToServerMessage>(&text)
{
match message {
ClientToServerMessage::StatusUpdate {
profile_name,
} => {
if let Some(mut pair) =
db.auth_sockets.get_mut(&user.id)
{
let (status, _) = pair.value_mut();
ClientToServerMessage::deserialize(Either::Left(&text))
}
if status
.profile_name
.as_ref()
.map(|x| x.len() > 64)
.unwrap_or(false)
{
continue;
}
status.profile_name = profile_name;
status.last_update = Utc::now();
let user_status = status.clone();
// We drop the pair to avoid holding the lock for too long
drop(pair);
let _ = broadcast_friends(
user.id,
ServerToClientMessage::StatusUpdate {
status: user_status,
},
&pool,
&db,
None,
)
.await;
}
}
}
}
Ok(Message::Binary(bytes)) => {
ClientToServerMessage::deserialize(Either::Right(&bytes))
}
Ok(Message::Close(_)) => {
let _ = close_socket(user.id, &pool, &db).await;
let _ = close_socket(socket_id, &pool, &db).await;
continue;
}
Ok(Message::Ping(msg)) => {
if let Some(socket) = db.auth_sockets.get(&user.id) {
let (_, socket) = socket.value();
let _ = socket.clone().pong(&msg).await;
if let Some(socket) = db.sockets.get(&socket_id) {
let _ = socket.socket.clone().pong(&msg).await;
}
continue;
}
_ => continue,
};
if message.is_err() {
continue;
}
let message = message.unwrap();
#[cfg(debug_assertions)]
if !message.is_binary() {
log::info!("Received message from {socket_id}: {:?}", message);
}
match message {
ClientToServerMessage::StatusUpdate { profile_name } => {
if let Some(mut pair) = db.sockets.get_mut(&socket_id) {
let ActiveSocket { status, .. } = pair.value_mut();
if status
.profile_name
.as_ref()
.map(|x| x.len() > 64)
.unwrap_or(false)
{
return;
}
status.profile_name = profile_name;
status.last_update = Utc::now();
let user_status = status.clone();
// We drop the pair to avoid holding the lock for too long
drop(pair);
let _ = broadcast_friends(
user.id,
ServerToClientMessage::StatusUpdate {
status: user_status,
},
&pool,
&db,
None,
)
.await;
}
}
_ => {}
ClientToServerMessage::SocketListen { .. } => {
// TODO: Listen to socket
// The code below probably won't need changes, but there's no way to connect to
// a tunnel socket yet, so we shouldn't be storing them
// let Some(active_socket) = db.sockets.get(&socket_id) else {
// return;
// };
// let Vacant(entry) = db.tunnel_sockets.entry(socket) else {
// continue;
// };
// entry.insert(TunnelSocket::new(
// socket_id,
// TunnelSocketType::Listening,
// ));
// active_socket.owned_tunnel_sockets.insert(socket);
// let _ = broadcast_friends(
// user.id,
// ServerToClientMessage::FriendSocketListening {
// user: user.id,
// socket,
// },
// &pool,
// &db,
// None,
// )
// .await;
}
ClientToServerMessage::SocketClose { socket } => {
let Some(active_socket) = db.sockets.get(&socket_id) else {
return;
};
if active_socket
.owned_tunnel_sockets
.remove(&socket)
.is_none()
{
continue;
}
let Some((_, tunnel_socket)) =
db.tunnel_sockets.remove(&socket)
else {
continue;
};
match tunnel_socket.socket_type {
TunnelSocketType::Listening => {
let _ = broadcast_friends(
user.id,
ServerToClientMessage::FriendSocketStoppedListening { user: user.id },
&pool,
&db,
None,
)
.await;
}
TunnelSocketType::Connected { connected_to } => {
let Some((_, other)) =
db.tunnel_sockets.remove(&connected_to)
else {
continue;
};
let Some(other_user) = db.sockets.get(&other.owner)
else {
continue;
};
let _ = send_message(
&other_user,
&ServerToClientMessage::SocketClosed { socket },
)
.await;
}
}
}
ClientToServerMessage::SocketSend { socket, data } => {
let Some(tunnel_socket) = db.tunnel_sockets.get(&socket)
else {
continue;
};
if tunnel_socket.owner != socket_id {
continue;
}
let TunnelSocketType::Connected { connected_to } =
tunnel_socket.socket_type
else {
continue;
};
let Some(other_tunnel) =
db.tunnel_sockets.get(&connected_to)
else {
continue;
};
let Some(other_user) = db.sockets.get(&other_tunnel.owner)
else {
continue;
};
let _ = send_message(
&other_user,
&ServerToClientMessage::SocketData {
socket: connected_to,
data,
},
)
.await;
}
}
}
let _ = close_socket(user.id, &pool, &db).await;
let _ = close_socket(socket_id, &pool, &db).await;
});
Ok(res)
@@ -207,6 +318,7 @@ pub async fn broadcast_friends(
sockets: &ActiveSockets,
friends: Option<Vec<FriendItem>>,
) -> Result<(), crate::database::models::DatabaseError> {
// FIXME Probably shouldn't be using database errors for this. Maybe ApiError?
let friends = if let Some(friends) = friends {
friends
} else {
@@ -221,11 +333,46 @@ pub async fn broadcast_friends(
};
if friend.accepted {
if let Some(socket) = sockets.auth_sockets.get(&friend_id.into()) {
let (_, socket) = socket.value();
if let Some(socket_ids) =
sockets.sockets_by_user_id.get(&friend_id.into())
{
for socket_id in socket_ids.iter() {
if let Some(socket) = sockets.sockets.get(&socket_id) {
let _ = send_message(socket.value(), &message).await;
}
}
}
}
}
let _ =
socket.clone().text(serde_json::to_string(&message)?).await;
Ok(())
}
pub async fn send_message(
socket: &ActiveSocket,
message: &ServerToClientMessage,
) -> Result<(), crate::database::models::DatabaseError> {
let mut socket = socket.socket.clone();
// FIXME Probably shouldn't swallow sending errors
let _ = match message.serialize() {
Ok(Either::Left(text)) => socket.text(text).await,
Ok(Either::Right(bytes)) => socket.binary(bytes).await,
Err(_) => Ok(()), // TODO: Maybe should log these? Though it is the backend
};
Ok(())
}
pub async fn send_message_to_user(
db: &ActiveSockets,
user: UserId,
message: &ServerToClientMessage,
) -> Result<(), crate::database::models::DatabaseError> {
if let Some(socket_ids) = db.sockets_by_user_id.get(&user) {
for socket_id in socket_ids.iter() {
if let Some(socket) = db.sockets.get(&socket_id) {
send_message(&socket, message).await?;
}
}
}
@@ -234,21 +381,66 @@ pub async fn broadcast_friends(
}
pub async fn close_socket(
id: UserId,
id: SocketId,
pool: &PgPool,
sockets: &ActiveSockets,
db: &ActiveSockets,
) -> Result<(), crate::database::models::DatabaseError> {
if let Some((_, (_, socket))) = sockets.auth_sockets.remove(&id) {
let _ = socket.close(None).await;
if let Some((_, socket)) = db.sockets.remove(&id) {
let user_id = socket.status.user_id;
db.sockets_by_user_id.remove_if(&user_id, |_, sockets| {
sockets.remove(&id);
sockets.is_empty()
});
let _ = socket.socket.close(None).await;
broadcast_friends(
id,
ServerToClientMessage::UserOffline { id },
user_id,
ServerToClientMessage::UserOffline { id: user_id },
pool,
sockets,
db,
None,
)
.await?;
for owned_socket in socket.owned_tunnel_sockets {
let Some((_, tunnel_socket)) =
db.tunnel_sockets.remove(&owned_socket)
else {
continue;
};
match tunnel_socket.socket_type {
TunnelSocketType::Listening => {
let _ = broadcast_friends(
user_id,
ServerToClientMessage::SocketClosed {
socket: owned_socket,
},
pool,
db,
None,
)
.await;
}
TunnelSocketType::Connected { connected_to } => {
let Some((_, other)) =
db.tunnel_sockets.remove(&connected_to)
else {
continue;
};
let Some(other_user) = db.sockets.get(&other.owner) else {
continue;
};
let _ = send_message(
&other_user,
&ServerToClientMessage::SocketClosed {
socket: connected_to,
},
)
.await;
}
}
}
}
Ok(())

View File

@@ -164,7 +164,7 @@ async fn find_version(
pool: &PgPool,
redis: &RedisPool,
) -> Result<Option<QueryVersion>, ApiError> {
let id_option = crate::models::ids::base62_impl::parse_base62(vcoords)
let id_option = crate::common::ids::base62_impl::parse_base62(vcoords)
.ok()
.map(|x| x as i64);

View File

@@ -117,7 +117,7 @@ pub enum ApiError {
#[error("Captcha Error. Try resubmitting the form.")]
Turnstile,
#[error("Error while decoding Base62: {0}")]
Decoding(#[from] crate::models::ids::DecodingError),
Decoding(#[from] crate::common::ids::DecodingError),
#[error("Image Parsing Error: {0}")]
ImageParse(#[from] image::ImageError),
#[error("Password Hashing Error: {0}")]

View File

@@ -1,4 +1,5 @@
use super::ApiError;
use crate::common::ids::base62_impl::to_base62;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::teams::ProjectPermissions;
@@ -6,7 +7,7 @@ use crate::{
auth::get_user_from_headers,
database::models::user_item,
models::{
ids::{base62_impl::to_base62, ProjectId, VersionId},
ids::{ProjectId, VersionId},
pats::Scopes,
},
queue::session::AuthQueue,

View File

@@ -1,12 +1,12 @@
use crate::auth::checks::is_visible_collection;
use crate::auth::{filter_visible_collections, get_user_from_headers};
use crate::common::ids::base62_impl::parse_base62;
use crate::database::models::{
collection_item, generate_collection_id, project_item,
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::collections::{Collection, CollectionStatus};
use crate::models::ids::base62_impl::parse_base62;
use crate::models::ids::{CollectionId, ProjectId};
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;

View File

@@ -1,11 +1,12 @@
use crate::auth::get_user_from_headers;
use crate::common::networking::message::ServerToClientMessage;
use crate::database::models::UserId;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::users::UserFriend;
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{close_socket, ServerToClientMessage};
use crate::routes::internal::statuses::send_message_to_user;
use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
@@ -76,22 +77,16 @@ pub async fn add_friend(
friend_id: UserId,
sockets: &ActiveSockets,
) -> Result<(), ApiError> {
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
let (friend_status, _) = pair.value();
if let Some(socket) =
sockets.auth_sockets.get(&friend_id.into())
{
let (_, socket) = socket.value();
let _ = socket
.clone()
.text(serde_json::to_string(
&ServerToClientMessage::StatusUpdate {
status: friend_status.clone(),
},
)?)
.await;
}
if let Some(friend_status) = sockets.get_status(user_id.into())
{
send_message_to_user(
sockets,
friend_id.into(),
&ServerToClientMessage::StatusUpdate {
status: friend_status.clone(),
},
)
.await?;
}
Ok(())
@@ -121,20 +116,12 @@ pub async fn add_friend(
.insert(&mut transaction)
.await?;
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
let (_, socket) = socket.value();
if socket
.clone()
.text(serde_json::to_string(
&ServerToClientMessage::FriendRequest { from: user.id },
)?)
.await
.is_err()
{
close_socket(user.id, &pool, &db).await?;
}
}
send_message_to_user(
&db,
friend.id.into(),
&ServerToClientMessage::FriendRequest { from: user.id },
)
.await?;
}
transaction.commit().await?;
@@ -178,18 +165,12 @@ pub async fn remove_friend(
)
.await?;
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
let (_, socket) = socket.value();
let _ = socket
.clone()
.text(serde_json::to_string(
&ServerToClientMessage::FriendRequestRejected {
from: user.id,
},
)?)
.await;
}
send_message_to_user(
&db,
friend.id.into(),
&ServerToClientMessage::FriendRequestRejected { from: user.id },
)
.await?;
transaction.commit().await?;

View File

@@ -1,19 +1,7 @@
use std::{collections::HashSet, fmt::Display, sync::Arc};
use actix_web::{
delete, get, patch, post,
web::{self, scope},
HttpRequest, HttpResponse,
};
use chrono::Utc;
use itertools::Itertools;
use rand::{distributions::Alphanumeric, Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use validator::Validate;
use super::ApiError;
use crate::common::ids::base62_impl::parse_base62;
use crate::{
auth::{checks::ValidateAuthorized, get_user_from_headers},
database::{
@@ -35,13 +23,21 @@ use crate::{
util::validate::validation_errors_to_string,
};
use crate::{
file_hosting::FileHost,
models::{
ids::base62_impl::parse_base62,
oauth_clients::DeleteOAuthClientQueryParam,
},
file_hosting::FileHost, models::oauth_clients::DeleteOAuthClientQueryParam,
util::routes::read_from_payload,
};
use actix_web::{
delete, get, patch, post,
web::{self, scope},
HttpRequest, HttpResponse,
};
use chrono::Utc;
use itertools::Itertools;
use rand::{distributions::Alphanumeric, Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use validator::Validate;
use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient;
use crate::models::ids::OAuthClientId as ApiOAuthClientId;

View File

@@ -3,13 +3,13 @@ use std::sync::Arc;
use super::ApiError;
use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::common::ids::base62_impl::parse_base62;
use crate::database::models::team_item::TeamMember;
use crate::database::models::{
generate_organization_id, team_item, Organization,
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::ids::UserId;
use crate::models::organizations::OrganizationId;
use crate::models::pats::Scopes;
@@ -786,7 +786,7 @@ pub async fn organization_projects_add(
let organization_owner_user_id = sqlx::query!(
"
SELECT u.id
SELECT u.id
FROM team_members
INNER JOIN users u ON u.id = team_members.user_id
WHERE team_id = $1 AND is_owner = TRUE
@@ -969,7 +969,7 @@ pub async fn organization_projects_remove(
sqlx::query!(
"
UPDATE team_members
SET
SET
is_owner = TRUE,
accepted = TRUE,
permissions = $2,

View File

@@ -1,5 +1,6 @@
use super::version_creation::{try_create_version_fields, InitialVersionData};
use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::common::ids::base62_impl::to_base62;
use crate::database::models::loader_fields::{
Loader, LoaderField, LoaderFieldEnumValue,
};
@@ -8,7 +9,6 @@ use crate::database::models::{self, image_item, User};
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
use crate::models::ids::base62_impl::to_base62;
use crate::models::ids::{ImageId, OrganizationId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;

View File

@@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::auth::checks::{filter_visible_versions, is_visible_project};
use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::common::ids::base62_impl::parse_base62;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::project_item::{GalleryItem, ModCategory};
use crate::database::models::thread_item::ThreadMessageBuilder;
@@ -11,7 +12,6 @@ use crate::database::redis::RedisPool;
use crate::database::{self, models as db_models};
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::images::ImageContext;
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;

View File

@@ -1,4 +1,5 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::common::ids::base62_impl::parse_base62;
use crate::database;
use crate::database::models::image_item;
use crate::database::models::thread_item::{
@@ -6,9 +7,7 @@ use crate::database::models::thread_item::{
};
use crate::database::redis::RedisPool;
use crate::models::ids::ImageId;
use crate::models::ids::{
base62_impl::parse_base62, ProjectId, UserId, VersionId,
};
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::reports::{ItemType, Report};

View File

@@ -5,6 +5,7 @@ use crate::auth::checks::{
filter_visible_versions, is_visible_project, is_visible_version,
};
use crate::auth::get_user_from_headers;
use crate::common::ids::base62_impl::parse_base62;
use crate::database;
use crate::database::models::loader_fields::{
self, LoaderField, LoaderFieldEnumValue, VersionField,
@@ -13,7 +14,6 @@ use crate::database::models::version_item::{DependencyBuilder, LoaderVersion};
use crate::database::models::{image_item, Organization};
use crate::database::redis::RedisPool;
use crate::models;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::ids::VersionId;
use crate::models::images::ImageContext;
use crate::models::pats::Scopes;
@@ -444,7 +444,7 @@ pub async fn version_edit_helper(
.collect::<Vec<i32>>();
sqlx::query!(
"
DELETE FROM version_fields
DELETE FROM version_fields
WHERE version_id = $1
AND field_id = ANY($2)
",