18 Commits

Author SHA1 Message Date
21163b277d update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m0s
2025-12-26 17:14:45 -05:00
7a0d3b7f40 merge
Some checks failed
Build and Push Image / build-and-push (push) Failing after 4m5s
2025-12-26 17:03:34 -05:00
6d2b8c77b2 update to dx 0.7.2 2025-12-26 17:03:00 -05:00
8bdd9edbb0 Increase multi-part body limit
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m52s
2025-12-22 15:55:46 +00:00
5e6b0b21a6 fix filename handling
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m48s
2025-12-14 16:22:51 -05:00
19274d444d fix typo 2025-12-14 16:09:11 -05:00
63e2f9f791 update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m41s
2025-12-14 16:07:53 -05:00
8964d1c069 testing using libvips 2025-08-17 14:57:21 -04:00
8808126905 testing using ffmpeg to generate thumbnails for avif 2025-08-17 03:16:19 -04:00
d36aaac836 empty 2025-08-13 23:14:21 -04:00
bb2c6c4683 removed woodpecker file 2025-08-13 21:57:33 -04:00
f8d457a096 moved woodpecker file to root 2025-08-13 21:51:10 -04:00
165adb2775 added woodpecker, updated build to update latest
All checks were successful
Build and Push Image / build-and-push (push) Successful in 6m21s
2025-08-13 21:48:08 -04:00
e832ccf07e ContextMenu Renderer Finalized 2025-08-10 00:11:05 -04:00
6df6de098b WIP ContextMenu Renderer 2025-08-09 17:14:41 -04:00
364b23e62a context menu rendering wip 2025-08-03 12:12:13 -04:00
7b2ed32043 added context menu 2025-08-02 13:38:15 -04:00
d094f7bbef misc 2025-07-29 20:27:59 -04:00
20 changed files with 2169 additions and 566 deletions

View File

@@ -1,37 +1,39 @@
name: "Build and Push Image" name: "Build and Push Image"
on: on:
push: push:
tags: tags:
- "v*" - "v*"
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkou code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Build
uses: docker/setup-buildx-action@v3
- name: Login - name: Set up Docker Build
uses: docker/login-action@v3 uses: docker/setup-buildx-action@v3
with:
registry: git.kaisei.app
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Extract tag version - name: Login
id: extract_tag uses: docker/login-action@v3
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV with:
registry: git.kaisei.app
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and Push - name: Extract tag version
uses: docker/build-push-action@v5 id: extract_tag
with: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
file: AobaServer/Dockerfile
context: . - name: Build and Push
push: true uses: docker/build-push-action@v5
tags: git.kaisei.app/amatsugu/aoba:${{ env.VERSION }} with:
build-args: VERSION=${{ env.VERSION }} file: AobaServer/Dockerfile
context: .
push: true
tags: |
git.kaisei.app/amatsugu/aoba:${{ env.VERSION }}
git.kaisei.app/amatsugu/aoba:latest
build-args: VERSION=${{ env.VERSION }}

