Compare commits

...

8 Commits

Author SHA1 Message Date
Amatsugu 73236e1fde remove debug service
Build and Push Image / build-and-push (push) Successful in 4m52s
2026-05-01 14:58:09 -04:00
Amatsugu 90078a0f62 addd audio file thumbnail generation
Build and Push Image / build-and-push (push) Successful in 5m1s
2026-05-01 02:16:54 -04:00
Amatsugu 14a6e18966 improved updating of items
Build and Push Image / build-and-push (push) Successful in 5m7s
2026-04-13 15:02:15 -04:00
Amatsugu a6e2156d97 added deletion of items 2026-04-13 14:58:10 -04:00
Amatsugu 8ff4fa74e4 wip passkey registration day 2 2026-04-12 16:59:40 -04:00
Amatsugu 6c2305cac9 wip passkey registration 2026-04-11 19:02:06 -04:00
Amatsugu 7a43d5c11f wip passkey registration 2026-04-11 18:56:04 -04:00
Amatsugu 4325280020 context menu implementation testing 2026-04-10 14:34:24 -04:00
29 changed files with 577 additions and 253 deletions
+18 -18
View File
@@ -24,8 +24,10 @@ dependencies = [
"dioxus",
"dioxus-primitives",
"dotenv",
"js-sys",
"prost",
"serde",
"serde-wasm-bindgen",
"serde_repr",
"tonic",
"tonic-build",
@@ -1669,10 +1671,12 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3340,9 +3344,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
@@ -3353,23 +3357,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3377,9 +3377,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3390,9 +3390,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
@@ -3412,9 +3412,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [
"js-sys",
"wasm-bindgen",
+4 -2
View File
@@ -7,7 +7,7 @@ edition = "2024"
[dependencies]
dioxus = { version = "0.7.5", features = ["router"] }
serde = "1.0.228"
serde = { version = "1.0.228", features = ["derive"] }
serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [
"codegen",
@@ -15,8 +15,10 @@ tonic = { version = "*", default-features = false, features = [
] }
prost = "0.13"
tonic-web-wasm-client = "0.7"
web-sys = { version = "0.3.91", features = ["Storage", "Window", "Navigator", "CredentialsContainer", "CredentialCreationOptions"] }
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"] }
+9 -4
View File
@@ -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",
@@ -1,64 +1,63 @@
use super::props::*;
use dioxus::prelude::*;
use dioxus_primitives::context_menu::{
self, ContextMenuContentProps, ContextMenuItemProps, ContextMenuProps, ContextMenuTriggerProps,
};
const CONTEXT_MENU_CSS: Asset = asset!("./style.scss");
#[component]
pub fn ContextMenu(props: ContextMenuProps) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: asset!("./style.css") }
context_menu::ContextMenu {
disabled: props.disabled,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
roving_loop: props.roving_loop,
attributes: props.attributes,
{props.children}
}
}
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! {
context_menu::ContextMenuTrigger {
padding: "20px",
background: "var(--primary-color)",
border: "1px dashed var(--primary-color-6)",
border_radius: ".5rem",
cursor: "context-menu",
user_select: "none",
text_align: "center",
attributes: props.attributes,
{props.children}
}
}
pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element
{
rsx! {
div{
class: "contextMenuTrigger",
oncontextmenu: move|e|{
},
{props.children}
}
}
}
#[component]
pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element {
rsx! {
context_menu::ContextMenuContent {
class: "context-menu-content",
id: props.id,
attributes: props.attributes,
{props.children}
}
}
pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element
{
rsx! {
div{
class: "contextMenuContent",
{props.children}
}
}
}
#[component]
pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element {
rsx! {
context_menu::ContextMenuItem {
class: "context-menu-item",
disabled: props.disabled,
value: props.value,
index: props.index,
on_select: props.on_select,
attributes: props.attributes,
{props.children}
}
}
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}
}
}
@@ -1,2 +1,3 @@
mod component;
pub use 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,71 +0,0 @@
.context-menu-content {
z-index: 1000;
min-width: 220px;
padding: 0.25rem;
border-radius: 0.5rem;
background: var(--dark, var(--primary-color-5))
var(--light, var(--primary-color));
box-shadow: inset 0 0 0 1px var(--dark, var(--primary-color-7))
var(--light, var(--primary-color-6));
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.context-menu-content[data-state="closed"] {
animation: context-menu-animate-out 150ms ease-in forwards;
}
@keyframes context-menu-animate-out {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.95) translateY(-2px);
}
}
.context-menu-content[data-state="open"] {
animation: context-menu-animate-in 150ms ease-out forwards;
}
@keyframes context-menu-animate-in {
0% {
opacity: 0;
transform: scale(0.95) translateY(-2px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.context-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: calc(0.5rem - 0.25rem);
color: var(--secondary-color-4);
cursor: pointer;
font-size: 14px;
outline: none;
transition: background-color 100ms ease-out;
user-select: none;
}
.context-menu-item[data-disabled="true"] {
color: var(--secondary-color-5);
cursor: not-allowed;
}
.context-menu-item:hover:not([data-disabled="true"]),
.context-menu-item:focus-visible {
background: var(--light, var(--primary-color-4))
var(--dark, var(--primary-color-7));
color: var(--light, var(--secondary-color-1))
var(--dark, var(--secondary-color-4));
}
+65 -18
View File
@@ -1,7 +1,7 @@
use dioxus::prelude::*;
use crate::{
components::{MediaItem, MediaItemPlaceHolder},
components::{MediaClassChangeEvent, MediaItem, MediaItemPlaceHolder},
rpc::{
aoba::{MediaModel, PageFilter},
get_rpc_client,
@@ -9,7 +9,8 @@ use crate::{
};
#[derive(PartialEq, Clone, Props)]
pub struct MediaGridProps {
pub struct MediaGridProps
{
pub query: Signal<String>,
pub max_page: Signal<i32>,
pub total_items: Signal<i32>,
@@ -18,19 +19,21 @@ pub struct MediaGridProps {
pub on_page_loaded: Option<EventHandler<PaginationInfo>>,
}
pub struct PaginationInfo {
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);
// items.set(None);
let mut client = get_rpc_client();
let request = PageFilter {
page_size: Some(props.page_size.cloned()),
@@ -38,24 +41,32 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
query: Some(props.query.cloned()),
};
let result = client.list_media(request).await;
if let Ok(items) = result {
if let Ok(items) = result
{
let res = items.into_inner();
return Ok(res);
} else {
}
else
{
let err = result.err().unwrap();
let message = err.message();
return Err(format!("Failed to load results: {message}"));
}
}));
use_effect(move || match media_result() {
Some(value) => match value {
Ok(result) => {
if let Some(pagination) = result.pagination {
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 {
if let Some(handler) = props.on_page_loaded
{
handler.call(PaginationInfo {
total_pages,
total_items,
@@ -71,7 +82,8 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
}
}),
},
_ => {}
_ =>
{}
});
rsx! {
@@ -79,7 +91,35 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
class: "mediaGrid",
{error_display}
{match items(){
Some(itms) => rsx!{MediaList { items: itms }},
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 }}
}}
}
@@ -87,7 +127,8 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
}
#[component]
fn PlaceholderGrid(count: usize) -> Element {
fn PlaceholderGrid(count: usize) -> Element
{
rsx! {
div{
class: "mediaGrid",
@@ -99,12 +140,18 @@ fn PlaceholderGrid(count: usize) -> Element {
}
#[component]
fn MediaList(items: Vec<MediaModel>) -> Element {
fn MediaList(
items: Vec<MediaModel>,
on_item_deleted: Option<EventHandler<String>>,
on_class_changed: Option<EventHandler<MediaClassChangeEvent>>,
) -> Element
{
rsx! {
{items.iter().enumerate().map(|(index, itm)| rsx!{
{items.iter().map(|itm| rsx!{
MediaItem {
item: itm.clone(),
index
on_deleted: on_item_deleted,
on_class_changed: on_class_changed
}
})}
}
+53 -36
View File
@@ -1,7 +1,5 @@
use dioxus::prelude::*;
use dioxus_primitives::context_menu::{
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
};
use dioxus_primitives::context_menu::{ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger};
use tonic::{Response, Status};
use web_sys::window;
@@ -13,47 +11,43 @@ use crate::{
},
};
pub struct MediaClassChangeEvent {
pub index: usize,
pub class: String,
pub struct MediaClassChangeEvent
{
pub id: String,
pub class: i32,
}
#[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps {
pub struct MediaItemProps
{
pub item: MediaModel,
pub index: usize,
pub on_class_changed: Option<EventHandler<MediaClassChangeEvent>>,
pub on_deleted: Option<EventHandler<usize>>,
pub on_deleted: Option<EventHandler<String>>,
}
#[component]
pub fn MediaItem(props: MediaItemProps) -> Element {
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 mut class_signal = use_signal(|| match class {
let class_string = match class
{
1 => "blur",
2 => "secret",
_ => "",
});
};
let url = item.media_url;
let download = format!("{HOST}{url}");
// class_signal.set(match class
// {
// 1 => "blur",
// 2 => "secret",
// _ => "",
// });
return rsx! {
ContextMenu{
ContextMenuTrigger{
a {
class: "mediaItem {class_signal()}",
class: "mediaItem {class_string}",
href: "{HOST}{url}",
target: "_blank",
"data-id" : id.clone(),
@@ -99,14 +93,16 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
}
},
{
if class_signal() != "" {
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{
class_signal.set("");
if let Ok(_) = set_class(&id, 0).await{
if let Some(handler) = props.on_class_changed{
handler.call(MediaClassChangeEvent { id, class: 0 });
}
}
});
},
@@ -121,14 +117,16 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
}else{rsx!{}}
}
{
if class_signal() != "blur" {
if class != 1 {
rsx!{ContextMenuItem {
index: 2 as usize,
index: 3 as usize,
value: "{id}",
on_select: move |id: String|{
spawn(async move {
if let Ok(_) = set_class(id, 1).await{
class_signal.set("blur");
if let Ok(_) = set_class(&id, 1).await{
if let Some(handler) = props.on_class_changed{
handler.call(MediaClassChangeEvent { id, class: 1 });
}
}
});
},
@@ -143,14 +141,16 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
}else{rsx!{}}
}
{
if class_signal() != "secret" {
if class != 3 {
rsx!{ContextMenuItem {
index: 2 as usize,
index: 4 as usize,
value: "{id}",
on_select: move |id: String|{
spawn(async move {
if let Ok(_) = set_class(id, 2).await{
class_signal.set("secret");
if let Ok(_) = set_class(&id, 2).await{
if let Some(handler) = props.on_class_changed{
handler.call(MediaClassChangeEvent { id, class: 2 });
}
}
});
},
@@ -165,8 +165,17 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
}else{rsx!{}}
}
ContextMenuItem {
index: 2 as usize,
value: "",
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{
@@ -181,7 +190,8 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
}
#[component]
pub fn MediaItemPlaceHolder() -> Element {
pub fn MediaItemPlaceHolder() -> Element
{
return rsx! {
div { class: "mediaItem placeholder",
img { },
@@ -196,12 +206,19 @@ pub fn MediaItemPlaceHolder() -> Element {
};
}
async fn set_class(id: String, class: i32) -> Result<Response<()>, Status> {
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 }),
id: Some(Id { value: id.clone() }),
})
.await;
}
+3 -3
View File
@@ -1,5 +1,5 @@
pub mod basic;
// mod context_menu;
mod context_menu;
mod icons;
mod media_grid;
mod media_item;
@@ -9,7 +9,7 @@ mod notif;
mod pagination;
mod passkey;
mod search;
// pub use context_menu::*;
pub use context_menu::*;
pub use media_grid::*;
pub use media_item::*;
pub use metrics_token::*;
@@ -18,4 +18,4 @@ pub use notif::*;
pub use pagination::*;
pub use passkey::*;
pub use search::*;
pub mod radio_group;
pub mod radio_group;
+50 -12
View File
@@ -1,10 +1,15 @@
use dioxus::prelude::*;
use web_sys::{CredentialCreationOptions, window};
use js_sys::{Uint8Array, wasm_bindgen::JsValue};
use web_sys::{
CredentialCreationOptions, PublicKeyCredentialCreationOptions, PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity, window,
};
use crate::components::basic::Button;
use crate::{components::basic::Button, rpc::aoba::PasskeyCredentialCreateOptions};
#[component]
pub fn PasskeyRegistrationButton() -> Element {
pub fn PasskeyRegistrationButton() -> Element
{
rsx! {
Button{
text: "Register Passkey",
@@ -15,22 +20,55 @@ pub fn PasskeyRegistrationButton() -> Element {
}
}
fn start_passkey_registration() {
create_credential();
fn start_passkey_registration()
{
create_credential(todo!());
}
fn create_credential() {
let credentials = window()
.expect("Failed to get window")
.navigator()
.credentials();
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 _result = credentials.create_with_options(&opts);
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 {
pub fn PasskeyLoginButton() -> Element
{
rsx! {
Button{
text: "Login with Passkey"
+18 -1
View File
@@ -6,7 +6,9 @@ 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
@@ -17,6 +19,7 @@ pub mod 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),
};
@@ -26,6 +29,7 @@ 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>>,
}
@@ -38,6 +42,12 @@ impl RpcConnection
return self.aoba.read().unwrap().clone().unwrap();
}
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();
@@ -58,6 +68,8 @@ impl RpcConnection
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));
}
@@ -90,6 +102,11 @@ pub fn get_auth_rpc_client() -> AuthRpcClient<Client>
return RPC_CLIENT.get_auth_client();
}
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();
+6 -5
View File
@@ -8,15 +8,16 @@
<ItemGroup>
<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="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="MaybeError" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.3" />
<PackageReference Include="MongoDB.Driver" Version="3.6.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
<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.16.0" />
<PackageReference Include="ZLinq" Version="1.5.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
<PackageReference Include="ZLinq" Version="1.5.6" />
</ItemGroup>
</Project>
+3
View File
@@ -36,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 },
+4 -1
View File
@@ -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()
{
+10 -3
View File
@@ -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 ?? [];
}
}
+23
View File
@@ -87,6 +87,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
@@ -133,6 +139,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)
+55 -2
View File
@@ -10,8 +10,11 @@ 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;
@@ -119,7 +122,8 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{
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}"),
};
}
@@ -156,6 +160,40 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
return result;
}
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;
@@ -231,8 +269,23 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
}
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
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();
}
}
+7 -7
View File
@@ -9,22 +9,22 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Fido2" Version="4.0.0" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.76.0" />
<PackageReference Include="Grpc.Tools" Version="2.78.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="10.0.3" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.17.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.15.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
</ItemGroup>
+10
View File
@@ -121,6 +121,16 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddAoba();
builder.Services.AddFido2(opts =>
{
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
});
#if DEBUG
builder.Services.AddHostedService<DebugService>();
#endif
+2 -2
View File
@@ -7,7 +7,7 @@ import "google/protobuf/empty.proto";
import "Proto/Types.proto";
service AccountRpc {
rpc RegisterPasskey(google.protobuf.Empty) returns (PasskeyRegistrationCreds);
rpc CompletePasskeyRegistration(PasskeyPublicKey) returns (google.protobuf.Empty);
rpc RegisterPasskey(google.protobuf.Empty) returns (PasskeyCredentialCreateOptions);
rpc CompletePasskeyRegistration(PasskeyRegistrationCredentials) returns (google.protobuf.Empty);
}
+1
View File
@@ -8,6 +8,7 @@ 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);
+45 -4
View File
@@ -40,6 +40,10 @@ message Id {
string value = 1;
}
message IdList {
repeated Id value = 1;
}
message MediaResponse {
optional MediaModel value = 1;
}
@@ -121,9 +125,46 @@ message PasskeyPayload {
}
message PasskeyRegistrationCreds{
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;
}
message PasskeyPublicKey{
}
+34 -4
View File
@@ -1,20 +1,50 @@
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 : AccountRpc.AccountRpcBase
public class AccountRpcService(IFido2 fido2, AccountsService accounts) : AccountRpc.AccountRpcBase
{
public override Task<PasskeyRegistrationCreds> RegisterPasskey(Empty request, ServerCallContext context)
public override async Task<PasskeyCredentialCreateOptions> RegisterPasskey(Empty request, ServerCallContext context)
{
return base.RegisterPasskey(request, 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(PasskeyPublicKey request, ServerCallContext context)
public override Task<Empty> CompletePasskeyRegistration(PasskeyRegistrationCredentials request, ServerCallContext context)
{
return base.CompletePasskeyRegistration(request, context);
}
+11
View File
@@ -57,4 +57,15 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
};
}
public override async Task<Empty> DeleteMedia(Id request, ServerCallContext context)
{
await aobaService.DeleteFileAsync(request.ToObjectId(), context.CancellationToken);
return new Empty();
}
public override async Task<Empty> DeleteMediaBulk(IdList request, ServerCallContext context)
{
await aobaService.DeleteFilesAsync(request.ToObjectId(), context.CancellationToken);
return new Empty();
}
}
+1 -8
View File
@@ -1,4 +1,5 @@
using AobaCore.Models;
using AobaCore.Services;
namespace AobaServer.Services;
@@ -7,13 +8,5 @@ public class DebugService(AobaService aobaService, ThumbnailService thumbnailSer
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
foreach (var item in mediaItems)
{
foreach (var size in item.Thumbnails.Keys)
{
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
}
}
}
}
+55
View File
@@ -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;
}
}
+5
View File
@@ -70,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());
}
}