Compare commits

..

40 Commits

Author SHA1 Message Date
Amatsugu ec0c6a3487 push history state
Build and Push Image / build-and-push (push) Successful in 5m12s
2026-04-10 01:15:51 -04:00
Amatsugu dd8faf8038 update 2026-04-08 22:45:29 -04:00
Amatsugu bd99b4beac update dx 2026-04-05 19:39:00 -04:00
Amatsugu 2517cd777f misc passkey prep 2026-04-05 19:20:02 -04:00
Amatsugu ea9ad2f8a7 rewrite components such that data always flows downwards 2026-04-05 17:03:19 -04:00
Amatsugu 44425723c6 media class on page change
Build and Push Image / build-and-push (push) Successful in 4m45s
2026-03-30 14:19:29 -04:00
Amatsugu 5caa08e145 moving list to it's own component 2026-03-29 22:31:56 -04:00
Amatsugu 44959589f8 async solve, now to fix list updating 2026-03-29 20:16:09 -04:00
Amatsugu b1ab165665 added ability to set media class of items
Build and Push Image / build-and-push (push) Successful in 5m4s
2026-03-29 03:19:34 -04:00
Amatsugu b8d01b567c added media class 2026-03-28 23:14:51 -04:00
Amatsugu 3a5dde9ee3 update .net + passkey wip 2026-03-10 17:48:07 -04:00
Amatsugu 9e09110b16 update dockerfile + wip passkey implementation 2026-03-02 17:07:30 -05:00
Amatsugu 511e62b58c sticky pagination
Build and Push Image / build-and-push (push) Successful in 4m6s
2025-12-28 15:20:21 -05:00
Amatsugu 41aa78b672 added pagination controls
Build and Push Image / build-and-push (push) Successful in 4m33s
2025-12-28 14:29:46 -05:00
Amatsugu 21163b277d update dockerfile
Build and Push Image / build-and-push (push) Successful in 4m0s
2025-12-26 17:14:45 -05:00
Amatsugu 7a0d3b7f40 merge
Build and Push Image / build-and-push (push) Failing after 4m5s
2025-12-26 17:03:34 -05:00
Amatsugu 6d2b8c77b2 update to dx 0.7.2 2025-12-26 17:03:00 -05:00
Amatsugu 8bdd9edbb0 Increase multi-part body limit
Build and Push Image / build-and-push (push) Successful in 3m52s
2025-12-22 15:55:46 +00:00
Amatsugu 5e6b0b21a6 fix filename handling
Build and Push Image / build-and-push (push) Successful in 3m48s
2025-12-14 16:22:51 -05:00
Amatsugu 19274d444d fix typo 2025-12-14 16:09:11 -05:00
Amatsugu 63e2f9f791 update dockerfile
Build and Push Image / build-and-push (push) Successful in 3m41s
2025-12-14 16:07:53 -05:00
Amatsugu 8964d1c069 testing using libvips 2025-08-17 14:57:21 -04:00
Amatsugu 8808126905 testing using ffmpeg to generate thumbnails for avif 2025-08-17 03:16:19 -04:00
Amatsugu d36aaac836 empty 2025-08-13 23:14:21 -04:00
Amatsugu bb2c6c4683 removed woodpecker file 2025-08-13 21:57:33 -04:00
Amatsugu f8d457a096 moved woodpecker file to root 2025-08-13 21:51:10 -04:00
Amatsugu 165adb2775 added woodpecker, updated build to update latest
Build and Push Image / build-and-push (push) Successful in 6m21s
2025-08-13 21:48:08 -04:00
Amatsugu e832ccf07e ContextMenu Renderer Finalized 2025-08-10 00:11:05 -04:00
Amatsugu 6df6de098b WIP ContextMenu Renderer 2025-08-09 17:14:41 -04:00
Amatsugu 364b23e62a context menu rendering wip 2025-08-03 12:12:13 -04:00
Amatsugu 7b2ed32043 added context menu 2025-08-02 13:38:15 -04:00
Amatsugu d094f7bbef misc 2025-07-29 20:27:59 -04:00
Amatsugu f98429159f Fix incorrect dispose
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-23 16:22:09 -04:00
Amatsugu 3dca408356 Cleanup potential memory leaks
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-23 15:45:35 -04:00
Amatsugu 1655e342b7 Removed avif + heif support 2025-07-17 20:02:41 -04:00
Amatsugu 7223c35658 Added avif + heif support
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-17 15:09:16 -04:00
Amatsugu b85bcd1c7a Add media url to MediaModel+Start work on tonenized search query model
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-12 18:10:21 -04:00
Amatsugu 8536d335bc Make Search Sticky 2025-07-12 13:52:32 -04:00
Amatsugu b34f7436c2 fix cirucular deps
Build and Push Image / build-and-push (push) Has been cancelled
added additonal file types
2025-07-09 21:08:10 -04:00
Amatsugu 8b5803c085 Make media seekable
Build and Push Image / build-and-push (push) Has been cancelled
2025-07-09 20:48:55 -04:00
49 changed files with 3541 additions and 1073 deletions
+29 -27
View File
@@ -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
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Build
uses: docker/setup-buildx-action@v3
- name: Set up Docker Build
uses: docker/setup-buildx-action@v3
- name: Login
uses: docker/login-action@v3
with:
registry: git.kaisei.app
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Login
uses: docker/login-action@v3
with:
registry: git.kaisei.app
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Extract tag version
id: extract_tag
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- 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 }}
build-args: VERSION=${{ env.VERSION }}
- 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 }}
+1931 -574
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -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 = "1.0.228"
serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [
"codegen",
@@ -15,7 +15,8 @@ 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"] }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
[build-dependencies]
tonic-build = { version = "*", default-features = false, features = ["prost"] }
+83
View File
@@ -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
View File
@@ -1,5 +1,6 @@
$mainBGColor: #584577;
$featureColor: #ce2d4f;
$focusColor: #ff3862;
$accentColor: #f0eaf8;
$mainTextColor: #eee;
@@ -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;
}
+82
View File
@@ -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;
}
}
}
+7 -4
View File
@@ -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,64 @@
use dioxus::prelude::*;
use dioxus_primitives::context_menu::{
self, ContextMenuContentProps, ContextMenuItemProps, ContextMenuProps, ContextMenuTriggerProps,
};
#[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}
}
}
}
#[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}
}
}
}
#[component]
pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element {
rsx! {
context_menu::ContextMenuContent {
class: "context-menu-content",
id: props.id,
attributes: props.attributes,
{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}
}
}
}
@@ -0,0 +1,2 @@
mod component;
pub use component::*;
@@ -0,0 +1,71 @@
.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));
}
+81 -47
View File
@@ -1,44 +1,47 @@
use dioxus::prelude::*;
use tonic::IntoRequest;
use crate::{
components::MediaItem,
rpc::{aoba::PageFilter, get_rpc_client},
components::{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 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 {
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;
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 {
return Ok(items.into_inner());
let res = items.into_inner();
return Ok(res);
} else {
let err = result.err().unwrap();
let message = err.message();
@@ -46,32 +49,63 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
}
}));
match media_result.cloned() {
use_effect(move || match media_result() {
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}"
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 }},
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>) -> Element {
rsx! {
{items.iter().enumerate().map(|(index, itm)| rsx!{
MediaItem {
item: itm.clone(),
index
}
})}
}
}
+193 -29
View File
@@ -1,43 +1,207 @@
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 index: usize,
pub class: String,
}
#[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps {
pub item: Option<MediaModel>,
pub item: MediaModel,
pub index: usize,
pub on_class_changed: Option<EventHandler<MediaClassChangeEvent>>,
pub on_deleted: Option<EventHandler<usize>>,
}
#[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}" }
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 {
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()}",
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_signal() != "" {
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("");
}
});
},
div{
class: "contextItem",
div{
class: "label",
"Mark Standard"
}
}
}}
}else{rsx!{}}
}
{
if class_signal() != "blur" {
rsx!{ContextMenuItem {
index: 2 as usize,
value: "{id}",
on_select: move |id: String|{
spawn(async move {
if let Ok(_) = set_class(id, 1).await{
class_signal.set("blur");
}
});
},
div{
class: "contextItem",
div{
class: "label",
"Mark NSFW"
}
}
}}
}else{rsx!{}}
}
{
if class_signal() != "secret" {
rsx!{ContextMenuItem {
index: 2 as usize,
value: "{id}",
on_select: move |id: String|{
spawn(async move {
if let Ok(_) = set_class(id, 2).await{
class_signal.set("secret");
}
});
},
div{
class: "contextItem",
div{
class: "label",
"Mark Secret"
}
}
}}
}else{rsx!{}}
}
ContextMenuItem {
index: 2 as usize,
value: "",
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 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 }),
})
.await;
}
+8 -1
View File
@@ -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;
+11 -6
View File
@@ -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! {
+60
View File
@@ -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);
}
+39
View File
@@ -0,0 +1,39 @@
use dioxus::prelude::*;
use web_sys::{CredentialCreationOptions, window};
use crate::components::basic::Button;
#[component]
pub fn PasskeyRegistrationButton() -> Element {
rsx! {
Button{
text: "Register Passkey",
onclick: move |_| {
start_passkey_registration();
}
}
}
}
fn start_passkey_registration() {
create_credential();
}
fn create_credential() {
let credentials = window()
.expect("Failed to get window")
.navigator()
.credentials();
let opts = CredentialCreationOptions::new();
let _result = credentials.create_with_options(&opts);
}
#[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;
}
}
+13 -3
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
use dioxus::signals::{Signal, Writable};
use dioxus::signals::{Signal, WritableExt};
use web_sys::window;
use crate::rpc::{login, logout};
+19 -5
View File
@@ -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
View File
@@ -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
}
})
}
}
}
+8 -3
View File
@@ -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]
+32 -16
View File
@@ -9,7 +9,8 @@ use crate::{
rpc::aoba::{auth_rpc_client::AuthRpcClient, metrics_rpc_client::MetricsRpcClient},
};
pub mod aoba {
pub mod aoba
{
tonic::include_proto!("aoba");
}
@@ -21,31 +22,38 @@ static RPC_CLIENT: RpcConnection = RpcConnection {
};
#[derive(Default)]
pub struct RpcConnection {
pub struct RpcConnection
{
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
auth: RwLock<Option<AuthRpcClient<Client>>>,
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_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);
@@ -58,9 +66,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 +80,26 @@ 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_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;
}
+55 -7
View File
@@ -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 -1
View File
@@ -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 {}
}
}
}
+44
View File
@@ -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()}" }
}
}
+2
View File
@@ -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;
+13 -5
View File
@@ -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 { }
}
}
+10 -10
View File
@@ -1,22 +1,22 @@
<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="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.3" />
<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="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" />
</ItemGroup>
</Project>
+16 -2
View File
@@ -1,6 +1,8 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using SixLabors.ImageSharp;
namespace AobaCore.Models;
[BsonIgnoreExtraElements]
@@ -16,23 +18,28 @@ 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()
{
{ ".jpg", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".jpeg", MediaType.Image },
{ ".jxr", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".png", MediaType.Image },
{ ".apng", MediaType.Image },
{ ".webp", MediaType.Image },
{ ".qoi", MediaType.Image },
{ ".ico", MediaType.Image },
{ ".gif", MediaType.Image },
{ ".mp3", MediaType.Audio },
{ ".flac", MediaType.Audio },
{ ".alac", MediaType.Audio },
{ ".mp4", MediaType.Video },
{ ".m4v", MediaType.Video },
{ ".webm", MediaType.Video },
{ ".mov", MediaType.Video },
{ ".avi", MediaType.Video },
@@ -79,7 +86,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}"
};
}
@@ -111,3 +118,10 @@ public enum MediaType
Code,
Raw
}
public enum MediaClass
{
Standard,
NSFW,
Secret
}
+3 -3
View File
@@ -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; }
}
+35 -9
View File
@@ -10,7 +10,7 @@ using MongoDB.Driver.GridFS;
namespace AobaCore.Services;
public class AobaService(IMongoDatabase db, ThumbnailService thumbnailService, ILogger<AobaService> logger)
public class AobaService(IMongoDatabase db)
{
private readonly IMongoCollection<Media> _media = db.GetCollection<Media>("media");
private readonly GridFSBucket _gridFs = new(db);
@@ -20,32 +20,47 @@ public class AobaService(IMongoDatabase db, ThumbnailService thumbnailService, I
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<PagedResult<Media>> FindMediaAsync(string? query, ObjectId userId, int page = 1, int pageSize = 100, CancellationToken cancellationToken = default)
{
var filter = Builders<Media>.Filter.And([
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(filter);
var find = _media.Find(Builders<Media>.Filter.And(filters));
var total = await find.CountDocumentsAsync();
var total = await find.CountDocumentsAsync(cancellationToken);
page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total);
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 +68,13 @@ public class AobaService(IMongoDatabase db, ThumbnailService thumbnailService, I
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);
@@ -79,6 +101,10 @@ public class AobaService(IMongoDatabase db, ThumbnailService thumbnailService, I
{
return ex;
}
finally
{
data.Dispose();
}
}
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default)
+88 -8
View File
@@ -3,6 +3,7 @@
using FFMpegCore;
using FFMpegCore.Pipes;
using MaybeError.Errors;
using MongoDB.Bson;
@@ -10,10 +11,12 @@ using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
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 +28,28 @@ 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;
}
/// <summary>
///
/// </summary>
@@ -34,11 +59,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 +117,29 @@ 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),
_ => 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 +150,13 @@ 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> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
@@ -141,7 +180,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
output.Position = 0;
return output;
}
catch(Exception ex)
catch (Exception ex)
{
return ex;
}
@@ -151,6 +190,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
}
}
public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
var inFilePath = $"/tmp/{fn}.avif";
var outFilePath = $"/tmp/{fn}.webp";
using var source = new FileStream(inFilePath, FileMode.CreateNew);
data.CopyTo(source);
source.Flush();
source.Dispose();
data.Dispose();
try
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "vips",
Arguments = $"smartcrop \"{inFilePath}\" \"{outFilePath}\"[Q=75] {w} {w}",
WorkingDirectory = "/tmp"
});
if (process == null)
return new Error("Failed to run vips command");
process.WaitForExit();
if (process.ExitCode != 0)
return new Error("Failed to convert");
var output = new MemoryStream();
using var oFile = File.OpenRead(outFilePath);
oFile.CopyTo(output);
output.Position = 0;
return output;
}
catch(Exception ex)
{
return ex;
}
finally
{
File.Delete(inFilePath);
File.Delete(outFilePath);
}
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
return new NotImplementedException();
+13 -11
View File
@@ -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" Version="4.0.0" />
<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">
<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.3" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.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.0" />
<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.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
</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>
+1
View File
@@ -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,
+19 -1
View File
@@ -14,10 +14,11 @@ 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)
{
var file = await aobaService.GetFileStreamAsync(id, cancellationToken: cancellationToken);
var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
if (file.HasError)
{
logger.LogError(file.Error.Exception, "Failed to load media stream");
@@ -28,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
return File(file, mime, true);
}
[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
View File
@@ -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
+4 -1
View File
@@ -121,10 +121,13 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddAoba();
#if DEBUG
builder.Services.AddHostedService<DebugService>();
#endif
builder.Services.Configure<FormOptions>(opt =>
{
opt.ValueLengthLimit = int.MaxValue;
opt.MultipartBodyLengthLimit = int.MaxValue;
opt.MultipartBodyLengthLimit = long.MaxValue;
});
var app = builder.Build();
+13
View File
@@ -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 (PasskeyRegistrationCreds);
rpc CompletePasskeyRegistration(PasskeyPublicKey) returns (google.protobuf.Empty);
}
+1
View File
@@ -12,5 +12,6 @@ service AobaRpc {
rpc ListMedia(PageFilter) returns (ListResponse);
rpc GetUser(Id) returns (UserResponse);
rpc GetShareXDestination(google.protobuf.Empty) returns (ShareXResponse);
rpc SetMediaClass(SetMediaClassRequest) returns(google.protobuf.Empty);
}
+1 -1
View File
@@ -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);
}
+36 -8
View File
@@ -9,8 +9,9 @@ message Credentials{
string password = 2;
}
message PassKeyPayload {
message SetMediaClassRequest{
Id id = 1;
MediaClass class = 2;
}
@@ -40,10 +41,7 @@ message Id {
}
message MediaResponse {
oneof result {
MediaModel value = 1;
google.protobuf.Empty empty = 2;
}
optional MediaModel value = 1;
}
message ListResponse {
@@ -54,8 +52,8 @@ message ListResponse {
message Pagination {
int32 page = 1;
int32 pageSize = 2;
int64 totalPages = 3;
int64 totalItems = 4;
int32 totalPages = 3;
int32 totalItems = 4;
optional string query = 5;
}
@@ -82,6 +80,8 @@ message MediaModel {
int32 viewCount = 5;
Id owner = 6;
string thumbUrl = 7;
string mediaUrl = 8;
MediaClass class = 9;
}
enum MediaType {
@@ -93,9 +93,37 @@ enum MediaType {
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 PasskeyRegistrationCreds{
}
message PasskeyPublicKey{
}
+22
View File
@@ -0,0 +1,22 @@
using Aoba.RPC;
using Aoba.RPC.Account;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
namespace AobaServer.Services;
public class AccountRpcService : AccountRpc.AccountRpcBase
{
public override Task<PasskeyRegistrationCreds> RegisterPasskey(Empty request, ServerCallContext context)
{
return base.RegisterPasskey(request, context);
}
public override Task<Empty> CompletePasskeyRegistration(PasskeyPublicKey request, ServerCallContext context)
{
return base.CompletePasskeyRegistration(request, context);
}
}
+8 -8
View File
@@ -9,10 +9,7 @@ using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MongoDB.Bson.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AobaServer.Services;
@@ -20,7 +17,7 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
{
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,10 +53,7 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
};
return new ShareXResponse
{
Destination = JsonSerializer.Serialize(dest, new JsonSerializerOptions
{
WriteIndented = true
})
Destination = JsonSerializer.Serialize(dest)
};
}
+19
View File
@@ -0,0 +1,19 @@
using AobaCore.Services;
namespace AobaServer.Services;
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
foreach (var item in mediaItems)
{
foreach (var size in item.Thumbnails.Keys)
{
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
}
}
}
}
+3 -1
View File
@@ -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,
};
}