1961
AobaClient/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
dioxus = { version = "0.6.0", features = ["router"] } dioxus = { version = "0.7.2", features = ["router"] }
serde = "1.0.219" serde = "1.0.219"
serde_repr = "0.1.20" serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [ tonic = { version = "*", default-features = false, features = [

View File

@@ -1,8 +1,9 @@
$mainBGColor: #584577; $mainBGColor: #584577;
$featureColor: #ce2d4f; $featureColor: #ce2d4f;
$accentColor: #f0eaf8; $focusColor: #ff3862;
$accentColor: #f0eaf8;
$mainTextColor: #eee;
$brightTextColor: #fff; $mainTextColor: #eee;
$invertTextColor: #222; $brightTextColor: #fff;
$invertBrightTextColor: #000; $invertTextColor: #222;
$invertBrightTextColor: #000;

View File

@@ -157,3 +157,41 @@ form {
padding: 5px; padding: 5px;
user-select: all; user-select: all;
} }
.contextMenu {
backdrop-filter: blur(10px) brightness(0.5) grayscale(1);
position: absolute;
z-index: 100;
.itemList {
display: flex;
flex-direction: column;
max-width: 300px;
}
.contextItem {
border-left: 4px solid $featureColor;
$size: 30px;
display: grid;
grid-template-columns: $size 1fr auto;
height: $size;
transition: border 0.1s ease-out;
cursor: default;
&.clickable {
cursor: pointer;
}
.label {
display: flex;
align-items: center;
padding: 5px 10px;
}
&:hover {
background-color: $accentColor;
color: $invertTextColor;
border-left: 10px solid $focusColor;
}
}
}

View File

@@ -7,7 +7,7 @@ pub struct InputProps {
pub label: Option<String>, pub label: Option<String>,
pub placeholder: Option<String>, pub placeholder: Option<String>,
pub name: String, pub name: String,
pub oninput: Option<EventHandler<FormEvent>>, pub oninput: Option<EventHandler<Event<FormData>>>,
pub required: Option<bool>, pub required: Option<bool>,
} }
@@ -22,9 +22,12 @@ pub fn Input(props: InputProps) -> Element {
r#type: props.r#type.unwrap_or("text".into()), r#type: props.r#type.unwrap_or("text".into()),
value: props.value, value: props.value,
oninput: move |e| { oninput: move |e| {
if let Some(mut s) = props.value { if let Some(mut s) = props.value {
s.set(e.value()); s.set(e.value());
} }
if let Some(handler) = props.oninput{
handler.call(e);
}
}, },
name: props.name, name: props.name,
placeholder: ph, placeholder: ph,

View File

@@ -0,0 +1,97 @@
use core::str;
use dioxus::prelude::*;
mod props {
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct ContextMenu {
pub top: f64,
pub left: f64,
pub items: Element,
}
#[derive(PartialEq, Clone, Props, Default)]
pub struct ContextMenuItem {
pub name: String,
pub sub_items: Option<Element>,
pub onclick: Option<EventHandler<MouseEvent>>,
}
}
#[derive(Clone, Copy, Default)]
pub struct ContextMenuRenderer {
pub menu: Signal<Option<Element>>,
}
impl ContextMenuRenderer {
pub fn close(&mut self) {
self.menu.set(None);
}
pub fn render(&self) -> Element {
if let Some(menu) = self.menu.cloned() {
rsx! {
{menu}
}
} else {
rsx! {}
}
}
}
#[component]
pub fn ContextMenuRoot() -> Element {
let renderer = use_context::<ContextMenuRenderer>();
rsx! {
{renderer.render()}
}
}
#[component]
pub fn ContextMenu(props: props::ContextMenu) -> Element {
rsx! {
div {
class: "contextMenu",
style: "left: {props.left}px; top: {props.top}px;",
ItemList { items: props.items }
}
}
}
#[component]
fn ItemList(items: Element) -> Element {
rsx! {
div{
class: "itemList",
{items}
}
}
}
#[component]
pub fn ContextMenuItem(props: props::ContextMenuItem) -> Element {
let mut renderer = use_context::<ContextMenuRenderer>();
if let Some(_sub) = props.sub_items {
todo!("Sub Menu");
}
rsx! {
div{
onclick: move |e|{
if let Some(handler) = props.onclick{
handler.call(e);
}
renderer.close();
},
class: "contextItem",
div {
class: "icon"
},
div {
class: "label",
{props.name}
}
}
}
}

View File

@@ -51,8 +51,11 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
Ok(result) => rsx! { Ok(result) => rsx! {
div { div {
class: "mediaGrid", class: "mediaGrid",
// oncontextmenu: oncontext,
{result.items.iter().map(|itm| rsx!{ {result.items.iter().map(|itm| rsx!{
MediaItem { item: Some(itm.clone()) } MediaItem {
item: itm.clone()
}
})}, })},
} }
}, },
@@ -69,7 +72,7 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
div{ div{
class: "mediaGrid", class: "mediaGrid",
{(0..50).map(|_| rsx!{ {(0..50).map(|_| rsx!{
MediaItem {} MediaItem { }
})} })}
} }
}, },

View File

