Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 719df155fb | |||
| e81d432b1f | |||
| 61d70aee28 | |||
| 73236e1fde | |||
| 90078a0f62 | |||
| 14a6e18966 | |||
| a6e2156d97 | |||
| 8ff4fa74e4 | |||
| 6c2305cac9 | |||
| 7a43d5c11f | |||
| 4325280020 | |||
| ec0c6a3487 | |||
| dd8faf8038 | |||
| bd99b4beac | |||
| 2517cd777f | |||
| ea9ad2f8a7 | |||
| 44425723c6 | |||
| 5caa08e145 | |||
| 44959589f8 | |||
| b1ab165665 | |||
| b8d01b567c | |||
| 3a5dde9ee3 | |||
| 9e09110b16 | |||
| 511e62b58c | |||
| 41aa78b672 | |||
| 21163b277d | |||
| 7a0d3b7f40 | |||
| 6d2b8c77b2 | |||
| 8bdd9edbb0 | |||
| 5e6b0b21a6 | |||
| 19274d444d | |||
| 63e2f9f791 | |||
| 8964d1c069 | |||
| 8808126905 | |||
| d36aaac836 | |||
| bb2c6c4683 | |||
| f8d457a096 | |||
| 165adb2775 | |||
| e832ccf07e | |||
| 6df6de098b | |||
| 364b23e62a | |||
| 7b2ed32043 | |||
| d094f7bbef | |||
| f98429159f | |||
| 3dca408356 | |||
| 1655e342b7 | |||
| 7223c35658 | |||
| b85bcd1c7a | |||
| 8536d335bc |
@@ -1,37 +1,39 @@
|
||||
name: "Build and Push Image"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkou code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Build
|
||||
uses: docker/setup-buildx-action@v3
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.kaisei.app
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
- name: Set up Docker Build
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract tag version
|
||||
id: extract_tag
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
- name: Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.kaisei.app
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
file: AobaServer/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: git.kaisei.app/amatsugu/aoba:${{ env.VERSION }}
|
||||
build-args: VERSION=${{ env.VERSION }}
|
||||
- name: Extract tag version
|
||||
id: extract_tag
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
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 }}
|
||||
|
||||
Generated
+1934
-577
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ edition = "2024"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dioxus = { version = "0.6.0", features = ["router"] }
|
||||
serde = "1.0.219"
|
||||
dioxus = { version = "0.7.5", features = ["router"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_repr = "0.1.20"
|
||||
tonic = { version = "*", default-features = false, features = [
|
||||
"codegen",
|
||||
@@ -15,7 +15,10 @@ tonic = { version = "*", default-features = false, features = [
|
||||
] }
|
||||
prost = "0.13"
|
||||
tonic-web-wasm-client = "0.7"
|
||||
web-sys = { version = "0.3.77", features = ["Storage", "Window"] }
|
||||
web-sys = { version = "0.3.91", features = ["Storage", "Window", "Navigator", "CredentialsContainer", "CredentialCreationOptions", "AttestationConveyancePreference", "PublicKeyCredentialCreationOptions", "PublicKeyCredentialRpEntity", "PublicKeyCredentialUserEntity"] }
|
||||
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
js-sys = "0.3.95"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = { version = "*", default-features = false, features = ["prost"] }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* This file contains the global styles for the styled dioxus components. You only
|
||||
* need to import this file once in your project root.
|
||||
*/
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--secondary-color-4);
|
||||
font-family: Inter, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--dark: initial;
|
||||
--light: ;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--dark: ;
|
||||
--light: initial;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Primary colors */
|
||||
--primary-color: var(--dark, #000) var(--light, #fff);
|
||||
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
|
||||
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
|
||||
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
|
||||
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
|
||||
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
|
||||
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
|
||||
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
|
||||
|
||||
/* Secondary colors */
|
||||
--secondary-color: var(--dark, #fff) var(--light, #000);
|
||||
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
|
||||
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
|
||||
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
|
||||
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
|
||||
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
|
||||
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
|
||||
|
||||
/* Highlight colors */
|
||||
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
|
||||
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
|
||||
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
|
||||
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
|
||||
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
|
||||
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
|
||||
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
|
||||
--contrast-error-color: var(--dark, var(--secondary-color-3))
|
||||
var(--light, var(--primary-color));
|
||||
--primary-info-color: var(--dark, var(--primary-color-5))
|
||||
var(--light, var(--primary-color));
|
||||
--secondary-info-color: var(--dark, var(--primary-color-7))
|
||||
var(--light, var(--secondary-color-3));
|
||||
}
|
||||
|
||||
/* Modern browsers with `scrollbar-*` support */
|
||||
@supports (scrollbar-width: auto) {
|
||||
:not(:hover) {
|
||||
scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%);
|
||||
}
|
||||
|
||||
:hover {
|
||||
scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy browsers with `::-webkit-scrollbar-*` support */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
:root::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
$mainBGColor: #584577;
|
||||
$featureColor: #ce2d4f;
|
||||
$accentColor: #f0eaf8;
|
||||
|
||||
$mainTextColor: #eee;
|
||||
$brightTextColor: #fff;
|
||||
$invertTextColor: #222;
|
||||
$invertBrightTextColor: #000;
|
||||
$mainBGColor: #584577;
|
||||
$featureColor: #ce2d4f;
|
||||
$focusColor: #ff3862;
|
||||
$accentColor: #f0eaf8;
|
||||
|
||||
$mainTextColor: #eee;
|
||||
$brightTextColor: #fff;
|
||||
$invertTextColor: #222;
|
||||
$invertBrightTextColor: #000;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@import "colors";
|
||||
div[role="menu"] {
|
||||
backdrop-filter: blur(10px) brightness(0.5) grayscale(1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 300px;
|
||||
width: auto;
|
||||
outline: none;
|
||||
width: max-content;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
@import "colors";
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
scrollbar-color: $accentColor transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -18,6 +20,9 @@
|
||||
.stickyTop {
|
||||
top: 0;
|
||||
position: sticky;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 3px 10px $mainBGColor;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -34,6 +39,8 @@ body {
|
||||
#content {
|
||||
grid-area: Content;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100dvh;
|
||||
padding: 10px;
|
||||
/* margin-left: $navBarSize; */
|
||||
}
|
||||
@@ -105,6 +112,14 @@ $mediaItemSize: 300px;
|
||||
|
||||
&.placeholder {
|
||||
}
|
||||
|
||||
&.blur img {
|
||||
filter: blur(20px);
|
||||
transition: filter 0.25s ease-out;
|
||||
}
|
||||
&.blur:hover img {
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,3 +167,70 @@ form {
|
||||
padding: 5px;
|
||||
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;
|
||||
grid-template-areas: "Icon Label";
|
||||
height: $size;
|
||||
transition: border 0.1s ease-out;
|
||||
cursor: default;
|
||||
width: 100%;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-area: Label;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
grid-area: Icon;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -3,13 +3,15 @@ use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
tonic_build::configure()
|
||||
.build_server(false)
|
||||
.build_client(true)
|
||||
.compile_protos(
|
||||
&[
|
||||
"../AobaServer/Proto/Aoba.proto",
|
||||
"../AobaServer/Proto/Account.proto",
|
||||
"../AobaServer/Proto/Auth.proto",
|
||||
"../AobaServer/Proto/Metrics.proto",
|
||||
"../AobaServer/Proto/Types.proto",
|
||||
@@ -20,15 +22,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn forward_env() {
|
||||
fn forward_env()
|
||||
{
|
||||
let dest_path = "./src/env.rs";
|
||||
let mut f = File::create(&dest_path).unwrap();
|
||||
f.write_all(b"// This file is automatically generated by build.rs\n\n")
|
||||
.unwrap();
|
||||
|
||||
dotenv().ok();
|
||||
for (key, value) in env::vars() {
|
||||
if key.starts_with("APP_") {
|
||||
for (key, value) in env::vars()
|
||||
{
|
||||
if key.starts_with("APP_")
|
||||
{
|
||||
f.write_all("#[allow(dead_code)]\n".as_bytes()).unwrap();
|
||||
let line = format!(
|
||||
"pub const {}: &'static str = \"{}\";\n",
|
||||
|
||||
@@ -7,7 +7,7 @@ pub struct InputProps {
|
||||
pub label: Option<String>,
|
||||
pub placeholder: Option<String>,
|
||||
pub name: String,
|
||||
pub oninput: Option<EventHandler<FormEvent>>,
|
||||
pub oninput: Option<EventHandler<Event<FormData>>>,
|
||||
pub required: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ pub fn Input(props: InputProps) -> Element {
|
||||
r#type: props.r#type.unwrap_or("text".into()),
|
||||
value: props.value,
|
||||
oninput: move |e| {
|
||||
if let Some(mut s) = props.value {
|
||||
s.set(e.value());
|
||||
}
|
||||
if let Some(mut s) = props.value {
|
||||
s.set(e.value());
|
||||
}
|
||||
if let Some(handler) = props.oninput{
|
||||
handler.call(e);
|
||||
}
|
||||
},
|
||||
name: props.name,
|
||||
placeholder: ph,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use super::props::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const CONTEXT_MENU_CSS: Asset = asset!("./style.scss");
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(props: ContextMenuProps) -> Element
|
||||
{
|
||||
rsx! {
|
||||
document::Link { rel: "stylesheet", href: CONTEXT_MENU_CSS }
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
class: "contextMenuTrigger",
|
||||
oncontextmenu: move|e|{
|
||||
|
||||
},
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
class: "contextMenuContent",
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div {
|
||||
class: "contextMenuItem",
|
||||
onclick: move |_|{
|
||||
props.on_select.call(props.value.clone());
|
||||
},
|
||||
div {
|
||||
class: "content",
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuNestedContent(props: ContextMenuNestedProps) -> Element
|
||||
{
|
||||
rsx! {
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod component;
|
||||
mod props;
|
||||
pub use component::*;
|
||||
@@ -0,0 +1,33 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ContextMenuProps
|
||||
{
|
||||
pub children: Element,
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ContextMenuItemProps
|
||||
{
|
||||
pub value: String,
|
||||
pub on_select: EventHandler<String>,
|
||||
pub children: Element,
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ContextMenuTriggerProps
|
||||
{
|
||||
pub children: Element,
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ContextMenuContentProps
|
||||
{
|
||||
pub children: Element,
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ContextMenuNestedProps
|
||||
{
|
||||
pub children: Element,
|
||||
}
|
||||
@@ -1,77 +1,158 @@
|
||||
use dioxus::prelude::*;
|
||||
use tonic::IntoRequest;
|
||||
|
||||
use crate::{
|
||||
components::MediaItem,
|
||||
rpc::{aoba::PageFilter, get_rpc_client},
|
||||
components::{MediaClassChangeEvent, MediaItem, MediaItemPlaceHolder},
|
||||
rpc::{
|
||||
aoba::{MediaModel, PageFilter},
|
||||
get_rpc_client,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
pub struct MediaGridProps {
|
||||
pub query: Option<String>,
|
||||
#[props(default = Some(1))]
|
||||
pub page: Option<i32>,
|
||||
#[props(default = Some(100))]
|
||||
pub page_size: Option<i32>,
|
||||
pub struct MediaGridProps
|
||||
{
|
||||
pub query: Signal<String>,
|
||||
pub max_page: Signal<i32>,
|
||||
pub total_items: Signal<i32>,
|
||||
pub page: Signal<i32>,
|
||||
pub page_size: Signal<i32>,
|
||||
pub on_page_loaded: Option<EventHandler<PaginationInfo>>,
|
||||
}
|
||||
|
||||
impl IntoRequest<PageFilter> for MediaGridProps {
|
||||
fn into_request(self) -> tonic::Request<PageFilter> {
|
||||
let f: PageFilter = self.into();
|
||||
f.into_request()
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<PageFilter> for MediaGridProps {
|
||||
fn into(self) -> PageFilter {
|
||||
PageFilter {
|
||||
page: self.page,
|
||||
page_size: self.page_size,
|
||||
query: self.query,
|
||||
}
|
||||
}
|
||||
pub struct PaginationInfo
|
||||
{
|
||||
pub total_pages: i32,
|
||||
pub total_items: i32,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MediaGrid(props: MediaGridProps) -> Element {
|
||||
pub fn MediaGrid(props: MediaGridProps) -> Element
|
||||
{
|
||||
let mut error_display = use_signal(|| {
|
||||
rsx! {}
|
||||
});
|
||||
let mut items = use_signal::<Option<Vec<MediaModel>>>(|| None);
|
||||
let media_result = use_resource(use_reactive!(|(props)| async move {
|
||||
// items.set(None);
|
||||
let mut client = get_rpc_client();
|
||||
let result = client.list_media(props.into_request()).await;
|
||||
if let Ok(items) = result {
|
||||
return Ok(items.into_inner());
|
||||
} else {
|
||||
let request = PageFilter {
|
||||
page_size: Some(props.page_size.cloned()),
|
||||
page: Some(props.page.cloned()),
|
||||
query: Some(props.query.cloned()),
|
||||
};
|
||||
let result = client.list_media(request).await;
|
||||
if let Ok(items) = result
|
||||
{
|
||||
let res = items.into_inner();
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
else
|
||||
{
|
||||
let err = result.err().unwrap();
|
||||
let message = err.message();
|
||||
return Err(format!("Failed to load results: {message}"));
|
||||
}
|
||||
}));
|
||||
|
||||
match media_result.cloned() {
|
||||
Some(value) => match value {
|
||||
Ok(result) => rsx! {
|
||||
div {
|
||||
class: "mediaGrid",
|
||||
{result.items.iter().map(|itm| rsx!{
|
||||
MediaItem { item: Some(itm.clone()) }
|
||||
})},
|
||||
}
|
||||
},
|
||||
Err(msg) => rsx! {
|
||||
div {
|
||||
class: "mediaGrid",
|
||||
div {
|
||||
"Failed to load results: {msg}"
|
||||
use_effect(move || match media_result()
|
||||
{
|
||||
Some(value) => match value
|
||||
{
|
||||
Ok(result) =>
|
||||
{
|
||||
if let Some(pagination) = result.pagination
|
||||
{
|
||||
let total_pages = pagination.total_pages;
|
||||
let total_items = pagination.total_items;
|
||||
if let Some(handler) = props.on_page_loaded
|
||||
{
|
||||
handler.call(PaginationInfo {
|
||||
total_pages,
|
||||
total_items,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
None => rsx! {
|
||||
div{
|
||||
class: "mediaGrid",
|
||||
{(0..50).map(|_| rsx!{
|
||||
MediaItem {}
|
||||
})}
|
||||
items.set(Some(result.items));
|
||||
error_display.set(rsx! {});
|
||||
}
|
||||
Err(msg) => error_display.set(rsx! {
|
||||
div{
|
||||
"Failed to load results: {msg}"
|
||||
}
|
||||
}),
|
||||
},
|
||||
_ =>
|
||||
{}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "mediaGrid",
|
||||
{error_display}
|
||||
{match items(){
|
||||
Some(itms) => rsx!{
|
||||
MediaList {
|
||||
items: itms,
|
||||
on_item_deleted: move |id|{
|
||||
if let Some(cur) = items.cloned(){
|
||||
let filtered = cur.iter()
|
||||
.filter(|i| i.id.clone().expect("No id").value != id)
|
||||
.map(|i|i.clone())
|
||||
.collect();
|
||||
items.set(Some(filtered));
|
||||
}
|
||||
},
|
||||
on_class_changed: move |e: MediaClassChangeEvent|{
|
||||
if let Some(cur) = items.cloned(){
|
||||
let updated = cur.iter()
|
||||
.map(|i|{
|
||||
let mut itm = i.clone();
|
||||
let id = itm.id.clone().expect("No id").value;
|
||||
if id == e.id{
|
||||
itm.class = e.class;
|
||||
}
|
||||
return itm;
|
||||
})
|
||||
.collect();
|
||||
items.set(Some(updated));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
None => rsx!{PlaceholderGrid { count: props.page_size.cloned() as usize }}
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlaceholderGrid(count: usize) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
class: "mediaGrid",
|
||||
{(0..count).map(|_| rsx!{
|
||||
MediaItemPlaceHolder { }
|
||||
})}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MediaList(
|
||||
items: Vec<MediaModel>,
|
||||
on_item_deleted: Option<EventHandler<String>>,
|
||||
on_class_changed: Option<EventHandler<MediaClassChangeEvent>>,
|
||||
) -> Element
|
||||
{
|
||||
rsx! {
|
||||
{items.iter().map(|itm| rsx!{
|
||||
MediaItem {
|
||||
item: itm.clone(),
|
||||
on_deleted: on_item_deleted,
|
||||
on_class_changed: on_class_changed
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,224 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_primitives::context_menu::{ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger};
|
||||
use tonic::{Response, Status};
|
||||
use web_sys::window;
|
||||
|
||||
use crate::{HOST, rpc::aoba::MediaModel};
|
||||
use crate::{
|
||||
HOST,
|
||||
rpc::{
|
||||
aoba::{Id, MediaModel, SetMediaClassRequest},
|
||||
get_rpc_client,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct MediaClassChangeEvent
|
||||
{
|
||||
pub id: String,
|
||||
pub class: i32,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
pub struct MediaItemProps {
|
||||
pub item: Option<MediaModel>,
|
||||
pub struct MediaItemProps
|
||||
{
|
||||
pub item: MediaModel,
|
||||
pub on_class_changed: Option<EventHandler<MediaClassChangeEvent>>,
|
||||
pub on_deleted: Option<EventHandler<String>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MediaItem(props: MediaItemProps) -> Element {
|
||||
if let Some(item) = props.item {
|
||||
let mtype = item.media_type().as_str_name();
|
||||
let filename = item.file_name;
|
||||
let id = item.id.unwrap().value;
|
||||
let thumb = item.thumb_url;
|
||||
return rsx! {
|
||||
a { class: "mediaItem", href: "{HOST}/m/{id}", target: "_blank",
|
||||
img { src: "{HOST}{thumb}" }
|
||||
span { class: "info",
|
||||
span { class: "name", "{filename}" }
|
||||
span { class: "details",
|
||||
span { "{mtype}" }
|
||||
span { "{item.view_count}" }
|
||||
pub fn MediaItem(props: MediaItemProps) -> Element
|
||||
{
|
||||
let item = props.item;
|
||||
let mtype = item.media_type().as_str_name();
|
||||
let filename = item.file_name;
|
||||
let id = item.id.unwrap().value;
|
||||
let thumb = item.thumb_url;
|
||||
let class = item.class;
|
||||
let class_string = match class
|
||||
{
|
||||
1 => "blur",
|
||||
2 => "secret",
|
||||
_ => "",
|
||||
};
|
||||
let url = item.media_url;
|
||||
let download = format!("{HOST}{url}");
|
||||
|
||||
return rsx! {
|
||||
ContextMenu{
|
||||
ContextMenuTrigger{
|
||||
a {
|
||||
class: "mediaItem {class_string}",
|
||||
href: "{HOST}{url}",
|
||||
target: "_blank",
|
||||
"data-id" : id.clone(),
|
||||
img { src: "{HOST}{thumb}" }
|
||||
span { class: "info",
|
||||
span { class: "name", "{filename}" }
|
||||
span { class: "details",
|
||||
span { "{mtype}" }
|
||||
span { "{item.view_count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return rsx! {
|
||||
div { class: "mediaItem placeholder",
|
||||
img { },
|
||||
span { class: "info",
|
||||
span { class: "name" }
|
||||
span { class: "details",
|
||||
span { }
|
||||
span { }
|
||||
},
|
||||
},
|
||||
ContextMenuContent{
|
||||
ContextMenuItem {
|
||||
index: 0 as usize,
|
||||
value: id.clone(),
|
||||
on_select: move |id: String|{
|
||||
window().expect("Failed to get window")
|
||||
.location().set_href(&format!("/media/{}", id))
|
||||
.expect("Failed to open Url");
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Details"
|
||||
}
|
||||
}
|
||||
},
|
||||
ContextMenuItem {
|
||||
index: 1 as usize,
|
||||
value: "{download}",
|
||||
on_select: move |url: String|{
|
||||
window().expect("Failed to get window").open_with_url_and_target(&url, "_blank").expect("Failed to open url");
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Download"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
if class != 0 {
|
||||
rsx!{ContextMenuItem {
|
||||
index: 2 as usize,
|
||||
value: "{id}",
|
||||
on_select: move |id: String|{
|
||||
spawn(async move {
|
||||
if let Ok(_) = set_class(&id, 0).await{
|
||||
if let Some(handler) = props.on_class_changed{
|
||||
handler.call(MediaClassChangeEvent { id, class: 0 });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Mark Standard"
|
||||
}
|
||||
}
|
||||
}}
|
||||
}else{rsx!{}}
|
||||
}
|
||||
{
|
||||
if class != 1 {
|
||||
rsx!{ContextMenuItem {
|
||||
index: 3 as usize,
|
||||
value: "{id}",
|
||||
on_select: move |id: String|{
|
||||
spawn(async move {
|
||||
if let Ok(_) = set_class(&id, 1).await{
|
||||
if let Some(handler) = props.on_class_changed{
|
||||
handler.call(MediaClassChangeEvent { id, class: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Mark NSFW"
|
||||
}
|
||||
}
|
||||
}}
|
||||
}else{rsx!{}}
|
||||
}
|
||||
{
|
||||
if class != 3 {
|
||||
rsx!{ContextMenuItem {
|
||||
index: 4 as usize,
|
||||
value: "{id}",
|
||||
on_select: move |id: String|{
|
||||
spawn(async move {
|
||||
if let Ok(_) = set_class(&id, 2).await{
|
||||
if let Some(handler) = props.on_class_changed{
|
||||
handler.call(MediaClassChangeEvent { id, class: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Mark Secret"
|
||||
}
|
||||
}
|
||||
}}
|
||||
}else{rsx!{}}
|
||||
}
|
||||
ContextMenuItem {
|
||||
index: 5 as usize,
|
||||
value: "{id}",
|
||||
on_select: move |id: String|{
|
||||
spawn(async move {
|
||||
if let Ok(_) = delete_media(id.clone()).await{
|
||||
if let Some(handler) = props.on_deleted {
|
||||
handler.call(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
div{
|
||||
class: "contextItem",
|
||||
div{
|
||||
class: "label",
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MediaItemPlaceHolder() -> Element
|
||||
{
|
||||
return rsx! {
|
||||
div { class: "mediaItem placeholder",
|
||||
img { },
|
||||
span { class: "info",
|
||||
span { class: "name" }
|
||||
span { class: "details",
|
||||
span { }
|
||||
span { }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn delete_media(id: String) -> Result<Response<()>, Status>
|
||||
{
|
||||
let mut client = get_rpc_client();
|
||||
return client.delete_media(Id { value: id }).await;
|
||||
}
|
||||
|
||||
async fn set_class(id: &String, class: i32) -> Result<Response<()>, Status>
|
||||
{
|
||||
let mut client = get_rpc_client();
|
||||
return client
|
||||
.set_media_class(SetMediaClassRequest {
|
||||
class: class,
|
||||
id: Some(Id { value: id.clone() }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
pub mod basic;
|
||||
mod context_menu;
|
||||
mod icons;
|
||||
mod media_grid;
|
||||
mod media_item;
|
||||
mod metrics_token;
|
||||
mod navbar;
|
||||
mod notif;
|
||||
mod pagination;
|
||||
mod passkey;
|
||||
mod search;
|
||||
pub use context_menu::*;
|
||||
pub use media_grid::*;
|
||||
pub use media_item::*;
|
||||
pub use metrics_token::*;
|
||||
pub use navbar::*;
|
||||
pub use notif::*;
|
||||
pub use pagination::*;
|
||||
pub use passkey::*;
|
||||
pub use search::*;
|
||||
mod icons;
|
||||
pub mod radio_group;
|
||||
|
||||
@@ -6,7 +6,8 @@ const NAV_CSS: Asset = asset!("/assets/style/nav.scss");
|
||||
const NAV_ICON: Asset = asset!("/assets/favicon.ico");
|
||||
|
||||
#[component]
|
||||
pub fn Navbar() -> Element {
|
||||
pub fn Navbar() -> Element
|
||||
{
|
||||
rsx! {
|
||||
document::Link { rel: "stylesheet", href: NAV_CSS }
|
||||
nav {
|
||||
@@ -19,17 +20,19 @@ pub fn Navbar() -> Element {
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MainNaviagation() -> Element {
|
||||
pub fn MainNaviagation() -> Element
|
||||
{
|
||||
rsx! {
|
||||
div { class: "mainNav",
|
||||
Link { class: "navItem", to: Route::Home {}, "Home" }
|
||||
Link { class: "navItem", to: Route::Home { page: None, q: None }, "Home" }
|
||||
Link { class: "navItem", to: Route::Settings {}, "Settings" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Branding() -> Element {
|
||||
pub fn Branding() -> Element
|
||||
{
|
||||
rsx! {
|
||||
div { class: "branding",
|
||||
img { src: NAV_ICON, alt: "Aoba" }
|
||||
@@ -38,14 +41,16 @@ pub fn Branding() -> Element {
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Widgets() -> Element {
|
||||
pub fn Widgets() -> Element
|
||||
{
|
||||
rsx! {
|
||||
div { class: "widgets" }
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Utils() -> Element {
|
||||
pub fn Utils() -> Element
|
||||
{
|
||||
let mut auth_context = use_context::<AuthContext>();
|
||||
let version = APP_VERSION;
|
||||
rsx! {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use dioxus::prelude::*;
|
||||
use web_sys::window;
|
||||
|
||||
#[component]
|
||||
pub fn Pagination(
|
||||
page: Signal<i32>,
|
||||
max_page: Signal<i32>,
|
||||
item_count: Signal<i32>,
|
||||
on_page_change: EventHandler<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|_| {
|
||||
on_page_change.call(1);
|
||||
scroll_document();
|
||||
},
|
||||
"First"
|
||||
}
|
||||
a {
|
||||
onclick: move|_| {
|
||||
let p = (cur_page_val - 1).max(1);
|
||||
on_page_change.call(p);
|
||||
scroll_document();
|
||||
},
|
||||
"Prev"
|
||||
}
|
||||
div { "Page {cur_page_val} of {max_page_val} ({item_count_val} Media Items)" }
|
||||
a {
|
||||
onclick: move|_| {
|
||||
let p = (cur_page_val + 1).min(max_page_val);
|
||||
on_page_change.call(p);
|
||||
scroll_document();
|
||||
},
|
||||
"Next"
|
||||
}
|
||||
a {
|
||||
onclick: move|_| {
|
||||
on_page_change.call(max_page_val);
|
||||
scroll_document();
|
||||
},
|
||||
"Last"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_document() {
|
||||
let window = window().expect("Failed to get window");
|
||||
let document = window.document().expect("Failed to get document");
|
||||
document
|
||||
.query_selector("#content")
|
||||
.expect("Failed to find content")
|
||||
.expect("Failed to find content")
|
||||
.scroll_to_with_x_and_y(0.0, 0.0);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use dioxus::prelude::*;
|
||||
use js_sys::{Uint8Array, wasm_bindgen::JsValue};
|
||||
use web_sys::{
|
||||
CredentialCreationOptions, PublicKeyCredentialCreationOptions, PublicKeyCredentialRpEntity,
|
||||
PublicKeyCredentialUserEntity, window,
|
||||
};
|
||||
|
||||
use crate::{components::basic::Button, rpc::aoba::PasskeyCredentialCreateOptions};
|
||||
|
||||
#[component]
|
||||
pub fn PasskeyRegistrationButton() -> Element
|
||||
{
|
||||
rsx! {
|
||||
Button{
|
||||
text: "Register Passkey",
|
||||
onclick: move |_| {
|
||||
start_passkey_registration();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_passkey_registration()
|
||||
{
|
||||
create_credential(todo!());
|
||||
}
|
||||
|
||||
fn create_credential(req_opts: PasskeyCredentialCreateOptions)
|
||||
{
|
||||
let window = window().expect("Window does not exist");
|
||||
let credentaials = window.navigator().credentials();
|
||||
|
||||
let opts = opts_from_rpc(req_opts);
|
||||
|
||||
let result = credentaials.create_with_options(&opts);
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn opts_from_rpc(rpc_opts: PasskeyCredentialCreateOptions) -> CredentialCreationOptions
|
||||
{
|
||||
let opt_user = &rpc_opts.user.expect("user is missing");
|
||||
let opt_rp = &rpc_opts.rp.expect("rp is missing");
|
||||
let opts = CredentialCreationOptions::new();
|
||||
let rp = PublicKeyCredentialRpEntity::new(&opt_rp.name);
|
||||
rp.set_id(&opt_rp.id);
|
||||
|
||||
let user = PublicKeyCredentialUserEntity::new_with_u8_array(
|
||||
&opt_user.name,
|
||||
&opt_user.display_name,
|
||||
&to_u8_array(&opt_user.id),
|
||||
);
|
||||
let pub_key_opts = PublicKeyCredentialCreationOptions::new_with_u8_array(
|
||||
&to_u8_array(&rpc_opts.challenge),
|
||||
&JsValue::undefined(),
|
||||
&rp,
|
||||
&user,
|
||||
);
|
||||
//pub_key_opts.set_exclude_credentials(val);
|
||||
opts.set_public_key(&pub_key_opts);
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
fn to_u8_array(value: &String) -> Uint8Array
|
||||
{
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PasskeyLoginButton() -> Element
|
||||
{
|
||||
rsx! {
|
||||
Button{
|
||||
text: "Login with Passkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_primitives::radio_group::{self, RadioGroupProps, RadioItemProps};
|
||||
|
||||
#[component]
|
||||
pub fn RadioGroup(props: RadioGroupProps) -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "stylesheet", href: asset!("./style.css") }
|
||||
radio_group::RadioGroup {
|
||||
class: "radio-group",
|
||||
value: props.value,
|
||||
default_value: props.default_value,
|
||||
on_value_change: props.on_value_change,
|
||||
disabled: props.disabled,
|
||||
required: props.required,
|
||||
name: props.name,
|
||||
horizontal: props.horizontal,
|
||||
roving_loop: props.roving_loop,
|
||||
attributes: props.attributes,
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RadioItem(props: RadioItemProps) -> Element {
|
||||
rsx! {
|
||||
radio_group::RadioItem {
|
||||
class: "radio-item",
|
||||
value: props.value,
|
||||
index: props.index,
|
||||
disabled: props.disabled,
|
||||
attributes: props.attributes,
|
||||
{props.children}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod component;
|
||||
pub use component::*;
|
||||
@@ -0,0 +1,48 @@
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--secondary-color-4);
|
||||
font-size: 14px;
|
||||
gap: .75rem;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
box-sizing: border-box;
|
||||
border-radius: 1.5rem;
|
||||
background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
|
||||
box-shadow: 0 0 0 1px var(--light, var(--primary-color-6))
|
||||
var(--dark, var(--primary-color-7));
|
||||
content: "";
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible::before {
|
||||
box-shadow: 0 0 0 2px var(--focused-border-color);
|
||||
}
|
||||
|
||||
&[data-state="checked"]::before {
|
||||
border: 0.25rem solid var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
|
||||
background: var(--secondary-color-4);
|
||||
}
|
||||
|
||||
&[data-disabled="true"]::before {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Search(query: Signal<String>) -> Element {
|
||||
pub fn Search(query: String, oninput: Option<EventHandler<String>>, onchange: Option<EventHandler<String>>) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div { class: "searchBar stickyTop",
|
||||
div { class: "searchBar",
|
||||
input {
|
||||
r#type: "search",
|
||||
placeholder: "Search Files",
|
||||
value: query,
|
||||
oninput: move |event| query.set(event.value()),
|
||||
oninput: move |event| {
|
||||
if let Some(handler) = oninput {
|
||||
handler.call(event.value());
|
||||
}
|
||||
},
|
||||
onchange: move |event|{
|
||||
if let Some(handler) = onchange {
|
||||
handler.call(event.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use dioxus::signals::{Signal, Writable};
|
||||
use dioxus::signals::{Signal, WritableExt};
|
||||
use web_sys::window;
|
||||
|
||||
use crate::rpc::{login, logout};
|
||||
|
||||
@@ -3,17 +3,31 @@ use dioxus::prelude::*;
|
||||
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login};
|
||||
|
||||
#[component]
|
||||
pub fn MainLayout() -> Element {
|
||||
pub fn MainLayout() -> Element
|
||||
{
|
||||
let auth_context = use_context::<AuthContext>();
|
||||
|
||||
if auth_context.jwt.cloned().is_none() {
|
||||
if auth_context.jwt.cloned().is_none()
|
||||
{
|
||||
return rsx! {
|
||||
Login {}
|
||||
Login { }
|
||||
};
|
||||
}
|
||||
|
||||
// let mut ct_renderer = use_context::<ContextMenuRenderer>();
|
||||
|
||||
return rsx! {
|
||||
Navbar {}
|
||||
div { id: "content", Outlet::<Route> {} }
|
||||
// ContextMenuRoot { }
|
||||
Navbar { }
|
||||
div {
|
||||
id: "content",
|
||||
// onclick: move |_| {
|
||||
// ct_renderer.close();
|
||||
// },
|
||||
// oncontextmenu: move |_| {
|
||||
// ct_renderer.close();
|
||||
// },
|
||||
Outlet::<Route> { }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+20
-5
@@ -8,7 +8,7 @@ pub mod rpc;
|
||||
pub mod views;
|
||||
|
||||
use contexts::AuthContext;
|
||||
use dioxus::prelude::*;
|
||||
use dioxus::{prelude::*, router::RouterConfig};
|
||||
use route::Route;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -23,24 +23,39 @@ pub const HOST: &'static str = "https://aoba.app";
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const MAIN_CSS: Asset = asset!("/assets/style/main.scss");
|
||||
const INPUT_CSS: Asset = asset!("/assets/style/inputs.scss");
|
||||
const DX_COMPONENTS: Asset = asset!("/assets/style/dx-components.scss");
|
||||
|
||||
fn main() {
|
||||
fn main()
|
||||
{
|
||||
dioxus::launch(App);
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
let _auth_state = use_context_provider(|| AuthContext::new());
|
||||
fn App() -> Element
|
||||
{
|
||||
use_context_provider(|| AuthContext::new());
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
||||
document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" }
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
document::Link { rel: "stylesheet", href: INPUT_CSS }
|
||||
document::Link { rel: "stylesheet", href: DX_COMPONENTS }
|
||||
document::Link {
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
|
||||
}
|
||||
Router::<Route> {}
|
||||
Router::<Route> { config: || RouterConfig::default()
|
||||
.on_update(|state|{
|
||||
match state.current() {
|
||||
Route::Home {page, q} => {
|
||||
info!("Page {}", page.unwrap_or(1));
|
||||
return None;
|
||||
// return Some(NavigationTarget::Internal(Route::Home { page, q }))
|
||||
},
|
||||
_ => None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
layouts::MainLayout,
|
||||
views::{Home, Settings},
|
||||
views::{Home, Media, Settings},
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
@@ -8,8 +8,13 @@ use dioxus::prelude::*;
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(MainLayout)]
|
||||
#[route("/")]
|
||||
Home {},
|
||||
|
||||
#[route("/?:page&:q")]
|
||||
Home { page: Option<i32>, q: Option<String> },
|
||||
// #[route("/")]
|
||||
// Home { },
|
||||
#[route("/media/:id")]
|
||||
Media { id: String },
|
||||
#[route("/settings")]
|
||||
Settings {},
|
||||
// #[end_layout]
|
||||
|
||||
+50
-17
@@ -6,50 +6,70 @@ use tonic_web_wasm_client::Client;
|
||||
|
||||
use crate::{
|
||||
RPC_HOST,
|
||||
rpc::aoba::{auth_rpc_client::AuthRpcClient, metrics_rpc_client::MetricsRpcClient},
|
||||
rpc::aoba::{
|
||||
account_rpc_client::AccountRpcClient, auth_rpc_client::AuthRpcClient, metrics_rpc_client::MetricsRpcClient,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod aoba {
|
||||
pub mod aoba
|
||||
{
|
||||
tonic::include_proto!("aoba");
|
||||
}
|
||||
|
||||
static RPC_CLIENT: RpcConnection = RpcConnection {
|
||||
aoba: RwLock::new(None),
|
||||
auth: RwLock::new(None),
|
||||
account: RwLock::new(None),
|
||||
metrics: RwLock::new(None),
|
||||
jwt: RwLock::new(None),
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RpcConnection {
|
||||
pub struct RpcConnection
|
||||
{
|
||||
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
|
||||
auth: RwLock<Option<AuthRpcClient<Client>>>,
|
||||
account: RwLock<Option<AccountRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
|
||||
metrics: RwLock<Option<MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
|
||||
jwt: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl RpcConnection {
|
||||
pub fn get_client(&self) -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
|
||||
impl RpcConnection
|
||||
{
|
||||
pub fn get_client(&self) -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>>
|
||||
{
|
||||
self.ensure_client();
|
||||
return self.aoba.read().unwrap().clone().unwrap();
|
||||
}
|
||||
|
||||
pub fn get_auth_client(&self) -> AuthRpcClient<Client> {
|
||||
pub fn get_account_client(&self) -> AccountRpcClient<InterceptedService<Client, AuthInterceptor>>
|
||||
{
|
||||
self.ensure_client();
|
||||
return self.account.read().unwrap().clone().unwrap();
|
||||
}
|
||||
|
||||
pub fn get_auth_client(&self) -> AuthRpcClient<Client>
|
||||
{
|
||||
self.ensure_client();
|
||||
return self.auth.read().unwrap().clone().unwrap();
|
||||
}
|
||||
|
||||
pub fn get_metrics_client(&self) -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
|
||||
pub fn get_metrics_client(&self) -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>
|
||||
{
|
||||
self.ensure_client();
|
||||
return self.metrics.read().unwrap().clone().unwrap();
|
||||
}
|
||||
|
||||
fn ensure_client(&self) {
|
||||
if self.aoba.read().unwrap().is_none() {
|
||||
fn ensure_client(&self)
|
||||
{
|
||||
if self.aoba.read().unwrap().is_none()
|
||||
{
|
||||
let wasm_client = Client::new(RPC_HOST.into());
|
||||
let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor);
|
||||
*self.aoba.write().unwrap() = Some(aoba_client);
|
||||
*self.auth.write().unwrap() = Some(AuthRpcClient::new(wasm_client.clone()));
|
||||
*self.account.write().unwrap() =
|
||||
Some(AccountRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor));
|
||||
*self.metrics.write().unwrap() =
|
||||
Some(MetricsRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor));
|
||||
}
|
||||
@@ -58,9 +78,12 @@ impl RpcConnection {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthInterceptor;
|
||||
impl Interceptor for AuthInterceptor {
|
||||
fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
|
||||
if let Some(jwt) = RPC_CLIENT.jwt.read().unwrap().clone() {
|
||||
impl Interceptor for AuthInterceptor
|
||||
{
|
||||
fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status>
|
||||
{
|
||||
if let Some(jwt) = RPC_CLIENT.jwt.read().unwrap().clone()
|
||||
{
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert("authorization", format!("Bearer {jwt}").parse().unwrap());
|
||||
@@ -69,21 +92,31 @@ impl Interceptor for AuthInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
|
||||
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>>
|
||||
{
|
||||
return RPC_CLIENT.get_client();
|
||||
}
|
||||
|
||||
pub fn get_auth_rpc_client() -> AuthRpcClient<Client> {
|
||||
pub fn get_auth_rpc_client() -> AuthRpcClient<Client>
|
||||
{
|
||||
return RPC_CLIENT.get_auth_client();
|
||||
}
|
||||
|
||||
pub fn get_metrics_rpc_client() -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
|
||||
pub fn get_account_rpc_client() -> AccountRpcClient<InterceptedService<Client, AuthInterceptor>>
|
||||
{
|
||||
return RPC_CLIENT.get_account_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);
|
||||
}
|
||||
|
||||
pub fn logout() {
|
||||
pub fn logout()
|
||||
{
|
||||
*RPC_CLIENT.jwt.write().unwrap() = None;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
use crate::components::{MediaGrid, Search};
|
||||
use dioxus::prelude::*;
|
||||
use crate::{
|
||||
components::{MediaGrid, Pagination, PaginationInfo, Search},
|
||||
route::Route,
|
||||
};
|
||||
use dioxus::{prelude::*, router::RouterConfig};
|
||||
|
||||
// #[component]
|
||||
// pub fn Home() -> Element
|
||||
// {
|
||||
// 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! {
|
||||
// div {
|
||||
// class: "stickyTop",
|
||||
// Search { query, page },
|
||||
// Pagination { page, max_page, item_count },
|
||||
// }
|
||||
// MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
|
||||
// }
|
||||
// }
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
let query = use_signal(|| "".to_string());
|
||||
|
||||
pub fn Home(page: Option<i32>, q: Option<String>) -> Element
|
||||
{
|
||||
let mut query = use_signal(|| q.unwrap_or("".to_string()));
|
||||
let mut page = use_signal(|| page.unwrap_or(1));
|
||||
let page_size = use_signal::<i32>(|| 100);
|
||||
let mut max_page = use_signal(|| 1 as i32);
|
||||
let mut item_count = use_signal(|| 0 as i32);
|
||||
rsx! {
|
||||
Search { query }
|
||||
MediaGrid { query: query.cloned() }
|
||||
div {
|
||||
class: "stickyTop",
|
||||
Search {
|
||||
query: query(),
|
||||
oninput: move |q| {
|
||||
query.set(q);
|
||||
page.set(1);
|
||||
},
|
||||
onchange: move |_|{
|
||||
router().push(format!("/?page={}&q={}", page(), query()));
|
||||
}
|
||||
},
|
||||
Pagination {
|
||||
page, max_page, item_count,
|
||||
on_page_change: move |p|{
|
||||
page.set(p);
|
||||
router().push(format!("/?page={}&q={}", page(), query()));
|
||||
}
|
||||
},
|
||||
}
|
||||
MediaGrid { query: query, page: page, max_page, total_items: item_count, page_size,
|
||||
on_page_loaded: move |p: PaginationInfo| {
|
||||
max_page.set(p.total_pages);
|
||||
item_count.set(p.total_items);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use dioxus::prelude::*;
|
||||
use tonic::IntoRequest;
|
||||
|
||||
use crate::{
|
||||
components::{basic::Input, Notif, NotifType},
|
||||
components::{Notif, NotifType, PasskeyLoginButton, basic::Input},
|
||||
contexts::AuthContext,
|
||||
rpc::{aoba::Credentials, get_auth_rpc_client},
|
||||
};
|
||||
@@ -72,6 +72,7 @@ pub fn Login() -> Element {
|
||||
required: true,
|
||||
}
|
||||
button { onclick: login, "Login!" }
|
||||
PasskeyLoginButton {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::HOST;
|
||||
use crate::rpc::{
|
||||
aoba::{Id, MediaModel},
|
||||
get_rpc_client,
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Media(id: String) -> Element {
|
||||
let media_result = use_resource(use_reactive!(|(id)| async move {
|
||||
let mut client = get_rpc_client();
|
||||
let result = client.get_media(Id { value: id.clone() }).await;
|
||||
if let Ok(item) = result {
|
||||
let res = item.into_inner();
|
||||
return res.value;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}));
|
||||
|
||||
return match media_result.cloned().unwrap_or(None) {
|
||||
Some(media) => {
|
||||
return rsx! {MediaPage{media: media}};
|
||||
}
|
||||
None => rsx! {"Not Found"},
|
||||
};
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MediaPage(media: MediaModel) -> Element {
|
||||
let url = media.thumb_url;
|
||||
// let id = media.id.expect("Media has no id").value.clone();
|
||||
let cur_class = use_signal(|| match media.class {
|
||||
0 => "Standard",
|
||||
1 => "NSFW",
|
||||
2 => "Secret",
|
||||
_ => "Unkown",
|
||||
});
|
||||
rsx! {
|
||||
img { src: "{HOST}{url}", }
|
||||
label { "Media Class: {cur_class()}" }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod home;
|
||||
mod login;
|
||||
mod media;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
pub use media::*;
|
||||
|
||||
mod settings;
|
||||
pub use settings::Settings;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::{components::MetricsToken, rpc::get_rpc_client};
|
||||
use crate::{
|
||||
components::{MetricsToken, PasskeyRegistrationButton},
|
||||
rpc::get_rpc_client,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Settings() -> Element {
|
||||
pub fn Settings() -> Element
|
||||
{
|
||||
let dst = use_resource(async move || {
|
||||
let result = get_rpc_client().get_share_x_destination(()).await;
|
||||
if let Ok(d) = result {
|
||||
if let Some(r) = d.into_inner().dst_result {
|
||||
return match r {
|
||||
if let Ok(d) = result
|
||||
{
|
||||
if let Some(r) = d.into_inner().dst_result
|
||||
{
|
||||
return match r
|
||||
{
|
||||
crate::rpc::aoba::share_x_response::DstResult::Destination(json) => json,
|
||||
crate::rpc::aoba::share_x_response::DstResult::Error(err) => err,
|
||||
};
|
||||
@@ -28,5 +35,6 @@ pub fn Settings() -> Element {
|
||||
pre { class: "codeSelect", "{d}" }
|
||||
}
|
||||
MetricsToken { }
|
||||
PasskeyRegistrationButton { }
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -1,22 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="FFMpegCore" Version="5.4.0" />
|
||||
<PackageReference Include="Fido2.Models" Version="4.0.1" />
|
||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="MaybeError" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
|
||||
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
|
||||
<PackageReference Include="ZLinq" Version="1.4.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="MaybeError" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.8.0" />
|
||||
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
<PackageReference Include="ZLinq" Version="1.5.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace AobaCore.Models;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
@@ -16,7 +18,9 @@ public class Media
|
||||
public ObjectId Owner { get; set; }
|
||||
public DateTime UploadDate { get; set; }
|
||||
public string[] Tags { get; set; } = [];
|
||||
public Size? Dimensions { get; set; }
|
||||
public Dictionary<ThumbnailSize, ObjectId> Thumbnails { get; set; } = [];
|
||||
public MediaClass Class { get; set; }
|
||||
|
||||
|
||||
public static readonly Dictionary<string, MediaType> KnownTypes = new()
|
||||
@@ -32,6 +36,9 @@ public class Media
|
||||
{ ".ico", MediaType.Image },
|
||||
{ ".gif", MediaType.Image },
|
||||
{ ".mp3", MediaType.Audio },
|
||||
{ ".ogg", MediaType.Audio },
|
||||
{ ".wav", MediaType.Audio },
|
||||
{ ".aac", MediaType.Audio },
|
||||
{ ".flac", MediaType.Audio },
|
||||
{ ".alac", MediaType.Audio },
|
||||
{ ".mp4", MediaType.Video },
|
||||
@@ -82,7 +89,7 @@ public class Media
|
||||
{
|
||||
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}"
|
||||
};
|
||||
}
|
||||
@@ -113,4 +120,11 @@ public enum MediaType
|
||||
Text,
|
||||
Code,
|
||||
Raw
|
||||
}
|
||||
|
||||
public enum MediaClass
|
||||
{
|
||||
Standard,
|
||||
NSFW,
|
||||
Secret
|
||||
}
|
||||
@@ -5,13 +5,13 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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 int Page { get; set; } = page;
|
||||
public int PageSize { get; set; } = pageSize;
|
||||
public long TotalItems { get; set; } = totalItems;
|
||||
public long TotalPages { get; set; } = totalItems / pageSize;
|
||||
public int TotalItems { get; set; } = totalItems;
|
||||
public int TotalPages { get; set; } = (totalItems / pageSize) + 1;
|
||||
public string? Query { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Fido2NetLib.Objects;
|
||||
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
@@ -19,6 +21,7 @@ public class User
|
||||
public bool IsArgon { get; set; }
|
||||
public ObjectId[] ApiKeys { get; set; } = [];
|
||||
public List<ObjectId> RegTokens { get; set; } = [];
|
||||
public List<PublicKeyCredentialDescriptor> CredentialDescriptors { get; set; } = [];
|
||||
|
||||
public ClaimsIdentity GetIdentity()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using AobaCore.Models;
|
||||
|
||||
using Fido2NetLib.Objects;
|
||||
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
using MongoDB.Bson;
|
||||
@@ -54,13 +56,18 @@ public class AccountsService(IMongoDatabase db)
|
||||
/* Get the salt */
|
||||
byte[] salt = new byte[16];
|
||||
Array.Copy(hashBytes, 0, salt, 0, 16);
|
||||
/* Compute the hash on the password the user entered */
|
||||
var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 10000, HashAlgorithmName.SHA1);
|
||||
byte[] hash = pbkdf2.GetBytes(20);
|
||||
|
||||
var hash= Rfc2898DeriveBytes.Pbkdf2(password, salt, 10000, HashAlgorithmName.SHA1, 20);
|
||||
/* Compare the results */
|
||||
for (int i = 0; i < 20; i++)
|
||||
if (hashBytes[i + 16] != hash[i])
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<PublicKeyCredentialDescriptor>> GetPublicKeyCredentialDescriptorsAsync(ObjectId id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var creds = await _users.Find(u => u.Id == id).Project(u => u.CredentialDescriptors).FirstOrDefaultAsync(cancellationToken);
|
||||
return creds ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,32 +20,52 @@ public class AobaService(IMongoDatabase db)
|
||||
return await _media.Find(m => m.LegacyId == id).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Media?> GetMediaFromFileAsync(ObjectId id, CancellationToken cancellationToken = default)
|
||||
public async Task<Media?> GetMediaAsync(ObjectId id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _media.Find(m => m.MediaId == id).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Media>> FindMediaAsync(string? query, ObjectId userId, int page = 1, int pageSize = 100)
|
||||
public async Task<List<Media>> GetMediaAsync(IEnumerable<ObjectId> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<Media>.Filter.And([
|
||||
string.IsNullOrWhiteSpace(query) ? "{}" : Builders<Media>.Filter.Text(query),
|
||||
Builders<Media>.Filter.Eq(m => m.Owner, userId)
|
||||
]);
|
||||
var sort = Builders<Media>.Sort.Descending(m => m.UploadDate);
|
||||
var find = _media.Find(filter);
|
||||
|
||||
var total = await find.CountDocumentsAsync();
|
||||
page -= 1;
|
||||
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
|
||||
return new PagedResult<Media>(items, page, pageSize, total);
|
||||
return await _media.Find(m => ids.Contains(m.MediaId)).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Media>> FindMediaAsync(string? query, ObjectId userId, int page = 1, int pageSize = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filters = new List<FilterDefinition<Media>>()
|
||||
{
|
||||
string.IsNullOrWhiteSpace(query) ? "{}" : Builders<Media>.Filter.Text(query),
|
||||
Builders<Media>.Filter.Eq(m => m.Owner, userId)
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
filters.Add(Builders<Media>.Filter.Ne(m => m.Class, MediaClass.Secret));
|
||||
var sort = Builders<Media>.Sort.Descending(m => m.UploadDate);
|
||||
var find = _media.Find(Builders<Media>.Filter.And(filters));
|
||||
|
||||
var total = await find.CountDocumentsAsync(cancellationToken);
|
||||
page -= 1;
|
||||
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync(cancellationToken);
|
||||
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(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _media.InsertOneAsync(media, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SetMediaClassAsync(ObjectId mediaId, MediaClass mediaClass, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var update = Builders<Media>.Update
|
||||
.Set(m => m.Class, mediaClass);
|
||||
await _media.UpdateOneAsync(m => m.MediaId == mediaId, update, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddThumbnailAsync(ObjectId mediaId, ObjectId thumbId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var upate = Builders<Media>.Update.Set(m => m.Thumbnails[size], thumbId);
|
||||
@@ -53,6 +73,13 @@ public class AobaService(IMongoDatabase db)
|
||||
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)
|
||||
{
|
||||
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);
|
||||
@@ -65,6 +92,12 @@ public class AobaService(IMongoDatabase db)
|
||||
}
|
||||
|
||||
|
||||
public async Task SetMediaTypeAsync(ObjectId mediaId, MediaType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var update = Builders<Media>.Update.Set(m => m.MediaType, type);
|
||||
await _media.UpdateOneAsync(m => m.MediaId == mediaId, update, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Maybe<Media>> UploadFileAsync(Stream data, string filename, ObjectId owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -79,6 +112,10 @@ public class AobaService(IMongoDatabase db)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
data.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default)
|
||||
@@ -107,6 +144,23 @@ public class AobaService(IMongoDatabase db)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteFilesAsync(IEnumerable<ObjectId> mediaIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var id in mediaIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await _gridFs.DeleteAsync(id, CancellationToken.None);
|
||||
await _media.DeleteOneAsync(m => m.MediaId == id, CancellationToken.None);
|
||||
}
|
||||
catch (GridFSFileNotFoundException)
|
||||
{
|
||||
//ignore if file was not found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task DeriveTagsAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -3,17 +3,23 @@
|
||||
using FFMpegCore;
|
||||
using FFMpegCore.Pipes;
|
||||
|
||||
|
||||
using MaybeError.Errors;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Driver.GridFS;
|
||||
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
@@ -25,6 +31,45 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<Error?> DeleteThumbnailDirectAsync(ObjectId thumbnailId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gridfs.DeleteAsync(thumbnailId);
|
||||
}
|
||||
catch (GridFSFileNotFoundException)
|
||||
{
|
||||
//Ignore if the file was not found (somehow already deleted)
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ExceptionError(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
@@ -34,11 +79,12 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
/// <returns></returns>
|
||||
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
|
||||
if (existingThumb != null)
|
||||
return existingThumb;
|
||||
|
||||
var media = await aobaService.GetMediaFromFileAsync(mediaId, cancellationToken);
|
||||
var media = await aobaService.GetMediaAsync(mediaId, cancellationToken);
|
||||
|
||||
if (media == null)
|
||||
return new Error("Media does not exist");
|
||||
@@ -91,17 +137,30 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
{
|
||||
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.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
|
||||
MediaType.Audio => GenerateAudioThumbnail(stream, size, ext, cancellationToken),
|
||||
MediaType.Text or MediaType.Code => await GenerateTextThumbnailAsync(stream, size, cancellationToken),
|
||||
_ => 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);
|
||||
img.Mutate(o =>
|
||||
if (ext is ".heif" or ".avif")
|
||||
{
|
||||
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 =
|
||||
o.Resize(new ResizeOptions
|
||||
@@ -112,12 +171,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
});
|
||||
});
|
||||
var result = new MemoryStream();
|
||||
await img.SaveAsWebpAsync(result, cancellationToken);
|
||||
await img.Value.SaveAsWebpAsync(result, cancellationToken);
|
||||
img.Value.Dispose();
|
||||
result.Position = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
public Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
public static Maybe<Stream> GenerateAudioThumbnail(Stream data, ThumbnailSize size, string ext, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
var w = (int)size;
|
||||
var fn = ObjectId.GenerateNewId().ToString();
|
||||
var filePath = $"/tmp/{fn}{ext}";
|
||||
|
||||
using var source = new FileStream(filePath, FileMode.CreateNew);
|
||||
data.CopyTo(source);
|
||||
source.Flush();
|
||||
source.Dispose();
|
||||
data.Dispose();
|
||||
//ffmpeg -i test.wav -lavfi "showspectrumpic=s=512x512:legend=0:color=plasma:scale=log" output3.png
|
||||
try
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
FFMpegArguments.FromFileInput(filePath, false)
|
||||
.OutputToPipe(new StreamPipeSink(output), opt =>
|
||||
{
|
||||
opt.WithCustomArgument("-lavfi \"showspectrumpic=s=512x512:legend=0:color=plasma:scale=log\"").ForceFormat("webp");
|
||||
}).ProcessSynchronously();
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public static Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var w = (int)size;
|
||||
var fn = ObjectId.GenerateNewId().ToString();
|
||||
@@ -141,7 +235,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
catch(Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
@@ -151,8 +245,64 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
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>> GenerateTextThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||
{
|
||||
//var w = (int)size;
|
||||
//using var image = new Image<Rgba32>(w, w);
|
||||
//var reader = new StreamReader(data);
|
||||
//var text = new char[500];
|
||||
//reader.ReadBlock(text, 0, text.Length);
|
||||
//image.Mutate(op =>
|
||||
//{
|
||||
// op.BackgroundColor(Color.Black);
|
||||
// var font = new Font(), 11);
|
||||
// var textOpts = new RichTextOptions(font);
|
||||
// op.DrawText(, new string(text), new Brush
|
||||
// {
|
||||
|
||||
// });
|
||||
//});
|
||||
return new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>9ffcc706-7f1b-48e3-bf30-eab69a90fded</UserSecretsId>
|
||||
@@ -9,22 +9,23 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.71.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.72.0">
|
||||
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.80.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.80.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.80.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.18.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="MimeTypesMap" Version="1.0.9" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" 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.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -33,6 +34,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Proto\Aoba.proto"></Protobuf>
|
||||
<Protobuf Include="Proto\Account.proto" />
|
||||
<Protobuf Include="Proto\Auth.proto"></Protobuf>
|
||||
<Protobuf Include="Proto\Metrics.proto"></Protobuf>
|
||||
<Protobuf Include="Proto\Types.proto"></Protobuf>
|
||||
|
||||
@@ -22,6 +22,7 @@ public class MediaApi(AobaService aoba) : ControllerBase
|
||||
if (media.HasError)
|
||||
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
media = media.Value,
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AobaServer.Controllers;
|
||||
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
|
||||
{
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("{id}/{*fn}")]
|
||||
[ResponseCache(Duration = int.MaxValue)]
|
||||
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -28,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
|
||||
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>
|
||||
/// Redirect legacy media urls to the new url
|
||||
/// </summary>
|
||||
@@ -58,6 +75,7 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
|
||||
}
|
||||
|
||||
[HttpGet("/t/{id}")]
|
||||
[ResponseCache(Duration = int.MaxValue)]
|
||||
public async Task<IActionResult> ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var thumb = await thumbnailService.GetThumbnailByFileIdAsync(id, cancellationToken);
|
||||
|
||||
+11
-7
@@ -1,5 +1,8 @@
|
||||
ARG NET_VERSION="10.0"
|
||||
ARG DX_VERSION="0.7.3"
|
||||
|
||||
# Client Side build - prep deps
|
||||
FROM rust:1 AS chef
|
||||
FROM rust:1-trixie AS chef
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
RUN cargo install cargo-chef
|
||||
WORKDIR /app
|
||||
@@ -22,28 +25,29 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
|
||||
|
||||
# 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 cargo binstall dioxus-cli --root /.cargo -y --force
|
||||
ARG DX_VERSION
|
||||
RUN cargo binstall dioxus-cli@${DX_VERSION} --root /.cargo -y --force
|
||||
ENV PATH="/.cargo/bin:$PATH"
|
||||
ARG VERSION
|
||||
ENV APP_VERSION=$VERSION
|
||||
# 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
|
||||
# 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
|
||||
RUN apt-get update && apt-get install -y ffmpeg
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${NET_VERSION} AS base
|
||||
RUN apt-get update && apt-get install -y ffmpeg #libvips libvips-tools
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
# This stage is used to build the service project
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${NET_VERSION}-noble AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["AobaServer/AobaServer.csproj", "AobaServer/"]
|
||||
RUN dotnet restore "./AobaServer/AobaServer.csproj"
|
||||
RUN dotnet restore "/src/AobaServer/AobaServer.csproj"
|
||||
COPY . .
|
||||
# Copy Built bundle from client builder
|
||||
COPY --from=client-builder /app/AobaClient/target/dx/aoba-client/release/web/public /src/AobaServer/wwwroot
|
||||
|
||||
+175
-162
@@ -1,164 +1,177 @@
|
||||
using AobaCore;
|
||||
|
||||
using AobaServer.Auth;
|
||||
using AobaServer.Middleware;
|
||||
using AobaServer.Models;
|
||||
using AobaServer.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Driver.Core.Extensions.DiagnosticSources;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(o =>
|
||||
using AobaCore;
|
||||
|
||||
using AobaServer.Auth;
|
||||
using AobaServer.Middleware;
|
||||
using AobaServer.Models;
|
||||
using AobaServer.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Driver.Core.Extensions.DiagnosticSources;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(o =>
|
||||
{
|
||||
o.Limits.MaxRequestBodySize = null;
|
||||
#if !DEBUG
|
||||
o.ListenAnyIP(8081, lo =>
|
||||
{
|
||||
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
o.ListenAnyIP(8080, lo =>
|
||||
{
|
||||
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
|
||||
});
|
||||
#endif
|
||||
});
|
||||
var config = builder.Configuration;
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers(opt => opt.ModelBinderProviders.Add(new BsonIdModelBinderProvider()));
|
||||
|
||||
builder.Services.AddObersability(builder.Configuration);
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
//DB
|
||||
var dbString = config["DB_STRING"];
|
||||
var settings = MongoClientSettings.FromConnectionString(dbString);
|
||||
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
|
||||
var dbClient = new MongoClient(settings);
|
||||
var db = dbClient.GetDatabase("Aoba");
|
||||
|
||||
builder.Services.AddSingleton(dbClient);
|
||||
builder.Services.AddSingleton<IMongoDatabase>(db);
|
||||
|
||||
var authCfg = new AuthConfigService(db);
|
||||
builder.Services.AddSingleton(authCfg);
|
||||
|
||||
|
||||
var authInfo = authCfg.GetDefaultAuthInfoAsync().GetAwaiter().GetResult();
|
||||
var signingKey = new SymmetricSecurityKey(authInfo.SecureKey);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = signingKey,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = authInfo.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = authInfo.Audience,
|
||||
ValidateLifetime = false,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
builder.Services.AddCors(o =>
|
||||
{
|
||||
o.AddPolicy("AllowAll", p =>
|
||||
{
|
||||
p.AllowAnyOrigin();
|
||||
p.AllowAnyMethod();
|
||||
p.AllowAnyHeader();
|
||||
});
|
||||
o.AddPolicy("RPC", p =>
|
||||
{
|
||||
p.AllowAnyMethod();
|
||||
p.AllowAnyHeader();
|
||||
p.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
|
||||
p.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
|
||||
var metricsAuthInfo = authCfg.GetAuthInfoAsync("aoba", "metrics").GetAwaiter().GetResult();
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = "Aoba";
|
||||
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => //Bearer auth
|
||||
{
|
||||
options.TokenValidationParameters = validationParams;
|
||||
options.TokenHandlers.Add(new MetricsTokenValidator(metricsAuthInfo));
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx => //Retreive token from cookie if not found in headers
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ctx.Token))
|
||||
ctx.Token = ctx.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
|
||||
|
||||
#if DEBUG //allow cookie based auth when in debug mode
|
||||
if (string.IsNullOrWhiteSpace(ctx.Token))
|
||||
ctx.Token = ctx.Request.Cookies.FirstOrDefault(c => c.Key == "token").Value;
|
||||
#endif
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnAuthenticationFailed = ctx =>
|
||||
{
|
||||
ctx.Response.Cookies.Append("token", "", new CookieOptions
|
||||
{
|
||||
MaxAge = TimeSpan.Zero,
|
||||
Expires = DateTime.Now
|
||||
});
|
||||
ctx.Options.ForwardChallenge = "Aoba";
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
}).AddScheme<AuthenticationSchemeOptions, AobaAuthenticationHandler>("Aoba", null);
|
||||
|
||||
|
||||
builder.Services.AddAoba();
|
||||
builder.Services.AddFido2(opts =>
|
||||
{
|
||||
o.Limits.MaxRequestBodySize = null;
|
||||
#if !DEBUG
|
||||
o.ListenAnyIP(8081, lo =>
|
||||
{
|
||||
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
o.ListenAnyIP(8080, lo =>
|
||||
{
|
||||
lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
|
||||
});
|
||||
opts.ServerName = "Aoba";
|
||||
opts.ServerDomain = "aoba.app";
|
||||
#if DEBUG
|
||||
opts.Origins = new HashSet<string> { "http://localhost:8081", "http://127.0.0.1:8080" };
|
||||
#else
|
||||
opts.Origins = new HashSet<string> { "https://aoba.app" };
|
||||
#endif
|
||||
});
|
||||
var config = builder.Configuration;
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers(opt => opt.ModelBinderProviders.Add(new BsonIdModelBinderProvider()));
|
||||
|
||||
builder.Services.AddObersability(builder.Configuration);
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
//DB
|
||||
var dbString = config["DB_STRING"];
|
||||
var settings = MongoClientSettings.FromConnectionString(dbString);
|
||||
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
|
||||
var dbClient = new MongoClient(settings);
|
||||
var db = dbClient.GetDatabase("Aoba");
|
||||
|
||||
builder.Services.AddSingleton(dbClient);
|
||||
builder.Services.AddSingleton<IMongoDatabase>(db);
|
||||
|
||||
var authCfg = new AuthConfigService(db);
|
||||
builder.Services.AddSingleton(authCfg);
|
||||
|
||||
|
||||
var authInfo = authCfg.GetDefaultAuthInfoAsync().GetAwaiter().GetResult();
|
||||
var signingKey = new SymmetricSecurityKey(authInfo.SecureKey);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = signingKey,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = authInfo.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = authInfo.Audience,
|
||||
ValidateLifetime = false,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
builder.Services.AddCors(o =>
|
||||
{
|
||||
o.AddPolicy("AllowAll", p =>
|
||||
{
|
||||
p.AllowAnyOrigin();
|
||||
p.AllowAnyMethod();
|
||||
p.AllowAnyHeader();
|
||||
});
|
||||
o.AddPolicy("RPC", p =>
|
||||
{
|
||||
p.AllowAnyMethod();
|
||||
p.AllowAnyHeader();
|
||||
p.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
|
||||
p.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
|
||||
var metricsAuthInfo = authCfg.GetAuthInfoAsync("aoba", "metrics").GetAwaiter().GetResult();
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = "Aoba";
|
||||
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => //Bearer auth
|
||||
{
|
||||
options.TokenValidationParameters = validationParams;
|
||||
options.TokenHandlers.Add(new MetricsTokenValidator(metricsAuthInfo));
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx => //Retreive token from cookie if not found in headers
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ctx.Token))
|
||||
ctx.Token = ctx.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
|
||||
|
||||
#if DEBUG //allow cookie based auth when in debug mode
|
||||
if (string.IsNullOrWhiteSpace(ctx.Token))
|
||||
ctx.Token = ctx.Request.Cookies.FirstOrDefault(c => c.Key == "token").Value;
|
||||
#endif
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnAuthenticationFailed = ctx =>
|
||||
{
|
||||
ctx.Response.Cookies.Append("token", "", new CookieOptions
|
||||
{
|
||||
MaxAge = TimeSpan.Zero,
|
||||
Expires = DateTime.Now
|
||||
});
|
||||
ctx.Options.ForwardChallenge = "Aoba";
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
}).AddScheme<AuthenticationSchemeOptions, AobaAuthenticationHandler>("Aoba", null);
|
||||
|
||||
|
||||
builder.Services.AddAoba();
|
||||
builder.Services.Configure<FormOptions>(opt =>
|
||||
{
|
||||
opt.ValueLengthLimit = int.MaxValue;
|
||||
opt.MultipartBodyLengthLimit = int.MaxValue;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
|
||||
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();
|
||||
});
|
||||
#if DEBUG
|
||||
builder.Services.AddHostedService<DebugService>();
|
||||
#endif
|
||||
builder.Services.Configure<FormOptions>(opt =>
|
||||
{
|
||||
opt.ValueLengthLimit = int.MaxValue;
|
||||
opt.MultipartBodyLengthLimit = long.MaxValue;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option csharp_namespace = "Aoba.RPC.Account";
|
||||
package aoba;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "Proto/Types.proto";
|
||||
|
||||
service AccountRpc {
|
||||
rpc RegisterPasskey(google.protobuf.Empty) returns (PasskeyCredentialCreateOptions);
|
||||
rpc CompletePasskeyRegistration(PasskeyRegistrationCredentials) returns (google.protobuf.Empty);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import "Proto/Types.proto";
|
||||
service AobaRpc {
|
||||
rpc GetMedia (Id) returns (MediaResponse);
|
||||
rpc DeleteMedia (Id) returns (google.protobuf.Empty);
|
||||
rpc DeleteMediaBulk (IdList) returns (google.protobuf.Empty);
|
||||
rpc UpdateMedia (google.protobuf.Empty) returns (google.protobuf.Empty);
|
||||
rpc ListMedia(PageFilter) returns (ListResponse);
|
||||
rpc GetUser(Id) returns (UserResponse);
|
||||
rpc GetShareXDestination(google.protobuf.Empty) returns (ShareXResponse);
|
||||
rpc SetMediaClass(SetMediaClassRequest) returns(google.protobuf.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ import "Proto/Types.proto";
|
||||
|
||||
service AuthRpc {
|
||||
rpc Login(Credentials) returns (LoginResponse);
|
||||
rpc LoginPasskey(PassKeyPayload) returns (LoginResponse);
|
||||
rpc LoginPasskey(PasskeyPayload) returns (LoginResponse);
|
||||
}
|
||||
|
||||
|
||||
+170
-101
@@ -1,101 +1,170 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
option csharp_namespace = "Aoba.RPC";
|
||||
package aoba;
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message Credentials{
|
||||
string user = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message SetMediaClassRequest{
|
||||
Id id = 1;
|
||||
MediaClass class = 2;
|
||||
}
|
||||
|
||||
|
||||
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 IdList {
|
||||
repeated Id value = 1;
|
||||
}
|
||||
|
||||
message MediaResponse {
|
||||
optional MediaModel value = 1;
|
||||
}
|
||||
|
||||
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;
|
||||
MediaClass class = 9;
|
||||
}
|
||||
|
||||
enum MediaType {
|
||||
Image = 0;
|
||||
Audio = 1;
|
||||
Video = 2;
|
||||
Text = 3;
|
||||
Code = 4;
|
||||
Raw = 5;
|
||||
}
|
||||
|
||||
enum MediaClass {
|
||||
Standard = 0;
|
||||
NSFW = 1;
|
||||
Secret = 2;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
message PasskeyPayload {
|
||||
|
||||
}
|
||||
|
||||
message PasskeyCredentialCreateOptions{
|
||||
string challenge = 1;
|
||||
PublicKeyCredentialUser user = 2;
|
||||
PublicKeyCredentialRpEntity rp = 3;
|
||||
repeated PubKeyCredParam pubKeyParams = 4;
|
||||
}
|
||||
|
||||
message PubKeyCredParam{
|
||||
string alg = 1;
|
||||
string type = 2;
|
||||
}
|
||||
|
||||
message PublicKeyCredentialRpEntity{
|
||||
string id = 1;
|
||||
string icon = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message PublicKeyCredentialUser{
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string displayName = 3;
|
||||
}
|
||||
|
||||
message PasskeyRegistrationCredentials{
|
||||
string id = 1;
|
||||
string rawId = 2;
|
||||
string type = 3;
|
||||
CredentialsClientResponse response = 4;
|
||||
}
|
||||
|
||||
message CredentialsClientResponse{
|
||||
string clientDataJSON = 1;
|
||||
string attestationObject = 2;
|
||||
string authenticatorData = 3;
|
||||
}
|
||||
|
||||
message PublicKeyCredentialDescriptor{
|
||||
string type = 1;
|
||||
string id = 2;
|
||||
repeated string transports = 3;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Aoba.RPC;
|
||||
using Aoba.RPC.Account;
|
||||
|
||||
using AobaCore.Services;
|
||||
|
||||
using AobaServer.Utils;
|
||||
|
||||
using Fido2NetLib;
|
||||
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
|
||||
using Grpc.Core;
|
||||
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
namespace AobaServer.Services;
|
||||
|
||||
public class AccountRpcService(IFido2 fido2, AccountsService accounts) : AccountRpc.AccountRpcBase
|
||||
{
|
||||
public override async Task<PasskeyCredentialCreateOptions> RegisterPasskey(Empty request, ServerCallContext context)
|
||||
{
|
||||
var curUser = await accounts.GetUserAsync(context.GetUserId(), context.CancellationToken);
|
||||
if (curUser == null)
|
||||
throw new Exception($"Logged in user does not exist somehow. Id: {context.GetUserId()}");
|
||||
var user = new Fido2User
|
||||
{
|
||||
DisplayName = curUser.Username,
|
||||
Id = curUser.Id.ToByteArray(),
|
||||
Name = curUser.Username
|
||||
};
|
||||
|
||||
var credOptions = fido2.RequestNewCredential(new RequestNewCredentialParams
|
||||
{
|
||||
User = user,
|
||||
ExcludeCredentials = curUser.CredentialDescriptors,
|
||||
AuthenticatorSelection = new AuthenticatorSelection
|
||||
{
|
||||
ResidentKey = Fido2NetLib.Objects.ResidentKeyRequirement.Required,
|
||||
UserVerification = Fido2NetLib.Objects.UserVerificationRequirement.Preferred
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return credOptions.ToRPC();
|
||||
}
|
||||
|
||||
public override Task<Empty> CompletePasskeyRegistration(PasskeyRegistrationCredentials request, ServerCallContext context)
|
||||
{
|
||||
return base.CompletePasskeyRegistration(request, context);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,18 +9,15 @@ using Google.Protobuf.WellKnownTypes;
|
||||
|
||||
using Grpc.Core;
|
||||
|
||||
using MongoDB.Bson.IO;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AobaServer.Services;
|
||||
|
||||
public class AobaRpcService(AobaService aobaService, AccountsService accountsService, AuthConfigService authConfig) : AobaRpc.AobaRpcBase
|
||||
public class AobaRpcService(AobaService aobaService, ThumbnailService thumbnailService, AccountsService accountsService, AuthConfigService authConfig) : AobaRpc.AobaRpcBase
|
||||
{
|
||||
public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context)
|
||||
{
|
||||
var media = await aobaService.GetMediaFromLegacyIdAsync(request.ToObjectId());
|
||||
var media = await aobaService.GetMediaAsync(request.ToObjectId(), context.CancellationToken);
|
||||
return media.ToResponse();
|
||||
}
|
||||
|
||||
@@ -31,6 +28,12 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
|
||||
return result.ToResponse();
|
||||
}
|
||||
|
||||
public override async Task<Empty> SetMediaClass(SetMediaClassRequest request, ServerCallContext context)
|
||||
{
|
||||
await aobaService.SetMediaClassAsync(request.Id.ToObjectId(), (AobaCore.Models.MediaClass)request.Class, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<ShareXResponse> GetShareXDestination(Empty request, ServerCallContext context)
|
||||
{
|
||||
var userId = context.GetHttpContext().User.GetId();
|
||||
@@ -50,11 +53,36 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
|
||||
};
|
||||
return new ShareXResponse
|
||||
{
|
||||
Destination = JsonSerializer.Serialize(dest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
})
|
||||
Destination = JsonSerializer.Serialize(dest)
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteMedia(Id request, ServerCallContext context)
|
||||
{
|
||||
var media = await aobaService.GetMediaAsync(request.ToObjectId());
|
||||
if (media == null)
|
||||
return new Empty();
|
||||
await aobaService.DeleteFileAsync(media.MediaId, context.CancellationToken);
|
||||
foreach (var (_, id) in media.Thumbnails)
|
||||
{
|
||||
await thumbnailService.DeleteThumbnailDirectAsync(id);
|
||||
}
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteMediaBulk(IdList request, ServerCallContext context)
|
||||
{
|
||||
var media = await aobaService.GetMediaAsync(request.ToObjectId(), context.CancellationToken);
|
||||
if(media.Count == 0)
|
||||
return new Empty();
|
||||
await aobaService.DeleteFilesAsync(request.ToObjectId(), context.CancellationToken);
|
||||
foreach (var item in media)
|
||||
{
|
||||
foreach (var (_, id) in item.Thumbnails)
|
||||
{
|
||||
await thumbnailService.DeleteThumbnailDirectAsync(id);
|
||||
}
|
||||
}
|
||||
return new Empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
using AobaCore.Models;
|
||||
using AobaCore.Services;
|
||||
|
||||
namespace AobaServer.Services;
|
||||
|
||||
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
//todo: clean up orphaned thumbnails
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Aoba.RPC;
|
||||
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
namespace AobaServer.Utils;
|
||||
|
||||
public static class PasskeyExtensions
|
||||
{
|
||||
public static PublicKeyCredentialRpEntity ToRPC(this Fido2NetLib.PublicKeyCredentialRpEntity value)
|
||||
{
|
||||
return new PublicKeyCredentialRpEntity
|
||||
{
|
||||
Id = value.Id,
|
||||
Icon = value.Icon,
|
||||
Name = value.Name,
|
||||
};
|
||||
}
|
||||
|
||||
public static PublicKeyCredentialUser ToRPC(this Fido2NetLib.Fido2User value)
|
||||
{
|
||||
return new PublicKeyCredentialUser
|
||||
{
|
||||
Id = value.Id.ToB64String(),
|
||||
DisplayName = value.DisplayName,
|
||||
Name = value.Name,
|
||||
};
|
||||
}
|
||||
|
||||
public static PubKeyCredParam ToRPC(this Fido2NetLib.PubKeyCredParam value)
|
||||
{
|
||||
return new PubKeyCredParam
|
||||
{
|
||||
Alg = value.Alg.ToString(),
|
||||
Type = value.Type.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<PubKeyCredParam> ToRPC(this IEnumerable<Fido2NetLib.PubKeyCredParam> value)
|
||||
{
|
||||
return value.Select(x => x.ToRPC());
|
||||
}
|
||||
|
||||
public static PasskeyCredentialCreateOptions ToRPC(this Fido2NetLib.CredentialCreateOptions value)
|
||||
{
|
||||
var opts = new PasskeyCredentialCreateOptions
|
||||
{
|
||||
Challenge = value.Challenge.ToB64String(),
|
||||
Rp = value.Rp.ToRPC(),
|
||||
User = value.User.ToRPC()
|
||||
};
|
||||
//todo: excluded credentials
|
||||
opts.PubKeyParams.AddRange(value.PubKeyCredParams.ToRPC());
|
||||
return opts;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public static class ProtoExtensions
|
||||
public static MediaResponse ToResponse(this Media? media)
|
||||
{
|
||||
if(media == null)
|
||||
return new MediaResponse() { Empty = new Empty() };
|
||||
return new MediaResponse() {};
|
||||
return new MediaResponse()
|
||||
{
|
||||
Value = media.ToMediaModel()
|
||||
@@ -56,6 +56,8 @@ public static class ProtoExtensions
|
||||
Owner = media.Owner.ToId(),
|
||||
ViewCount = media.ViewCount,
|
||||
ThumbUrl = thumbUrl,
|
||||
MediaUrl = media.GetMediaUrl(),
|
||||
Class = (Aoba.RPC.MediaClass)media.Class,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,4 +70,9 @@ public static class ProtoExtensions
|
||||
{
|
||||
return id.Value.ToObjectId();
|
||||
}
|
||||
|
||||
public static IEnumerable<ObjectId> ToObjectId(this IdList id)
|
||||
{
|
||||
return id.Value.Select(v => v.ToObjectId());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user