1066 lines
33 KiB
Rust
1066 lines
33 KiB
Rust
use crate::{
|
|
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
|
models::{
|
|
_entities::{audio_albums, audio_tracks},
|
|
users,
|
|
},
|
|
};
|
|
use axum::{
|
|
body::Body,
|
|
extract::{DefaultBodyLimit, Multipart},
|
|
http::{
|
|
header::{self, HeaderMap},
|
|
StatusCode,
|
|
},
|
|
};
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
use bytes::Bytes;
|
|
use chrono::{NaiveDate, Utc};
|
|
use loco_rs::{config::Config, prelude::*};
|
|
use sea_orm::{
|
|
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
|
QueryOrder, Set,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::{
|
|
path::{Path as StdPath, PathBuf},
|
|
str::FromStr,
|
|
};
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
|
use uuid::Uuid;
|
|
|
|
const AUDIO_MAX_BYTES: usize = 50 * 1024 * 1024;
|
|
const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
|
|
pub const AUDIO_STORAGE_DIR: &str = "audio";
|
|
pub const IMAGE_STORAGE_DIR: &str = "images";
|
|
|
|
/// Album-create form, parsed manually from `multipart/form-data` so the page
|
|
/// can both upload a cover image and submit any number of `track_ids`
|
|
/// checkboxes (a urlencoded `Form` can't deserialize repeated keys into a Vec).
|
|
struct ParsedAlbumForm {
|
|
title: Option<String>,
|
|
artist: Option<String>,
|
|
release_date: Option<String>,
|
|
description: Option<String>,
|
|
published: bool,
|
|
cover: Option<Vec<u8>>,
|
|
track_ids: Vec<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AlbumSongForm {
|
|
track_id: Uuid,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct UploadResponse {
|
|
filename: String,
|
|
url: String,
|
|
size: usize,
|
|
}
|
|
|
|
pub fn uploads_root(config: &Config) -> Result<PathBuf> {
|
|
config
|
|
.settings
|
|
.as_ref()
|
|
.and_then(|settings| settings.get("uploads_root"))
|
|
.and_then(|value| value.as_str())
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| Error::string("settings.uploads_root must be configured"))
|
|
}
|
|
|
|
fn slugify(title: &str) -> String {
|
|
let mut slug = String::new();
|
|
let mut last_was_dash = false;
|
|
|
|
for ch in title.chars().flat_map(char::to_lowercase) {
|
|
if ch.is_ascii_alphanumeric() {
|
|
slug.push(ch);
|
|
last_was_dash = false;
|
|
} else if !last_was_dash && !slug.is_empty() {
|
|
slug.push('-');
|
|
last_was_dash = true;
|
|
}
|
|
}
|
|
|
|
let slug = slug.trim_matches('-').to_string();
|
|
if slug.is_empty() {
|
|
Uuid::new_v4().to_string()
|
|
} else {
|
|
slug
|
|
}
|
|
}
|
|
|
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
|
value.and_then(|value| {
|
|
let value = value.trim().to_string();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn safe_filename(filename: &str) -> Result<&str> {
|
|
if filename.is_empty()
|
|
|| filename.contains('/')
|
|
|| filename.contains('\\')
|
|
|| filename.contains("..")
|
|
{
|
|
return Err(Error::BadRequest("invalid filename".to_string()));
|
|
}
|
|
Ok(filename)
|
|
}
|
|
|
|
fn audio_content_type(extension: &str) -> &'static str {
|
|
match extension {
|
|
"aac" => "audio/aac",
|
|
"flac" => "audio/flac",
|
|
"m4a" => "audio/mp4",
|
|
"ogg" => "audio/ogg",
|
|
"wav" => "audio/wav",
|
|
"webm" => "audio/webm",
|
|
_ => "audio/mpeg",
|
|
}
|
|
}
|
|
|
|
fn image_content_type(extension: &str) -> &'static str {
|
|
match extension {
|
|
"gif" => "image/gif",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"png" => "image/png",
|
|
"webp" => "image/webp",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
fn detect_audio_extension(data: &[u8]) -> Result<&'static str> {
|
|
if data.len() < 12 {
|
|
return Err(Error::BadRequest("audio file is too small".to_string()));
|
|
}
|
|
if data.starts_with(b"ID3") || (data[0] == 0xff && (data[1] & 0xe0) == 0xe0) {
|
|
return Ok("mp3");
|
|
}
|
|
if data.starts_with(b"RIFF") && &data[8..12] == b"WAVE" {
|
|
return Ok("wav");
|
|
}
|
|
if data.starts_with(b"OggS") {
|
|
return Ok("ogg");
|
|
}
|
|
if data.starts_with(b"fLaC") {
|
|
return Ok("flac");
|
|
}
|
|
if data.len() >= 12 && &data[4..8] == b"ftyp" {
|
|
return Ok("m4a");
|
|
}
|
|
if data.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
|
|
return Ok("webm");
|
|
}
|
|
if data.starts_with(&[0xff, 0xf1]) || data.starts_with(&[0xff, 0xf9]) {
|
|
return Ok("aac");
|
|
}
|
|
Err(Error::BadRequest("unsupported audio format".to_string()))
|
|
}
|
|
|
|
fn detect_image_extension(data: &[u8]) -> Result<&'static str> {
|
|
if data.len() < 12 {
|
|
return Err(Error::BadRequest("image file is too small".to_string()));
|
|
}
|
|
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
|
|
return Ok("png");
|
|
}
|
|
if data.starts_with(&[0xff, 0xd8, 0xff]) {
|
|
return Ok("jpg");
|
|
}
|
|
if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
|
|
return Ok("webp");
|
|
}
|
|
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
|
return Ok("gif");
|
|
}
|
|
Err(Error::BadRequest("unsupported image format".to_string()))
|
|
}
|
|
|
|
async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result<Vec<u8>> {
|
|
while let Some(mut field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
|
{
|
|
if field.name() != Some("file") {
|
|
continue;
|
|
}
|
|
|
|
let mut data = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
|
{
|
|
data.extend_from_slice(&chunk);
|
|
if data.len() > max_bytes {
|
|
return Err(Error::BadRequest(format!(
|
|
"file is larger than {} MB",
|
|
max_bytes / 1024 / 1024
|
|
)));
|
|
}
|
|
}
|
|
|
|
if data.is_empty() {
|
|
return Err(Error::BadRequest("empty file upload".to_string()));
|
|
}
|
|
return Ok(data);
|
|
}
|
|
|
|
Err(Error::BadRequest("multipart field `file` is required".to_string()))
|
|
}
|
|
|
|
async fn read_track_upload(
|
|
mut multipart: Multipart,
|
|
) -> Result<(Vec<u8>, Option<String>, Option<i32>, bool, bool)> {
|
|
let mut data = None;
|
|
let mut title = None;
|
|
let mut track_number = None;
|
|
let mut featured = false;
|
|
let mut published = false;
|
|
|
|
while let Some(mut field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
|
{
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name == "file" {
|
|
let mut file = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
|
{
|
|
file.extend_from_slice(&chunk);
|
|
if file.len() > AUDIO_MAX_BYTES {
|
|
return Err(Error::BadRequest("file is larger than 50 MB".to_string()));
|
|
}
|
|
}
|
|
data = Some(file);
|
|
} else {
|
|
let value = field
|
|
.text()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
|
match name.as_str() {
|
|
"title" => title = normalize_empty(Some(value)),
|
|
"track_number" => {
|
|
track_number = value.trim().parse::<i32>().ok().filter(|number| *number > 0)
|
|
}
|
|
"featured" => featured = value == "on" || value == "true" || value == "1",
|
|
"published" => published = value == "on" || value == "true" || value == "1",
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
let data = data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?;
|
|
if data.is_empty() {
|
|
return Err(Error::BadRequest("empty file upload".to_string()));
|
|
}
|
|
|
|
Ok((data, title, track_number, featured, published))
|
|
}
|
|
|
|
/// Parse the new-album `multipart/form-data` body: text fields, an optional
|
|
/// cover image file, and zero or more `track_ids` checkbox values.
|
|
async fn read_album_form(mut multipart: Multipart) -> Result<ParsedAlbumForm> {
|
|
let mut form = ParsedAlbumForm {
|
|
title: None,
|
|
artist: None,
|
|
release_date: None,
|
|
description: None,
|
|
published: false,
|
|
cover: None,
|
|
track_ids: Vec::new(),
|
|
};
|
|
|
|
while let Some(mut field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
|
{
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name == "cover" {
|
|
let mut data = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
|
{
|
|
data.extend_from_slice(&chunk);
|
|
if data.len() > IMAGE_MAX_BYTES {
|
|
return Err(Error::BadRequest(format!(
|
|
"cover image is larger than {} MB",
|
|
IMAGE_MAX_BYTES / 1024 / 1024
|
|
)));
|
|
}
|
|
}
|
|
// An unselected file input still sends an empty `cover` part.
|
|
if !data.is_empty() {
|
|
form.cover = Some(data);
|
|
}
|
|
} else {
|
|
let value = field
|
|
.text()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
|
match name.as_str() {
|
|
"title" => form.title = normalize_empty(Some(value)),
|
|
"artist" => form.artist = normalize_empty(Some(value)),
|
|
"release_date" => form.release_date = normalize_empty(Some(value)),
|
|
"description" => form.description = normalize_empty(Some(value)),
|
|
"published" => {
|
|
form.published = value == "on" || value == "true" || value == "1";
|
|
}
|
|
"track_ids" => {
|
|
let trimmed = value.trim();
|
|
if !trimmed.is_empty() {
|
|
let id = Uuid::parse_str(trimmed)
|
|
.map_err(|_| Error::BadRequest("invalid song selection".to_string()))?;
|
|
form.track_ids.push(id);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(form)
|
|
}
|
|
|
|
async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
|
|
let base = slugify(title);
|
|
let mut slug = base.clone();
|
|
let mut suffix = 2;
|
|
|
|
while audio_albums::Entity::find()
|
|
.filter(audio_albums::Column::Slug.eq(&slug))
|
|
.count(&ctx.db)
|
|
.await?
|
|
> 0
|
|
{
|
|
slug = format!("{base}-{suffix}");
|
|
suffix += 1;
|
|
}
|
|
|
|
Ok(slug)
|
|
}
|
|
|
|
async fn unique_track_slug(ctx: &AppContext, album_id: Option<Uuid>, title: &str) -> Result<String> {
|
|
let base = slugify(title);
|
|
let mut slug = base.clone();
|
|
let mut suffix = 2;
|
|
|
|
loop {
|
|
let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug));
|
|
query = if let Some(album_id) = album_id {
|
|
query.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
|
} else {
|
|
query.filter(audio_tracks::Column::AlbumId.is_null())
|
|
};
|
|
|
|
if query.count(&ctx.db).await? == 0 {
|
|
break;
|
|
}
|
|
slug = format!("{base}-{suffix}");
|
|
suffix += 1;
|
|
}
|
|
|
|
Ok(slug)
|
|
}
|
|
|
|
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
|
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
|
return false;
|
|
};
|
|
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
|
return false;
|
|
};
|
|
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
|
else {
|
|
return false;
|
|
};
|
|
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
|
return false;
|
|
};
|
|
|
|
admin::is_admin(ctx, &user)
|
|
}
|
|
|
|
async fn album_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_albums::Model> {
|
|
audio_albums::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)
|
|
}
|
|
|
|
async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_tracks::Model> {
|
|
audio_tracks::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)
|
|
}
|
|
|
|
async fn store_upload(ctx: &AppContext, folder: &str, extension: &str, data: Vec<u8>) -> Result<String> {
|
|
let filename = format!("{}.{}", Uuid::new_v4(), extension);
|
|
let key = format!("{folder}/{filename}");
|
|
ctx.storage
|
|
.upload(StdPath::new(&key), &Bytes::from(data))
|
|
.await?;
|
|
Ok(filename)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn image_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
|
|
let extension = detect_image_extension(&data)?;
|
|
let size = data.len();
|
|
let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;
|
|
|
|
format::json(UploadResponse {
|
|
url: format!("/images/{filename}"),
|
|
filename,
|
|
size,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn image_serve(Path(filename): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
|
|
let filename = safe_filename(&filename)?;
|
|
let extension = filename.rsplit('.').next().unwrap_or("");
|
|
let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
|
|
let body: Vec<u8> = ctx.storage.download(StdPath::new(&key)).await?;
|
|
Response::builder()
|
|
.header(header::CONTENT_TYPE, image_content_type(extension))
|
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
|
.body(Body::from(body))
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn audio_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?;
|
|
let extension = detect_audio_extension(&data)?;
|
|
let size = data.len();
|
|
let filename = store_upload(&ctx, AUDIO_STORAGE_DIR, extension, data).await?;
|
|
|
|
format::json(UploadResponse {
|
|
url: format!("/audio/stream/{filename}"),
|
|
filename,
|
|
size,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn public_albums(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let albums = audio_albums::Entity::find()
|
|
.filter(audio_albums::Column::Published.eq(true))
|
|
.order_by_desc(audio_albums::Column::PublishedAt)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
format::view(
|
|
&v,
|
|
"audio/albums.html",
|
|
json!({
|
|
"albums": albums,
|
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn public_album(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(slug): Path<String>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let album = audio_albums::Entity::find()
|
|
.filter(audio_albums::Column::Slug.eq(slug))
|
|
.filter(audio_albums::Column::Published.eq(true))
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)?;
|
|
|
|
let mut active = album.clone().into_active_model();
|
|
active.view_count = Set(album.view_count + 1);
|
|
let album = active.update(&ctx.db).await?;
|
|
|
|
let tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
|
.filter(audio_tracks::Column::Published.eq(true))
|
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
|
.order_by_asc(audio_tracks::Column::Title)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
format::view(
|
|
&v,
|
|
"audio/album.html",
|
|
json!({
|
|
"album": album,
|
|
"tracks": tracks,
|
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Published tracks of an album as JSON, so the audio listing page can
|
|
/// queue a whole album without navigating to its detail page.
|
|
#[debug_handler]
|
|
async fn public_album_tracks(
|
|
Path(slug): Path<String>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let album = audio_albums::Entity::find()
|
|
.filter(audio_albums::Column::Slug.eq(slug))
|
|
.filter(audio_albums::Column::Published.eq(true))
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)?;
|
|
|
|
let tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
|
.filter(audio_tracks::Column::Published.eq(true))
|
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
|
.order_by_asc(audio_tracks::Column::Title)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
let items: Vec<serde_json::Value> = tracks
|
|
.into_iter()
|
|
.map(|t| {
|
|
json!({
|
|
"src": format!("/audio/tracks/{}/stream", t.id),
|
|
"title": t.title,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
format::json(json!({ "tracks": items }))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn public_tracks(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::Published.eq(true))
|
|
.order_by_desc(audio_tracks::Column::PublishedAt)
|
|
.order_by_desc(audio_tracks::Column::CreatedAt)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
format::view(
|
|
&v,
|
|
"audio/tracks.html",
|
|
json!({
|
|
"tracks": tracks,
|
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_albums(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let albums = audio_albums::Entity::find()
|
|
.order_by_desc(audio_albums::Column::CreatedAt)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let mut rows = Vec::new();
|
|
for album in albums {
|
|
let track_count = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
rows.push(json!({ "album": album, "track_count": track_count }));
|
|
}
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/audio/albums.html",
|
|
json!({ "albums": rows, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_tracks(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let tracks = audio_tracks::Entity::find()
|
|
.order_by_desc(audio_tracks::Column::CreatedAt)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/audio/songs.html",
|
|
json!({ "tracks": tracks, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_album_new(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let available_tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.is_null())
|
|
.order_by_asc(audio_tracks::Column::Title)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
format::view(
|
|
&v,
|
|
"admin/audio/new_album.html",
|
|
json!({
|
|
"available_tracks": available_tracks,
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_album_create(
|
|
auth: auth::JWT,
|
|
State(ctx): State<AppContext>,
|
|
multipart: Multipart,
|
|
) -> Result<Response> {
|
|
let admin_user = admin::current_admin(auth, &ctx).await?;
|
|
let form = read_album_form(multipart).await?;
|
|
|
|
let title = form
|
|
.title
|
|
.ok_or_else(|| Error::BadRequest("album title is required".to_string()))?;
|
|
let release_date = form
|
|
.release_date
|
|
.and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok());
|
|
|
|
// Store the uploaded cover (if any) and keep its filename as cover_image_id.
|
|
let cover_image_id = match form.cover {
|
|
Some(data) => {
|
|
let extension = detect_image_extension(&data)?;
|
|
Some(store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let album = audio_albums::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
title: Set(title.clone()),
|
|
slug: Set(unique_album_slug(&ctx, &title).await?),
|
|
description: Set(form.description),
|
|
cover_image_id: Set(cover_image_id),
|
|
artist: Set(form.artist),
|
|
release_date: Set(release_date),
|
|
published: Set(form.published),
|
|
uploader_id: Set(admin_user.id),
|
|
view_count: Set(0),
|
|
published_at: Set(form.published.then(|| Utc::now().into())),
|
|
..Default::default()
|
|
}
|
|
.insert(&ctx.db)
|
|
.await?;
|
|
|
|
for track_id in form.track_ids {
|
|
let track = track_by_id(&ctx, track_id).await?;
|
|
if track.album_id.is_some() {
|
|
return Err(Error::BadRequest(
|
|
"selected song already belongs to an album".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut active = track.into_active_model();
|
|
active.album_id = Set(Some(album.id));
|
|
active.update(&ctx.db).await?;
|
|
}
|
|
|
|
format::redirect("/admin/audio/albums")
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_album_tracks(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(album_id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let album = album_by_id(&ctx, album_id).await?;
|
|
let tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
|
.order_by_asc(audio_tracks::Column::Title)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let available_tracks = audio_tracks::Entity::find()
|
|
.filter(audio_tracks::Column::AlbumId.is_null())
|
|
.order_by_asc(audio_tracks::Column::Title)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/audio/tracks.html",
|
|
json!({
|
|
"album": album,
|
|
"tracks": tracks,
|
|
"available_tracks": available_tracks,
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_album_add_track(
|
|
auth: auth::JWT,
|
|
Path(album_id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
Form(params): Form<AlbumSongForm>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
album_by_id(&ctx, album_id).await?;
|
|
let track = track_by_id(&ctx, params.track_id).await?;
|
|
|
|
if track.album_id.is_some() {
|
|
return Err(Error::BadRequest("song already belongs to an album".to_string()));
|
|
}
|
|
|
|
let mut active = track.into_active_model();
|
|
active.album_id = Set(Some(album_id));
|
|
active.update(&ctx.db).await?;
|
|
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_remove_from_album(
|
|
auth: auth::JWT,
|
|
Path(id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let track = track_by_id(&ctx, id).await?;
|
|
let album_id = track.album_id;
|
|
let mut active = track.into_active_model();
|
|
active.album_id = Set(None);
|
|
active.track_number = Set(None);
|
|
active.update(&ctx.db).await?;
|
|
|
|
if let Some(album_id) = album_id {
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
} else {
|
|
format::redirect("/admin/audio/tracks")
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_upload_form(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(album_id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
format::view(
|
|
&v,
|
|
"admin/audio/upload_track.html",
|
|
json!({ "album": album_by_id(&ctx, album_id).await?, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_song_upload_form(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
format::view(
|
|
&v,
|
|
"admin/audio/upload_track.html",
|
|
json!({ "album": null, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
async fn create_uploaded_track(
|
|
ctx: &AppContext,
|
|
album_id: Option<Uuid>,
|
|
multipart: Multipart,
|
|
) -> Result<audio_tracks::Model> {
|
|
let (data, title, track_number, featured, published) = read_track_upload(multipart).await?;
|
|
let extension = detect_audio_extension(&data)?;
|
|
let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?;
|
|
let title = title.unwrap_or_else(|| filename.trim_end_matches(&format!(".{extension}")).to_string());
|
|
|
|
audio_tracks::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
album_id: Set(album_id),
|
|
title: Set(title.clone()),
|
|
slug: Set(unique_track_slug(ctx, album_id, &title).await?),
|
|
audio_file_id: Set(filename),
|
|
track_number: Set(track_number),
|
|
duration: Set(None),
|
|
featured: Set(featured),
|
|
published: Set(published),
|
|
play_count: Set(0),
|
|
published_at: Set(published.then(|| Utc::now().into())),
|
|
..Default::default()
|
|
}
|
|
.insert(&ctx.db)
|
|
.await
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_upload(
|
|
auth: auth::JWT,
|
|
Path(album_id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
multipart: Multipart,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
album_by_id(&ctx, album_id).await?;
|
|
|
|
create_uploaded_track(&ctx, Some(album_id), multipart).await?;
|
|
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_song_upload(
|
|
auth: auth::JWT,
|
|
State(ctx): State<AppContext>,
|
|
multipart: Multipart,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
create_uploaded_track(&ctx, None, multipart).await?;
|
|
format::redirect("/admin/audio/tracks")
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_delete(
|
|
auth: auth::JWT,
|
|
Path(id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let track = track_by_id(&ctx, id).await?;
|
|
let album_id = track.album_id;
|
|
let _ = ctx
|
|
.storage
|
|
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
|
|
.await;
|
|
track.delete(&ctx.db).await?;
|
|
if let Some(album_id) = album_id {
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
} else {
|
|
format::redirect("/admin/audio/tracks")
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_publish(
|
|
auth: auth::JWT,
|
|
Path(id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let track = track_by_id(&ctx, id).await?;
|
|
let album_id = track.album_id;
|
|
let mut active = track.into_active_model();
|
|
active.published = Set(true);
|
|
active.published_at = Set(Some(Utc::now().into()));
|
|
active.update(&ctx.db).await?;
|
|
|
|
if let Some(album_id) = album_id {
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
} else {
|
|
format::redirect("/admin/audio/tracks")
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn admin_track_unpublish(
|
|
auth: auth::JWT,
|
|
Path(id): Path<Uuid>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
admin::current_admin(auth, &ctx).await?;
|
|
let track = track_by_id(&ctx, id).await?;
|
|
let album_id = track.album_id;
|
|
let mut active = track.into_active_model();
|
|
active.published = Set(false);
|
|
active.published_at = Set(None);
|
|
active.update(&ctx.db).await?;
|
|
|
|
if let Some(album_id) = album_id {
|
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
|
} else {
|
|
format::redirect("/admin/audio/tracks")
|
|
}
|
|
}
|
|
|
|
async fn stream_audio_file(config: &Config, filename: &str, headers: &HeaderMap) -> Result<Response> {
|
|
let filename = safe_filename(filename)?;
|
|
let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename);
|
|
let mut file = tokio::fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
|
|
let total_len = file.metadata().await?.len();
|
|
let extension = filename.rsplit('.').next().unwrap_or("mp3");
|
|
let content_type = audio_content_type(extension);
|
|
|
|
let (status, start, end) = parse_range(headers, total_len)?;
|
|
let len = end.saturating_sub(start) + 1;
|
|
file.seek(SeekFrom::Start(start)).await?;
|
|
|
|
let mut body = vec![0; len as usize];
|
|
file.read_exact(&mut body).await?;
|
|
|
|
let mut builder = Response::builder()
|
|
.status(status)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.header(header::CONTENT_LENGTH, len.to_string());
|
|
|
|
if status == StatusCode::PARTIAL_CONTENT {
|
|
builder = builder.header(
|
|
header::CONTENT_RANGE,
|
|
format!("bytes {start}-{end}/{total_len}"),
|
|
);
|
|
}
|
|
|
|
builder.body(Body::from(body)).map_err(Error::from)
|
|
}
|
|
|
|
fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64, u64)> {
|
|
if total_len == 0 {
|
|
return Ok((StatusCode::OK, 0, 0));
|
|
}
|
|
|
|
let Some(range_header) = headers.get(header::RANGE) else {
|
|
return Ok((StatusCode::OK, 0, total_len - 1));
|
|
};
|
|
let range = range_header
|
|
.to_str()
|
|
.map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
|
|
let Some(range) = range.strip_prefix("bytes=") else {
|
|
return Err(Error::BadRequest("invalid range header".to_string()));
|
|
};
|
|
let Some((start, end)) = range.split_once('-') else {
|
|
return Err(Error::BadRequest("invalid range header".to_string()));
|
|
};
|
|
|
|
let suffix_range = start.is_empty();
|
|
let start = if suffix_range {
|
|
let suffix = u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
|
|
total_len.saturating_sub(suffix)
|
|
} else {
|
|
u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
|
|
};
|
|
let end = if suffix_range || end.is_empty() {
|
|
total_len - 1
|
|
} else {
|
|
u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
|
|
};
|
|
|
|
if start >= total_len || end >= total_len || start > end {
|
|
return Err(Error::CustomError(
|
|
StatusCode::RANGE_NOT_SATISFIABLE,
|
|
loco_rs::controller::ErrorDetail::new("range-not-satisfiable", "range not satisfiable"),
|
|
));
|
|
}
|
|
|
|
Ok((StatusCode::PARTIAL_CONTENT, start, end))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn raw_audio_stream(
|
|
Path(filename): Path<String>,
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
stream_audio_file(&ctx.config, &filename, &headers).await
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn track_stream(
|
|
Path(id): Path<Uuid>,
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let track = track_by_id(&ctx, id).await?;
|
|
if !track.published {
|
|
return Err(Error::NotFound);
|
|
}
|
|
|
|
let mut active = track.clone().into_active_model();
|
|
active.play_count = Set(track.play_count + 1);
|
|
let track = active.update(&ctx.db).await?;
|
|
|
|
stream_audio_file(&ctx.config, &track.audio_file_id, &headers).await
|
|
}
|
|
|
|
pub fn routes() -> Routes {
|
|
Routes::new()
|
|
.add("/images/upload", post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
|
.add("/images/{filename}", get(image_serve))
|
|
.add("/audio/upload", post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
|
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
|
.add("/audio/albums", get(public_albums))
|
|
.add("/audio/albums/{slug}", get(public_album))
|
|
.add("/audio/albums/{slug}/tracks", get(public_album_tracks))
|
|
.add("/audio/tracks", get(public_tracks))
|
|
.add("/audio/tracks/{id}/stream", get(track_stream))
|
|
.add("/admin/audio/albums", get(admin_albums))
|
|
.add("/admin/audio/albums/create", get(admin_album_new))
|
|
.add("/admin/audio/albums/create", post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
|
.add("/admin/audio/tracks", get(admin_tracks))
|
|
.add("/admin/audio/tracks/upload", get(admin_song_upload_form))
|
|
.add("/admin/audio/tracks/upload-file", post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
|
.add("/admin/audio/albums/{album_id}/tracks", get(admin_album_tracks))
|
|
.add("/admin/audio/albums/{album_id}/tracks/add", post(admin_album_add_track))
|
|
.add("/admin/audio/albums/{album_id}/tracks/upload", get(admin_track_upload_form))
|
|
.add("/admin/audio/albums/{album_id}/tracks/upload-file", post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
|
.add("/admin/audio/tracks/{id}/publish", post(admin_track_publish))
|
|
.add("/admin/audio/tracks/{id}/unpublish", post(admin_track_unpublish))
|
|
.add("/admin/audio/tracks/{id}/remove-from-album", post(admin_track_remove_from_album))
|
|
.add("/admin/audio/tracks/{id}/delete", post(admin_track_delete))
|
|
}
|