@@ -1,25 +1,72 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use web_sys::window;
use crate::{HOST, rpc::aoba::MediaModel}; use crate::{
HOST,
components::{ContextMenu, ContextMenuItem, ContextMenuRenderer},
rpc::aoba::MediaModel,
};
#[derive(PartialEq, Clone, Props)] #[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps { pub struct MediaItemProps {
pub item: Option<MediaModel>, pub item: Option<MediaModel>,
// pub oncontextmenu: Option<EventHandler<Event<MouseData>>>,
} }
#[component] #[component]
pub fn MediaItem(props: MediaItemProps) -> Element { pub fn MediaItem(props: MediaItemProps) -> Element {
let mut ct_renderer = use_context::<ContextMenuRenderer>();
if let Some(item) = props.item { if let Some(item) = props.item {
let mtype = item.media_type().as_str_name(); let mtype = item.media_type().as_str_name();
let filename = item.file_name; let filename = item.file_name;
let id = item.id.unwrap().value; let id = item.id.unwrap().value;
let thumb = item.thumb_url; let thumb = item.thumb_url;
let url = item.media_url; let url = item.media_url;
let download = format!("{HOST}{url}");
let oncontext = move |event: Event<MouseData>| {
println!("ContextMenu");
event.prevent_default();
event.stop_propagation();
let data = event.data();
if data.modifiers().ctrl() {
return;
}
let pos = data.coordinates().client();
let left = pos.x;
let top = pos.y;
let download = download.clone();
let menu: Element = rsx! {
ContextMenu {
left: left,
top: top,
items: rsx! {
ContextMenuItem {
name: "Details",
},
ContextMenuItem {
name: "Download",
onclick: move |_|{
_ = window().unwrap().open_with_url_and_target(&download, "_blank");
}
},
ContextMenuItem {
name: "Delete",
},
},
}
};
ct_renderer.menu.set(Some(menu));
};
return rsx! { return rsx! {
a { a {
class: "mediaItem", class: "mediaItem",
href: "{HOST}/{url}", href: "{HOST}{url}",
target: "_blank", target: "_blank",
oncontextmenu: oncontext,
"data-id" : id, "data-id" : id,
img { src: "{HOST}{thumb}" } img { src: "{HOST}{thumb}" }
span { class: "info", span { class: "info",
@@ -29,7 +76,7 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
span { "{item.view_count}" } span { "{item.view_count}" }
} }
} }
} },
}; };
} else { } else {
return rsx! { return rsx! {

View File

@@ -1,10 +1,12 @@
pub mod basic; pub mod basic;
mod context_menu;
mod media_grid; mod media_grid;
mod media_item; mod media_item;
mod metrics_token; mod metrics_token;
mod navbar; mod navbar;
mod notif; mod notif;
mod search; mod search;
pub use context_menu::*;
pub use media_grid::*; pub use media_grid::*;
pub use media_item::*; pub use media_item::*;
pub use metrics_token::*; pub use metrics_token::*;

View File

@@ -1,4 +1,4 @@
use dioxus::signals::{Signal, Writable}; use dioxus::signals::{Signal, WritableExt};
use web_sys::window; use web_sys::window;
use crate::rpc::{login, logout}; use crate::rpc::{login, logout};

View File

@@ -1,6 +1,11 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login}; use crate::{
Route,
components::{ContextMenuRenderer, ContextMenuRoot, Navbar},
contexts::AuthContext,
views::Login,
};
#[component] #[component]
pub fn MainLayout() -> Element { pub fn MainLayout() -> Element {
@@ -8,12 +13,24 @@ pub fn MainLayout() -> Element {
if auth_context.jwt.cloned().is_none() { if auth_context.jwt.cloned().is_none() {
return rsx! { return rsx! {
Login {} Login { }
}; };
} }
let mut ct_renderer = use_context::<ContextMenuRenderer>();
return rsx! { return rsx! {
Navbar {} ContextMenuRoot { }
div { id: "content", Outlet::<Route> {} } Navbar { }
div {
id: "content",
onclick: move |_| {
ct_renderer.close();
},
oncontextmenu: move |_| {
ct_renderer.close();
},
Outlet::<Route> { }
}
}; };
} }

View File

@@ -11,6 +11,8 @@ use contexts::AuthContext;
use dioxus::prelude::*; use dioxus::prelude::*;
use route::Route; use route::Route;
use crate::components::ContextMenuRenderer;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub const HOST: &'static str = "http://localhost:8081"; pub const HOST: &'static str = "http://localhost:8081";
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -30,7 +32,8 @@ fn main() {
#[component] #[component]
fn App() -> Element { fn App() -> Element {
let _auth_state = use_context_provider(|| AuthContext::new()); use_context_provider(|| AuthContext::new());
use_context_provider(|| ContextMenuRenderer::default());
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
@@ -41,6 +44,7 @@ fn App() -> Element {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap", href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
} }
Router::<Route> {}
Router::<Route> { }
} }
} }

View File

@@ -40,6 +40,11 @@ public class AobaService(IMongoDatabase db)
return new PagedResult<Media>(items, page, pageSize, total); return new PagedResult<Media>(items, page, pageSize, total);
} }
public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default)
{
var filter = Builders<Media>.Filter.Eq(m => m.Ext, ext);
return await _media.Find(filter).ToListAsync();
}
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default) public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
{ {
@@ -53,6 +58,13 @@ public class AobaService(IMongoDatabase db)
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken); await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
} }
public async Task RemoveThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var upate = Builders<Media>.Update.Unset(m => m.Thumbnails[size]);
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
}
public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default) public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{ {
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken); var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);

