diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..08162a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/.git +**/target +**/.claude +**/media +**/NUL +**/nul diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..5138b2d --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,51 @@ +name: Build and Publish + +on: + push: + tags: + - 'v*.*.*' + +env: + IMAGE_NAME: ultradesu/furumusic + +jobs: + build_docker: + name: Build and Publish Docker Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + run: | + VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2) + echo "cargo_version=${VERSION}" >> $GITHUB_OUTPUT + + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "docker_tags=${IMAGE_NAME}:${TAG_NAME},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == refs/heads/* ]]; then + BRANCH=${GITHUB_REF#refs/heads/} + echo "docker_tags=${IMAGE_NAME}:${BRANCH},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + else + echo "docker_tags=${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.docker_tags }} + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore index 56e92e7..2492dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /nul +/.claude +/media diff --git a/Cargo.lock b/Cargo.lock index 4a22b96..468cde7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,9 +150,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "askama" -version = "0.16.0" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" dependencies = [ "askama_macros", "itoa", @@ -163,9 +163,9 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.16.0" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" dependencies = [ "askama_parser", "memchr", @@ -177,18 +177,18 @@ dependencies = [ [[package]] name = "askama_macros" -version = "0.16.0" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" dependencies = [ "askama_derive", ] [[package]] name = "askama_parser" -version = "0.16.0" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" dependencies = [ "rustc-hash", "unicode-ident", @@ -540,6 +540,8 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cot" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86599f83c4c655eec5d93c17f0ae42f37435e5940cf99c06145813309f059f07" dependencies = [ "ahash", "aide", @@ -559,7 +561,7 @@ dependencies = [ "form_urlencoded", "futures-core", "futures-util", - "grass_compiler", + "grass", "hex", "http", "http-body-util", @@ -591,6 +593,8 @@ dependencies = [ [[package]] name = "cot_codegen" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb54edb7e3f83eacaf6d8e76da12448ba5d34e481193e59aa89820e034d3221" dependencies = [ "darling 0.23.0", "heck", @@ -602,6 +606,8 @@ dependencies = [ [[package]] name = "cot_core" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439acd4526d5ca44174a30ba68842cd938751993cfbdc1451d2225d5ffc2a8c1" dependencies = [ "askama", "axum", @@ -630,6 +636,8 @@ dependencies = [ [[package]] name = "cot_macros" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250406bc5d4d20de14064500759e91cf5a2bcabaca719688cd83f2a78a9735fa" dependencies = [ "askama_derive", "cot_codegen", @@ -1312,6 +1320,16 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "grass" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a68216437ef68f0738e48d6c7bb9e6e6a92237e001b03d838314b068f33c94" +dependencies = [ + "getrandom 0.2.17", + "grass_compiler", +] + [[package]] name = "grass_compiler" version = "0.13.4" diff --git a/Cargo.toml b/Cargo.toml index eec9382..e54337b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" [dependencies] -cot = { path = "../cot/cot", features = ["postgres", "json", "openapi", "swagger-ui"] } +cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] } schemars = { version = "0.9", features = ["derive"] } serde = { version = "1", features = ["derive"] } openidconnect = "4.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e4766a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM rust:1-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock* ./ +COPY build.rs ./build.rs +COPY prompts ./prompts +COPY src ./src +COPY templates ./templates + +RUN cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /data +COPY --from=builder /app/target/release/furumusic /usr/local/bin/furumusic + +EXPOSE 8000 +CMD ["furumusic", "-l", "0.0.0.0:8000"] diff --git a/src/music/mod.rs b/src/music/mod.rs index a581dc4..7213882 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -30,7 +30,7 @@ pub struct MediaFile { pub sha256_hash: LimitedString<64>, // Audio-specific fields (NULL for non-audio files) /// e.g. "mp3", "flac", "ogg", "wav" - pub audio_format: Option>, + pub audio_format: Option, /// Bitrate in kbps pub audio_bitrate: Option, /// Sample rate in Hz @@ -57,7 +57,7 @@ pub struct Artist { pub image_file_id: Option, pub is_hidden: bool, /// NULL = human-created, non-NULL = LLM model that created it - pub model_name: Option>, + pub model_name: Option, pub created_at: LimitedString<32>, pub updated_at: LimitedString<32>, } @@ -91,7 +91,7 @@ impl Artist { name_sort: LimitedString::new(&normalize_name(name)).unwrap(), image_file_id: None, is_hidden: false, - model_name: model_name.map(|s| LimitedString::new(s).unwrap()), + model_name: model_name.map(str::to_owned), created_at: now.clone(), updated_at: now, }; @@ -173,7 +173,7 @@ pub struct Release { pub total_discs: Option, pub is_hidden: bool, /// NULL = human-created, non-NULL = LLM model that created it - pub model_name: Option>, + pub model_name: Option, pub created_at: LimitedString<32>, pub updated_at: LimitedString<32>, } @@ -206,7 +206,7 @@ impl Release { total_tracks: None, total_discs: None, is_hidden: false, - model_name: model_name.map(|s| LimitedString::new(s).unwrap()), + model_name: model_name.map(str::to_owned), created_at: now.clone(), updated_at: now, }; @@ -355,7 +355,7 @@ pub struct Track { pub year: Option, pub is_hidden: bool, /// NULL = human-created, non-NULL = LLM model that created it - pub model_name: Option>, + pub model_name: Option, pub created_at: LimitedString<32>, pub updated_at: LimitedString<32>, } @@ -585,7 +585,7 @@ impl Track { cover_file_id: None, year, is_hidden: false, - model_name: model_name.map(|s| LimitedString::new(s).unwrap()), + model_name: model_name.map(str::to_owned), created_at: now.clone(), updated_at: now, }; @@ -622,7 +622,7 @@ impl MediaFile { mime_type: LimitedString::new(mime_type).unwrap(), file_size_bytes, sha256_hash: LimitedString::new(sha256_hash).unwrap(), - audio_format: audio_format.map(|s| LimitedString::new(s).unwrap()), + audio_format: audio_format.map(str::to_owned), audio_bitrate, audio_sample_rate, audio_bit_depth, @@ -674,7 +674,7 @@ impl MediaFile { } pub fn audio_format_str(&self) -> &str { - self.audio_format.as_ref().map_or("", |s| s.as_str()) + self.audio_format.as_deref().unwrap_or("") } pub fn created_at_str(&self) -> &str { diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 07fa8b0..c8e8fe5 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -20,8 +20,8 @@ pub struct ScheduledJob { pub description: String, pub cron_expression: LimitedString<100>, pub enabled: bool, - pub last_run_at: Option>, - pub next_run_at: Option>, + pub last_run_at: Option, + pub next_run_at: Option, pub created_at: LimitedString<32>, pub updated_at: LimitedString<32>, } @@ -51,8 +51,7 @@ impl ScheduledJob { "Updating cron expression" ); existing.cron_expression = LimitedString::new(cron_expression).unwrap(); - existing.next_run_at = compute_next_run(cron_expression) - .map(|s| LimitedString::new(&s).unwrap()); + existing.next_run_at = compute_next_run(cron_expression); changed = true; } if existing.description != description { @@ -73,7 +72,7 @@ impl ScheduledJob { cron_expression: LimitedString::new(cron_expression).unwrap(), enabled: true, last_run_at: None, - next_run_at: next.map(|s| LimitedString::new(&s).unwrap()), + next_run_at: next, created_at: now.clone(), updated_at: now, }; @@ -98,11 +97,11 @@ impl ScheduledJob { } pub fn last_run_at_str(&self) -> &str { - self.last_run_at.as_ref().map_or("", |v| v.as_str()) + self.last_run_at.as_deref().unwrap_or("") } pub fn next_run_at_str(&self) -> &str { - self.next_run_at.as_ref().map_or("", |v| v.as_str()) + self.next_run_at.as_deref().unwrap_or("") } pub async fn delete_by_name(db: &Database, name: &str) -> cot::db::Result<()> { @@ -127,7 +126,7 @@ pub struct JobRun { pub job_name: LimitedString<100>, pub status: LimitedString<32>, pub started_at: LimitedString<32>, - pub finished_at: Option>, + pub finished_at: Option, pub duration_ms: Option, pub log_output: Option, pub error_message: Option, @@ -154,7 +153,7 @@ impl JobRun { pub async fn set_completed(&mut self, db: &Database, duration_ms: i64, log: &str) -> cot::db::Result<()> { self.status = LimitedString::new("completed").unwrap(); - self.finished_at = Some(now_iso()); + self.finished_at = Some(now_iso().to_string()); self.duration_ms = Some(duration_ms); self.log_output = Some(log.to_owned()); self.save(db).await @@ -162,7 +161,7 @@ impl JobRun { pub async fn set_failed(&mut self, db: &Database, duration_ms: i64, log: &str, error: &str) -> cot::db::Result<()> { self.status = LimitedString::new("failed").unwrap(); - self.finished_at = Some(now_iso()); + self.finished_at = Some(now_iso().to_string()); self.duration_ms = Some(duration_ms); self.log_output = Some(log.to_owned()); self.error_message = Some(error.to_owned()); @@ -224,7 +223,7 @@ impl JobRun { } pub fn finished_at_str(&self) -> &str { - self.finished_at.as_ref().map_or("", |v| v.as_str()) + self.finished_at.as_deref().unwrap_or("") } pub fn duration_display(&self) -> String { @@ -277,7 +276,7 @@ impl JobRunRow { job_name: LimitedString::new(&self.job_name).unwrap(), status: LimitedString::new(&self.status).unwrap(), started_at: LimitedString::new(&self.started_at).unwrap(), - finished_at: self.finished_at.map(|s| LimitedString::new(&s).unwrap()), + finished_at: self.finished_at, duration_ms: self.duration_ms, log_output: self.log_output, error_message: self.error_message, @@ -968,7 +967,7 @@ impl SchedulerHandle { } if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(db, job_name).await { - sched_job.last_run_at = Some(now_iso()); + sched_job.last_run_at = Some(now_iso().to_string()); sched_job.updated_at = now_iso(); let _ = sched_job.save(db).await; } @@ -993,8 +992,7 @@ impl SchedulerHandle { // Update DB if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await { sched_job.cron_expression = LimitedString::new(new_cron).unwrap(); - sched_job.next_run_at = compute_next_run(new_cron) - .map(|s| LimitedString::new(&s).unwrap()); + sched_job.next_run_at = compute_next_run(new_cron); sched_job.updated_at = now_iso(); let _ = sched_job.save(&self.shared_db).await; } @@ -1024,8 +1022,7 @@ impl SchedulerHandle { sched_job.enabled = enabled; if enabled { - sched_job.next_run_at = compute_next_run(sched_job.cron_expression_str()) - .map(|s| LimitedString::new(&s).unwrap()); + sched_job.next_run_at = compute_next_run(sched_job.cron_expression_str()); } sched_job.updated_at = now_iso(); let _ = sched_job.save(&self.shared_db).await; @@ -1312,7 +1309,7 @@ pub async fn trigger_job_now( } if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(db, job_name).await { - sched_job.last_run_at = Some(now_iso()); + sched_job.last_run_at = Some(now_iso().to_string()); sched_job.updated_at = now_iso(); let _ = sched_job.save(db).await; } diff --git a/src/user.rs b/src/user.rs index ad95d0b..0d48337 100644 --- a/src/user.rs +++ b/src/user.rs @@ -13,9 +13,9 @@ pub struct User { id: Auto, #[model(unique)] username: LimitedString<255>, - password: Option, - email: Option>, - display_name: Option>, + password: Option, + email: Option, + display_name: Option, avatar_url: Option, role: LimitedString<32>, is_active: bool, @@ -49,9 +49,9 @@ impl User { let mut user = Self { id: Auto::auto(), username: LimitedString::new(username).unwrap(), - password: Some(hash), - email: email.map(|e| LimitedString::new(e).unwrap()), - display_name: display_name.map(|d| LimitedString::new(d).unwrap()), + password: Some(hash.into_string()), + email: email.map(str::to_owned), + display_name: display_name.map(str::to_owned), avatar_url: None, role: LimitedString::new(role).unwrap(), is_active: true, @@ -72,10 +72,10 @@ impl User { role: &str, ) -> cot::db::Result<()> { self.username = LimitedString::new(username).unwrap(); - self.email = email.map(|e| LimitedString::new(e).unwrap()); - self.display_name = display_name.map(|d| LimitedString::new(d).unwrap()); + self.email = email.map(str::to_owned); + self.display_name = display_name.map(str::to_owned); if let Some(pw) = new_password { - self.password = Some(PasswordHash::from_password(&Password::new(pw))); + self.password = Some(PasswordHash::from_password(&Password::new(pw)).into_string()); } self.role = LimitedString::new(role).unwrap(); self.save(db).await @@ -95,8 +95,10 @@ impl User { } /// Return a reference to the password hash, if set. - pub fn password_ref(&self) -> Option<&PasswordHash> { - self.password.as_ref() + pub fn password_ref(&self) -> Option { + self.password + .as_ref() + .and_then(|hash| PasswordHash::new(hash.clone()).ok()) } /// Parse the stored role code into a `Role`, defaulting to `User`. @@ -142,8 +144,8 @@ impl User { id: Auto::auto(), username: LimitedString::new(username).unwrap(), password: None, - email: email.map(|e| LimitedString::new(e).unwrap()), - display_name: display_name.map(|d| LimitedString::new(d).unwrap()), + email: email.map(str::to_owned), + display_name: display_name.map(str::to_owned), avatar_url: None, role: LimitedString::new(role).unwrap(), is_active: true, @@ -160,10 +162,7 @@ impl User { /// Find a user by email address. pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result> { - let Ok(email) = LimitedString::<255>::new(email) else { - return Ok(None); - }; - cot::db::query!(User, $email == Some(email)).get(db).await + cot::db::query!(User, $email == Some(email.to_owned())).get(db).await } } @@ -179,8 +178,8 @@ pub struct OidcLink { user_id: i64, issuer: LimitedString<255>, sub: LimitedString<255>, - email: Option>, - name: Option>, + email: Option, + name: Option, avatar_url: Option, } @@ -220,8 +219,8 @@ impl OidcLink { user_id, issuer: LimitedString::new(issuer).unwrap(), sub: LimitedString::new(sub).unwrap(), - email: email.map(|e| LimitedString::new(e).unwrap()), - name: name.map(|n| LimitedString::new(n).unwrap()), + email: email.map(str::to_owned), + name: name.map(str::to_owned), avatar_url: None, }; link.insert(db).await?; @@ -235,8 +234,8 @@ impl OidcLink { email: Option<&str>, name: Option<&str>, ) -> cot::db::Result<()> { - self.email = email.map(|e| LimitedString::new(e).unwrap()); - self.name = name.map(|n| LimitedString::new(n).unwrap()); + self.email = email.map(str::to_owned); + self.name = name.map(str::to_owned); self.save(db).await }