mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-15 10:10:12 +00:00
WIP: revise avatars service
Some checks failed
Build and push Docker image / .net docker build (push) Has been cancelled
.net checks / run .net tests (push) Has been cancelled
.net checks / dotnet-format (push) Has been cancelled
Build and push Rust service Docker images / rust docker build (push) Has been cancelled
rust checks / cargo fmt (push) Has been cancelled
Some checks failed
Build and push Docker image / .net docker build (push) Has been cancelled
.net checks / run .net tests (push) Has been cancelled
.net checks / dotnet-format (push) Has been cancelled
Build and push Rust service Docker images / rust docker build (push) Has been cancelled
rust checks / cargo fmt (push) Has been cancelled
This commit is contained in:
parent
0a474c43eb
commit
f69587ceaf
26 changed files with 912 additions and 202 deletions
30
Cargo.lock
generated
30
Cargo.lock
generated
|
|
@ -307,6 +307,7 @@ dependencies = [
|
||||||
"matchit 0.8.4",
|
"matchit 0.8.4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
|
|
@ -1917,6 +1918,15 @@ version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnetwork"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -2048,6 +2058,7 @@ name = "libpk"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"fred",
|
"fred",
|
||||||
"json-subscriber",
|
"json-subscriber",
|
||||||
|
|
@ -2274,6 +2285,23 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.3.1",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin 0.9.8",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nibble_vec"
|
name = "nibble_vec"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -3812,6 +3840,7 @@ dependencies = [
|
||||||
"hashbrown 0.15.2",
|
"hashbrown 0.15.2",
|
||||||
"hashlink 0.10.0",
|
"hashlink 0.10.0",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"ipnetwork",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
@ -3934,6 +3963,7 @@ dependencies = [
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac",
|
||||||
"home",
|
"home",
|
||||||
|
"ipnetwork",
|
||||||
"itoa",
|
"itoa",
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-t
|
||||||
sentry = { version = "0.36.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "reqwest", "rustls"] } # replace native-tls with rustls
|
sentry = { version = "0.36.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "reqwest", "rustls"] } # replace native-tls with rustls
|
||||||
serde = { version = "1.0.196", features = ["derive"] }
|
serde = { version = "1.0.196", features = ["derive"] }
|
||||||
serde_json = "1.0.117"
|
serde_json = "1.0.117"
|
||||||
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid"] }
|
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid", "ipnetwork"] }
|
||||||
tokio = { version = "1.46.1", features = ["full"] }
|
tokio = { version = "1.46.1", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,8 @@ public class Groups
|
||||||
{
|
{
|
||||||
ctx.CheckOwnGroup(target);
|
ctx.CheckOwnGroup(target);
|
||||||
|
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Avatar);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url);
|
||||||
|
|
||||||
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
@ -365,8 +365,8 @@ public class Groups
|
||||||
{
|
{
|
||||||
ctx.CheckOwnGroup(target);
|
ctx.CheckOwnGroup(target);
|
||||||
|
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Banner);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url, true);
|
||||||
|
|
||||||
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,9 @@ public class Member
|
||||||
uriBuilder.Query = "";
|
uriBuilder.Query = "";
|
||||||
img.CleanUrl = uriBuilder.Uri.AbsoluteUri;
|
img.CleanUrl = uriBuilder.Uri.AbsoluteUri;
|
||||||
|
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Avatar);
|
||||||
|
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url);
|
||||||
await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = img.CleanUrl ?? img.Url }, conn);
|
await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = img.CleanUrl ?? img.Url }, conn);
|
||||||
|
|
||||||
dispatchData.Add("avatar_url", img.CleanUrl);
|
dispatchData.Add("avatar_url", img.CleanUrl);
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ public class MemberAvatar
|
||||||
|
|
||||||
ctx.CheckSystem().CheckOwnMember(target);
|
ctx.CheckSystem().CheckOwnMember(target);
|
||||||
|
|
||||||
avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
|
avatarArg = await _avatarHosting.TryRehostImage(ctx, avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(avatarArg.Value.Url);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, avatarArg.Value.Url);
|
||||||
await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url);
|
await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url);
|
||||||
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
|
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,8 +230,8 @@ public class MemberEdit
|
||||||
async Task SetBannerImage(ParsedImage img)
|
async Task SetBannerImage(ParsedImage img)
|
||||||
{
|
{
|
||||||
ctx.CheckOwnMember(target);
|
ctx.CheckOwnMember(target);
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Banner);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url, true);
|
||||||
|
|
||||||
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -573,8 +573,8 @@ public class SystemEdit
|
||||||
{
|
{
|
||||||
ctx.CheckOwnSystem(target);
|
ctx.CheckOwnSystem(target);
|
||||||
|
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Avatar);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url);
|
||||||
|
|
||||||
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
@ -660,8 +660,8 @@ public class SystemEdit
|
||||||
{
|
{
|
||||||
ctx.CheckOwnSystem(target);
|
ctx.CheckOwnSystem(target);
|
||||||
|
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Avatar);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url);
|
||||||
|
|
||||||
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
@ -782,8 +782,8 @@ public class SystemEdit
|
||||||
|
|
||||||
else if (await ctx.MatchImage() is { } img)
|
else if (await ctx.MatchImage() is { } img)
|
||||||
{
|
{
|
||||||
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
|
img = await _avatarHosting.TryRehostImage(ctx, img, AvatarHostingService.RehostedImageType.Banner);
|
||||||
await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
|
await _avatarHosting.VerifyAvatarOrThrow(ctx, img.Url, true);
|
||||||
|
|
||||||
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url });
|
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using PluralKit.Core;
|
using PluralKit.Core;
|
||||||
|
using Serilog;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ public class AvatarHostingService
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task VerifyAvatarOrThrow(string url, bool isBanner = false)
|
public async Task VerifyAvatarOrThrow(Context ctx, string url, bool isBanner = false)
|
||||||
{
|
{
|
||||||
if (url.Length > Limits.MaxUriLength)
|
if (url.Length > Limits.MaxUriLength)
|
||||||
throw Errors.UrlTooLong(url);
|
throw Errors.UrlTooLong(url);
|
||||||
|
|
@ -35,6 +36,7 @@ public class AvatarHostingService
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var kind = isBanner ? "banner" : "avatar";
|
var kind = isBanner ? "banner" : "avatar";
|
||||||
|
if (ctx.Premium) kind = "premium_" + kind;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -56,11 +58,11 @@ public class AvatarHostingService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
|
public async Task<ParsedImage> TryRehostImage(Context ctx, ParsedImage input, RehostedImageType type)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uploaded = await TryUploadAvatar(input.Url, type, userId, system);
|
var uploaded = await TryUploadAvatar(ctx, input.Url, type);
|
||||||
if (uploaded != null)
|
if (uploaded != null)
|
||||||
{
|
{
|
||||||
// todo: make new image type called Cdn?
|
// todo: make new image type called Cdn?
|
||||||
|
|
@ -78,7 +80,7 @@ public class AvatarHostingService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> TryUploadAvatar(string? avatarUrl, RehostedImageType type, ulong userId, PKSystem? system)
|
public async Task<string?> TryUploadAvatar(Context ctx, string? avatarUrl, RehostedImageType type)
|
||||||
{
|
{
|
||||||
if (!AvatarUtils.IsDiscordCdnUrl(avatarUrl))
|
if (!AvatarUtils.IsDiscordCdnUrl(avatarUrl))
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -93,8 +95,10 @@ public class AvatarHostingService
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (ctx.Premium) kind = "premium_" + kind;
|
||||||
|
|
||||||
var response = await _client.PostAsJsonAsync(_config.AvatarServiceUrl + "/pull",
|
var response = await _client.PostAsJsonAsync(_config.AvatarServiceUrl + "/pull",
|
||||||
new { url = avatarUrl, kind, uploaded_by = userId, system_id = system?.Uuid.ToString() });
|
new { url = avatarUrl, kind, uploaded_by = ctx.Author.Id, system_id = ctx.System.Uuid.ToString() });
|
||||||
if (response.StatusCode != HttpStatusCode.OK)
|
if (response.StatusCode != HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
|
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ public static class AvatarUtils
|
||||||
if (match.Groups["query"].Success)
|
if (match.Groups["query"].Success)
|
||||||
newUrl += "&" + match.Groups["query"].Value;
|
newUrl += "&" + match.Groups["query"].Value;
|
||||||
|
|
||||||
|
//if it's our cdn, add proxy=true to the end to allow for proxy image resizing
|
||||||
|
if (url.StartsWith("https://cdn.pluralkit.me")) newUrl += "?proxy=true";
|
||||||
|
|
||||||
return newUrl;
|
return newUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
126
crates/api/src/endpoints/images.rs
Normal file
126
crates/api/src/endpoints/images.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
use crate::ApiContext;
|
||||||
|
use crate::auth::AuthState;
|
||||||
|
use crate::error::{GENERIC_BAD_REQUEST, GENERIC_NOT_FOUND, fail};
|
||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{Path, Request};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{extract::State, response::Json};
|
||||||
|
use hyper::Uri;
|
||||||
|
use libpk::config;
|
||||||
|
use libpk::db::repository::avatars as avatars_db;
|
||||||
|
use libpk::db::types::avatars::*;
|
||||||
|
use pk_macros::api_endpoint;
|
||||||
|
use pluralkit_models::PKSystemConfig;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::Postgres;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
use sqlx::types::chrono::Utc;
|
||||||
|
use std::result::Result::Ok;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct APIImage {
|
||||||
|
url: String,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api_endpoint]
|
||||||
|
pub async fn image_data(
|
||||||
|
State(ctx): State<ApiContext>,
|
||||||
|
Path((system_uuid, image_uuid)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Json<APIImage> {
|
||||||
|
let img: Image = match avatars_db::get_by_id(&ctx.db, system_uuid, image_uuid).await {
|
||||||
|
Ok(Some(img)) => img,
|
||||||
|
Ok(None) => return Err(GENERIC_NOT_FOUND),
|
||||||
|
Err(err) => fail!(?err, "failed to query image"),
|
||||||
|
};
|
||||||
|
let mut proxy_url: Option<String> = None;
|
||||||
|
if let Some(proxy_hash) = img.meta.proxy_image {
|
||||||
|
let proxy_img = match avatars_db::get_by_hash(&ctx.db, proxy_hash.to_string()).await {
|
||||||
|
Ok(Some(img)) => img,
|
||||||
|
Ok(None) => {
|
||||||
|
warn!(
|
||||||
|
system_uuid = system_uuid.to_string(),
|
||||||
|
image_uuid = image_uuid.to_string(),
|
||||||
|
"failed to find proxy image"
|
||||||
|
);
|
||||||
|
return Err(GENERIC_NOT_FOUND);
|
||||||
|
}
|
||||||
|
Err(err) => fail!(?err, "failed to query proxy image"),
|
||||||
|
};
|
||||||
|
proxy_url = Some(proxy_img.url)
|
||||||
|
}
|
||||||
|
return Ok(Json(APIImage {
|
||||||
|
url: img.data.url,
|
||||||
|
proxy_url: proxy_url,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api_endpoint]
|
||||||
|
pub async fn upload(
|
||||||
|
Extension(auth): Extension<AuthState>,
|
||||||
|
State(ctx): State<ApiContext>,
|
||||||
|
mut req: Request,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(system_id) = auth.system_id() else {
|
||||||
|
return Err(crate::error::GENERIC_AUTH_ERROR);
|
||||||
|
};
|
||||||
|
|
||||||
|
let uuid: Uuid = match sqlx::query_scalar("select uuid from systems where id = $1")
|
||||||
|
.bind(system_id)
|
||||||
|
.fetch_optional(&ctx.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(uuid)) => uuid,
|
||||||
|
Ok(None) => fail!(
|
||||||
|
system = system_id,
|
||||||
|
"failed to find uuid for existing system"
|
||||||
|
),
|
||||||
|
Err(err) => fail!(?err, "failed to query system uuid"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sys_config = match sqlx::query_as::<Postgres, PKSystemConfig>(
|
||||||
|
"select * from system_config where system = $1",
|
||||||
|
)
|
||||||
|
.bind(system_id)
|
||||||
|
.fetch_optional(&ctx.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(sys_config)) => sys_config,
|
||||||
|
Ok(None) => fail!(
|
||||||
|
system = system_id,
|
||||||
|
"failed to find system config for existing system"
|
||||||
|
),
|
||||||
|
Err(err) => fail!(?err, "failed to query system config"),
|
||||||
|
};
|
||||||
|
if !sys_config.premium_lifetime {
|
||||||
|
if let Some(premium_until) = sys_config.premium_until {
|
||||||
|
if premium_until < Utc::now().naive_utc() {
|
||||||
|
return Err(GENERIC_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(GENERIC_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/upload",
|
||||||
|
config
|
||||||
|
.api
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.avatars_service_url
|
||||||
|
.clone()
|
||||||
|
.expect("expected avatars url")
|
||||||
|
);
|
||||||
|
|
||||||
|
*req.uri_mut() = Uri::try_from(url).unwrap();
|
||||||
|
let headers = req.headers_mut();
|
||||||
|
headers.append(
|
||||||
|
"x-pluralkit-systemuuid",
|
||||||
|
HeaderValue::from_str(&uuid.to_string()).expect("expected valid uuid for header"),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ctx.rproxy_client.request(req).await?.into_response())
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
|
pub mod images;
|
||||||
pub mod private;
|
pub mod private;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
|
|
||||||
|
|
@ -82,5 +82,7 @@ macro_rules! define_error {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
define_error! { GENERIC_AUTH_ERROR, StatusCode::UNAUTHORIZED, 0, "401: Missing or invalid Authorization header" }
|
||||||
define_error! { GENERIC_BAD_REQUEST, StatusCode::BAD_REQUEST, 0, "400: Bad Request" }
|
define_error! { GENERIC_BAD_REQUEST, StatusCode::BAD_REQUEST, 0, "400: Bad Request" }
|
||||||
|
define_error! { GENERIC_NOT_FOUND, StatusCode::NOT_FOUND, 0, "404: Not Found" }
|
||||||
define_error! { GENERIC_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR, 0, "500: Internal Server Error" }
|
define_error! { GENERIC_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR, 0, "500: Internal Server Error" }
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@ fn router(ctx: ApiContext) -> Router {
|
||||||
.route("/private/discord/shard_state", get(endpoints::private::discord_state))
|
.route("/private/discord/shard_state", get(endpoints::private::discord_state))
|
||||||
.route("/private/stats", get(endpoints::private::meta))
|
.route("/private/stats", get(endpoints::private::meta))
|
||||||
|
|
||||||
|
.route("/private/images/{system_uuid}/{image_uuid}", get(endpoints::images::image_data))
|
||||||
|
.route("/private/images/upload", post(endpoints::images::upload))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/oembed.json", get(rproxy))
|
.route("/v2/systems/{system_id}/oembed.json", get(rproxy))
|
||||||
.route("/v2/members/{member_id}/oembed.json", get(rproxy))
|
.route("/v2/members/{member_id}/oembed.json", get(rproxy))
|
||||||
.route("/v2/groups/{group_id}/oembed.json", get(rproxy))
|
.route("/v2/groups/{group_id}/oembed.json", get(rproxy))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ path = "src/cleanup.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libpk = { path = "../libpk" }
|
libpk = { path = "../libpk" }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true, features = ["multipart"]}
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use reqwest::{ClientBuilder, StatusCode};
|
use reqwest::{ClientBuilder, StatusCode, Url};
|
||||||
use sqlx::prelude::FromRow;
|
use std::{path::Path, sync::Arc, time::Duration};
|
||||||
use std::{sync::Arc, time::Duration};
|
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[libpk::main]
|
#[libpk::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
|
@ -36,109 +36,196 @@ async fn main() -> anyhow::Result<()> {
|
||||||
loop {
|
loop {
|
||||||
// no infinite loops
|
// no infinite loops
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
match cleanup_job(pool.clone(), bucket.clone()).await {
|
match cleanup_job(pool.clone()).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error!(?error, "failed to run avatar cleanup job");
|
error!(?error, "failed to run avatar cleanup job");
|
||||||
// sentry
|
// sentry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
match cleanup_hash_job(pool.clone(), bucket.clone()).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => {
|
||||||
|
error!(?error, "failed to run hash cleanup job");
|
||||||
|
// sentry
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct CleanupJobEntry {
|
struct CleanupJobEntry {
|
||||||
id: String,
|
id: Uuid,
|
||||||
|
system_uuid: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_job(pool: sqlx::PgPool, bucket: Arc<s3::Bucket>) -> anyhow::Result<()> {
|
async fn cleanup_job(pool: sqlx::PgPool) -> anyhow::Result<()> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
let image_id: Option<CleanupJobEntry> = sqlx::query_as(
|
let entry: Option<CleanupJobEntry> = sqlx::query_as(
|
||||||
// no timestamp checking here
|
// no timestamp checking here
|
||||||
// images are only added to the table after 24h
|
// images are only added to the table after 24h
|
||||||
r#"
|
r#"
|
||||||
select id from image_cleanup_jobs
|
select id, system_uuid from image_cleanup_jobs
|
||||||
for update skip locked limit 1;"#,
|
for update skip locked limit 1;"#,
|
||||||
)
|
)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if image_id.is_none() {
|
if entry.is_none() {
|
||||||
info!("no job to run, sleeping for 1 minute");
|
info!("no job to run, sleeping for 1 minute");
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let image_id = image_id.unwrap().id;
|
let entry = entry.unwrap();
|
||||||
|
let image_id = entry.id;
|
||||||
|
let system_uuid = entry.system_uuid;
|
||||||
info!("got image {image_id}, cleaning up...");
|
info!("got image {image_id}, cleaning up...");
|
||||||
|
|
||||||
let image_data = libpk::db::repository::avatars::get_by_id(&pool, image_id.clone()).await?;
|
let image =
|
||||||
if image_data.is_none() {
|
libpk::db::repository::avatars::get_by_id(&pool, system_uuid.clone(), image_id.clone())
|
||||||
|
.await?;
|
||||||
|
if image.is_none() {
|
||||||
// unsure how this can happen? there is a FK reference
|
// unsure how this can happen? there is a FK reference
|
||||||
info!("image {image_id} was already deleted, skipping");
|
info!("image {image_id} was already deleted, skipping");
|
||||||
sqlx::query("delete from image_cleanup_jobs where id = $1")
|
sqlx::query("delete from image_cleanup_jobs where id = $1 and system_uuid = $2")
|
||||||
.bind(image_id)
|
.bind(image_id)
|
||||||
|
.bind(system_uuid)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let image_data = image_data.unwrap();
|
let image = image.unwrap();
|
||||||
|
|
||||||
let config = libpk::config
|
let config = libpk::config
|
||||||
.avatars
|
.avatars
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("missing avatar service config");
|
.expect("missing avatar service config");
|
||||||
|
|
||||||
let path = image_data
|
if let Some(store_id) = config.fastly_store_id.as_ref() {
|
||||||
.url
|
|
||||||
.strip_prefix(config.cdn_url.as_str())
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let s3_resp = bucket.delete_object(path).await?;
|
|
||||||
match s3_resp.status_code() {
|
|
||||||
204 => {
|
|
||||||
info!("successfully deleted image {image_id} from s3");
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
anyhow::bail!("s3 returned bad error code {}", s3_resp.status_code());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(zone_id) = config.cloudflare_zone_id.as_ref() {
|
|
||||||
let client = ClientBuilder::new()
|
let client = ClientBuilder::new()
|
||||||
.connect_timeout(Duration::from_secs(3))
|
.connect_timeout(Duration::from_secs(3))
|
||||||
.timeout(Duration::from_secs(3))
|
.timeout(Duration::from_secs(3))
|
||||||
.build()
|
.build()
|
||||||
.context("error making client")?;
|
.context("error making client")?;
|
||||||
|
|
||||||
let cf_resp = client
|
let url = Url::parse(&image.data.url).expect("invalid url");
|
||||||
.post(format!(
|
let extension = Path::new(url.path())
|
||||||
"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let key = format!("{system_uuid}:{image_id}.{extension}");
|
||||||
|
|
||||||
|
let kv_resp = client
|
||||||
|
.delete(format!(
|
||||||
|
"https://api.fastly.com/resources/stores/kv/{store_id}/keys/{key}"
|
||||||
))
|
))
|
||||||
.header(
|
.header("Fastly-Key", config.fastly_token.as_ref().unwrap())
|
||||||
"Authorization",
|
|
||||||
format!("Bearer {}", config.cloudflare_token.as_ref().unwrap()),
|
|
||||||
)
|
|
||||||
.body(format!(r#"{{"files":["{}"]}}"#, image_data.url))
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match cf_resp.status() {
|
match kv_resp.status() {
|
||||||
StatusCode::OK => {
|
StatusCode::OK => {
|
||||||
info!(
|
info!(
|
||||||
"successfully purged url {} from cloudflare cache",
|
"successfully purged image {}:{}.{} from fastly kv",
|
||||||
image_data.url
|
system_uuid, image_id, extension
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let status = cf_resp.status();
|
let status = kv_resp.status();
|
||||||
tracing::info!("raw response from cloudflare: {:#?}", cf_resp.text().await?);
|
tracing::info!("raw response from fastly: {:#?}", kv_resp.text().await?);
|
||||||
anyhow::bail!("cloudflare returned bad error code {}", status);
|
tracing::warn!("fastly returned bad error code {}", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cdn_url_parsed = Url::parse(config.cdn_url.as_str())?;
|
||||||
|
let cdn_host = cdn_url_parsed.host_str().unwrap_or(config.cdn_url.as_str());
|
||||||
|
|
||||||
|
let cache_resp = client
|
||||||
|
.post(format!(
|
||||||
|
"https://api.fastly.com/purge/{}/{}/{}.{}",
|
||||||
|
cdn_host, system_uuid, image_id, extension
|
||||||
|
))
|
||||||
|
.header("Fastly-Key", config.fastly_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match cache_resp.status() {
|
||||||
|
StatusCode::OK => {
|
||||||
|
info!(
|
||||||
|
"successfully purged image {}/{}.{} from fastly cache",
|
||||||
|
system_uuid, image_id, extension
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let status = cache_resp.status();
|
||||||
|
tracing::info!("raw response from fastly: {:#?}", cache_resp.text().await?);
|
||||||
|
tracing::warn!("fastly returned bad error code {}", status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("delete from images where id = $1")
|
sqlx::query("delete from images_assets where id = $1 and system_uuid = $2")
|
||||||
.bind(image_id.clone())
|
.bind(image_id.clone())
|
||||||
|
.bind(system_uuid.clone())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct HashCleanupJobEntry {
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_hash_job(pool: sqlx::PgPool, bucket: Arc<s3::Bucket>) -> anyhow::Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let config = libpk::config
|
||||||
|
.avatars
|
||||||
|
.as_ref()
|
||||||
|
.expect("missing avatar service config");
|
||||||
|
|
||||||
|
let entry: Option<HashCleanupJobEntry> = sqlx::query_as(
|
||||||
|
// no timestamp checking here
|
||||||
|
// images are only added to the table after 24h
|
||||||
|
r#"
|
||||||
|
select hash from image_hash_cleanup_jobs
|
||||||
|
for update skip locked limit 1;"#,
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if entry.is_none() {
|
||||||
|
info!("no hash job to run, sleeping for 1 minute");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let hash = entry.hash;
|
||||||
|
info!("got orphaned hash {hash}, cleaning up...");
|
||||||
|
|
||||||
|
let url: Option<String> = sqlx::query_scalar("select url from images_hashes where hash = $1")
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(url) = url {
|
||||||
|
let path = url.strip_prefix(config.cdn_url.as_str()).unwrap();
|
||||||
|
let s3_resp = bucket.delete_object(path).await?;
|
||||||
|
match s3_resp.status_code() {
|
||||||
|
204 => {
|
||||||
|
info!("successfully deleted image {hash} from s3");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!("s3 returned bad error code {}", s3_resp.status_code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("delete from images_hashes where hash = $1")
|
||||||
|
.bind(&hash)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ mod pull;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::extract::State;
|
use axum::extract::{DefaultBodyLimit, Multipart, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
|
@ -21,12 +22,18 @@ use reqwest::{Client, ClientBuilder};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const NORMAL_HARD_LIMIT: usize = 8 * 1024 * 1024;
|
||||||
|
const PREMIUM_SOFT_LIMIT: usize = 30 * 1024 * 1024;
|
||||||
|
const PREMIUM_HARD_LIMIT: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum PKAvatarError {
|
pub enum PKAvatarError {
|
||||||
// todo: split off into logical groups (cdn/url error, image format error, etc)
|
// todo: split off into logical groups (cdn/url error, image format error, etc)
|
||||||
|
|
@ -82,60 +89,130 @@ pub struct PullRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PullResponse {
|
pub struct ImageResponse {
|
||||||
url: String,
|
url: String,
|
||||||
new: bool,
|
new: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn gen_proxy_image(state: &AppState, data: Vec<u8>) -> Result<Option<String>, PKAvatarError> {
|
||||||
|
let encoded_proxy = process::process_async(data, ImageKind::Avatar).await?;
|
||||||
|
let store_proxy_res = crate::store::store(&state.bucket, &encoded_proxy).await?;
|
||||||
|
let proxy_url = format!("{}{}", state.config.cdn_url, store_proxy_res.path);
|
||||||
|
db::add_image_data(
|
||||||
|
&state.pool,
|
||||||
|
&ImageData {
|
||||||
|
hash: encoded_proxy.hash.to_string(),
|
||||||
|
url: proxy_url,
|
||||||
|
file_size: encoded_proxy.data.len() as i32,
|
||||||
|
width: encoded_proxy.width as i32,
|
||||||
|
height: encoded_proxy.height as i32,
|
||||||
|
content_type: encoded_proxy.format.to_mime_type().to_string(),
|
||||||
|
created_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Some(encoded_proxy.hash.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_image(
|
||||||
|
state: &AppState,
|
||||||
|
data: Vec<u8>,
|
||||||
|
mut meta: ImageMeta,
|
||||||
|
) -> Result<ImageResponse, PKAvatarError> {
|
||||||
|
let original_file_size = data.len();
|
||||||
|
let system_uuid = meta.system_uuid;
|
||||||
|
|
||||||
|
if meta.kind.is_premium() && original_file_size > NORMAL_HARD_LIMIT {
|
||||||
|
meta.proxy_image = gen_proxy_image(&state, data.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = process::process_async(data, meta.kind).await?;
|
||||||
|
let store_res = crate::store::store(&state.bucket, &encoded).await?;
|
||||||
|
meta.image = store_res.id.clone();
|
||||||
|
let storage_url = format!("{}{}", state.config.cdn_url, store_res.path);
|
||||||
|
|
||||||
|
let res = db::add_image(
|
||||||
|
&state.pool,
|
||||||
|
Image {
|
||||||
|
meta: meta,
|
||||||
|
data: ImageData {
|
||||||
|
hash: store_res.id,
|
||||||
|
url: storage_url,
|
||||||
|
file_size: encoded.data.len() as i32,
|
||||||
|
width: encoded.width as i32,
|
||||||
|
height: encoded.height as i32,
|
||||||
|
content_type: encoded.format.to_mime_type().to_string(),
|
||||||
|
created_at: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if original_file_size >= PREMIUM_SOFT_LIMIT {
|
||||||
|
warn!(
|
||||||
|
"large image {} of size {} uploaded",
|
||||||
|
res.uuid, original_file_size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_url = format!(
|
||||||
|
"{}images/{}/{}.{}",
|
||||||
|
state.config.edge_url,
|
||||||
|
system_uuid,
|
||||||
|
res.uuid,
|
||||||
|
encoded
|
||||||
|
.format
|
||||||
|
.extensions_str()
|
||||||
|
.first()
|
||||||
|
.expect("expected valid extension")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ImageResponse {
|
||||||
|
url: final_url,
|
||||||
|
new: res.is_new,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn pull(
|
async fn pull(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<PullRequest>,
|
Json(req): Json<PullRequest>,
|
||||||
) -> Result<Json<PullResponse>, PKAvatarError> {
|
) -> Result<Json<ImageResponse>, PKAvatarError> {
|
||||||
let parsed = pull::parse_url(&req.url) // parsing beforehand to "normalize"
|
let parsed = pull::parse_url(&req.url) // parsing beforehand to "normalize"
|
||||||
.map_err(|_| PKAvatarError::InvalidCdnUrl)?;
|
.map_err(|_| PKAvatarError::InvalidCdnUrl)?;
|
||||||
if !(req.force || req.url.contains("https://serve.apparyllis.com/")) {
|
if !(req.force || req.url.contains("https://serve.apparyllis.com/")) {
|
||||||
if let Some(existing) = db::get_by_attachment_id(&state.pool, parsed.attachment_id).await? {
|
if let Some(existing) = db::get_by_attachment_id(&state.pool, parsed.attachment_id).await? {
|
||||||
// remove any pending image cleanup
|
// remove any pending image cleanup
|
||||||
db::remove_deletion_queue(&state.pool, parsed.attachment_id).await?;
|
db::remove_deletion_queue(&state.pool, parsed.attachment_id).await?;
|
||||||
return Ok(Json(PullResponse {
|
return Ok(Json(ImageResponse {
|
||||||
url: existing.url,
|
url: existing.data.url,
|
||||||
new: false,
|
new: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let result = crate::pull::pull(&state.pull_client, &parsed, req.kind.is_premium()).await?;
|
||||||
let result = crate::pull::pull(state.pull_client, &parsed).await?;
|
|
||||||
|
|
||||||
let original_file_size = result.data.len();
|
let original_file_size = result.data.len();
|
||||||
let encoded = process::process_async(result.data, req.kind).await?;
|
|
||||||
|
|
||||||
let store_res = crate::store::store(&state.bucket, &encoded).await?;
|
Ok(Json(
|
||||||
let final_url = format!("{}{}", state.config.cdn_url, store_res.path);
|
handle_image(
|
||||||
let is_new = db::add_image(
|
&state,
|
||||||
&state.pool,
|
result.data,
|
||||||
ImageMeta {
|
ImageMeta {
|
||||||
id: store_res.id,
|
id: Uuid::default(),
|
||||||
url: final_url.clone(),
|
system_uuid: req.system_id.expect("expected system id"),
|
||||||
content_type: encoded.format.mime_type().to_string(),
|
image: "".to_string(),
|
||||||
original_url: Some(parsed.full_url),
|
proxy_image: None,
|
||||||
original_type: Some(result.content_type),
|
kind: req.kind,
|
||||||
original_file_size: Some(original_file_size as i32),
|
original_url: Some(parsed.full_url),
|
||||||
original_attachment_id: Some(parsed.attachment_id as i64),
|
original_file_size: Some(original_file_size as i32),
|
||||||
file_size: encoded.data.len() as i32,
|
original_type: Some(result.content_type),
|
||||||
width: encoded.width as i32,
|
original_attachment_id: Some(parsed.attachment_id as i64),
|
||||||
height: encoded.height as i32,
|
uploaded_by_account: req.uploaded_by.map(|x| x as i64),
|
||||||
kind: req.kind,
|
uploaded_by_ip: None,
|
||||||
uploaded_at: None,
|
uploaded_at: None,
|
||||||
uploaded_by_account: req.uploaded_by.map(|x| x as i64),
|
},
|
||||||
uploaded_by_system: req.system_id,
|
)
|
||||||
},
|
.await?,
|
||||||
)
|
))
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(PullResponse {
|
|
||||||
url: final_url,
|
|
||||||
new: is_new,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify(
|
async fn verify(
|
||||||
|
|
@ -143,13 +220,14 @@ async fn verify(
|
||||||
Json(req): Json<PullRequest>,
|
Json(req): Json<PullRequest>,
|
||||||
) -> Result<(), PKAvatarError> {
|
) -> Result<(), PKAvatarError> {
|
||||||
let result = crate::pull::pull(
|
let result = crate::pull::pull(
|
||||||
state.pull_client,
|
&state.pull_client,
|
||||||
&ParsedUrl {
|
&ParsedUrl {
|
||||||
full_url: req.url.clone(),
|
full_url: req.url.clone(),
|
||||||
channel_id: 0,
|
channel_id: 0,
|
||||||
attachment_id: 0,
|
attachment_id: 0,
|
||||||
filename: "".to_string(),
|
filename: "".to_string(),
|
||||||
},
|
},
|
||||||
|
req.kind.is_premium(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -158,6 +236,81 @@ async fn verify(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<ImageResponse>, PKAvatarError> {
|
||||||
|
let mut data: Option<Vec<u8>> = None;
|
||||||
|
let mut kind: Option<ImageKind> = None;
|
||||||
|
let mut system_id: Option<Uuid> = None;
|
||||||
|
let mut upload_ip: Option<IpAddr> = None;
|
||||||
|
|
||||||
|
if let Some(val) = headers.get("x-pluralkit-systemuuid")
|
||||||
|
&& let Ok(s) = val.to_str()
|
||||||
|
{
|
||||||
|
system_id = Uuid::parse_str(s).ok();
|
||||||
|
}
|
||||||
|
if let Some(val) = headers.get("x-pluralkit-client-ip")
|
||||||
|
&& let Ok(s) = val.to_str()
|
||||||
|
{
|
||||||
|
upload_ip = IpAddr::from_str(s).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PKAvatarError::InternalError(e.into()))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
match name.as_str() {
|
||||||
|
"file" => {
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PKAvatarError::InternalError(e.into()))?;
|
||||||
|
data = Some(bytes.to_vec());
|
||||||
|
}
|
||||||
|
"kind" => {
|
||||||
|
let txt = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PKAvatarError::InternalError(e.into()))?;
|
||||||
|
kind = ImageKind::from_string(&txt);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = data.ok_or(PKAvatarError::MissingHeader("file"))?;
|
||||||
|
let kind = kind.ok_or(PKAvatarError::MissingHeader("kind"))?;
|
||||||
|
let system_id = system_id.ok_or(PKAvatarError::MissingHeader("x-pluralkit-systemuuid"))?;
|
||||||
|
let upload_ip = upload_ip.ok_or(PKAvatarError::MissingHeader("x-pluralkit-client-ip"))?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
handle_image(
|
||||||
|
&state,
|
||||||
|
data,
|
||||||
|
ImageMeta {
|
||||||
|
id: Uuid::default(),
|
||||||
|
system_uuid: system_id,
|
||||||
|
image: "".to_string(),
|
||||||
|
proxy_image: None,
|
||||||
|
kind: kind,
|
||||||
|
original_url: None,
|
||||||
|
original_file_size: None,
|
||||||
|
original_type: None,
|
||||||
|
original_attachment_id: None,
|
||||||
|
uploaded_by_account: None,
|
||||||
|
uploaded_by_ip: Some(upload_ip),
|
||||||
|
uploaded_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn stats(State(state): State<AppState>) -> Result<Json<Stats>, PKAvatarError> {
|
pub async fn stats(State(state): State<AppState>) -> Result<Json<Stats>, PKAvatarError> {
|
||||||
Ok(Json(db::get_stats(&state.pool).await?))
|
Ok(Json(db::get_stats(&state.pool).await?))
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +374,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/verify", post(verify))
|
.route("/verify", post(verify))
|
||||||
.route("/pull", post(pull))
|
.route("/pull", post(pull))
|
||||||
|
.route("/upload", post(upload))
|
||||||
.route("/stats", get(stats))
|
.route("/stats", get(stats))
|
||||||
|
.layer(DefaultBodyLimit::max(PREMIUM_HARD_LIMIT))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let host = &config.bind_addr;
|
let host = &config.bind_addr;
|
||||||
|
|
|
||||||
97
crates/avatars/src/new.sql
Normal file
97
crates/avatars/src/new.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
create table images_hashes (
|
||||||
|
hash text primary key,
|
||||||
|
url text not null,
|
||||||
|
file_size int not null,
|
||||||
|
width int not null,
|
||||||
|
height int not null,
|
||||||
|
content_type text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table images_assets (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
system_uuid uuid not null,
|
||||||
|
image text references images_hashes(hash),
|
||||||
|
proxy_image text references images_hashes(hash),
|
||||||
|
kind text not null,
|
||||||
|
|
||||||
|
original_url text,
|
||||||
|
original_file_size int,
|
||||||
|
original_type text,
|
||||||
|
original_attachment_id bigint,
|
||||||
|
|
||||||
|
uploaded_by_account bigint,
|
||||||
|
uploaded_by_ip inet,
|
||||||
|
uploaded_at timestamptz not null default now()
|
||||||
|
|
||||||
|
unique (id, system_uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into images_hashes (
|
||||||
|
hash,
|
||||||
|
url,
|
||||||
|
file_size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
content_type,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
file_size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
coalesce(content_type, 'image/webp'),
|
||||||
|
uploaded_at
|
||||||
|
from images;
|
||||||
|
|
||||||
|
alter table images rename to images_legacy;
|
||||||
|
|
||||||
|
create index if not exists images_original_url_idx on images_assets (original_url);
|
||||||
|
create index if not exists images_original_attachment_id_idx on images_assets (original_attachment_id);
|
||||||
|
create index if not exists images_uploaded_by_account_idx on images_assets (uploaded_by_account);
|
||||||
|
|
||||||
|
create index if not exists images_system_id_idx on images_assets (system_uuid);
|
||||||
|
create index if not exists images_proxy_hash_idx on images_assets (image);
|
||||||
|
|
||||||
|
-- image cleanup stuffs
|
||||||
|
alter table image_cleanup_jobs rename to image_cleanup_jobs_legacy;
|
||||||
|
|
||||||
|
create table image_cleanup_jobs (
|
||||||
|
id uuid primary key,
|
||||||
|
system_uuid uuid not null,
|
||||||
|
|
||||||
|
foreign key (id, system_uuid)
|
||||||
|
references images_assets(id, system_uuid)
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table image_cleanup_pending_jobs rename to image_cleanup_pending_jobs_legacy;
|
||||||
|
|
||||||
|
create table image_cleanup_pending_jobs (
|
||||||
|
id uuid primary key,
|
||||||
|
system_uuid uuid not null,
|
||||||
|
ts timestamp not null default now(),
|
||||||
|
|
||||||
|
foreign key (id, system_uuid)
|
||||||
|
references images_assets(id, system_uuid)
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table image_hash_cleanup_jobs (
|
||||||
|
hash text primary key
|
||||||
|
|
||||||
|
foreign key (hash)
|
||||||
|
references images_hashes(hash)
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table image_hash_cleanup_pending_jobs (
|
||||||
|
hash text primary key,
|
||||||
|
ts timestamp not null default now()
|
||||||
|
|
||||||
|
foreign key (hash)
|
||||||
|
references images_hashes(hash)
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
@ -12,32 +12,10 @@ pub struct ProcessOutput {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub hash: Hash,
|
pub hash: Hash,
|
||||||
pub format: ProcessedFormat,
|
pub format: ImageFormat,
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub enum ProcessedFormat {
|
|
||||||
Webp,
|
|
||||||
Gif,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcessedFormat {
|
|
||||||
pub fn mime_type(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ProcessedFormat::Gif => "image/gif",
|
|
||||||
ProcessedFormat::Webp => "image/webp",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extension(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ProcessedFormat::Webp => "webp",
|
|
||||||
ProcessedFormat::Gif => "gif",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moving Vec<u8> in here since the thread needs ownership of it now, it's fine, don't need it after
|
// Moving Vec<u8> in here since the thread needs ownership of it now, it's fine, don't need it after
|
||||||
pub async fn process_async(data: Vec<u8>, kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
pub async fn process_async(data: Vec<u8>, kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
||||||
tokio::task::spawn_blocking(move || process(&data, kind))
|
tokio::task::spawn_blocking(move || process(&data, kind))
|
||||||
|
|
@ -49,13 +27,16 @@ pub async fn process_async(data: Vec<u8>, kind: ImageKind) -> Result<ProcessOutp
|
||||||
pub fn process(data: &[u8], kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
pub fn process(data: &[u8], kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
||||||
let time_before = Instant::now();
|
let time_before = Instant::now();
|
||||||
let reader = reader_for(data);
|
let reader = reader_for(data);
|
||||||
match reader.format() {
|
let format = reader.format();
|
||||||
|
match format {
|
||||||
Some(ImageFormat::Png | ImageFormat::WebP | ImageFormat::Jpeg | ImageFormat::Tiff) => {} // ok :)
|
Some(ImageFormat::Png | ImageFormat::WebP | ImageFormat::Jpeg | ImageFormat::Tiff) => {} // ok :)
|
||||||
Some(ImageFormat::Gif) => {
|
Some(ImageFormat::Gif) => {
|
||||||
// animated gifs will need to be handled totally differently
|
// animated gifs will need to be handled totally differently
|
||||||
// so split off processing here and come back if it's not applicable
|
// so split off processing here and come back if it's not applicable
|
||||||
// (non-banner gifs + 1-frame animated gifs still need to be webp'd)
|
// (non-banner gifs + 1-frame animated gifs still need to be webp'd)
|
||||||
if let Some(output) = process_gif(data, kind)? {
|
if !kind.is_premium()
|
||||||
|
&& let Some(output) = process_gif(data, kind)?
|
||||||
|
{
|
||||||
return Ok(output);
|
return Ok(output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +51,18 @@ pub fn process(data: &[u8], kind: ImageKind) -> Result<ProcessOutput, PKAvatarEr
|
||||||
// need to make a new reader??? why can't it just use the same one. reduce duplication?
|
// need to make a new reader??? why can't it just use the same one. reduce duplication?
|
||||||
let reader = reader_for(data);
|
let reader = reader_for(data);
|
||||||
|
|
||||||
|
//if it's a 'premium' image, skip encoding
|
||||||
|
if kind.is_premium() {
|
||||||
|
let hash = Hash::sha256(&data);
|
||||||
|
return Ok(ProcessOutput {
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
hash: hash,
|
||||||
|
format: reader.format().expect("expected supported format"),
|
||||||
|
data: data.to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let time_after_parse = Instant::now();
|
let time_after_parse = Instant::now();
|
||||||
|
|
||||||
// apparently `image` sometimes decodes webp images wrong/weird.
|
// apparently `image` sometimes decodes webp images wrong/weird.
|
||||||
|
|
@ -204,7 +197,7 @@ fn process_gif_inner(
|
||||||
|
|
||||||
Ok(Some(ProcessOutput {
|
Ok(Some(ProcessOutput {
|
||||||
data,
|
data,
|
||||||
format: ProcessedFormat::Gif,
|
format: ImageFormat::Gif,
|
||||||
hash,
|
hash,
|
||||||
width: width as u32,
|
width: width as u32,
|
||||||
height: height as u32,
|
height: height as u32,
|
||||||
|
|
@ -249,7 +242,7 @@ fn encode(image: DynamicImage) -> ProcessOutput {
|
||||||
|
|
||||||
ProcessOutput {
|
ProcessOutput {
|
||||||
data: encoded_lossy,
|
data: encoded_lossy,
|
||||||
format: ProcessedFormat::Webp,
|
format: ImageFormat::WebP,
|
||||||
hash,
|
hash,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ use std::fmt::Write;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{error, instrument};
|
use tracing::{error, instrument};
|
||||||
|
|
||||||
const MAX_SIZE: u64 = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct PullResult {
|
pub struct PullResult {
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
|
|
@ -19,8 +17,9 @@ pub struct PullResult {
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn pull(
|
pub async fn pull(
|
||||||
client: Arc<Client>,
|
client: &Arc<Client>,
|
||||||
parsed_url: &ParsedUrl,
|
parsed_url: &ParsedUrl,
|
||||||
|
premium: bool,
|
||||||
) -> Result<PullResult, PKAvatarError> {
|
) -> Result<PullResult, PKAvatarError> {
|
||||||
let time_before = Instant::now();
|
let time_before = Instant::now();
|
||||||
let mut trimmed_url = trim_url_query(&parsed_url.full_url)?;
|
let mut trimmed_url = trim_url_query(&parsed_url.full_url)?;
|
||||||
|
|
@ -59,10 +58,14 @@ pub async fn pull(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let max_size = match premium {
|
||||||
|
true => super::PREMIUM_HARD_LIMIT as u64,
|
||||||
|
false => super::NORMAL_HARD_LIMIT as u64,
|
||||||
|
};
|
||||||
let size = match response.content_length() {
|
let size = match response.content_length() {
|
||||||
None => return Err(PKAvatarError::MissingHeader("Content-Length")),
|
None => return Err(PKAvatarError::MissingHeader("Content-Length")),
|
||||||
Some(size) if size > MAX_SIZE => {
|
Some(size) if size > max_size => {
|
||||||
return Err(PKAvatarError::ImageFileSizeTooLarge(size, MAX_SIZE));
|
return Err(PKAvatarError::ImageFileSizeTooLarge(size, max_size));
|
||||||
}
|
}
|
||||||
Some(size) => size,
|
Some(size) => size,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ pub async fn store(bucket: &s3::Bucket, res: &ProcessOutput) -> anyhow::Result<S
|
||||||
"images/{}/{}.{}",
|
"images/{}/{}.{}",
|
||||||
&encoded_hash[..2],
|
&encoded_hash[..2],
|
||||||
&encoded_hash[2..],
|
&encoded_hash[2..],
|
||||||
res.format.extension()
|
res.format
|
||||||
|
.extensions_str()
|
||||||
|
.first()
|
||||||
|
.expect("expected valid extension")
|
||||||
);
|
);
|
||||||
|
|
||||||
// todo: something better than these retries
|
// todo: something better than these retries
|
||||||
|
|
@ -28,7 +31,7 @@ pub async fn store(bucket: &s3::Bucket, res: &ProcessOutput) -> anyhow::Result<S
|
||||||
retry_count += 1;
|
retry_count += 1;
|
||||||
|
|
||||||
let resp = bucket
|
let resp = bucket
|
||||||
.put_object_with_content_type(&path, &res.data, res.format.mime_type())
|
.put_object_with_content_type(&path, &res.data, res.format.to_mime_type())
|
||||||
.await?;
|
.await?;
|
||||||
match resp.status_code() {
|
match resp.status_code() {
|
||||||
200 => {
|
200 => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ twilight-model = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
config = "0.14.0"
|
config = "0.14.0"
|
||||||
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
json-subscriber = { version = "0.2.2", features = ["env-filter"] }
|
json-subscriber = { version = "0.2.2", features = ["env-filter"] }
|
||||||
metrics-exporter-prometheus = { version = "0.15.3", default-features = false, features = ["tokio", "http-listener", "tracing"] }
|
metrics-exporter-prometheus = { version = "0.15.3", default-features = false, features = ["tokio", "http-listener", "tracing"] }
|
||||||
sentry-tracing = "0.36.0"
|
sentry-tracing = "0.36.0"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ pub struct ApiConfig {
|
||||||
#[serde(default = "_default_api_addr")]
|
#[serde(default = "_default_api_addr")]
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
|
|
||||||
|
pub avatars_service_url: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ratelimit_redis_addr: Option<String>,
|
pub ratelimit_redis_addr: Option<String>,
|
||||||
|
|
||||||
|
|
@ -68,6 +70,7 @@ pub struct ApiConfig {
|
||||||
pub struct AvatarsConfig {
|
pub struct AvatarsConfig {
|
||||||
pub s3: S3Config,
|
pub s3: S3Config,
|
||||||
pub cdn_url: String,
|
pub cdn_url: String,
|
||||||
|
pub edge_url: String,
|
||||||
|
|
||||||
#[serde(default = "_default_api_addr")]
|
#[serde(default = "_default_api_addr")]
|
||||||
pub bind_addr: String,
|
pub bind_addr: String,
|
||||||
|
|
@ -76,9 +79,9 @@ pub struct AvatarsConfig {
|
||||||
pub migrate_worker_count: u32,
|
pub migrate_worker_count: u32,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cloudflare_zone_id: Option<String>,
|
pub fastly_store_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cloudflare_token: Option<String>,
|
pub fastly_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,60 @@
|
||||||
use sqlx::{PgPool, Postgres, Transaction};
|
use sqlx::{PgPool, Postgres, Transaction};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::types::avatars::*;
|
use crate::db::types::avatars::*;
|
||||||
|
|
||||||
pub async fn get_by_id(pool: &PgPool, id: String) -> anyhow::Result<Option<ImageMeta>> {
|
pub async fn get_by_id(
|
||||||
Ok(sqlx::query_as("select * from images where id = $1")
|
pool: &PgPool,
|
||||||
.bind(id)
|
system_uuid: Uuid,
|
||||||
.fetch_optional(pool)
|
id: Uuid,
|
||||||
.await?)
|
) -> anyhow::Result<Option<Image>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"select * from images_assets a join images_hashes h ON a.image = h.hash where id = $1 and system_uuid = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(system_uuid)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_system(pool: &PgPool, system_uuid: Uuid) -> anyhow::Result<Vec<Image>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"select * from images_assets a join images_hashes h ON a.image = h.hash where system_uuid = $1",
|
||||||
|
)
|
||||||
|
.bind(system_uuid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_full_by_hash(
|
||||||
|
pool: &PgPool,
|
||||||
|
system_uuid: Uuid,
|
||||||
|
image_hash: String,
|
||||||
|
) -> anyhow::Result<Option<Image>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"select * from images_assets a join images_hashes h ON a.image = h.hash where system_uuid = $1 and h.hash = $2",
|
||||||
|
)
|
||||||
|
.bind(system_uuid)
|
||||||
|
.bind(image_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_hash(pool: &PgPool, image_hash: String) -> anyhow::Result<Option<ImageData>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as("select * from images_hashes where hash = $1")
|
||||||
|
.bind(image_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_original_url(
|
pub async fn get_by_original_url(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
original_url: &str,
|
original_url: &str,
|
||||||
) -> anyhow::Result<Option<ImageMeta>> {
|
) -> anyhow::Result<Option<Image>> {
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_as("select * from images where original_url = $1")
|
sqlx::query_as("select * from images_assets a join images_hashes h ON a.image = h.hash where original_url = $1")
|
||||||
.bind(original_url)
|
.bind(original_url)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?,
|
.await?,
|
||||||
|
|
@ -24,9 +64,9 @@ pub async fn get_by_original_url(
|
||||||
pub async fn get_by_attachment_id(
|
pub async fn get_by_attachment_id(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
attachment_id: u64,
|
attachment_id: u64,
|
||||||
) -> anyhow::Result<Option<ImageMeta>> {
|
) -> anyhow::Result<Option<Image>> {
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_as("select * from images where original_attachment_id = $1")
|
sqlx::query_as("select * from images_assets a join images_hashes h ON a.image = h.hash where original_attachment_id = $1")
|
||||||
.bind(attachment_id as i64)
|
.bind(attachment_id as i64)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?,
|
.await?,
|
||||||
|
|
@ -73,28 +113,56 @@ pub async fn get_stats(pool: &PgPool) -> anyhow::Result<Stats> {
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_image(pool: &PgPool, meta: ImageMeta) -> anyhow::Result<bool> {
|
pub async fn add_image(pool: &PgPool, image: Image) -> anyhow::Result<ImageResult> {
|
||||||
let kind_str = match meta.kind {
|
let kind_str = image.meta.kind.to_string();
|
||||||
ImageKind::Avatar => "avatar",
|
|
||||||
ImageKind::Banner => "banner",
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = sqlx::query("insert into images (id, url, content_type, original_url, file_size, width, height, original_file_size, original_type, original_attachment_id, kind, uploaded_by_account, uploaded_by_system, uploaded_at) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, (now() at time zone 'utc')) on conflict (id) do nothing")
|
add_image_data(pool, &image.data).await?;
|
||||||
.bind(meta.id)
|
|
||||||
.bind(meta.url)
|
if let Some(img) = get_full_by_hash(pool, image.meta.system_uuid, image.meta.image).await? {
|
||||||
.bind(meta.content_type)
|
return Ok(ImageResult {
|
||||||
.bind(meta.original_url)
|
is_new: false,
|
||||||
.bind(meta.file_size)
|
uuid: img.meta.id,
|
||||||
.bind(meta.width)
|
});
|
||||||
.bind(meta.height)
|
}
|
||||||
.bind(meta.original_file_size)
|
|
||||||
.bind(meta.original_type)
|
let res: (uuid::Uuid,) = sqlx::query_as(
|
||||||
.bind(meta.original_attachment_id)
|
"insert into images_assets (system_uuid, image, proxy_image, kind, original_url, original_file_size, original_type, original_attachment_id, uploaded_by_account)
|
||||||
.bind(kind_str)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
.bind(meta.uploaded_by_account)
|
returning id"
|
||||||
.bind(meta.uploaded_by_system)
|
)
|
||||||
.execute(pool).await?;
|
.bind(image.meta.system_uuid)
|
||||||
Ok(res.rows_affected() > 0)
|
.bind(image.data.hash)
|
||||||
|
.bind (image.meta.proxy_image)
|
||||||
|
.bind(kind_str)
|
||||||
|
.bind(image.meta.original_url)
|
||||||
|
.bind(image.meta.original_file_size)
|
||||||
|
.bind(image.meta.original_type)
|
||||||
|
.bind(image.meta.original_attachment_id)
|
||||||
|
.bind(image.meta.uploaded_by_account)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ImageResult {
|
||||||
|
is_new: true,
|
||||||
|
uuid: res.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_image_data(pool: &PgPool, image_data: &ImageData) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"insert into images_hashes (hash, url, file_size, width, height, content_type)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
on conflict (hash) do nothing",
|
||||||
|
)
|
||||||
|
.bind(&image_data.hash)
|
||||||
|
.bind(&image_data.url)
|
||||||
|
.bind(image_data.file_size)
|
||||||
|
.bind(image_data.width)
|
||||||
|
.bind(image_data.height)
|
||||||
|
.bind(&image_data.content_type)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_queue(
|
pub async fn push_queue(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
FromRow,
|
FromRow,
|
||||||
|
|
@ -5,23 +7,52 @@ use sqlx::{
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(FromRow)]
|
#[derive(FromRow, Serialize)]
|
||||||
pub struct ImageMeta {
|
pub struct ImageData {
|
||||||
pub id: String,
|
pub hash: String,
|
||||||
pub kind: ImageKind,
|
|
||||||
pub content_type: String,
|
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub file_size: i32,
|
pub file_size: i32,
|
||||||
pub width: i32,
|
pub width: i32,
|
||||||
pub height: i32,
|
pub height: i32,
|
||||||
pub uploaded_at: Option<DateTime<Utc>>,
|
pub content_type: String,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
pub struct ImageMeta {
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub system_uuid: Uuid,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub image: String,
|
||||||
|
pub proxy_image: Option<String>,
|
||||||
|
pub kind: ImageKind,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub original_url: Option<String>,
|
pub original_url: Option<String>,
|
||||||
pub original_attachment_id: Option<i64>,
|
#[serde(skip_serializing)]
|
||||||
pub original_file_size: Option<i32>,
|
pub original_file_size: Option<i32>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub original_type: Option<String>,
|
pub original_type: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub original_attachment_id: Option<i64>,
|
||||||
|
|
||||||
pub uploaded_by_account: Option<i64>,
|
pub uploaded_by_account: Option<i64>,
|
||||||
pub uploaded_by_system: Option<Uuid>,
|
pub uploaded_by_ip: Option<IpAddr>,
|
||||||
|
pub uploaded_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
pub struct Image {
|
||||||
|
#[sqlx(flatten)]
|
||||||
|
pub meta: ImageMeta,
|
||||||
|
#[sqlx(flatten)]
|
||||||
|
pub data: ImageData,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImageResult {
|
||||||
|
pub is_new: bool,
|
||||||
|
pub uuid: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromRow, Serialize)]
|
#[derive(FromRow, Serialize)]
|
||||||
|
|
@ -36,6 +67,8 @@ pub struct Stats {
|
||||||
pub enum ImageKind {
|
pub enum ImageKind {
|
||||||
Avatar,
|
Avatar,
|
||||||
Banner,
|
Banner,
|
||||||
|
PremiumAvatar,
|
||||||
|
PremiumBanner,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageKind {
|
impl ImageKind {
|
||||||
|
|
@ -43,8 +76,30 @@ impl ImageKind {
|
||||||
match self {
|
match self {
|
||||||
Self::Avatar => (512, 512),
|
Self::Avatar => (512, 512),
|
||||||
Self::Banner => (1024, 1024),
|
Self::Banner => (1024, 1024),
|
||||||
|
Self::PremiumAvatar => (0, 0),
|
||||||
|
Self::PremiumBanner => (0, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn is_premium(&self) -> bool {
|
||||||
|
matches!(self, ImageKind::PremiumAvatar | ImageKind::PremiumBanner)
|
||||||
|
}
|
||||||
|
pub fn to_string(&self) -> &str {
|
||||||
|
return match self {
|
||||||
|
ImageKind::Avatar => "avatar",
|
||||||
|
ImageKind::Banner => "banner",
|
||||||
|
ImageKind::PremiumAvatar => "premium_avatar",
|
||||||
|
ImageKind::PremiumBanner => "premium_banner",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pub fn from_string(str: &str) -> Option<ImageKind> {
|
||||||
|
return match str {
|
||||||
|
"avatar" => Some(ImageKind::Avatar),
|
||||||
|
"banner" => Some(ImageKind::Banner),
|
||||||
|
"premium_avatar" => Some(ImageKind::PremiumAvatar),
|
||||||
|
"premium_banner" => Some(ImageKind::PremiumBanner),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromRow)]
|
#[derive(FromRow)]
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,12 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"queue deleted image cleanup job",
|
"queue deleted image cleanup job",
|
||||||
queue_deleted_image_cleanup
|
queue_deleted_image_cleanup
|
||||||
);
|
);
|
||||||
|
// on hh:15 and hh:45
|
||||||
|
doforever!(
|
||||||
|
"15,45 * * * *",
|
||||||
|
"queue orphaned hash cleanup job",
|
||||||
|
queue_orphaned_hash_cleanup
|
||||||
|
);
|
||||||
// non-standard cron: at hh:mm:00, hh:mm:30
|
// non-standard cron: at hh:mm:00, hh:mm:30
|
||||||
doforever!("0,30 * * * * *", "stats api updater", update_stats_api);
|
doforever!("0,30 * * * * *", "stats api updater", update_stats_api);
|
||||||
// every hour (could probably even be less frequent, basebackups are taken rarely)
|
// every hour (could probably even be less frequent, basebackups are taken rarely)
|
||||||
|
|
|
||||||
|
|
@ -228,32 +228,44 @@ pub async fn update_discord_stats(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_CHECK_COLUMNS: &[(&str, &str)] = &[
|
||||||
|
("systems", "avatar_url"),
|
||||||
|
("systems", "banner_image"),
|
||||||
|
("system_guild", "avatar_url"),
|
||||||
|
("members", "avatar_url"),
|
||||||
|
("members", "banner_image"),
|
||||||
|
("members", "webhook_avatar_url"),
|
||||||
|
("member_guild", "avatar_url"),
|
||||||
|
("groups", "icon"),
|
||||||
|
("groups", "banner_image"),
|
||||||
|
];
|
||||||
|
|
||||||
pub async fn queue_deleted_image_cleanup(ctx: AppCtx) -> anyhow::Result<()> {
|
pub async fn queue_deleted_image_cleanup(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
// if an image is present on no member, add it to the pending deletion queue
|
// if an image is present on no member, add it to the pending deletion queue
|
||||||
// if it is still present on no member after 24h, actually delete it
|
// if it is still present on no member after 24h, actually delete it
|
||||||
|
let mut usage_query = String::new();
|
||||||
let usage_query = r#"
|
for (table, col) in IMAGE_CHECK_COLUMNS {
|
||||||
and not exists (select from systems where avatar_url = images.url)
|
usage_query.push_str(&format!(
|
||||||
and not exists (select from systems where banner_image = images.url)
|
r#"
|
||||||
and not exists (select from system_guild where avatar_url = images.url)
|
and not exists (
|
||||||
|
select 1 from {table}
|
||||||
and not exists (select from members where avatar_url = images.url)
|
where {col} = h.url
|
||||||
and not exists (select from members where banner_image = images.url)
|
or {col} like '%/' || a.system_uuid::text || '/' || a.id::text || '.%'
|
||||||
and not exists (select from members where webhook_avatar_url = images.url)
|
)
|
||||||
and not exists (select from member_guild where avatar_url = images.url)
|
"#
|
||||||
|
));
|
||||||
and not exists (select from groups where icon = images.url)
|
}
|
||||||
and not exists (select from groups where banner_image = images.url);
|
|
||||||
"#;
|
|
||||||
|
|
||||||
ctx.data
|
ctx.data
|
||||||
.execute(
|
.execute(
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
insert into image_cleanup_pending_jobs
|
insert into image_cleanup_pending_jobs
|
||||||
select id, now() from images where
|
select a.id, a.system_uuid, now() from images_assets a
|
||||||
not exists (select from image_cleanup_pending_jobs j where j.id = images.id)
|
join images_hashes h on a.image = h.hash where
|
||||||
and not exists (select from image_cleanup_jobs j where j.id = images.id)
|
a.kind not in ('premium_banner', 'premium_avatar')
|
||||||
|
and not exists (select from image_cleanup_pending_jobs j where j.id = a.id)
|
||||||
|
and not exists (select from image_cleanup_jobs j where j.id = a.id)
|
||||||
{}
|
{}
|
||||||
"#,
|
"#,
|
||||||
usage_query
|
usage_query
|
||||||
|
|
@ -266,12 +278,14 @@ pub async fn queue_deleted_image_cleanup(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
.execute(
|
.execute(
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
insert into image_cleanup_jobs
|
insert into image_cleanup_jobs (id, system_uuid)
|
||||||
select image_cleanup_pending_jobs.id from image_cleanup_pending_jobs
|
select p.id, p.system_uuid from image_cleanup_pending_jobs p
|
||||||
left join images on images.id = image_cleanup_pending_jobs.id
|
join images_assets a on a.id = p.id
|
||||||
|
join images_hashes h on a.image = h.hash
|
||||||
where
|
where
|
||||||
ts < now() - '24 hours'::interval
|
a.kind not in ('premium_banner', 'premium_avatar')
|
||||||
and not exists (select from image_cleanup_jobs j where j.id = images.id)
|
and ts < now() - '24 hours'::interval
|
||||||
|
and not exists (select from image_cleanup_jobs j where j.id = p.id)
|
||||||
{}
|
{}
|
||||||
"#,
|
"#,
|
||||||
usage_query
|
usage_query
|
||||||
|
|
@ -283,6 +297,62 @@ pub async fn queue_deleted_image_cleanup(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn queue_orphaned_hash_cleanup(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
|
let mut usage_checks = String::new();
|
||||||
|
for (table, col) in IMAGE_CHECK_COLUMNS {
|
||||||
|
usage_checks.push_str(&format!(
|
||||||
|
"and not exists (select 1 from {table} where {col} = h.url) "
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.data
|
||||||
|
.execute(
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
insert into image_hash_cleanup_pending_jobs (hash, ts)
|
||||||
|
select h.hash, now()
|
||||||
|
from images_hashes h
|
||||||
|
where
|
||||||
|
not exists (
|
||||||
|
select 1 from images_assets a
|
||||||
|
where a.image = h.hash
|
||||||
|
or a.proxy_image = h.hash
|
||||||
|
)
|
||||||
|
{usage_checks}
|
||||||
|
and not exists (select 1 from image_hash_cleanup_pending_jobs p where p.hash = h.hash)
|
||||||
|
and not exists (select 1 from image_hash_cleanup_jobs j where j.hash = h.hash)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.data
|
||||||
|
.execute(
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
insert into image_hash_cleanup_jobs (hash)
|
||||||
|
select p.hash
|
||||||
|
from image_hash_cleanup_pending_jobs p
|
||||||
|
join images_hashes h ON h.hash = p.hash
|
||||||
|
where
|
||||||
|
p.ts < now() - '24 hours'::interval
|
||||||
|
and not exists (
|
||||||
|
select 1 from images_assets a
|
||||||
|
where a.image = h.hash
|
||||||
|
or a.proxy_image = h.hash
|
||||||
|
)
|
||||||
|
{usage_checks}
|
||||||
|
and not exists (select 1 from image_hash_cleanup_jobs j where j.hash = p.hash)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_stats_api(ctx: AppCtx) -> anyhow::Result<()> {
|
pub async fn update_stats_api(ctx: AppCtx) -> anyhow::Result<()> {
|
||||||
let client = ClientBuilder::new()
|
let client = ClientBuilder::new()
|
||||||
.connect_timeout(Duration::from_secs(3))
|
.connect_timeout(Duration::from_secs(3))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue