36 Commits

Author SHA1 Message Date
511e62b58c sticky pagination
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m6s
2025-12-28 15:20:21 -05:00
41aa78b672 added pagination controls
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m33s
2025-12-28 14:29:46 -05:00
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
f98429159f Fix incorrect dispose
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-23 16:22:09 -04:00
3dca408356 Cleanup potential memory leaks
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-23 15:45:35 -04:00
1655e342b7 Removed avif + heif support 2025-07-17 20:02:41 -04:00
7223c35658 Added avif + heif support
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-17 15:09:16 -04:00
b85bcd1c7a Add media url to MediaModel+Start work on tonenized search query model
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-12 18:10:21 -04:00
8536d335bc Make Search Sticky 2025-07-12 13:52:32 -04:00
b34f7436c2 fix cirucular deps
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
added additonal file types
2025-07-09 21:08:10 -04:00
8b5803c085 Make media seekable
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 20:48:55 -04:00
5e2a7b53c9 Added additional media types
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 20:30:45 -04:00
d88cfa2cd3 Fixes to video thumb generation
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 20:06:59 -04:00
cd30876beb Fixed user selection
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 17:44:49 -04:00
544645b73b Fixed ffmpeg install
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 17:21:11 -04:00
76be6dc8e7 Fix Auth info
Some checks failed
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-06 01:43:14 -04:00
bc8a4414cd Fix login
All checks were successful
Build and Push Image / build-and-push (push) Successful in 5m23s
2025-07-06 01:37:32 -04:00
7427bbb576 refactor proto files + added metrics token
All checks were successful
Build and Push Image / build-and-push (push) Successful in 5m24s
2025-07-06 01:28:52 -04:00
cc64675c9c metrics service
fixes to auth info
2025-07-05 23:55:40 -04:00
47 changed files with 2621 additions and 718 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

@@ -2,6 +2,8 @@
@import "colors"; @import "colors";
* { * {
box-sizing: border-box; box-sizing: border-box;
scrollbar-color: $accentColor transparent;
scrollbar-width: thin;
} }
:root { :root {
@@ -18,6 +20,9 @@
.stickyTop { .stickyTop {
top: 0; top: 0;
position: sticky; position: sticky;
z-index: 100;
backdrop-filter: blur(20px);
box-shadow: 0 3px 10px $mainBGColor;
} }
body { body {
@@ -34,6 +39,8 @@ body {
#content { #content {
grid-area: Content; grid-area: Content;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto;
height: 100dvh;
padding: 10px; padding: 10px;
/* margin-left: $navBarSize; */ /* margin-left: $navBarSize; */
} }
@@ -152,3 +159,62 @@ 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;
}
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 5;
padding: 10px;
a {
transition: all 0.25s ease-out;
cursor: pointer;
user-select: none;
padding: 5px 10px;
background-color: $featureColor;
border-radius: 4px;
&:hover {
background-color: $focusColor;
}
}
}

View File

@@ -8,8 +8,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.build_server(false) .build_server(false)
.build_client(true) .build_client(true)
.compile_protos( .compile_protos(
&["../AobaServer/Proto/Aoba.proto", "../AobaServer/Proto/Auth.proto"], &[
&["../AobaServer/Proto/"], "../AobaServer/Proto/Aoba.proto",
"../AobaServer/Proto/Auth.proto",
"../AobaServer/Proto/Metrics.proto",
"../AobaServer/Proto/Types.proto",
],
&["../AobaServer/"],
)?; )?;
forward_env(); forward_env();
Ok(()) Ok(())

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

@@ -9,6 +9,8 @@ use crate::{
#[derive(PartialEq, Clone, Props)] #[derive(PartialEq, Clone, Props)]
pub struct MediaGridProps { pub struct MediaGridProps {
pub query: Option<String>, pub query: Option<String>,
pub max_page: Signal<i32>,
pub total_items: Signal<i32>,
#[props(default = Some(1))] #[props(default = Some(1))]
pub page: Option<i32>, pub page: Option<i32>,
#[props(default = Some(100))] #[props(default = Some(100))]
@@ -33,12 +35,13 @@ impl Into<PageFilter> for MediaGridProps {
} }
#[component] #[component]
pub fn MediaGrid(props: MediaGridProps) -> Element { pub fn MediaGrid(mut props: MediaGridProps) -> Element {
let media_result = use_resource(use_reactive!(|(props)| async move { let media_result = use_resource(use_reactive!(|(props)| async move {
let mut client = get_rpc_client(); let mut client = get_rpc_client();
let result = client.list_media(props.into_request()).await; let result = client.list_media(props.into_request()).await;
if let Ok(items) = result { if let Ok(items) = result {
return Ok(items.into_inner()); let res = items.into_inner();
return Ok(res);
} else { } else {
let err = result.err().unwrap(); let err = result.err().unwrap();
let message = err.message(); let message = err.message();
@@ -48,14 +51,24 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
match media_result.cloned() { match media_result.cloned() {
Some(value) => match value { Some(value) => match value {
Ok(result) => rsx! { Ok(result) => {
div { let pagination = result.pagination.unwrap();
class: "mediaGrid", let total_pages = pagination.total_pages;
{result.items.iter().map(|itm| rsx!{ let total_items = pagination.total_items;
MediaItem { item: Some(itm.clone()) } props.max_page.set(total_pages.max(1));
})}, props.total_items.set(total_items.max(1));
} return rsx! {
}, div {
class: "mediaGrid",
// oncontextmenu: oncontext,
{result.items.iter().map(|itm| rsx!{
MediaItem {
item: itm.clone()
}
})},
}
};
}
Err(msg) => rsx! { Err(msg) => rsx! {
div { div {
class: "mediaGrid", class: "mediaGrid",
@@ -69,7 +82,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,21 +1,73 @@
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 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 { class: "mediaItem", href: "{HOST}/m/{id}", target: "_blank", a {
class: "mediaItem",
href: "{HOST}{url}",
target: "_blank",
oncontextmenu: oncontext,
"data-id" : id,
img { src: "{HOST}{thumb}" } img { src: "{HOST}{thumb}" }
span { class: "info", span { class: "info",
span { class: "name", "{filename}" } span { class: "name", "{filename}" }
@@ -24,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

@@ -0,0 +1,25 @@
use dioxus::prelude::*;
use crate::rpc::get_metrics_rpc_client;
#[component]
pub fn MetricsToken() -> Element {
let token = use_resource(async move || {
let response = get_metrics_rpc_client().get_token(()).await;
if let Ok(d) = response {
let jwt = d.into_inner();
return jwt.token;
}
return "".to_string();
});
let token_value = token.cloned().unwrap_or("".to_string());
return rsx! {
pre {
class: "codeSelect",
"{token_value}"
}
};
}

View File

@@ -1,12 +1,18 @@
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 navbar; mod navbar;
mod notif; mod notif;
mod pagination;
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 navbar::*; pub use navbar::*;
pub use notif::*; pub use notif::*;
pub use pagination::*;
pub use search::*; pub use search::*;
mod icons; mod icons;

View File

@@ -1,5 +1,3 @@
use std::env;
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::{Route, contexts::AuthContext, env::APP_VERSION}; use crate::{Route, contexts::AuthContext, env::APP_VERSION};
@@ -24,7 +22,7 @@ pub fn Navbar() -> Element {
pub fn MainNaviagation() -> Element { pub fn MainNaviagation() -> Element {
rsx! { rsx! {
div { class: "mainNav", div { class: "mainNav",
Link { class: "navItem", to: Route::Home {}, "Home" } Link { class: "navItem", to: Route::Home { }, "Home" }
Link { class: "navItem", to: Route::Settings {}, "Settings" } Link { class: "navItem", to: Route::Settings {}, "Settings" }
} }
} }

View File

@@ -0,0 +1,30 @@
use dioxus::prelude::*;
#[component]
pub fn Pagination(page: Signal<i32>, max_page: Signal<i32>, item_count: Signal<i32>) -> Element {
let cur_page_val = page.cloned();
let max_page_val = max_page.cloned();
let item_count_val = item_count.cloned();
rsx! {
div {
class: "pagination",
a {
onclick: move|_| page.set(1),
"First"
}
a {
onclick: move|_| page.set((cur_page_val - 1).max(1)),
"Prev"
}
div { "Page {cur_page_val} of {max_page_val} ({item_count_val} Media Items)" }
a {
onclick: move|_| page.set((cur_page_val + 1).min(max_page_val)),
"Next"
}
a {
onclick: move|_| page.set(max_page_val),
"Last"
}
}
}
}

View File

@@ -1,14 +1,14 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[component] #[component]
pub fn Search(query: Signal<String>) -> Element { pub fn Search(query: Signal<String>, page: Signal<i32>) -> Element {
rsx! { rsx! {
div { class: "searchBar stickyTop", div { class: "searchBar",
input { input {
r#type: "search", r#type: "search",
placeholder: "Search Files", placeholder: "Search Files",
value: query, value: query,
oninput: move |event| query.set(event.value()), oninput: move |event| {query.set(event.value()); page.set(1);},
} }
} }
} }

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

@@ -1,6 +1,6 @@
use crate::{ use crate::{
layouts::MainLayout, layouts::MainLayout,
views::{Home, Settings}, views::{Home, Media, Settings},
}; };
use dioxus::prelude::*; use dioxus::prelude::*;
@@ -8,8 +8,11 @@ use dioxus::prelude::*;
#[rustfmt::skip] #[rustfmt::skip]
pub enum Route { pub enum Route {
#[layout(MainLayout)] #[layout(MainLayout)]
#[route("/")] #[route("/")]
Home {}, Home { },
#[route("/media/:id")]
Media { id: String },
#[route("/settings")] #[route("/settings")]
Settings {}, Settings {},
// #[end_layout] // #[end_layout]

View File

@@ -1,19 +1,22 @@
use std::sync::RwLock; use std::sync::RwLock;
use aoba::{aoba_rpc_client::AobaRpcClient, auth_rpc_client::AuthRpcClient}; use aoba::aoba_rpc_client::AobaRpcClient;
use tonic::service::{Interceptor, interceptor::InterceptedService}; use tonic::service::{Interceptor, interceptor::InterceptedService};
use tonic_web_wasm_client::Client; use tonic_web_wasm_client::Client;
use crate::RPC_HOST; use crate::{
RPC_HOST,
rpc::aoba::{auth_rpc_client::AuthRpcClient, metrics_rpc_client::MetricsRpcClient},
};
pub mod aoba { pub mod aoba {
tonic::include_proto!("aoba"); tonic::include_proto!("aoba");
tonic::include_proto!("aoba.auth");
} }
static RPC_CLIENT: RpcConnection = RpcConnection { static RPC_CLIENT: RpcConnection = RpcConnection {
aoba: RwLock::new(None), aoba: RwLock::new(None),
auth: RwLock::new(None), auth: RwLock::new(None),
metrics: RwLock::new(None),
jwt: RwLock::new(None), jwt: RwLock::new(None),
}; };
@@ -21,6 +24,7 @@ static RPC_CLIENT: RpcConnection = RpcConnection {
pub struct RpcConnection { pub struct RpcConnection {
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>, aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
auth: RwLock<Option<AuthRpcClient<Client>>>, auth: RwLock<Option<AuthRpcClient<Client>>>,
metrics: RwLock<Option<MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
jwt: RwLock<Option<String>>, jwt: RwLock<Option<String>>,
} }
@@ -35,12 +39,19 @@ impl RpcConnection {
return self.auth.read().unwrap().clone().unwrap(); return self.auth.read().unwrap().clone().unwrap();
} }
pub fn get_metrics_client(&self) -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
self.ensure_client();
return self.metrics.read().unwrap().clone().unwrap();
}
fn ensure_client(&self) { fn ensure_client(&self) {
if self.aoba.read().unwrap().is_none() { if self.aoba.read().unwrap().is_none() {
let wasm_client = Client::new(RPC_HOST.into()); let wasm_client = Client::new(RPC_HOST.into());
let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor); let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor);
*self.aoba.write().unwrap() = Some(aoba_client); *self.aoba.write().unwrap() = Some(aoba_client);
*self.auth.write().unwrap() = Some(AuthRpcClient::new(wasm_client.clone())); *self.auth.write().unwrap() = Some(AuthRpcClient::new(wasm_client.clone()));
*self.metrics.write().unwrap() =
Some(MetricsRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor));
} }
} }
} }
@@ -66,6 +77,9 @@ pub fn get_auth_rpc_client() -> AuthRpcClient<Client> {
return RPC_CLIENT.get_auth_client(); return RPC_CLIENT.get_auth_client();
} }
pub fn get_metrics_rpc_client() -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
return RPC_CLIENT.get_metrics_client();
}
pub fn login(jwt: String) { pub fn login(jwt: String) {
*RPC_CLIENT.jwt.write().unwrap() = Some(jwt); *RPC_CLIENT.jwt.write().unwrap() = Some(jwt);
} }

View File

@@ -1,12 +1,18 @@
use crate::components::{MediaGrid, Search}; use crate::components::{MediaGrid, Pagination, Search};
use dioxus::prelude::*; use dioxus::prelude::*;
#[component] #[component]
pub fn Home() -> Element { pub fn Home() -> Element {
let query = use_signal(|| "".to_string()); let query = use_signal(|| "".to_string());
let page = use_signal(|| 1 as i32);
let max_page = use_signal(|| 1 as i32);
let item_count = use_signal(|| 0 as i32);
rsx! { rsx! {
Search { query } div {
MediaGrid { query: query.cloned() } class: "stickyTop",
Search { query, page },
Pagination { page, max_page, item_count },
}
MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
} }
} }

View File

@@ -0,0 +1,8 @@
use dioxus::prelude::*;
#[component]
pub fn Media(id: String) -> Element {
rsx! {
{id}
}
}

View File

@@ -1,7 +1,9 @@
mod home; mod home;
mod login; mod login;
mod media;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
pub use media::*;
mod settings; mod settings;
pub use settings::Settings; pub use settings::Settings;

View File

@@ -1,6 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::rpc::get_rpc_client; use crate::{components::MetricsToken, rpc::get_rpc_client};
#[component] #[component]
pub fn Settings() -> Element { pub fn Settings() -> Element {
@@ -27,5 +27,6 @@ pub fn Settings() -> Element {
div { div {
pre { class: "codeSelect", "{d}" } pre { class: "codeSelect", "{d}" }
} }
MetricsToken { }
} }
} }

View File

@@ -9,14 +9,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" /> <PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
<PackageReference Include="MaybeError" Version="1.1.0" /> <PackageReference Include="MaybeError" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" /> <PackageReference Include="MongoDB.Driver" Version="3.4.1" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" /> <PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" /> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="ZLinq" Version="1.4.12" /> <PackageReference Include="ZLinq" Version="1.5.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -16,15 +16,9 @@ using System.Threading.Tasks;
namespace AobaCore; namespace AobaCore;
public static class Extensions public static class Extensions
{ {
public static IServiceCollection AddAoba(this IServiceCollection services, string dbString) public static IServiceCollection AddAoba(this IServiceCollection services)
{ {
var settings = MongoClientSettings.FromConnectionString(dbString);
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
var dbClient = new MongoClient(settings);
var db = dbClient.GetDatabase("Aoba");
services.AddSingleton(dbClient);
services.AddSingleton<IMongoDatabase>(db);
services.AddSingleton<AobaService>(); services.AddSingleton<AobaService>();
services.AddSingleton<ThumbnailService>(); services.AddSingleton<ThumbnailService>();
services.AddSingleton<AccountsService>(); services.AddSingleton<AccountsService>();

View File

@@ -1,6 +1,8 @@
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using SixLabors.ImageSharp;
namespace AobaCore.Models; namespace AobaCore.Models;
[BsonIgnoreExtraElements] [BsonIgnoreExtraElements]
@@ -16,23 +18,27 @@ public class Media
public ObjectId Owner { get; set; } public ObjectId Owner { get; set; }
public DateTime UploadDate { get; set; } public DateTime UploadDate { get; set; }
public string[] Tags { get; set; } = []; public string[] Tags { get; set; } = [];
public Size? Dimensions { get; set; }
public Dictionary<ThumbnailSize, ObjectId> Thumbnails { get; set; } = []; public Dictionary<ThumbnailSize, ObjectId> Thumbnails { get; set; } = [];
public static readonly Dictionary<string, MediaType> KnownTypes = new() public static readonly Dictionary<string, MediaType> KnownTypes = new()
{ {
{ ".jpg", MediaType.Image }, { ".jpg", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".jpeg", MediaType.Image }, { ".jpeg", MediaType.Image },
{ ".jxr", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".png", MediaType.Image }, { ".png", MediaType.Image },
{ ".apng", MediaType.Image }, { ".apng", MediaType.Image },
{ ".webp", MediaType.Image }, { ".webp", MediaType.Image },
{ ".qoi", MediaType.Image },
{ ".ico", MediaType.Image }, { ".ico", MediaType.Image },
{ ".gif", MediaType.Image }, { ".gif", MediaType.Image },
{ ".mp3", MediaType.Audio }, { ".mp3", MediaType.Audio },
{ ".flac", MediaType.Audio }, { ".flac", MediaType.Audio },
{ ".alac", MediaType.Audio }, { ".alac", MediaType.Audio },
{ ".mp4", MediaType.Video }, { ".mp4", MediaType.Video },
{ ".m4v", MediaType.Video },
{ ".webm", MediaType.Video }, { ".webm", MediaType.Video },
{ ".mov", MediaType.Video }, { ".mov", MediaType.Video },
{ ".avi", MediaType.Video }, { ".avi", MediaType.Video },
@@ -50,6 +56,10 @@ public class Media
{ ".xml", MediaType.Code }, { ".xml", MediaType.Code },
{ ".json", MediaType.Code }, { ".json", MediaType.Code },
{ ".py", MediaType.Code }, { ".py", MediaType.Code },
{ ".rs", MediaType.Code },
{ ".zed", MediaType.Code },
{ ".ts", MediaType.Code },
{ ".astro", MediaType.Code },
}; };
[BsonConstructor] [BsonConstructor]
@@ -75,7 +85,7 @@ public class Media
{ {
return this switch return this switch
{ {
//Media { MediaType: MediaType.Raw or MediaType.Text or MediaType.Code} => $"/i/dl/{MediaId}/{Filename}", Media { MediaType: MediaType.Raw or MediaType.Text or MediaType.Code } => $"/m/{MediaId}/{Uri.EscapeDataString(Filename)}",
_ => $"/m/{MediaId}" _ => $"/m/{MediaId}"
}; };
} }

View File

@@ -5,13 +5,13 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace AobaCore.Models; namespace AobaCore.Models;
public class PagedResult<T>(List<T> items, int page, int pageSize, long totalItems) public class PagedResult<T>(List<T> items, int page, int pageSize, int totalItems)
{ {
public List<T> Items { get; set; } = items; public List<T> Items { get; set; } = items;
public int Page { get; set; } = page; public int Page { get; set; } = page;
public int PageSize { get; set; } = pageSize; public int PageSize { get; set; } = pageSize;
public long TotalItems { get; set; } = totalItems; public int TotalItems { get; set; } = totalItems;
public long TotalPages { get; set; } = totalItems / pageSize; public int TotalPages { get; set; } = (totalItems / pageSize) + 1;
public string? Query { get; set; } public string? Query { get; set; }
} }

View File

@@ -2,6 +2,8 @@ using AobaCore.Models;
using MaybeError.Errors; using MaybeError.Errors;
using Microsoft.Extensions.Logging;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Driver.GridFS; using MongoDB.Driver.GridFS;
@@ -35,9 +37,14 @@ public class AobaService(IMongoDatabase db)
var total = await find.CountDocumentsAsync(); var total = await find.CountDocumentsAsync();
page -= 1; page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync(); var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total); return new PagedResult<Media>(items, page, pageSize, (int)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)
{ {
@@ -51,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);
@@ -69,6 +83,7 @@ public class AobaService(IMongoDatabase db)
{ {
var fileId = await _gridFs.UploadFromStreamAsync(filename, data, cancellationToken: cancellationToken); var fileId = await _gridFs.UploadFromStreamAsync(filename, data, cancellationToken: cancellationToken);
var media = new Media(fileId, filename, owner); var media = new Media(fileId, filename, owner);
await AddMediaAsync(media, cancellationToken); await AddMediaAsync(media, cancellationToken);
return media; return media;
} }
@@ -76,6 +91,10 @@ public class AobaService(IMongoDatabase db)
{ {
return ex; return ex;
} }
finally
{
data.Dispose();
}
} }
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default) public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default)

View File

@@ -3,6 +3,7 @@
using FFMpegCore; using FFMpegCore;
using FFMpegCore.Pipes; using FFMpegCore.Pipes;
using MaybeError.Errors; using MaybeError.Errors;
using MongoDB.Bson; using MongoDB.Bson;
@@ -10,10 +11,12 @@ using MongoDB.Driver;
using MongoDB.Driver.GridFS; using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Processing; 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;
@@ -24,7 +27,28 @@ namespace AobaCore.Services;
public class ThumbnailService(IMongoDatabase db, AobaService aobaService) public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{ {
private readonly GridFSBucket _gridfs = new GridFSBucket(db); private readonly GridFSBucket _gridfs = new GridFSBucket(db);
private Lock _lock = new();
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>
/// ///
@@ -35,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;
@@ -92,17 +117,29 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{ {
return type switch return type switch
{ {
MediaType.Image => await GenerateImageThumbnailAsync(stream, size, cancellationToken), MediaType.Image => await GenerateImageThumbnailAsync(stream, size, ext, cancellationToken),
MediaType.Video => GenerateVideoThumbnail(stream, size, cancellationToken), MediaType.Video => GenerateVideoThumbnail(stream, size, cancellationToken),
MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken), MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
_ => new Error($"No Thumbnail for {type}"), _ => new Error($"No Thumbnail for {type}"),
}; };
} }
public static async Task<Stream> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, CancellationToken cancellationToken = default) private static Maybe<Image> LoadImage(Stream stream, string ext)
{ {
var img = Image.Load(stream); if (ext is ".heif" or ".avif")
img.Mutate(o => {
return new Error("Unsupported image type");
}
else
return Image.Load(stream);
}
public static async Task<Maybe<Stream>> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, string ext, CancellationToken cancellationToken = default)
{
var img = LoadImage(stream, ext);
if (img.HasError)
return img.Error;
img.Value.Mutate(o =>
{ {
var size = var size =
o.Resize(new ResizeOptions o.Resize(new ResizeOptions
@@ -113,33 +150,85 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
}); });
}); });
var result = new MemoryStream(); var result = new MemoryStream();
await img.SaveAsWebpAsync(result, cancellationToken); await img.Value.SaveAsWebpAsync(result, cancellationToken);
img.Value.Dispose();
result.Position = 0; result.Position = 0;
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 source = new MemoryStream(); var fn = ObjectId.GenerateNewId().ToString();
var filePath = $"/tmp/{fn}.in";
using var source = new FileStream(filePath, FileMode.CreateNew);
data.CopyTo(source); data.CopyTo(source);
source.Position = 0; source.Flush();
var output = new MemoryStream(); source.Dispose();
FFMpegArguments.FromPipeInput(new StreamPipeSource(source), opt => data.Dispose();
try
{ {
opt.WithCustomArgument("-t 5"); var output = new MemoryStream();
}).OutputToPipe(new StreamPipeSink(output), opt => FFMpegArguments.FromFileInput(filePath, false, opt =>
{
opt.WithCustomArgument("-t 5");
}).OutputToPipe(new StreamPipeSink(output), opt =>
{
opt.WithCustomArgument($"-vf \"crop='min(in_w,in_h)':'min(in_w,in_h)',scale={w}:{w}\" -loop 0 -r 15")
.ForceFormat("webp");
}).ProcessSynchronously();
output.Position = 0;
return output;
}
catch (Exception ex)
{ {
opt.WithCustomArgument($"-vf \"crop='min(in_w,in_h)':'min(in_w,in_h)',scale={w}:{w}\" -loop 0 -r 15") return ex;
.ForceFormat("webp"); }
}).Configure(cfg => finally
{ {
#if !DEBUG File.Delete(filePath);
cfg.BinaryFolder = "/usr/bin"; }
#endif }
}).ProcessSynchronously();
output.Position = 0; public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
return output; {
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)

View File

@@ -16,7 +16,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" /> <PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" /> <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="MimeTypesMap" Version="1.0.9" /> <PackageReference Include="MimeTypesMap" Version="1.0.9" />
@@ -24,7 +24,7 @@
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -34,6 +34,8 @@
<ItemGroup> <ItemGroup>
<Protobuf Include="Proto\Aoba.proto"></Protobuf> <Protobuf Include="Proto\Aoba.proto"></Protobuf>
<Protobuf Include="Proto\Auth.proto"></Protobuf> <Protobuf Include="Proto\Auth.proto"></Protobuf>
<Protobuf Include="Proto\Metrics.proto"></Protobuf>
<Protobuf Include="Proto\Types.proto"></Protobuf>
</ItemGroup> </ItemGroup>
</Project> </Project>

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

@@ -1,6 +1,7 @@
using AobaCore.Services; using AobaCore.Services;
using AobaServer.Models; using AobaServer.Models;
using AobaServer.Services;
using AobaServer.Utils; using AobaServer.Utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -16,7 +17,7 @@ namespace AobaServer.Controllers;
#if DEBUG #if DEBUG
[AllowAnonymous] [AllowAnonymous]
[Route("auth")] [Route("auth")]
public class AuthController(AccountsService accountsService, AuthInfo authInfo) : Controller public class AuthController(AccountsService accountsService, AuthConfigService authConfig) : Controller
{ {
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string username, [FromForm] string password, CancellationToken cancellationToken) public async Task<IActionResult> Login([FromForm] string username, [FromForm] string password, CancellationToken cancellationToken)
@@ -25,6 +26,7 @@ public class AuthController(AccountsService accountsService, AuthInfo authInfo)
if (user == null) if (user == null)
return Problem("Invalid login Credentials", statusCode: StatusCodes.Status400BadRequest); return Problem("Invalid login Credentials", statusCode: StatusCodes.Status400BadRequest);
var authInfo = await authConfig.GetDefaultAuthInfoAsync();
Response.Cookies.Append("token", user.GetToken(authInfo), new CookieOptions Response.Cookies.Append("token", user.GetToken(authInfo), new CookieOptions
{ {
IsEssential = true, IsEssential = true,

View File

@@ -14,10 +14,11 @@ 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}/{*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)
{ {
var file = await aobaService.GetFileStreamAsync(id, cancellationToken: cancellationToken); var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
if (file.HasError) if (file.HasError)
{ {
logger.LogError(file.Error.Exception, "Failed to load media stream"); logger.LogError(file.Error.Exception, "Failed to load media stream");
@@ -28,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>
@@ -58,6 +75,7 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
} }
[HttpGet("/t/{id}")] [HttpGet("/t/{id}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, CancellationToken cancellationToken = default) public async Task<IActionResult> ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, CancellationToken cancellationToken = default)
{ {
var thumb = await thumbnailService.GetThumbnailByFileIdAsync(id, cancellationToken); var thumb = await thumbnailService.GetThumbnailByFileIdAsync(id, cancellationToken);

View File

@@ -22,23 +22,24 @@ 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 #libvips libvips-tools
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
# This stage is used to build the service project # This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["AobaServer/AobaServer.csproj", "AobaServer/"] COPY ["AobaServer/AobaServer.csproj", "AobaServer/"]
@@ -47,7 +48,7 @@ COPY . .
# Copy Built bundle from client builder # Copy Built bundle from client builder
COPY --from=client-builder /app/AobaClient/target/dx/aoba-client/release/web/public /src/AobaServer/wwwroot COPY --from=client-builder /app/AobaClient/target/dx/aoba-client/release/web/public /src/AobaServer/wwwroot
WORKDIR "/src/AobaServer" WORKDIR "/src/AobaServer"
RUN dotnet build "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/build # RUN dotnet build "./AobaServer.csproj" -c $BUILD_CONFIGURATION #-o /app/build
# This stage is used to publish the service project to be copied to the final stage # This stage is used to publish the service project to be copied to the final stage
FROM build AS publish FROM build AS publish
@@ -57,8 +58,8 @@ RUN dotnet publish "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) # This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
COPY --from=client-builder /bin/ffmpeg /bin/ffprobe /bin/ffplay /usr/bin/ ARG VERSION
ENV APP_VERSION=$VERSION ENV APP_VERSION=$VERSION
ENTRYPOINT ["dotnet", "AobaServer.dll"] ENTRYPOINT ["dotnet", "AobaServer.dll"]

View File

@@ -1,4 +1,6 @@
using MongoDB.Bson.IO; using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization.Attributes;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
@@ -7,6 +9,8 @@ namespace AobaServer.Models;
public class AuthInfo public class AuthInfo
{ {
[BsonId]
public ObjectId Id { get; set; }
public required string Issuer { get; set; } public required string Issuer { get; set; }
public required string Audience { get; set; } public required string Audience { get; set; }
public required byte[] SecureKey { get; set; } public required byte[] SecureKey { get; set; }

View File

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

View File

@@ -3,6 +3,7 @@ import "google/protobuf/empty.proto";
option csharp_namespace = "Aoba.RPC"; option csharp_namespace = "Aoba.RPC";
package aoba; package aoba;
import "Proto/Types.proto";
service AobaRpc { service AobaRpc {
rpc GetMedia (Id) returns (MediaResponse); rpc GetMedia (Id) returns (MediaResponse);
@@ -13,73 +14,3 @@ service AobaRpc {
rpc GetShareXDestination(google.protobuf.Empty) returns (ShareXResponse); rpc GetShareXDestination(google.protobuf.Empty) returns (ShareXResponse);
} }
message PageFilter {
optional int32 page = 1;
optional int32 pageSize = 2;
optional string query = 3;
}
message Id {
string value = 1;
}
message MediaResponse {
oneof result {
MediaModel value = 1;
google.protobuf.Empty empty = 2;
}
}
message ListResponse {
repeated MediaModel items = 1;
Pagination pagination = 2;
}
message Pagination {
int32 page = 1;
int32 pageSize = 2;
int64 totalPages = 3;
int64 totalItems = 4;
optional string query = 5;
}
message UserResponse {
oneof userResult {
UserModel user = 1;
google.protobuf.Empty empty = 2;
}
}
message UserModel {
Id id = 1;
string username = 2;
string email = 3;
bool isAdmin = 4;
}
message MediaModel {
Id id = 1;
string fileName = 2;
MediaType mediaType = 3;
string ext = 4;
int32 viewCount = 5;
Id owner = 6;
string thumbUrl = 7;
}
enum MediaType {
Image = 0;
Audio = 1;
Video = 2;
Text = 3;
Code = 4;
Raw = 5;
}
message ShareXResponse {
oneof dstResult {
string destination = 1;
string error = 2;
}
}

View File

@@ -1,33 +1,12 @@
syntax = "proto3"; syntax = "proto3";
option csharp_namespace = "Aoba.RPC.Auth"; option csharp_namespace = "Aoba.RPC.Auth";
package aoba.Auth; package aoba;
import "Proto/Types.proto";
service AuthRpc { service AuthRpc {
rpc Login(Credentials) returns (LoginResponse); rpc Login(Credentials) returns (LoginResponse);
rpc LoginPasskey(PassKeyPayload) returns (LoginResponse); rpc LoginPasskey(PassKeyPayload) returns (LoginResponse);
} }
message Credentials{
string user = 1;
string password = 2;
}
message PassKeyPayload {
}
message Jwt{
string token = 1;
}
message LoginResponse{
oneof result {
Jwt jwt = 1;
LoginError error = 2;
}
}
message LoginError{
string message = 1;
}

View File

@@ -0,0 +1,12 @@
syntax = "proto3";
option csharp_namespace = "Aoba.RPC.Metrics";
package aoba;
import "google/protobuf/empty.proto";
import "Proto/Types.proto";
service MetricsRpc {
rpc GetToken(google.protobuf.Empty) returns (Jwt);
}

View File

@@ -0,0 +1,113 @@
syntax = "proto3";
option csharp_namespace = "Aoba.RPC";
package aoba;
import "google/protobuf/empty.proto";
message Credentials{
string user = 1;
string password = 2;
}
message PassKeyPayload {
}
message Jwt{
string token = 1;
}
message LoginResponse{
oneof result {
Jwt jwt = 1;
LoginError error = 2;
}
}
message LoginError{
string message = 1;
}
message PageFilter {
optional int32 page = 1;
optional int32 pageSize = 2;
optional string query = 3;
}
message Id {
string value = 1;
}
message MediaResponse {
oneof result {
MediaModel value = 1;
google.protobuf.Empty empty = 2;
}
}
message ListResponse {
repeated MediaModel items = 1;
Pagination pagination = 2;
}
message Pagination {
int32 page = 1;
int32 pageSize = 2;
int32 totalPages = 3;
int32 totalItems = 4;
optional string query = 5;
}
message UserResponse {
oneof userResult {
UserModel user = 1;
google.protobuf.Empty empty = 2;
}
}
message UserModel {
Id id = 1;
string username = 2;
string email = 3;
bool isAdmin = 4;
}
message MediaModel {
Id id = 1;
string fileName = 2;
MediaType mediaType = 3;
string ext = 4;
int32 viewCount = 5;
Id owner = 6;
string thumbUrl = 7;
string mediaUrl = 8;
}
enum MediaType {
Image = 0;
Audio = 1;
Video = 2;
Text = 3;
Code = 4;
Raw = 5;
}
message ShareXResponse {
oneof dstResult {
string destination = 1;
string error = 2;
}
}
message SearchQuery {
optional string queryText = 1;
repeated Filter filters = 2;
}
message Filter {
string key = 1;
repeated string values = 2;
}

View File

@@ -9,13 +9,12 @@ using AobaServer.Utils;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens; using Aoba.RPC;
using System.IdentityModel.Tokens.Jwt;
namespace AobaServer.Services; namespace AobaServer.Services;
public class AobaAuthService(AccountsService accountsService, AuthInfo authInfo) : Aoba.RPC.Auth.AuthRpc.AuthRpcBase public class AobaAuthService(AccountsService accountsService, AuthConfigService authConfig) : AuthRpc.AuthRpcBase
{ {
[AllowAnonymous] [AllowAnonymous]
public override async Task<LoginResponse> Login(Credentials request, ServerCallContext context) public override async Task<LoginResponse> Login(Credentials request, ServerCallContext context)
@@ -29,10 +28,11 @@ public class AobaAuthService(AccountsService accountsService, AuthInfo authInfo)
Message = "Invalid login credentials" Message = "Invalid login credentials"
} }
}; };
var authInfo = await authConfig.GetDefaultAuthInfoAsync();
var token = user.GetToken(authInfo); var token = user.GetToken(authInfo);
return new LoginResponse return new LoginResponse
{ {
Jwt = new Jwt Jwt = new ()
{ {
Token = token Token = token
} }

View File

@@ -16,7 +16,7 @@ using System.Text.Json.Serialization;
namespace AobaServer.Services; namespace AobaServer.Services;
public class AobaRpcService(AobaService aobaService, AccountsService accountsService, AuthInfo authInfo) : AobaRpc.AobaRpcBase public class AobaRpcService(AobaService aobaService, AccountsService accountsService, AuthConfigService authConfig) : AobaRpc.AobaRpcBase
{ {
public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context) public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context)
{ {
@@ -37,6 +37,7 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
var user = await accountsService.GetUserAsync(userId, context.CancellationToken); var user = await accountsService.GetUserAsync(userId, context.CancellationToken);
if (user == null) if (user == null)
return new ShareXResponse { Error = "User does not exist" }; return new ShareXResponse { Error = "User does not exist" };
var authInfo = await authConfig.GetDefaultAuthInfoAsync();
var token = user.GetToken(authInfo); var token = user.GetToken(authInfo);
var dest = new ShareXDestination var dest = new ShareXDestination
{ {

View File

@@ -0,0 +1,26 @@
using AobaServer.Models;
using MongoDB.Driver;
namespace AobaServer.Services;
public class AuthConfigService(IMongoDatabase db)
{
public IMongoCollection<AuthInfo> _authInfo = db.GetCollection<AuthInfo>("auth_config");
public async Task<AuthInfo> GetAuthInfoAsync(string issuer, string audience)
{
var info = await _authInfo.Find("{}").FirstOrDefaultAsync();
if(info != null)
return info;
info = AuthInfo.Create(issuer, audience);
await _authInfo.InsertOneAsync(info);
return info;
}
public Task<AuthInfo> GetDefaultAuthInfoAsync()
{
return GetAuthInfoAsync("aobaV2", "aoba");
}
}

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);
}
}
}
}

View File

@@ -0,0 +1,32 @@
using Aoba.RPC;
using Aoba.RPC.Auth;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace AobaServer.Services;
public class MetricsRpcService(AuthConfigService authConfig): Aoba.RPC.Metrics.MetricsRpc.MetricsRpcBase
{
public override async Task<Jwt> GetToken(Empty request, ServerCallContext context)
{
var authInfo = await authConfig.GetAuthInfoAsync("aoba", "metrics");
var handler = new JwtSecurityTokenHandler();
var jwt = handler.CreateEncodedJwt(new SecurityTokenDescriptor
{
Audience = authInfo.Audience,
Issuer = authInfo.Issuer,
IssuedAt = DateTime.UtcNow,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(authInfo.SecureKey), SecurityAlgorithms.HmacSha256)
});
return new Jwt { Token = jwt };
}
}

View File

@@ -56,6 +56,7 @@ public static class ProtoExtensions
Owner = media.Owner.ToId(), Owner = media.Owner.ToId(),
ViewCount = media.ViewCount, ViewCount = media.ViewCount,
ThumbUrl = thumbUrl, ThumbUrl = thumbUrl,
MediaUrl = media.GetMediaUrl()
}; };
} }