View File

@@ -16,6 +16,7 @@ using SixLabors.ImageSharp.Processing;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
@@ -27,6 +28,28 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{ {
private readonly GridFSBucket _gridfs = new GridFSBucket(db); private readonly GridFSBucket _gridfs = new GridFSBucket(db);
public async Task<Error?> DeleteThumbnailAsync(ObjectId mediaId, ThumbnailSize size)
{
var thumbId = await aobaService.GetThumbnailIdAsync(mediaId, size);
if (thumbId == default)
return null;
try
{
await _gridfs.DeleteAsync(thumbId);
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (GridFSFileNotFoundException)
{
//Ignore if the file was not found (somehow already deleted)
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (Exception e)
{
return new ExceptionError(e);
}
return null;
}
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
@@ -36,6 +59,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
/// <returns></returns> /// <returns></returns>
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default) public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{ {
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken); var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
if (existingThumb != null) if (existingThumb != null)
return existingThumb; return existingThumb;
@@ -132,7 +156,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
return result; return result;
} }
public Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default) public static Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{ {
var w = (int)size; var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString(); var fn = ObjectId.GenerateNewId().ToString();
@@ -166,6 +190,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
} }
} }
public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
var inFilePath = $"/tmp/{fn}.avif";
var outFilePath = $"/tmp/{fn}.webp";
using var source = new FileStream(inFilePath, FileMode.CreateNew);
data.CopyTo(source);
source.Flush();
source.Dispose();
data.Dispose();
try
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "vips",
Arguments = $"smartcrop \"{inFilePath}\" \"{outFilePath}\"[Q=75] {w} {w}",
WorkingDirectory = "/tmp"
});
if (process == null)
return new Error("Failed to run vips command");
process.WaitForExit();
if (process.ExitCode != 0)
return new Error("Failed to convert");
var output = new MemoryStream();
using var oFile = File.OpenRead(outFilePath);
oFile.CopyTo(output);
output.Position = 0;
return output;
}
catch(Exception ex)
{
return ex;
}
finally
{
File.Delete(inFilePath);
File.Delete(outFilePath);
}
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default) public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{ {
return new NotImplementedException(); return new NotImplementedException();

View File

@@ -22,6 +22,7 @@ public class MediaApi(AobaService aoba) : ControllerBase
if (media.HasError) if (media.HasError)
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest); return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
return Ok(new return Ok(new
{ {
media = media.Value, media = media.Value,

View File

@@ -14,7 +14,7 @@ namespace AobaServer.Controllers;
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
{ {
[HttpGet("{id}")] [HttpGet("{id}")]
[HttpGet("{id}/*")] [HttpGet("{id}/{*fn}")]
[ResponseCache(Duration = int.MaxValue)] [ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken) public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
{ {
@@ -29,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
return File(file, mime, true); return File(file, mime, true);
} }
[HttpGet("{id}/dl")]
[HttpGet("{id}/dl/{*fn}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> DownloadMediaAsync(ObjectId id, [FromServices] MongoClient client, [FromQuery] string? fn = null, CancellationToken cancellationToken = default)
{
var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
if (file.HasError)
{
logger.LogError(file.Error.Exception, "Failed to load media stream");
return NotFound();
}
var mime = MimeTypesMap.GetMimeType(file.Value.FileInfo.Filename);
_ = aobaService.IncrementViewCountAsync(id, cancellationToken);
return File(file, mime, fn ?? file.Value.FileInfo.Filename, true);
}
/// <summary> /// <summary>
/// Redirect legacy media urls to the new url /// Redirect legacy media urls to the new url
/// </summary> /// </summary>

View File

@@ -22,17 +22,17 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
# Install `dx` # Install `dx`
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall dioxus-cli --root /.cargo -y --force RUN cargo binstall dioxus-cli@0.7.2 --root /.cargo -y --force
ENV PATH="/.cargo/bin:$PATH" ENV PATH="/.cargo/bin:$PATH"
ARG VERSION ARG VERSION
ENV APP_VERSION=$VERSION ENV APP_VERSION=$VERSION
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled # Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
RUN dx bundle --platform web RUN dx bundle --release --platform web
# Server Build # Server Build
# This stage is used when running from VS in fast mode (Default for Debug configuration) # This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
RUN apt-get update && apt-get install -y ffmpeg RUN apt-get update && apt-get install -y ffmpeg #libvips libvips-tools
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080

View File

@@ -1,164 +1,167 @@
using AobaCore; using AobaCore;
using AobaServer.Auth; using AobaServer.Auth;
using AobaServer.Middleware; using AobaServer.Middleware;
using AobaServer.Models; using AobaServer.Models;
using AobaServer.Services; using AobaServer.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Driver.Core.Extensions.DiagnosticSources; using MongoDB.Driver.Core.Extensions.DiagnosticSources;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o => builder.WebHost.ConfigureKestrel(o =>
{ {
o.Limits.MaxRequestBodySize = null; o.Limits.MaxRequestBodySize = null;
#if !DEBUG #if !DEBUG
o.ListenAnyIP(8081, lo => o.ListenAnyIP(8081, lo =>
{ {
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
}); });
o.ListenAnyIP(8080, lo => o.ListenAnyIP(8080, lo =>
{ {
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
}); });
#endif #endif
}); });
var config = builder.Configuration; var config = builder.Configuration;
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(opt => opt.ModelBinderProviders.Add(new BsonIdModelBinderProvider())); builder.Services.AddControllers(opt => opt.ModelBinderProviders.Add(new BsonIdModelBinderProvider()));
builder.Services.AddObersability(builder.Configuration); builder.Services.AddObersability(builder.Configuration);
builder.Services.AddGrpc(); builder.Services.AddGrpc();
//DB //DB
var dbString = config["DB_STRING"]; var dbString = config["DB_STRING"];
var settings = MongoClientSettings.FromConnectionString(dbString); var settings = MongoClientSettings.FromConnectionString(dbString);
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
var dbClient = new MongoClient(settings); var dbClient = new MongoClient(settings);
var db = dbClient.GetDatabase("Aoba"); var db = dbClient.GetDatabase("Aoba");
builder.Services.AddSingleton(dbClient); builder.Services.AddSingleton(dbClient);
builder.Services.AddSingleton<IMongoDatabase>(db); builder.Services.AddSingleton<IMongoDatabase>(db);
var authCfg = new AuthConfigService(db); var authCfg = new AuthConfigService(db);
builder.Services.AddSingleton(authCfg); builder.Services.AddSingleton(authCfg);
var authInfo = authCfg.GetDefaultAuthInfoAsync().GetAwaiter().GetResult(); var authInfo = authCfg.GetDefaultAuthInfoAsync().GetAwaiter().GetResult();
var signingKey = new SymmetricSecurityKey(authInfo.SecureKey); var signingKey = new SymmetricSecurityKey(authInfo.SecureKey);
var validationParams = new TokenValidationParameters var validationParams = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey, IssuerSigningKey = signingKey,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = authInfo.Issuer, ValidIssuer = authInfo.Issuer,
ValidateAudience = true, ValidateAudience = true,
ValidAudience = authInfo.Audience, ValidAudience = authInfo.Audience,
ValidateLifetime = false, ValidateLifetime = false,
ClockSkew = TimeSpan.FromMinutes(1), ClockSkew = TimeSpan.FromMinutes(1),
}; };
builder.Services.AddCors(o => builder.Services.AddCors(o =>
{ {
o.AddPolicy("AllowAll", p => o.AddPolicy("AllowAll", p =>
{ {
p.AllowAnyOrigin(); p.AllowAnyOrigin();
p.AllowAnyMethod(); p.AllowAnyMethod();
p.AllowAnyHeader(); p.AllowAnyHeader();
}); });
o.AddPolicy("RPC", p => o.AddPolicy("RPC", p =>
{ {
p.AllowAnyMethod(); p.AllowAnyMethod();
p.AllowAnyHeader(); p.AllowAnyHeader();
p.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding"); p.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
p.AllowAnyOrigin(); p.AllowAnyOrigin();
}); });
}); });
var metricsAuthInfo = authCfg.GetAuthInfoAsync("aoba", "metrics").GetAwaiter().GetResult(); var metricsAuthInfo = authCfg.GetAuthInfoAsync("aoba", "metrics").GetAwaiter().GetResult();
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "Aoba"; options.DefaultChallengeScheme = "Aoba";
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => //Bearer auth }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => //Bearer auth
{ {
options.TokenValidationParameters = validationParams; options.TokenValidationParameters = validationParams;
options.TokenHandlers.Add(new MetricsTokenValidator(metricsAuthInfo)); options.TokenHandlers.Add(new MetricsTokenValidator(metricsAuthInfo));
options.Events = new JwtBearerEvents options.Events = new JwtBearerEvents
{ {
OnMessageReceived = ctx => //Retreive token from cookie if not found in headers OnMessageReceived = ctx => //Retreive token from cookie if not found in headers
{ {
if (string.IsNullOrWhiteSpace(ctx.Token)) if (string.IsNullOrWhiteSpace(ctx.Token))
ctx.Token = ctx.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", ""); ctx.Token = ctx.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
#if DEBUG //allow cookie based auth when in debug mode #if DEBUG //allow cookie based auth when in debug mode
if (string.IsNullOrWhiteSpace(ctx.Token)) if (string.IsNullOrWhiteSpace(ctx.Token))
ctx.Token = ctx.Request.Cookies.FirstOrDefault(c => c.Key == "token").Value; ctx.Token = ctx.Request.Cookies.FirstOrDefault(c => c.Key == "token").Value;
#endif #endif
return Task.CompletedTask; return Task.CompletedTask;
}, },
OnAuthenticationFailed = ctx => OnAuthenticationFailed = ctx =>
{ {
ctx.Response.Cookies.Append("token", "", new CookieOptions ctx.Response.Cookies.Append("token", "", new CookieOptions
{ {
MaxAge = TimeSpan.Zero, MaxAge = TimeSpan.Zero,
Expires = DateTime.Now Expires = DateTime.Now
}); });
ctx.Options.ForwardChallenge = "Aoba"; ctx.Options.ForwardChallenge = "Aoba";
return Task.CompletedTask; return Task.CompletedTask;
} }
}; };
}).AddScheme<AuthenticationSchemeOptions, AobaAuthenticationHandler>("Aoba", null); }).AddScheme<AuthenticationSchemeOptions, AobaAuthenticationHandler>("Aoba", null);
builder.Services.AddAoba(); builder.Services.AddAoba();
builder.Services.Configure<FormOptions>(opt => #if DEBUG
{ builder.Services.AddHostedService<DebugService>();
opt.ValueLengthLimit = int.MaxValue; #endif
opt.MultipartBodyLengthLimit = int.MaxValue; builder.Services.Configure<FormOptions>(opt =>
}); {
opt.ValueLengthLimit = int.MaxValue;
var app = builder.Build(); opt.MultipartBodyLengthLimit = long.MaxValue;
});
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) var app = builder.Build();
{
app.UseExceptionHandler("/Home/Error"); // Configure the HTTP request pipeline.
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. if (!app.Environment.IsDevelopment())
app.UseHsts(); {
} app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); app.UseHsts();
app.UseStaticFiles(); }
app.UseRouting();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.UseStaticFiles();
app.UseCors(); app.UseRouting();
app.UseAuthentication(); app.UseCors();
app.UseAuthorization();
app.MapControllers(); app.UseAuthentication();
app.MapObserability(); app.UseAuthorization();
app.MapGrpcService<AobaRpcService>()
.RequireAuthorization() app.MapControllers();
.RequireCors("RPC"); app.MapObserability();
app.MapGrpcService<MetricsRpcService>() app.MapGrpcService<AobaRpcService>()
.RequireAuthorization() .RequireAuthorization()
.RequireCors("RPC"); .RequireCors("RPC");
app.MapGrpcService<AobaAuthService>() app.MapGrpcService<MetricsRpcService>()
.AllowAnonymous() .RequireAuthorization()
.RequireCors("RPC"); .RequireCors("RPC");
app.MapFallbackToFile("index.html"); app.MapGrpcService<AobaAuthService>()
.AllowAnonymous()
app.Run(); .RequireCors("RPC");
app.MapFallbackToFile("index.html");
app.Run();

View File

@@ -0,0 +1,19 @@
using AobaCore.Services;
namespace AobaServer.Services;
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
foreach (var item in mediaItems)
{
foreach (var size in item.Thumbnails.Keys)
{
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
}
}
}
}