JJ Colocate

This commit is contained in:
2025-06-30 14:23:20 -04:00
parent 360fa53439
commit 24abf5607f
77 changed files with 5700 additions and 5700 deletions

View File

@@ -1,30 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

126
.gitattributes vendored
View File

@@ -1,63 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

724
.gitignore vendored
View File

@@ -1,363 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

View File

@@ -1,2 +1,2 @@
[build]
target = "wasm32-unknown-unknown"
[build]
target = "wasm32-unknown-unknown"

View File

@@ -1,6 +1,6 @@
**/target
**/dist
LICENSES
LICENSE
temp
**/target
**/dist
LICENSES
LICENSE
temp
README.md

14
AobaClient/.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk

4916
AobaClient/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,37 @@
[package]
name = "aoba-client"
version = "0.1.0"
authors = ["Amatsugu <khamraj@kaisei.app>"]
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"
serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [
"codegen",
"prost",
] }
prost = "0.13"
tonic-web-wasm-client = "0.7"
web-sys = { version = "0.3.77", features = ["Storage", "Window"] }
[build-dependencies]
tonic-build = { version = "*", default-features = false, features = ["prost"] }
[features]
default = ["web"]
web = ["dioxus/web"]
[profile]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"
[package]
name = "aoba-client"
version = "0.1.0"
authors = ["Amatsugu <khamraj@kaisei.app>"]
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"
serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [
"codegen",
"prost",
] }
prost = "0.13"
tonic-web-wasm-client = "0.7"
web-sys = { version = "0.3.77", features = ["Storage", "Window"] }
[build-dependencies]
tonic-build = { version = "*", default-features = false, features = ["prost"] }
[features]
default = ["web"]
web = ["dioxus/web"]
[profile]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

View File

@@ -1,21 +1,21 @@
[application]
[web.app]
# HTML title tag content
title = "aoba-client"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []
[application]
[web.app]
# HTML title tag content
title = "aoba-client"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

View File

@@ -1,25 +1,25 @@
# Development
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Serving Your App
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve
```
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```
# Development
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Serving Your App
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve
```
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

View File

@@ -1,28 +1,28 @@
label {
display: flex;
flex-direction: column;
}
textarea,
input[type="url"],
input[type="email"],
input[type="number"],
input[type="tel"],
input[type="text"] {
}
.searchBar {
display: grid;
width: 100%;
input {
padding: 10px;
font-size: 1.5rem;
border-radius: 20px;
}
}
textarea {
min-height: 200px;
min-width: 500px;
}
label {
display: flex;
flex-direction: column;
}
textarea,
input[type="url"],
input[type="email"],
input[type="number"],
input[type="tel"],
input[type="text"] {
}
.searchBar {
display: grid;
width: 100%;
input {
padding: 10px;
font-size: 1.5rem;
border-radius: 20px;
}
}
textarea {
min-height: 200px;
min-width: 500px;
}

View File

@@ -1,151 +1,151 @@
@import "mixins";
@import "colors";
* {
box-sizing: border-box;
}
:root {
background-color: $mainBGColor;
color: $mainTextColor;
box-sizing: border-box;
font-family: "Noto Sans", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings: "wdth" 100;
}
.stickyTop {
top: 0;
position: sticky;
}
body {
padding: 0;
margin: 0;
}
#main:has(#content) {
display: grid;
grid-template-columns: $navBarSize 1fr;
grid-template-areas: "Nav Content";
}
#content {
grid-area: Content;
overflow-x: hidden;
padding: 10px;
/* margin-left: $navBarSize; */
}
$mediaItemSize: 300px;
.mediaGrid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin: 10px 0;
.mediaItem {
width: $mediaItemSize;
height: $mediaItemSize;
overflow: hidden;
display: grid;
grid-template-columns: $mediaItemSize;
grid-template-areas: "A";
box-shadow: 0 0 2px #000;
color: $mainTextColor;
text-decoration: none;
transition:
transform 0.25s ease-out,
box-shadow 0.25s ease-out;
> * {
grid-area: A;
}
img {
aspect-ratio: 1;
object-fit: cover;
width: 100%;
object-position: center;
background-color: $invertTextColor;
border: 0;
outline: none;
}
.info {
align-self: end;
backdrop-filter: blur(20px) brightness(0.5);
transition: transform 0.25s ease-out;
transform: translateY(100%);
padding: 2px;
.name {
text-align: center;
width: 100%;
display: block;
overflow: hidden;
}
.details {
display: flex;
justify-content: space-between;
}
}
&:hover {
transform: scale(110%) translateZ(2px);
box-shadow: 0 0 8px #000;
.info {
transform: translateY(0%);
}
}
}
}
#main:has(#centralModal) {
display: grid;
place-items: center;
height: 100dvh;
width: 100dvw;
}
#centralModal {
display: flex;
flex-direction: column;
}
form {
display: flex;
flex-direction: column;
gap: 5px;
}
.notif {
background-color: red;
display: grid;
grid-template-columns: 50px 1fr;
height: 50px;
border-radius: 20px;
border-bottom-left-radius: 0;
border-top-right-radius: 0;
.icon {
padding: 10px;
}
.message {
padding: 10px;
align-self: center;
}
}
.codeSelect {
line-break: anywhere;
white-space: pre-wrap;
background-color: $featureColor;
padding: 5px;
user-select: all;
}
@import "mixins";
@import "colors";
* {
box-sizing: border-box;
}
:root {
background-color: $mainBGColor;
color: $mainTextColor;
box-sizing: border-box;
font-family: "Noto Sans", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings: "wdth" 100;
}
.stickyTop {
top: 0;
position: sticky;
}
body {
padding: 0;
margin: 0;
}
#main:has(#content) {
display: grid;
grid-template-columns: $navBarSize 1fr;
grid-template-areas: "Nav Content";
}
#content {
grid-area: Content;
overflow-x: hidden;
padding: 10px;
/* margin-left: $navBarSize; */
}
$mediaItemSize: 300px;
.mediaGrid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin: 10px 0;
.mediaItem {
width: $mediaItemSize;
height: $mediaItemSize;
overflow: hidden;
display: grid;
grid-template-columns: $mediaItemSize;
grid-template-areas: "A";
box-shadow: 0 0 2px #000;
color: $mainTextColor;
text-decoration: none;
transition:
transform 0.25s ease-out,
box-shadow 0.25s ease-out;
> * {
grid-area: A;
}
img {
aspect-ratio: 1;
object-fit: cover;
width: 100%;
object-position: center;
background-color: $invertTextColor;
border: 0;
outline: none;
}
.info {
align-self: end;
backdrop-filter: blur(20px) brightness(0.5);
transition: transform 0.25s ease-out;
transform: translateY(100%);
padding: 2px;
.name {
text-align: center;
width: 100%;
display: block;
overflow: hidden;
}
.details {
display: flex;
justify-content: space-between;
}
}
&:hover {
transform: scale(110%) translateZ(2px);
box-shadow: 0 0 8px #000;
.info {
transform: translateY(0%);
}
}
}
}
#main:has(#centralModal) {
display: grid;
place-items: center;
height: 100dvh;
width: 100dvw;
}
#centralModal {
display: flex;
flex-direction: column;
}
form {
display: flex;
flex-direction: column;
gap: 5px;
}
.notif {
background-color: red;
display: grid;
grid-template-columns: 50px 1fr;
height: 50px;
border-radius: 20px;
border-bottom-left-radius: 0;
border-top-right-radius: 0;
.icon {
padding: 10px;
}
.message {
padding: 10px;
align-self: center;
}
}
.codeSelect {
line-break: anywhere;
white-space: pre-wrap;
background-color: $featureColor;
padding: 5px;
user-select: all;
}

View File

@@ -1,43 +1,43 @@
$navBarSize: 64px;
@mixin mobile {
@media (max-width: 700px) {
@content;
}
}
@mixin max-screen($size) {
@media (max-width: #{$size}) {
@content;
}
}
@mixin max-container($size) {
@container (max-width: #{$size}) {
@content;
}
}
@mixin small-container {
@container (max-width: 500px) {
@content;
}
}
@mixin medium-container {
@container (max-width: 800px) {
@content;
}
}
@mixin large-container {
@container (max-width: 1000px) {
@content;
}
}
@mixin xlarge-container {
@container (max-width: 1200px) {
@content;
}
}
$navBarSize: 64px;
@mixin mobile {
@media (max-width: 700px) {
@content;
}
}
@mixin max-screen($size) {
@media (max-width: #{$size}) {
@content;
}
}
@mixin max-container($size) {
@container (max-width: #{$size}) {
@content;
}
}
@mixin small-container {
@container (max-width: 500px) {
@content;
}
}
@mixin medium-container {
@container (max-width: 800px) {
@content;
}
}
@mixin large-container {
@container (max-width: 1000px) {
@content;
}
}
@mixin xlarge-container {
@container (max-width: 1200px) {
@content;
}
}

View File

@@ -1,43 +1,43 @@
@import "mixins";
@import "colors";
nav {
display: grid;
grid-template-areas: "Branding" "Nav" "Widgets" "Utils";
grid-template-rows: auto 1fr auto auto;
background-color: $featureColor;
height: 100dvh;
position: fixed;
width: $navBarSize;
box-shadow: 0 0 3px #000;
gap: 20px;
padding: 20px 0;
> * {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.branding {
grid-area: Branding;
img {
width: $navBarSize;
object-fit: contain;
}
}
.mainNav {
grid-area: Nav;
}
.widgets {
grid-area: Widgets;
}
.utils {
grid-area: Utils;
}
}
@import "mixins";
@import "colors";
nav {
display: grid;
grid-template-areas: "Branding" "Nav" "Widgets" "Utils";
grid-template-rows: auto 1fr auto auto;
background-color: $featureColor;
height: 100dvh;
position: fixed;
width: $navBarSize;
box-shadow: 0 0 3px #000;
gap: 20px;
padding: 20px 0;
> * {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.branding {
grid-area: Branding;
img {
width: $navBarSize;
object-fit: contain;
}
}
.mainNav {
grid-area: Nav;
}
.widgets {
grid-area: Widgets;
}
.utils {
grid-area: Utils;
}
}

View File

@@ -1,11 +1,11 @@
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/Auth.proto"],
&["../AobaServer/Proto/"],
)?;
Ok(())
}
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/Auth.proto"],
&["../AobaServer/Proto/"],
)?;
Ok(())
}

View File

@@ -1,30 +1,30 @@
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct ButtonProps {
pub variant: Option<ButtonVariant>,
pub text: String,
pub onclick: Option<EventHandler<Event<MouseData>>>,
}
#[derive(PartialEq, Clone)]
pub enum ButtonVariant {
Base,
Muted,
Accented,
}
#[component]
pub fn Button(props: ButtonProps) -> Element {
rsx! {
button {
onclick: move |event| {
event.prevent_default();
if let Some(h) = props.onclick {
h.call(event);
}
},
"{props.text}"
}
}
}
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct ButtonProps {
pub variant: Option<ButtonVariant>,
pub text: String,
pub onclick: Option<EventHandler<Event<MouseData>>>,
}
#[derive(PartialEq, Clone)]
pub enum ButtonVariant {
Base,
Muted,
Accented,
}
#[component]
pub fn Button(props: ButtonProps) -> Element {
rsx! {
button {
onclick: move |event| {
event.prevent_default();
if let Some(h) = props.onclick {
h.call(event);
}
},
"{props.text}"
}
}
}

View File

@@ -1,35 +1,35 @@
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct InputProps {
pub r#type: Option<String>,
pub value: Option<Signal<String>>,
pub label: Option<String>,
pub placeholder: Option<String>,
pub name: String,
pub oninput: Option<EventHandler<FormEvent>>,
pub required: Option<bool>,
}
#[component]
pub fn Input(props: InputProps) -> Element {
let label = props.label.unwrap_or("".into());
let ph = props.placeholder.unwrap_or(label.clone());
rsx! {
label {
"{label}"
input {
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());
}
},
name: props.name,
placeholder: ph,
required: props.required,
}
}
}
}
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct InputProps {
pub r#type: Option<String>,
pub value: Option<Signal<String>>,
pub label: Option<String>,
pub placeholder: Option<String>,
pub name: String,
pub oninput: Option<EventHandler<FormEvent>>,
pub required: Option<bool>,
}
#[component]
pub fn Input(props: InputProps) -> Element {
let label = props.label.unwrap_or("".into());
let ph = props.placeholder.unwrap_or(label.clone());
rsx! {
label {
"{label}"
input {
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());
}
},
name: props.name,
placeholder: ph,
required: props.required,
}
}
}
}

View File

@@ -1,4 +1,4 @@
mod button;
mod input;
pub use button::*;
pub use input::*;
mod button;
mod input;
pub use button::*;
pub use input::*;

View File

@@ -1,52 +1,52 @@
use dioxus::prelude::*;
#[component]
pub fn Info() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 0 1 .67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 1 1-.671-1.34l.041-.022ZM12 9a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}
#[component]
pub fn Warn() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}
#[component]
pub fn Error() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}
use dioxus::prelude::*;
#[component]
pub fn Info() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 0 1 .67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 1 1-.671-1.34l.041-.022ZM12 9a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}
#[component]
pub fn Warn() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}
#[component]
pub fn Error() -> Element {
rsx! {
svg {
class: "size-6",
fill: "currentColor",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path {
clip_rule: "evenodd",
d: "M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z",
fill_rule: "evenodd",
}
}
}
}

View File

@@ -1,77 +1,77 @@
use dioxus::prelude::*;
use tonic::IntoRequest;
use crate::{
components::MediaItem,
rpc::{aoba::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>,
}
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,
}
}
}
#[component]
pub fn MediaGrid(props: MediaGridProps) -> Element {
let media_result = use_resource(use_reactive!(|(props)| async move {
let mut client = get_rpc_client();
let result = client.list_media(props.into_request()).await;
if let Ok(items) = result {
return Ok(items.into_inner());
} else {
let err = result.err().unwrap();
let message = err.message();
return Err(format!("Failed to load results: {message}"));
}
}));
match media_result.cloned() {
Some(value) => match value {
Ok(result) => rsx! {
div {
class: "mediaGrid",
{result.items.iter().map(|itm| rsx!{
MediaItem { item: itm.clone() }
})},
}
},
Err(msg) => rsx! {
div {
class: "mediaGrid",
div {
"Failed to load results: {msg}"
}
}
},
},
None => rsx! {
div{
class: "mediaGrid",
div {
"Loading..."
}
}
},
}
}
use dioxus::prelude::*;
use tonic::IntoRequest;
use crate::{
components::MediaItem,
rpc::{aoba::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>,
}
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,
}
}
}
#[component]
pub fn MediaGrid(props: MediaGridProps) -> Element {
let media_result = use_resource(use_reactive!(|(props)| async move {
let mut client = get_rpc_client();
let result = client.list_media(props.into_request()).await;
if let Ok(items) = result {
return Ok(items.into_inner());
} else {
let err = result.err().unwrap();
let message = err.message();
return Err(format!("Failed to load results: {message}"));
}
}));
match media_result.cloned() {
Some(value) => match value {
Ok(result) => rsx! {
div {
class: "mediaGrid",
{result.items.iter().map(|itm| rsx!{
MediaItem { item: itm.clone() }
})},
}
},
Err(msg) => rsx! {
div {
class: "mediaGrid",
div {
"Failed to load results: {msg}"
}
}
},
},
None => rsx! {
div{
class: "mediaGrid",
div {
"Loading..."
}
}
},
}
}

View File

@@ -1,29 +1,29 @@
use dioxus::prelude::*;
use crate::{HOST, rpc::aoba::MediaModel};
#[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps {
pub item: MediaModel,
}
#[component]
pub fn MediaItem(props: MediaItemProps) -> Element {
let mtype = props.item.media_type().as_str_name();
let filename = props.item.file_name;
let id = props.item.media_id.unwrap().value;
let src = format!("{HOST}/m/thumb/{id}");
rsx! {
a { class: "mediaItem", href: "{HOST}/m/{id}", target: "_blank",
img { src }
span { class: "info",
span { class: "name", "{filename}" }
span { class: "details",
span { "{mtype}" }
span { "{props.item.view_count}" }
}
}
}
}
}
use dioxus::prelude::*;
use crate::{HOST, rpc::aoba::MediaModel};
#[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps {
pub item: MediaModel,
}
#[component]
pub fn MediaItem(props: MediaItemProps) -> Element {
let mtype = props.item.media_type().as_str_name();
let filename = props.item.file_name;
let id = props.item.media_id.unwrap().value;
let src = format!("{HOST}/m/thumb/{id}");
rsx! {
a { class: "mediaItem", href: "{HOST}/m/{id}", target: "_blank",
img { src }
span { class: "info",
span { class: "name", "{filename}" }
span { class: "details",
span { "{mtype}" }
span { "{props.item.view_count}" }
}
}
}
}
}

View File

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

View File

@@ -1,56 +1,56 @@
use dioxus::prelude::*;
use crate::{Route, contexts::AuthContext};
const NAV_CSS: Asset = asset!("/assets/style/nav.scss");
const NAV_ICON: Asset = asset!("/assets/favicon.ico");
#[component]
pub fn Navbar() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: NAV_CSS }
nav {
Branding {}
MainNaviagation {}
Widgets {}
Utils {}
}
}
}
#[component]
pub fn MainNaviagation() -> Element {
rsx! {
div { class: "mainNav",
Link { class: "navItem", to: Route::Home {}, "Home" }
Link { class: "navItem", to: Route::Settings {}, "Settings" }
}
}
}
#[component]
pub fn Branding() -> Element {
rsx! {
div { class: "branding",
img { src: NAV_ICON, alt: "Aoba" }
}
}
}
#[component]
pub fn Widgets() -> Element {
rsx! {
div { class: "widgets" }
}
}
#[component]
pub fn Utils() -> Element {
let mut auth_context = use_context::<AuthContext>();
rsx! {
div { class: "utils",
div { onclick: move |_| auth_context.logout(), "Logout" }
}
}
}
use dioxus::prelude::*;
use crate::{Route, contexts::AuthContext};
const NAV_CSS: Asset = asset!("/assets/style/nav.scss");
const NAV_ICON: Asset = asset!("/assets/favicon.ico");
#[component]
pub fn Navbar() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: NAV_CSS }
nav {
Branding {}
MainNaviagation {}
Widgets {}
Utils {}
}
}
}
#[component]
pub fn MainNaviagation() -> Element {
rsx! {
div { class: "mainNav",
Link { class: "navItem", to: Route::Home {}, "Home" }
Link { class: "navItem", to: Route::Settings {}, "Settings" }
}
}
}
#[component]
pub fn Branding() -> Element {
rsx! {
div { class: "branding",
img { src: NAV_ICON, alt: "Aoba" }
}
}
}
#[component]
pub fn Widgets() -> Element {
rsx! {
div { class: "widgets" }
}
}
#[component]
pub fn Utils() -> Element {
let mut auth_context = use_context::<AuthContext>();
rsx! {
div { class: "utils",
div { onclick: move |_| auth_context.logout(), "Logout" }
}
}
}

View File

@@ -1,39 +1,39 @@
use dioxus::prelude::*;
use crate::components::icons;
#[derive(PartialEq, Clone, Props)]
pub struct NotifProps {
r#type: Option<NotifType>,
message: String,
}
#[derive(PartialEq, Clone)]
pub enum NotifType {
Notice,
Error,
Warning,
}
#[component]
pub fn Notif(props: NotifProps) -> Element {
let t = props.r#type.unwrap_or(NotifType::Notice);
let type_class = match t {
NotifType::Notice => "notice",
NotifType::Error => "error",
NotifType::Warning => "warning",
};
let m = props.message;
rsx! {
div { class: "notif {type_class}",
div { class: "icon",
match t {
NotifType::Notice => icons::Error(),
NotifType::Error => icons::Error(),
NotifType::Warning => icons::Warn(),
}
}
div { class: "message", "{m}" }
}
}
}
use dioxus::prelude::*;
use crate::components::icons;
#[derive(PartialEq, Clone, Props)]
pub struct NotifProps {
r#type: Option<NotifType>,
message: String,
}
#[derive(PartialEq, Clone)]
pub enum NotifType {
Notice,
Error,
Warning,
}
#[component]
pub fn Notif(props: NotifProps) -> Element {
let t = props.r#type.unwrap_or(NotifType::Notice);
let type_class = match t {
NotifType::Notice => "notice",
NotifType::Error => "error",
NotifType::Warning => "warning",
};
let m = props.message;
rsx! {
div { class: "notif {type_class}",
div { class: "icon",
match t {
NotifType::Notice => icons::Error(),
NotifType::Error => icons::Error(),
NotifType::Warning => icons::Warn(),
}
}
div { class: "message", "{m}" }
}
}
}

View File

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

View File

@@ -1,42 +1,42 @@
use dioxus::signals::{Signal, Writable};
use web_sys::window;
use crate::rpc::{login, logout};
#[derive(Clone, Copy, Default)]
pub struct AuthContext {
pub jwt: Signal<Option<String>>,
}
impl AuthContext {
pub fn login(&mut self, token: String) {
self.jwt.set(Some(token.clone()));
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
_ = local_storage.set_item("token", token.as_str());
login(token.clone());
}
pub fn logout(&mut self) {
self.jwt.set(None);
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
_ = local_storage.remove_item("token");
logout();
}
pub fn new() -> Self {
println!("new");
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
match local_storage.get_item("token") {
Ok(value) => {
if let Some(jwt) = value {
login(jwt.clone());
return AuthContext {
jwt: Signal::new(Some(jwt)),
};
}
return AuthContext::default();
}
Err(_) => AuthContext::default(),
}
}
}
use dioxus::signals::{Signal, Writable};
use web_sys::window;
use crate::rpc::{login, logout};
#[derive(Clone, Copy, Default)]
pub struct AuthContext {
pub jwt: Signal<Option<String>>,
}
impl AuthContext {
pub fn login(&mut self, token: String) {
self.jwt.set(Some(token.clone()));
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
_ = local_storage.set_item("token", token.as_str());
login(token.clone());
}
pub fn logout(&mut self) {
self.jwt.set(None);
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
_ = local_storage.remove_item("token");
logout();
}
pub fn new() -> Self {
println!("new");
let local_storage = window().unwrap().local_storage().unwrap().unwrap();
match local_storage.get_item("token") {
Ok(value) => {
if let Some(jwt) = value {
login(jwt.clone());
return AuthContext {
jwt: Signal::new(Some(jwt)),
};
}
return AuthContext::default();
}
Err(_) => AuthContext::default(),
}
}
}

View File

@@ -1,2 +1,2 @@
mod auth_context;
pub use auth_context::*;
mod auth_context;
pub use auth_context::*;

View File

@@ -1,19 +1,19 @@
use dioxus::prelude::*;
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login};
#[component]
pub fn MainLayout() -> Element {
let auth_context = use_context::<AuthContext>();
if auth_context.jwt.cloned().is_none() {
return rsx! {
Login {}
};
}
return rsx! {
Navbar {}
div { id: "content", Outlet::<Route> {} }
};
}
use dioxus::prelude::*;
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login};
#[component]
pub fn MainLayout() -> Element {
let auth_context = use_context::<AuthContext>();
if auth_context.jwt.cloned().is_none() {
return rsx! {
Login {}
};
}
return rsx! {
Navbar {}
div { id: "content", Outlet::<Route> {} }
};
}

View File

@@ -1,2 +1,2 @@
mod main_layout;
pub use main_layout::*;
mod main_layout;
pub use main_layout::*;

View File

@@ -1,45 +1,45 @@
pub mod components;
pub mod contexts;
mod layouts;
pub mod models;
pub mod route;
pub mod rpc;
pub mod views;
use contexts::AuthContext;
use dioxus::prelude::*;
use route::Route;
#[cfg(debug_assertions)]
pub const HOST: &'static str = "http://localhost:8081";
#[cfg(debug_assertions)]
pub const RPC_HOST: &'static str = "http://localhost:8081";
#[cfg(not(debug_assertions))]
pub const RPC_HOST: &'static str = "https://grpc.aoba.app:8443";
#[cfg(not(debug_assertions))]
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");
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
let _auth_state = 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: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
}
Router::<Route> {}
}
}
pub mod components;
pub mod contexts;
mod layouts;
pub mod models;
pub mod route;
pub mod rpc;
pub mod views;
use contexts::AuthContext;
use dioxus::prelude::*;
use route::Route;
#[cfg(debug_assertions)]
pub const HOST: &'static str = "http://localhost:8081";
#[cfg(debug_assertions)]
pub const RPC_HOST: &'static str = "http://localhost:8081";
#[cfg(not(debug_assertions))]
pub const RPC_HOST: &'static str = "https://grpc.aoba.app:8443";
#[cfg(not(debug_assertions))]
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");
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
let _auth_state = 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: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
}
Router::<Route> {}
}
}

View File

@@ -1,23 +1,23 @@
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub struct Media {
pub id: String,
pub media_id: String,
pub filename: String,
pub media_type: MediaType,
pub ext: String,
pub view_count: i32,
pub owner: String,
}
#[derive(Serialize_repr, Deserialize_repr, Clone, PartialEq)]
#[repr(i32)]
pub enum MediaType {
Image,
Audio,
Video,
Text,
Code,
Raw,
}
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub struct Media {
pub id: String,
pub media_id: String,
pub filename: String,
pub media_type: MediaType,
pub ext: String,
pub view_count: i32,
pub owner: String,
}
#[derive(Serialize_repr, Deserialize_repr, Clone, PartialEq)]
#[repr(i32)]
pub enum MediaType {
Image,
Audio,
Video,
Text,
Code,
Raw,
}

View File

@@ -1 +1 @@
pub mod media;
pub mod media;

View File

@@ -1,16 +1,16 @@
use crate::{
layouts::MainLayout,
views::{Home, Settings},
};
use dioxus::prelude::*;
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[layout(MainLayout)]
#[route("/")]
Home {},
#[route("/settings")]
Settings {},
// #[end_layout]
}
use crate::{
layouts::MainLayout,
views::{Home, Settings},
};
use dioxus::prelude::*;
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[layout(MainLayout)]
#[route("/")]
Home {},
#[route("/settings")]
Settings {},
// #[end_layout]
}

View File

@@ -1,75 +1,75 @@
use std::sync::RwLock;
use aoba::{aoba_rpc_client::AobaRpcClient, auth_rpc_client::AuthRpcClient};
use tonic::service::{Interceptor, interceptor::InterceptedService};
use tonic_web_wasm_client::Client;
use crate::RPC_HOST;
pub mod aoba {
tonic::include_proto!("aoba");
tonic::include_proto!("aoba.auth");
}
static RPC_CLIENT: RpcConnection = RpcConnection {
aoba: RwLock::new(None),
auth: RwLock::new(None),
jwt: RwLock::new(None),
};
#[derive(Default)]
pub struct RpcConnection {
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
auth: RwLock<Option<AuthRpcClient<Client>>>,
jwt: RwLock<Option<String>>,
}
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> {
self.ensure_client();
return self.auth.read().unwrap().clone().unwrap();
}
fn ensure_client(&self) {
if self.aoba.read().unwrap().is_none() {
let wasm_client = Client::new(RPC_HOST.into());
let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor);
*self.aoba.write().unwrap() = Some(aoba_client);
*self.auth.write().unwrap() = Some(AuthRpcClient::new(wasm_client.clone()));
}
}
}
#[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() {
request
.metadata_mut()
.insert("authorization", format!("Bearer {jwt}").parse().unwrap());
}
return Ok(request);
}
}
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
return RPC_CLIENT.get_client();
}
pub fn get_auth_rpc_client() -> AuthRpcClient<Client> {
return RPC_CLIENT.get_auth_client();
}
pub fn login(jwt: String) {
*RPC_CLIENT.jwt.write().unwrap() = Some(jwt);
}
pub fn logout() {
*RPC_CLIENT.jwt.write().unwrap() = None;
}
use std::sync::RwLock;
use aoba::{aoba_rpc_client::AobaRpcClient, auth_rpc_client::AuthRpcClient};
use tonic::service::{Interceptor, interceptor::InterceptedService};
use tonic_web_wasm_client::Client;
use crate::RPC_HOST;
pub mod aoba {
tonic::include_proto!("aoba");
tonic::include_proto!("aoba.auth");
}
static RPC_CLIENT: RpcConnection = RpcConnection {
aoba: RwLock::new(None),
auth: RwLock::new(None),
jwt: RwLock::new(None),
};
#[derive(Default)]
pub struct RpcConnection {
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
auth: RwLock<Option<AuthRpcClient<Client>>>,
jwt: RwLock<Option<String>>,
}
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> {
self.ensure_client();
return self.auth.read().unwrap().clone().unwrap();
}
fn ensure_client(&self) {
if self.aoba.read().unwrap().is_none() {
let wasm_client = Client::new(RPC_HOST.into());
let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor);
*self.aoba.write().unwrap() = Some(aoba_client);
*self.auth.write().unwrap() = Some(AuthRpcClient::new(wasm_client.clone()));
}
}
}
#[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() {
request
.metadata_mut()
.insert("authorization", format!("Bearer {jwt}").parse().unwrap());
}
return Ok(request);
}
}
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
return RPC_CLIENT.get_client();
}
pub fn get_auth_rpc_client() -> AuthRpcClient<Client> {
return RPC_CLIENT.get_auth_client();
}
pub fn login(jwt: String) {
*RPC_CLIENT.jwt.write().unwrap() = Some(jwt);
}
pub fn logout() {
*RPC_CLIENT.jwt.write().unwrap() = None;
}

View File

@@ -1,12 +1,12 @@
use crate::components::{MediaGrid, Search};
use dioxus::prelude::*;
#[component]
pub fn Home() -> Element {
let query = use_signal(|| "".to_string());
rsx! {
Search { query }
MediaGrid { query: query.cloned() }
}
}
use crate::components::{MediaGrid, Search};
use dioxus::prelude::*;
#[component]
pub fn Home() -> Element {
let query = use_signal(|| "".to_string());
rsx! {
Search { query }
MediaGrid { query: query.cloned() }
}
}

View File

@@ -1,78 +1,78 @@
use dioxus::prelude::*;
use tonic::IntoRequest;
use crate::{
components::{basic::Input, Notif, NotifType},
contexts::AuthContext,
rpc::{aoba::Credentials, get_auth_rpc_client},
};
#[component]
pub fn Login() -> Element {
let username = use_signal(|| "".to_string());
let password = use_signal(|| "".to_string());
let mut error: Signal<Option<String>> = use_signal(|| None);
let mut auth_context = use_context::<AuthContext>();
let login = move |e: Event<MouseData>| {
e.prevent_default();
if username.cloned().is_empty() || password.cloned().is_empty() {
error.set(Some("Username and Password are required".into()));
return;
}
spawn(async move {
let mut auth = get_auth_rpc_client();
let result = auth
.login(
Credentials {
user: username.cloned(),
password: password.cloned(),
}
.into_request(),
)
.await;
match result {
Ok(res) => {
match res.into_inner().result.unwrap() {
crate::rpc::aoba::login_response::Result::Jwt(jwt) => {
auth_context.login(jwt.token);
}
crate::rpc::aoba::login_response::Result::Error(login_error) => {
auth_context.logout();
error.set(Some(login_error.message));
}
};
}
Err(_err) => {
auth_context.logout();
}
}
});
};
rsx! {
div { id: "centralModal",
if let Some(err) = error.cloned() {
Notif { r#type: NotifType::Error, message: err }
}
form {
Input {
r#type: "text",
name: "username",
label: "Username",
value: username,
required: true,
}
Input {
r#type: "password",
name: "password",
label: "Password",
value: password,
required: true,
}
button { onclick: login, "Login!" }
}
}
}
}
use dioxus::prelude::*;
use tonic::IntoRequest;
use crate::{
components::{basic::Input, Notif, NotifType},
contexts::AuthContext,
rpc::{aoba::Credentials, get_auth_rpc_client},
};
#[component]
pub fn Login() -> Element {
let username = use_signal(|| "".to_string());
let password = use_signal(|| "".to_string());
let mut error: Signal<Option<String>> = use_signal(|| None);
let mut auth_context = use_context::<AuthContext>();
let login = move |e: Event<MouseData>| {
e.prevent_default();
if username.cloned().is_empty() || password.cloned().is_empty() {
error.set(Some("Username and Password are required".into()));
return;
}
spawn(async move {
let mut auth = get_auth_rpc_client();
let result = auth
.login(
Credentials {
user: username.cloned(),
password: password.cloned(),
}
.into_request(),
)
.await;
match result {
Ok(res) => {
match res.into_inner().result.unwrap() {
crate::rpc::aoba::login_response::Result::Jwt(jwt) => {
auth_context.login(jwt.token);
}
crate::rpc::aoba::login_response::Result::Error(login_error) => {
auth_context.logout();
error.set(Some(login_error.message));
}
};
}
Err(_err) => {
auth_context.logout();
}
}
});
};
rsx! {
div { id: "centralModal",
if let Some(err) = error.cloned() {
Notif { r#type: NotifType::Error, message: err }
}
form {
Input {
r#type: "text",
name: "username",
label: "Username",
value: username,
required: true,
}
Input {
r#type: "password",
name: "password",
label: "Password",
value: password,
required: true,
}
button { onclick: login, "Login!" }
}
}
}
}

View File

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

View File

@@ -1,31 +1,31 @@
use dioxus::prelude::*;
use crate::rpc::get_rpc_client;
#[component]
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 {
crate::rpc::aoba::share_x_response::DstResult::Destination(json) => json,
crate::rpc::aoba::share_x_response::DstResult::Error(err) => err,
};
}
return "No Result".to_string();
}
let err = result.err().unwrap();
let status = err.message();
return format!("Failed to load config: {status}").to_string();
});
let d = dst.cloned().unwrap_or("".to_string());
rsx! {
"this is settings"
div {
pre { class: "codeSelect", "{d}" }
}
}
}
use dioxus::prelude::*;
use crate::rpc::get_rpc_client;
#[component]
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 {
crate::rpc::aoba::share_x_response::DstResult::Destination(json) => json,
crate::rpc::aoba::share_x_response::DstResult::Error(err) => err,
};
}
return "No Result".to_string();
}
let err = result.err().unwrap();
let status = err.message();
return format!("Failed to load config: {status}").to_string();
});
let d = dst.cloned().unwrap_or("".to_string());
rsx! {
"this is settings"
div {
pre { class: "codeSelect", "{d}" }
}
}
}

View File

@@ -1,21 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
<PackageReference Include="MaybeError" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.5" />
<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.11.0" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
<PackageReference Include="MaybeError" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.5" />
<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.11.0" />
</ItemGroup>
</Project>

View File

@@ -1,47 +1,47 @@
global using MaybeError;
using AobaCore.Services;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using MongoDB.Driver.Core.Extensions.DiagnosticSources;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore;
public static class Extensions
{
public static IServiceCollection AddAoba(this IServiceCollection services, string dbString)
{
var settings = MongoClientSettings.FromConnectionString(dbString);
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
var dbClient = new MongoClient(settings);
var db = dbClient.GetDatabase("Aoba");
services.AddSingleton(dbClient);
services.AddSingleton<IMongoDatabase>(db);
services.AddSingleton<AobaService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<AccountsService>();
services.AddHostedService<AobaIndexCreationService>();
return services;
}
public static async Task EnsureIndexAsync<T>(this IMongoCollection<T> collection, CreateIndexModel<T> indexModel)
{
try
{
await collection.Indexes.CreateOneAsync(indexModel);
}
catch (MongoCommandException e) when (e.Code == 85 || e.Code == 86) //CodeName "IndexOptionsConflict" or "NameConflict"
{
await collection.Indexes.DropOneAsync(indexModel.Options.Name);
await collection.Indexes.CreateOneAsync(indexModel);
}
}
}
global using MaybeError;
using AobaCore.Services;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using MongoDB.Driver.Core.Extensions.DiagnosticSources;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore;
public static class Extensions
{
public static IServiceCollection AddAoba(this IServiceCollection services, string dbString)
{
var settings = MongoClientSettings.FromConnectionString(dbString);
settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber());
var dbClient = new MongoClient(settings);
var db = dbClient.GetDatabase("Aoba");
services.AddSingleton(dbClient);
services.AddSingleton<IMongoDatabase>(db);
services.AddSingleton<AobaService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<AccountsService>();
services.AddHostedService<AobaIndexCreationService>();
return services;
}
public static async Task EnsureIndexAsync<T>(this IMongoCollection<T> collection, CreateIndexModel<T> indexModel)
{
try
{
await collection.Indexes.CreateOneAsync(indexModel);
}
catch (MongoCommandException e) when (e.Code == 85 || e.Code == 86) //CodeName "IndexOptionsConflict" or "NameConflict"
{
await collection.Indexes.DropOneAsync(indexModel.Options.Name);
await collection.Indexes.CreateOneAsync(indexModel);
}
}
}

View File

@@ -1,98 +1,98 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace AobaCore.Models;
[BsonIgnoreExtraElements]
public class Media
{
[BsonId]
public ObjectId Id { get; set; }
public ObjectId MediaId { get; set; }
public string Filename { get; set; }
public MediaType MediaType { get; set; }
public string Ext { get; set; }
public int ViewCount { get; set; }
public ObjectId Owner { get; set; }
public DateTime UploadDate { get; set; }
public static readonly Dictionary<string, MediaType> KnownTypes = new()
{
{ ".jpg", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".jpeg", MediaType.Image },
{ ".png", MediaType.Image },
{ ".apng", MediaType.Image },
{ ".webp", MediaType.Image },
{ ".ico", MediaType.Image },
{ ".gif", MediaType.Image },
{ ".mp3", MediaType.Audio },
{ ".flac", MediaType.Audio },
{ ".alac", MediaType.Audio },
{ ".mp4", MediaType.Video },
{ ".webm", MediaType.Video },
{ ".mov", MediaType.Video },
{ ".avi", MediaType.Video },
{ ".mkv", MediaType.Video },
{ ".txt", MediaType.Text },
{ ".log", MediaType.Text },
{ ".css", MediaType.Code },
{ ".cs", MediaType.Code },
{ ".cpp", MediaType.Code },
{ ".lua", MediaType.Code },
{ ".js", MediaType.Code },
{ ".htm", MediaType.Code },
{ ".html", MediaType.Code },
{ ".cshtml", MediaType.Code },
{ ".xml", MediaType.Code },
{ ".json", MediaType.Code },
{ ".py", MediaType.Code },
};
[BsonConstructor]
private Media()
{
Filename = string.Empty;
Ext = string.Empty;
}
public Media(ObjectId fileId, string filename, ObjectId owner)
{
MediaType = GetMediaType(filename);
Ext = Path.GetExtension(filename);
Filename = filename;
MediaId = fileId;
Owner = owner;
Id = ObjectId.GenerateNewId();
UploadDate = DateTime.UtcNow;
}
public string GetMediaUrl()
{
return this switch
{
//Media { MediaType: MediaType.Raw or MediaType.Text or MediaType.Code} => $"/i/dl/{MediaId}/{Filename}",
_ => $"/m/{MediaId}"
};
}
public static MediaType GetMediaType(string filename)
{
string ext = Path.GetExtension(filename);
if (KnownTypes.TryGetValue(ext, out MediaType mType))
return mType;
else
return MediaType.Raw;
}
}
public enum MediaType
{
Image,
Audio,
Video,
Text,
Code,
Raw
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace AobaCore.Models;
[BsonIgnoreExtraElements]
public class Media
{
[BsonId]
public ObjectId Id { get; set; }
public ObjectId MediaId { get; set; }
public string Filename { get; set; }
public MediaType MediaType { get; set; }
public string Ext { get; set; }
public int ViewCount { get; set; }
public ObjectId Owner { get; set; }
public DateTime UploadDate { get; set; }
public static readonly Dictionary<string, MediaType> KnownTypes = new()
{
{ ".jpg", MediaType.Image },
{ ".avif", MediaType.Image },
{ ".jpeg", MediaType.Image },
{ ".png", MediaType.Image },
{ ".apng", MediaType.Image },
{ ".webp", MediaType.Image },
{ ".ico", MediaType.Image },
{ ".gif", MediaType.Image },
{ ".mp3", MediaType.Audio },
{ ".flac", MediaType.Audio },
{ ".alac", MediaType.Audio },
{ ".mp4", MediaType.Video },
{ ".webm", MediaType.Video },
{ ".mov", MediaType.Video },
{ ".avi", MediaType.Video },
{ ".mkv", MediaType.Video },
{ ".txt", MediaType.Text },
{ ".log", MediaType.Text },
{ ".css", MediaType.Code },
{ ".cs", MediaType.Code },
{ ".cpp", MediaType.Code },
{ ".lua", MediaType.Code },
{ ".js", MediaType.Code },
{ ".htm", MediaType.Code },
{ ".html", MediaType.Code },
{ ".cshtml", MediaType.Code },
{ ".xml", MediaType.Code },
{ ".json", MediaType.Code },
{ ".py", MediaType.Code },
};
[BsonConstructor]
private Media()
{
Filename = string.Empty;
Ext = string.Empty;
}
public Media(ObjectId fileId, string filename, ObjectId owner)
{
MediaType = GetMediaType(filename);
Ext = Path.GetExtension(filename);
Filename = filename;
MediaId = fileId;
Owner = owner;
Id = ObjectId.GenerateNewId();
UploadDate = DateTime.UtcNow;
}
public string GetMediaUrl()
{
return this switch
{
//Media { MediaType: MediaType.Raw or MediaType.Text or MediaType.Code} => $"/i/dl/{MediaId}/{Filename}",
_ => $"/m/{MediaId}"
};
}
public static MediaType GetMediaType(string filename)
{
string ext = Path.GetExtension(filename);
if (KnownTypes.TryGetValue(ext, out MediaType mType))
return mType;
else
return MediaType.Raw;
}
}
public enum MediaType
{
Image,
Audio,
Video,
Text,
Code,
Raw
}

View File

@@ -1,19 +1,19 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace AobaCore.Models;
public record MediaThumbnail
{
[BsonId]
public required ObjectId Id { get; init; }
public Dictionary<ThumbnailSize, ObjectId> Sizes { get; set; } = [];
}
public enum ThumbnailSize
{
Small = 128,
Medium = 256,
Large = 512,
ExtraLarge = 1024
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace AobaCore.Models;
public record MediaThumbnail
{
[BsonId]
public required ObjectId Id { get; init; }
public Dictionary<ThumbnailSize, ObjectId> Sizes { get; set; } = [];
}
public enum ThumbnailSize
{
Small = 128,
Medium = 256,
Large = 512,
ExtraLarge = 1024
}

View File

@@ -1,18 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Models;
public class PagedResult<T>(List<T> items, int page, int pageSize, long 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 string? Query { get; set; }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Models;
public class PagedResult<T>(List<T> items, int page, int pageSize, long 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 string? Query { get; set; }
}

View File

@@ -1,36 +1,36 @@
using Microsoft.IdentityModel.Tokens;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Models;
public class User
{
[BsonId]
public ObjectId Id { get; set; }
public required string Username { get; set; }
public required string PasswordHash { get; set; }
public required string Role { get; set; }
public bool IsArgon { get; set; }
public ObjectId[] ApiKeys { get; set; } = [];
public List<ObjectId> RegTokens { get; set; } = [];
public ClaimsIdentity GetIdentity()
{
var id = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Id.ToString()),
new Claim(ClaimTypes.Name, Username),
});
if (Role != null)
id.AddClaim(new Claim(ClaimTypes.Role, Role));
return id;
}
}
using Microsoft.IdentityModel.Tokens;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Models;
public class User
{
[BsonId]
public ObjectId Id { get; set; }
public required string Username { get; set; }
public required string PasswordHash { get; set; }
public required string Role { get; set; }
public bool IsArgon { get; set; }
public ObjectId[] ApiKeys { get; set; } = [];
public List<ObjectId> RegTokens { get; set; } = [];
public ClaimsIdentity GetIdentity()
{
var id = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Id.ToString()),
new Claim(ClaimTypes.Name, Username),
});
if (Role != null)
id.AddClaim(new Claim(ClaimTypes.Role, Role));
return id;
}
}

View File

@@ -1,66 +1,66 @@
using AobaCore.Models;
using Isopoh.Cryptography.Argon2;
using MongoDB.Bson;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Services;
public class AccountsService(IMongoDatabase db)
{
public readonly IMongoCollection<User> _users = db.GetCollection<User>("users");
public async Task<User?> GetUserAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return await _users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<User?> VerifyLoginAsync(string username, string password, CancellationToken cancellationToken = default)
{
var user = await _users.Find(u => u.Username == username).FirstOrDefaultAsync(cancellationToken);
if(user == null)
return null;
if(user.IsArgon && Argon2.Verify(user.PasswordHash, password))
return user;
if(LegacyVerifyPassword( password, user.PasswordHash))
{
#if !DEBUG
var argon2Hash = Argon2.Hash(password);
var update = Builders<User>.Update.Set(u => u.PasswordHash, argon2Hash).Set(u => u.IsArgon, true);
await _users.UpdateOneAsync(u => u.Id == user.Id, update, cancellationToken: cancellationToken);
#endif
return user;
}
return null;
}
public static bool LegacyVerifyPassword(string password, string passwordHash)
{
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
return false;
/* Extract the bytes */
byte[] hashBytes = Convert.FromBase64String(passwordHash);
/* 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);
/* Compare the results */
for (int i = 0; i < 20; i++)
if (hashBytes[i + 16] != hash[i])
return false;
return true;
}
}
using AobaCore.Models;
using Isopoh.Cryptography.Argon2;
using MongoDB.Bson;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Services;
public class AccountsService(IMongoDatabase db)
{
public readonly IMongoCollection<User> _users = db.GetCollection<User>("users");
public async Task<User?> GetUserAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return await _users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<User?> VerifyLoginAsync(string username, string password, CancellationToken cancellationToken = default)
{
var user = await _users.Find(u => u.Username == username).FirstOrDefaultAsync(cancellationToken);
if(user == null)
return null;
if(user.IsArgon && Argon2.Verify(user.PasswordHash, password))
return user;
if(LegacyVerifyPassword( password, user.PasswordHash))
{
#if !DEBUG
var argon2Hash = Argon2.Hash(password);
var update = Builders<User>.Update.Set(u => u.PasswordHash, argon2Hash).Set(u => u.IsArgon, true);
await _users.UpdateOneAsync(u => u.Id == user.Id, update, cancellationToken: cancellationToken);
#endif
return user;
}
return null;
}
public static bool LegacyVerifyPassword(string password, string passwordHash)
{
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
return false;
/* Extract the bytes */
byte[] hashBytes = Convert.FromBase64String(passwordHash);
/* 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);
/* Compare the results */
for (int i = 0; i < 20; i++)
if (hashBytes[i + 16] != hash[i])
return false;
return true;
}
}

View File

@@ -1,30 +1,30 @@
using AobaCore.Models;
using Microsoft.Extensions.Hosting;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
namespace AobaCore.Services;
public class AobaIndexCreationService(IMongoDatabase db): BackgroundService
{
private readonly IMongoCollection<Media> _media = db.GetCollection<Media>("media");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
BsonSerializer.RegisterSerializer(new EnumSerializer<ThumbnailSize>(BsonType.String));
var textKeys = Builders<Media>.IndexKeys
.Text(m => m.Filename);
var textModel = new CreateIndexModel<Media>(textKeys, new CreateIndexOptions
{
Name = "Text",
Background = true
});
await _media.EnsureIndexAsync(textModel);
}
using AobaCore.Models;
using Microsoft.Extensions.Hosting;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
namespace AobaCore.Services;
public class AobaIndexCreationService(IMongoDatabase db): BackgroundService
{
private readonly IMongoCollection<Media> _media = db.GetCollection<Media>("media");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
BsonSerializer.RegisterSerializer(new EnumSerializer<ThumbnailSize>(BsonType.String));
var textKeys = Builders<Media>.IndexKeys
.Text(m => m.Filename);
var textModel = new CreateIndexModel<Media>(textKeys, new CreateIndexOptions
{
Name = "Text",
Background = true
});
await _media.EnsureIndexAsync(textModel);
}
}

View File

@@ -1,98 +1,98 @@
using AobaCore.Models;
using MaybeError.Errors;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
namespace AobaCore.Services;
public class AobaService(IMongoDatabase db)
{
private readonly IMongoCollection<Media> _media = db.GetCollection<Media>("media");
private readonly GridFSBucket _gridFs = new(db);
public async Task<Media?> GetMediaAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return await _media.Find(m => m.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<Media?> GetMediaFromFileAsync(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)
{
var filter = Builders<Media>.Filter.And([
string.IsNullOrWhiteSpace(query) ? "{}" : Builders<Media>.Filter.Text(query),
Builders<Media>.Filter.Eq(m => m.Owner, userId)
]);
var sort = Builders<Media>.Sort.Descending(m => m.UploadDate);
var find = _media.Find(filter);
var total = await find.CountDocumentsAsync();
page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total);
}
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
{
return _media.InsertOneAsync(media, null, cancellationToken);
}
public Task IncrementViewCountAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return _media.UpdateOneAsync(m => m.Id == id, Builders<Media>.Update.Inc(m => m.ViewCount, 1), cancellationToken: cancellationToken);
}
public Task IncrementFileViewCountAsync(ObjectId fileId, CancellationToken cancellationToken = default)
{
return _media.UpdateOneAsync(m => m.MediaId == fileId, Builders<Media>.Update.Inc(m => m.ViewCount, 1), cancellationToken: cancellationToken);
}
public async Task<Maybe<Media>> UploadFileAsync(Stream data, string filename, ObjectId owner, CancellationToken cancellationToken = default)
{
try
{
var fileId = await _gridFs.UploadFromStreamAsync(filename, data, cancellationToken: cancellationToken);
var media = new Media(fileId, filename, owner);
await AddMediaAsync(media, cancellationToken);
return media;
}
catch (Exception ex)
{
return ex;
}
}
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId id, bool seekable = false, CancellationToken cancellationToken = default)
{
try
{
return await _gridFs.OpenDownloadStreamAsync(id, new GridFSDownloadOptions { Seekable = seekable }, cancellationToken);
}
catch (GridFSException ex)
{
return ex;
}
}
public async Task DeleteFileAsync(ObjectId fileId, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await _gridFs.DeleteAsync(fileId, CancellationToken.None);
await _media.DeleteOneAsync(m => m.MediaId == fileId, CancellationToken.None);
}
catch (GridFSFileNotFoundException)
{
//ignore if file was not found
}
}
}
using AobaCore.Models;
using MaybeError.Errors;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
namespace AobaCore.Services;
public class AobaService(IMongoDatabase db)
{
private readonly IMongoCollection<Media> _media = db.GetCollection<Media>("media");
private readonly GridFSBucket _gridFs = new(db);
public async Task<Media?> GetMediaAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return await _media.Find(m => m.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<Media?> GetMediaFromFileAsync(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)
{
var filter = Builders<Media>.Filter.And([
string.IsNullOrWhiteSpace(query) ? "{}" : Builders<Media>.Filter.Text(query),
Builders<Media>.Filter.Eq(m => m.Owner, userId)
]);
var sort = Builders<Media>.Sort.Descending(m => m.UploadDate);
var find = _media.Find(filter);
var total = await find.CountDocumentsAsync();
page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total);
}
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
{
return _media.InsertOneAsync(media, null, cancellationToken);
}
public Task IncrementViewCountAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return _media.UpdateOneAsync(m => m.Id == id, Builders<Media>.Update.Inc(m => m.ViewCount, 1), cancellationToken: cancellationToken);
}
public Task IncrementFileViewCountAsync(ObjectId fileId, CancellationToken cancellationToken = default)
{
return _media.UpdateOneAsync(m => m.MediaId == fileId, Builders<Media>.Update.Inc(m => m.ViewCount, 1), cancellationToken: cancellationToken);
}
public async Task<Maybe<Media>> UploadFileAsync(Stream data, string filename, ObjectId owner, CancellationToken cancellationToken = default)
{
try
{
var fileId = await _gridFs.UploadFromStreamAsync(filename, data, cancellationToken: cancellationToken);
var media = new Media(fileId, filename, owner);
await AddMediaAsync(media, cancellationToken);
return media;
}
catch (Exception ex)
{
return ex;
}
}
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId id, bool seekable = false, CancellationToken cancellationToken = default)
{
try
{
return await _gridFs.OpenDownloadStreamAsync(id, new GridFSDownloadOptions { Seekable = seekable }, cancellationToken);
}
catch (GridFSException ex)
{
return ex;
}
}
public async Task DeleteFileAsync(ObjectId fileId, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await _gridFs.DeleteAsync(fileId, CancellationToken.None);
await _media.DeleteOneAsync(m => m.MediaId == fileId, CancellationToken.None);
}
catch (GridFSFileNotFoundException)
{
//ignore if file was not found
}
}
}

View File

@@ -1,145 +1,145 @@
using AobaCore.Models;
using FFMpegCore;
using FFMpegCore.Pipes;
using MaybeError.Errors;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Services;
public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
private readonly IMongoCollection<MediaThumbnail> _thumbnails = db.GetCollection<MediaThumbnail>("thumbs");
/// <summary>
///
/// </summary>
/// <param name="id">File id</param>
/// <param name="size"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var existingThumb = await GetThumbnailAsync(id, size, cancellationToken);
if (existingThumb != null)
return existingThumb;
var media = await aobaService.GetMediaFromFileAsync(id, cancellationToken);
if (media == null)
return new Error("Media does not exist");
try
{
using var mediaData = await _gridfs.OpenDownloadStreamAsync(media.MediaId, new GridFSDownloadOptions { Seekable = true }, cancellationToken);
var thumb = await GenerateThumbnailAsync(mediaData, size, media.MediaType, media.Ext, cancellationToken);
if (thumb.HasError)
return thumb.Error;
cancellationToken.ThrowIfCancellationRequested();
#if !DEBUG
var thumbId = await _gridfs.UploadFromStreamAsync($"{media.Filename}.webp", thumb, cancellationToken: CancellationToken.None);
var update = Builders<MediaThumbnail>.Update.Set(t => t.Sizes[size], thumbId);
await _thumbnails.UpdateOneAsync(t => t.Id == id, update, cancellationToken: CancellationToken.None);
#endif
thumb.Value.Position = 0;
return thumb;
} catch (Exception ex) {
return ex;
}
}
/// <summary>
///
/// </summary>
/// <param name="id">File Id</param>
/// <param name="size"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Stream?> GetThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var thumb = await _thumbnails.Find(t => t.Id == id).FirstOrDefaultAsync(cancellationToken);
if (thumb == null)
return null;
if (!thumb.Sizes.TryGetValue(size, out var tid))
return null;
var thumbData = await _gridfs.OpenDownloadStreamAsync(tid, cancellationToken: cancellationToken);
return thumbData;
}
public async Task<Maybe<Stream>> GenerateThumbnailAsync(Stream stream, ThumbnailSize size, MediaType type, string ext, CancellationToken cancellationToken = default)
{
return type switch
{
MediaType.Image => await GenerateImageThumbnailAsync(stream, size, cancellationToken),
MediaType.Video => await GenerateVideoThumbnailAsync(stream, size, cancellationToken),
MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
_ => new Error($"No Thumbnail for {type}"),
};
}
public async Task<Stream> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var img = Image.Load(stream);
img.Mutate(o =>
{
var size =
o.Resize(new ResizeOptions
{
Position = AnchorPositionMode.Center,
Mode = ResizeMode.Crop,
Size = new Size(300, 300)
});
});
var result = new MemoryStream();
await img.SaveAsWebpAsync(result, cancellationToken);
result.Position = 0;
return result;
}
public async Task<Maybe<Stream>> GenerateVideoThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var w = (int)size;
var source = new MemoryStream();
data.CopyTo(source);
source.Position = 0;
var output = new MemoryStream();
await FFMpegArguments.FromPipeInput(new StreamPipeSource(source))
.OutputToPipe(new StreamPipeSink(output), opt =>
{
opt.WithCustomArgument($"-t 5 -vf \"crop='min(in_w,in_h)':'min(in_w,in_h)',scale={w}:{w}\" -loop 0")
.ForceFormat("webp");
}).ProcessAsynchronously();
output.Position = 0;
return output;
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
return new NotImplementedException();
}
}
using AobaCore.Models;
using FFMpegCore;
using FFMpegCore.Pipes;
using MaybeError.Errors;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Services;
public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
private readonly IMongoCollection<MediaThumbnail> _thumbnails = db.GetCollection<MediaThumbnail>("thumbs");
/// <summary>
///
/// </summary>
/// <param name="id">File id</param>
/// <param name="size"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var existingThumb = await GetThumbnailAsync(id, size, cancellationToken);
if (existingThumb != null)
return existingThumb;
var media = await aobaService.GetMediaFromFileAsync(id, cancellationToken);
if (media == null)
return new Error("Media does not exist");
try
{
using var mediaData = await _gridfs.OpenDownloadStreamAsync(media.MediaId, new GridFSDownloadOptions { Seekable = true }, cancellationToken);
var thumb = await GenerateThumbnailAsync(mediaData, size, media.MediaType, media.Ext, cancellationToken);
if (thumb.HasError)
return thumb.Error;
cancellationToken.ThrowIfCancellationRequested();
#if !DEBUG
var thumbId = await _gridfs.UploadFromStreamAsync($"{media.Filename}.webp", thumb, cancellationToken: CancellationToken.None);
var update = Builders<MediaThumbnail>.Update.Set(t => t.Sizes[size], thumbId);
await _thumbnails.UpdateOneAsync(t => t.Id == id, update, cancellationToken: CancellationToken.None);
#endif
thumb.Value.Position = 0;
return thumb;
} catch (Exception ex) {
return ex;
}
}
/// <summary>
///
/// </summary>
/// <param name="id">File Id</param>
/// <param name="size"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Stream?> GetThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var thumb = await _thumbnails.Find(t => t.Id == id).FirstOrDefaultAsync(cancellationToken);
if (thumb == null)
return null;
if (!thumb.Sizes.TryGetValue(size, out var tid))
return null;
var thumbData = await _gridfs.OpenDownloadStreamAsync(tid, cancellationToken: cancellationToken);
return thumbData;
}
public async Task<Maybe<Stream>> GenerateThumbnailAsync(Stream stream, ThumbnailSize size, MediaType type, string ext, CancellationToken cancellationToken = default)
{
return type switch
{
MediaType.Image => await GenerateImageThumbnailAsync(stream, size, cancellationToken),
MediaType.Video => await GenerateVideoThumbnailAsync(stream, size, cancellationToken),
MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
_ => new Error($"No Thumbnail for {type}"),
};
}
public async Task<Stream> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var img = Image.Load(stream);
img.Mutate(o =>
{
var size =
o.Resize(new ResizeOptions
{
Position = AnchorPositionMode.Center,
Mode = ResizeMode.Crop,
Size = new Size(300, 300)
});
});
var result = new MemoryStream();
await img.SaveAsWebpAsync(result, cancellationToken);
result.Position = 0;
return result;
}
public async Task<Maybe<Stream>> GenerateVideoThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var w = (int)size;
var source = new MemoryStream();
data.CopyTo(source);
source.Position = 0;
var output = new MemoryStream();
await FFMpegArguments.FromPipeInput(new StreamPipeSource(source))
.OutputToPipe(new StreamPipeSink(output), opt =>
{
opt.WithCustomArgument($"-t 5 -vf \"crop='min(in_w,in_h)':'min(in_w,in_h)',scale={w}:{w}\" -loop 0")
.ForceFormat("webp");
}).ProcessAsynchronously();
output.Position = 0;
return output;
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
return new NotImplementedException();
}
}

View File

@@ -1 +1 @@
wwwroot
wwwroot

View File

@@ -1 +1 @@
wwwroot/*
wwwroot/*

View File

@@ -1,39 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>9ffcc706-7f1b-48e3-bf30-eab69a90fded</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</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">
<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.21.2" />
<PackageReference Include="MimeTypesMap" Version="1.0.9" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AobaCore\AobaCore.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Proto\Aoba.proto"></Protobuf>
<Protobuf Include="Proto\Auth.proto"></Protobuf>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>9ffcc706-7f1b-48e3-bf30-eab69a90fded</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</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">
<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.21.2" />
<PackageReference Include="MimeTypesMap" Version="1.0.9" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AobaCore\AobaCore.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Proto\Aoba.proto"></Protobuf>
<Protobuf Include="Proto\Auth.proto"></Protobuf>
</ItemGroup>
</Project>

View File

@@ -1,28 +1,28 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Text.Encodings.Web;
namespace AobaServer.Auth;
internal class AobaAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.BodyWriter.Complete();
return Task.CompletedTask;
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status403Forbidden;
Response.BodyWriter.Complete();
return Task.CompletedTask;
}
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Text.Encodings.Web;
namespace AobaServer.Auth;
internal class AobaAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.BodyWriter.Complete();
return Task.CompletedTask;
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status403Forbidden;
Response.BodyWriter.Complete();
return Task.CompletedTask;
}
}

View File

@@ -1,44 +1,44 @@
using AobaServer.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AobaServer.Auth;
public class MetricsTokenValidator(AuthInfo authInfo) : JwtSecurityTokenHandler
{
private readonly JwtSecurityTokenHandler _handler = new();
public override Task<TokenValidationResult> ValidateTokenAsync(string token, TokenValidationParameters validationParameters)
{
try
{
var principal = _handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(authInfo.SecureKey),
ValidateIssuer = true,
ValidIssuer = authInfo.Issuer,
ValidateAudience = true,
ValidAudience = "metrics",
ValidateLifetime = false,
ClockSkew = TimeSpan.FromMinutes(1)
}, out var validatedToken);
return Task.FromResult(new TokenValidationResult
{
IsValid = true,
SecurityToken = validatedToken,
ClaimsIdentity = new ClaimsIdentity(principal.Identity),
});
}
catch (Exception e)
{
return Task.FromResult(new TokenValidationResult
{
IsValid = false,
Exception = e
});
}
}
}
using AobaServer.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AobaServer.Auth;
public class MetricsTokenValidator(AuthInfo authInfo) : JwtSecurityTokenHandler
{
private readonly JwtSecurityTokenHandler _handler = new();
public override Task<TokenValidationResult> ValidateTokenAsync(string token, TokenValidationParameters validationParameters)
{
try
{
var principal = _handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(authInfo.SecureKey),
ValidateIssuer = true,
ValidIssuer = authInfo.Issuer,
ValidateAudience = true,
ValidAudience = "metrics",
ValidateLifetime = false,
ClockSkew = TimeSpan.FromMinutes(1)
}, out var validatedToken);
return Task.FromResult(new TokenValidationResult
{
IsValid = true,
SecurityToken = validatedToken,
ClaimsIdentity = new ClaimsIdentity(principal.Identity),
});
}
catch (Exception e)
{
return Task.FromResult(new TokenValidationResult
{
IsValid = false,
Exception = e
});
}
}
}

View File

@@ -1,19 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace AobaServer.Controllers.Api;
[Route("/api/auth")]
public class AuthApi : ControllerBase
{
[HttpGet("login")]
public Task<IActionResult> LoginAsync()
{
throw new NotImplementedException();
}
[HttpGet("register")]
public Task<IActionResult> RegisterAsync()
{
throw new NotImplementedException();
}
}
using Microsoft.AspNetCore.Mvc;
namespace AobaServer.Controllers.Api;
[Route("/api/auth")]
public class AuthApi : ControllerBase
{
[HttpGet("login")]
public Task<IActionResult> LoginAsync()
{
throw new NotImplementedException();
}
[HttpGet("register")]
public Task<IActionResult> RegisterAsync()
{
throw new NotImplementedException();
}
}

View File

@@ -1,38 +1,38 @@
using AobaCore.Models;
using AobaCore.Services;
using AobaServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
namespace AobaServer.Controllers.Api;
[ApiController, Authorize]
[Route("/api/media")]
public class MediaApi(AobaService aoba) : ControllerBase
{
[HttpPost("upload")]
public async Task<IActionResult> UploadAsync([FromForm] IFormFile file, CancellationToken cancellationToken)
{
var media = await aoba.UploadFileAsync(file.OpenReadStream(), file.FileName, User.GetId(), cancellationToken);
if (media.HasError)
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
return Ok(new
{
media = media.Value,
url = media.Value.GetMediaUrl()
});
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(ObjectId id, CancellationToken cancellationToken)
{
await aoba.DeleteFileAsync(id, cancellationToken);
return Ok();
}
}
using AobaCore.Models;
using AobaCore.Services;
using AobaServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
namespace AobaServer.Controllers.Api;
[ApiController, Authorize]
[Route("/api/media")]
public class MediaApi(AobaService aoba) : ControllerBase
{
[HttpPost("upload")]
public async Task<IActionResult> UploadAsync([FromForm] IFormFile file, CancellationToken cancellationToken)
{
var media = await aoba.UploadFileAsync(file.OpenReadStream(), file.FileName, User.GetId(), cancellationToken);
if (media.HasError)
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
return Ok(new
{
media = media.Value,
url = media.Value.GetMediaUrl()
});
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(ObjectId id, CancellationToken cancellationToken)
{
await aoba.DeleteFileAsync(id, cancellationToken);
return Ok();
}
}

View File

@@ -1,37 +1,37 @@
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace AobaServer.Controllers;
//allow login via http during debug testing
#if DEBUG
[AllowAnonymous]
[Route("auth")]
public class AuthController(AccountsService accountsService, AuthInfo authInfo) : Controller
{
[HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string username, [FromForm] string password, CancellationToken cancellationToken)
{
var user = await accountsService.VerifyLoginAsync(username, password, cancellationToken);
if (user == null)
return Problem("Invalid login Credentials", statusCode: StatusCodes.Status400BadRequest);
Response.Cookies.Append("token", user.GetToken(authInfo), new CookieOptions
{
IsEssential = true,
SameSite = SameSiteMode.Strict,
Secure = true,
});
return Ok();
}
}
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace AobaServer.Controllers;
//allow login via http during debug testing
#if DEBUG
[AllowAnonymous]
[Route("auth")]
public class AuthController(AccountsService accountsService, AuthInfo authInfo) : Controller
{
[HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string username, [FromForm] string password, CancellationToken cancellationToken)
{
var user = await accountsService.VerifyLoginAsync(username, password, cancellationToken);
if (user == null)
return Problem("Invalid login Credentials", statusCode: StatusCodes.Status400BadRequest);
Response.Cookies.Append("token", user.GetToken(authInfo), new CookieOptions
{
IsEssential = true,
SameSite = SameSiteMode.Strict,
Secure = true,
});
return Ok();
}
}
#endif

View File

@@ -1,65 +1,65 @@
using AobaCore.Models;
using AobaCore.Services;
using HeyRed.Mime;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;
namespace AobaServer.Controllers;
[Route("/m")]
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
{
[HttpGet("{id}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
{
var file = await aobaService.GetFileStreamAsync(id, 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.IncrementFileViewCountAsync(id, cancellationToken);
return File(file, mime, true);
}
/// <summary>
/// Redirect legacy media urls to the new url
/// </summary>
/// <param name="id"></param>
/// <param name="rest"></param>
/// <param name="aoba"></param>
/// <returns></returns>
[HttpGet("/i/{id}/{*rest}")]
public async Task<IActionResult> LegacyRedirectAsync(ObjectId id, string rest, CancellationToken cancellationToken)
{
var media = await aobaService.GetMediaAsync(id, cancellationToken);
if (media == null)
return NotFound();
return LocalRedirectPermanent($"/m/{media.MediaId}/{rest}");
}
[HttpGet("thumb/{id}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, [FromQuery] ThumbnailSize size = ThumbnailSize.Medium, CancellationToken cancellationToken = default)
{
var thumb = await thumbnailService.GetOrCreateThumbnailAsync(id, size, cancellationToken);
if (thumb.HasError)
{
logger.LogError("Failed to generate thumbnail: {}", thumb.Error);
return DefaultThumbnailAsync();
}
return File(thumb, "image/webp", true);
}
[NonAction]
private IActionResult DefaultThumbnailAsync()
{
return NoContent();
}
}
using AobaCore.Models;
using AobaCore.Services;
using HeyRed.Mime;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;
namespace AobaServer.Controllers;
[Route("/m")]
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
{
[HttpGet("{id}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
{
var file = await aobaService.GetFileStreamAsync(id, 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.IncrementFileViewCountAsync(id, cancellationToken);
return File(file, mime, true);
}
/// <summary>
/// Redirect legacy media urls to the new url
/// </summary>
/// <param name="id"></param>
/// <param name="rest"></param>
/// <param name="aoba"></param>
/// <returns></returns>
[HttpGet("/i/{id}/{*rest}")]
public async Task<IActionResult> LegacyRedirectAsync(ObjectId id, string rest, CancellationToken cancellationToken)
{
var media = await aobaService.GetMediaAsync(id, cancellationToken);
if (media == null)
return NotFound();
return LocalRedirectPermanent($"/m/{media.MediaId}/{rest}");
}
[HttpGet("thumb/{id}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, [FromQuery] ThumbnailSize size = ThumbnailSize.Medium, CancellationToken cancellationToken = default)
{
var thumb = await thumbnailService.GetOrCreateThumbnailAsync(id, size, cancellationToken);
if (thumb.HasError)
{
logger.LogError("Failed to generate thumbnail: {}", thumb.Error);
return DefaultThumbnailAsync();
}
return File(thumb, "image/webp", true);
}
[NonAction]
private IActionResult DefaultThumbnailAsync()
{
return NoContent();
}
}

View File

@@ -1,61 +1,61 @@
# Client Side build - prep deps
FROM rust:1 AS chef
RUN rustup target add wasm32-unknown-unknown
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
WORKDIR /app/AobaClient
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS client-builder
WORKDIR /app/AobaClient
COPY --from=planner /app/AobaClient/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY /AobaClient /app/AobaClient
COPY /AobaServer/Proto /app/AobaServer/Proto
# Install Protobuf
RUN apt update
RUN apt install -y protobuf-compiler libprotobuf-dev
# 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
ENV PATH="/.cargo/bin:$PATH"
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
RUN dx bundle --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
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 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["AobaServer/AobaServer.csproj", "AobaServer/"]
RUN dotnet restore "./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
WORKDIR "/src/AobaServer"
RUN dotnet build "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN sudo apt-get install -y ffmpeg libgdiplus
ENTRYPOINT ["dotnet", "AobaServer.dll"]
# Client Side build - prep deps
FROM rust:1 AS chef
RUN rustup target add wasm32-unknown-unknown
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
WORKDIR /app/AobaClient
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS client-builder
WORKDIR /app/AobaClient
COPY --from=planner /app/AobaClient/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY /AobaClient /app/AobaClient
COPY /AobaServer/Proto /app/AobaServer/Proto
# Install Protobuf
RUN apt update
RUN apt install -y protobuf-compiler libprotobuf-dev
# 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
ENV PATH="/.cargo/bin:$PATH"
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
RUN dx bundle --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
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 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["AobaServer/AobaServer.csproj", "AobaServer/"]
RUN dotnet restore "./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
WORKDIR "/src/AobaServer"
RUN dotnet build "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./AobaServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN sudo apt-get install -y ffmpeg libgdiplus
ENTRYPOINT ["dotnet", "AobaServer.dll"]

View File

@@ -1,73 +1,73 @@
#nullable enable
using AobaServer.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace AobaServer.Middleware;
public static class OpenTelemetry
{
public static void AddObersability(this IServiceCollection services, IConfiguration configuration)
{
var otel = services.AddOpenTelemetry();
otel.ConfigureResource(res =>
{
res.AddService(serviceName: $"Breeze: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}");
});
// Add Metrics for ASP.NET Core and our custom metrics and export to Prometheus
otel.WithMetrics(metrics => metrics
// Metrics provider from OpenTelemetry
.AddAspNetCoreInstrumentation()
.AddCustomMetrics()
// Metrics provides by ASP.NET Core in .NET 8
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
// Metrics provided by System.Net libraries
.AddMeter("System.Net.Http")
.AddMeter("System.Net.NameResolution")
.AddPrometheusExporter());
// Add Tracing for ASP.NET Core and our custom ActivitySource and export to Jaeger
var tracingOtlpEndpoint = configuration["OTLP_ENDPOINT_URL"];
otel.WithTracing(tracing =>
{
tracing.AddSource("MongoDB.Driver.Core.Extensions.DiagnosticSources");
tracing.AddAspNetCoreInstrumentation();
tracing.AddHttpClientInstrumentation();
if (!string.IsNullOrWhiteSpace(tracingOtlpEndpoint))
{
tracing.AddOtlpExporter(otlpOptions =>
{
otlpOptions.Endpoint = new Uri(tracingOtlpEndpoint);
});
}
});
}
public static MeterProviderBuilder AddCustomMetrics(this MeterProviderBuilder builder)
{
return builder;
}
public static IEndpointRouteBuilder MapObserability(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPrometheusScrapingEndpoint().RequireAuthorization();
return endpoints;
}
}
#nullable enable
using AobaServer.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace AobaServer.Middleware;
public static class OpenTelemetry
{
public static void AddObersability(this IServiceCollection services, IConfiguration configuration)
{
var otel = services.AddOpenTelemetry();
otel.ConfigureResource(res =>
{
res.AddService(serviceName: $"Breeze: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}");
});
// Add Metrics for ASP.NET Core and our custom metrics and export to Prometheus
otel.WithMetrics(metrics => metrics
// Metrics provider from OpenTelemetry
.AddAspNetCoreInstrumentation()
.AddCustomMetrics()
// Metrics provides by ASP.NET Core in .NET 8
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
// Metrics provided by System.Net libraries
.AddMeter("System.Net.Http")
.AddMeter("System.Net.NameResolution")
.AddPrometheusExporter());
// Add Tracing for ASP.NET Core and our custom ActivitySource and export to Jaeger
var tracingOtlpEndpoint = configuration["OTLP_ENDPOINT_URL"];
otel.WithTracing(tracing =>
{
tracing.AddSource("MongoDB.Driver.Core.Extensions.DiagnosticSources");
tracing.AddAspNetCoreInstrumentation();
tracing.AddHttpClientInstrumentation();
if (!string.IsNullOrWhiteSpace(tracingOtlpEndpoint))
{
tracing.AddOtlpExporter(otlpOptions =>
{
otlpOptions.Endpoint = new Uri(tracingOtlpEndpoint);
});
}
});
}
public static MeterProviderBuilder AddCustomMetrics(this MeterProviderBuilder builder)
{
return builder;
}
public static IEndpointRouteBuilder MapObserability(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPrometheusScrapingEndpoint().RequireAuthorization();
return endpoints;
}
}

View File

@@ -1,75 +1,75 @@
using MongoDB.Bson.IO;
using System.Security.Cryptography;
using System.Text.Json;
namespace AobaServer.Models;
public class AuthInfo
{
public required string Issuer { get; set; }
public required string Audience { get; set; }
public required byte[] SecureKey { get; set; }
/// <summary>
/// Save this auth into in a json format to the sepcified file
/// </summary>
/// <param name="path">File path</param>
/// <returns></returns>
public AuthInfo Save(string path)
{
File.WriteAllText(path, JsonSerializer.Serialize(this));
return this;
}
/// <summary>
/// Generate a new Auth Info with newly generated keys
/// </summary>
/// <param name="issuer"></param>
/// <param name="audience"></param>
/// <returns></returns>
public static AuthInfo Create(string issuer, string audience)
{
var auth = new AuthInfo
{
Issuer = issuer,
Audience = audience,
SecureKey = GenetateJWTKey()
};
return auth;
}
/// <summary>
/// Load auth info from a json file
/// </summary>
/// <param name="path">File path</param>
/// <returns></returns>
internal static AuthInfo? Load(string path)
{
return JsonSerializer.Deserialize<AuthInfo>(File.ReadAllText(path));
}
internal static AuthInfo LoadOrCreate(string path, string issuer, string audience)
{
if (File.Exists(path))
{
var loaded = Load(path);
if (loaded != null)
return loaded;
}
var info = Create(issuer, audience);
info.Save(path);
return info;
}
/// <summary>
/// Generate a new key for use by JWT
/// </summary>
/// <returns></returns>
public static byte[] GenetateJWTKey(int size = 64)
{
var key = new byte[size];
RandomNumberGenerator.Fill(key);
return key;
}
using MongoDB.Bson.IO;
using System.Security.Cryptography;
using System.Text.Json;
namespace AobaServer.Models;
public class AuthInfo
{
public required string Issuer { get; set; }
public required string Audience { get; set; }
public required byte[] SecureKey { get; set; }
/// <summary>
/// Save this auth into in a json format to the sepcified file
/// </summary>
/// <param name="path">File path</param>
/// <returns></returns>
public AuthInfo Save(string path)
{
File.WriteAllText(path, JsonSerializer.Serialize(this));
return this;
}
/// <summary>
/// Generate a new Auth Info with newly generated keys
/// </summary>
/// <param name="issuer"></param>
/// <param name="audience"></param>
/// <returns></returns>
public static AuthInfo Create(string issuer, string audience)
{
var auth = new AuthInfo
{
Issuer = issuer,
Audience = audience,
SecureKey = GenetateJWTKey()
};
return auth;
}
/// <summary>
/// Load auth info from a json file
/// </summary>
/// <param name="path">File path</param>
/// <returns></returns>
internal static AuthInfo? Load(string path)
{
return JsonSerializer.Deserialize<AuthInfo>(File.ReadAllText(path));
}
internal static AuthInfo LoadOrCreate(string path, string issuer, string audience)
{
if (File.Exists(path))
{
var loaded = Load(path);
if (loaded != null)
return loaded;
}
var info = Create(issuer, audience);
info.Save(path);
return info;
}
/// <summary>
/// Generate a new key for use by JWT
/// </summary>
/// <returns></returns>
public static byte[] GenetateJWTKey(int size = 64)
{
var key = new byte[size];
RandomNumberGenerator.Fill(key);
return key;
}
}

View File

@@ -1,31 +1,31 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
using MongoDB.Bson;
namespace AobaServer.Models;
public class BsonIdModelBinderProvider : IModelBinderProvider
{
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType == typeof(ObjectId))
return new BsonIdModelBinder();
return default;
}
}
public class BsonIdModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (value == ValueProviderResult.None)
return Task.CompletedTask;
if (ObjectId.TryParse(value.FirstValue, out var id))
bindingContext.Result = ModelBindingResult.Success(id);
else
bindingContext.Result = ModelBindingResult.Failed();
return Task.CompletedTask;
}
}
using Microsoft.AspNetCore.Mvc.ModelBinding;
using MongoDB.Bson;
namespace AobaServer.Models;
public class BsonIdModelBinderProvider : IModelBinderProvider
{
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType == typeof(ObjectId))
return new BsonIdModelBinder();
return default;
}
}
public class BsonIdModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (value == ValueProviderResult.None)
return Task.CompletedTask;
if (ObjectId.TryParse(value.FirstValue, out var id))
bindingContext.Result = ModelBindingResult.Success(id);
else
bindingContext.Result = ModelBindingResult.Failed();
return Task.CompletedTask;
}
}

View File

@@ -1,18 +1,18 @@
namespace AobaServer.Models;
public class ShareXDestination
{
public string Version { get; set; } = "14.0.1";
public string Name { get; set; } = "Aoba";
public string DestinationType { get; set; } = "ImageUploader, TextUploader, FileUploader";
public string RequestMethod { get; set; } = "POST";
public string RequestURL { get; set; } = "https://aoba.app/api/media/upload";
public Dictionary<string, string> Headers { get; set; } = [];
public string Body { get; set; } = "MultipartFormData";
public Dictionary<string, string> Arguments { get; set; } = new() { { "name", "$filename$" } };
public string FileFormName { get; set; } = "file";
public string[] RegexList { get; set; } = ["([^/]+)/?$"];
public string URL { get; set; } = "https://aoba.app{json:url}";
public string? ThumbnailURL { get; set; }
public string? DeletionURL { get; set; }
namespace AobaServer.Models;
public class ShareXDestination
{
public string Version { get; set; } = "14.0.1";
public string Name { get; set; } = "Aoba";
public string DestinationType { get; set; } = "ImageUploader, TextUploader, FileUploader";
public string RequestMethod { get; set; } = "POST";
public string RequestURL { get; set; } = "https://aoba.app/api/media/upload";
public Dictionary<string, string> Headers { get; set; } = [];
public string Body { get; set; } = "MultipartFormData";
public Dictionary<string, string> Arguments { get; set; } = new() { { "name", "$filename$" } };
public string FileFormName { get; set; } = "file";
public string[] RegexList { get; set; } = ["([^/]+)/?$"];
public string URL { get; set; } = "https://aoba.app{json:url}";
public string? ThumbnailURL { get; set; }
public string? DeletionURL { get; set; }
}

View File

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

View File

@@ -1,30 +1,30 @@
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:8081"
},
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:8081"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8081"
},
"publishAllPorts": true,
"useSSL": false
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:8081"
},
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:8081"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8081"
},
"publishAllPorts": true,
"useSSL": false
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

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

View File

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

View File

@@ -1,43 +1,43 @@
using Aoba.RPC.Auth;
using AobaCore.Models;
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace AobaServer.Services;
public class AobaAuthService(AccountsService accountsService, AuthInfo authInfo) : Aoba.RPC.Auth.AuthRpc.AuthRpcBase
{
[AllowAnonymous]
public override async Task<LoginResponse> Login(Credentials request, ServerCallContext context)
{
var user = await accountsService.VerifyLoginAsync(request.User, request.Password, context.CancellationToken);
if (user == null)
return new LoginResponse
{
Error = new LoginError
{
Message = "Invalid login credentials"
}
};
var token = user.GetToken(authInfo);
return new LoginResponse
{
Jwt = new Jwt
{
Token = token
}
};
}
using Aoba.RPC.Auth;
using AobaCore.Models;
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace AobaServer.Services;
public class AobaAuthService(AccountsService accountsService, AuthInfo authInfo) : Aoba.RPC.Auth.AuthRpc.AuthRpcBase
{
[AllowAnonymous]
public override async Task<LoginResponse> Login(Credentials request, ServerCallContext context)
{
var user = await accountsService.VerifyLoginAsync(request.User, request.Password, context.CancellationToken);
if (user == null)
return new LoginResponse
{
Error = new LoginError
{
Message = "Invalid login credentials"
}
};
var token = user.GetToken(authInfo);
return new LoginResponse
{
Jwt = new Jwt
{
Token = token
}
};
}
}

View File

@@ -1,59 +1,59 @@
using Aoba.RPC;
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MongoDB.Bson.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AobaServer.Services;
public class AobaRpcService(AobaService aobaService, AccountsService accountsService, AuthInfo authInfo) : AobaRpc.AobaRpcBase
{
public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context)
{
var media = await aobaService.GetMediaAsync(request.ToObjectId());
return media.ToResponse();
}
public override async Task<ListResponse> ListMedia(PageFilter request, ServerCallContext context)
{
var user = context.GetUserId();
var result = await aobaService.FindMediaAsync(request.Query, user, request.HasPage ? request.Page : 1, request.HasPageSize ? request.PageSize : 100);
return result.ToResponse();
}
public override async Task<ShareXResponse> GetShareXDestination(Empty request, ServerCallContext context)
{
var userId = context.GetHttpContext().User.GetId();
var user = await accountsService.GetUserAsync(userId, context.CancellationToken);
if (user == null)
return new ShareXResponse { Error = "User does not exist" };
var token = user.GetToken(authInfo);
var dest = new ShareXDestination
{
DeletionURL = string.Empty,
ThumbnailURL = string.Empty,
Headers = new()
{
{ "Authorization", $"Bearer {token}" }
}
};
return new ShareXResponse
{
Destination = JsonSerializer.Serialize(dest, new JsonSerializerOptions
{
WriteIndented = true
})
};
}
using Aoba.RPC;
using AobaCore.Services;
using AobaServer.Models;
using AobaServer.Utils;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MongoDB.Bson.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AobaServer.Services;
public class AobaRpcService(AobaService aobaService, AccountsService accountsService, AuthInfo authInfo) : AobaRpc.AobaRpcBase
{
public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context)
{
var media = await aobaService.GetMediaAsync(request.ToObjectId());
return media.ToResponse();
}
public override async Task<ListResponse> ListMedia(PageFilter request, ServerCallContext context)
{
var user = context.GetUserId();
var result = await aobaService.FindMediaAsync(request.Query, user, request.HasPage ? request.Page : 1, request.HasPageSize ? request.PageSize : 100);
return result.ToResponse();
}
public override async Task<ShareXResponse> GetShareXDestination(Empty request, ServerCallContext context)
{
var userId = context.GetHttpContext().User.GetId();
var user = await accountsService.GetUserAsync(userId, context.CancellationToken);
if (user == null)
return new ShareXResponse { Error = "User does not exist" };
var token = user.GetToken(authInfo);
var dest = new ShareXDestination
{
DeletionURL = string.Empty,
ThumbnailURL = string.Empty,
Headers = new()
{
{ "Authorization", $"Bearer {token}" }
}
};
return new ShareXResponse
{
Destination = JsonSerializer.Serialize(dest, new JsonSerializerOptions
{
WriteIndented = true
})
};
}
}

View File

@@ -1,47 +1,47 @@
using AobaCore.Models;
using AobaServer.Models;
using Grpc.Core;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Bson;
using MongoDB.Driver;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AobaServer.Utils;
public static class Extensions
{
public static ObjectId ToObjectId(this string? value)
{
if(value == null)
return ObjectId.Empty;
if(ObjectId.TryParse(value, out ObjectId result))
return result;
return ObjectId.Empty;
}
public static string GetToken(this User user, AuthInfo authInfo)
{
var handler = new JwtSecurityTokenHandler();
var signCreds = new SigningCredentials(new SymmetricSecurityKey(authInfo.SecureKey), SecurityAlgorithms.HmacSha256);
var identity = user.GetIdentity();
var token = handler.CreateEncodedJwt(authInfo.Issuer, authInfo.Audience, identity, notBefore: DateTime.Now, expires: null, issuedAt: DateTime.Now, signCreds);
return token;
}
public static ObjectId GetId(this ClaimsPrincipal user)
{
return user.FindFirstValue(ClaimTypes.NameIdentifier).ToObjectId();
}
public static ObjectId GetUserId(this ServerCallContext context)
{
return context.GetHttpContext().User.GetId();
}
}
using AobaCore.Models;
using AobaServer.Models;
using Grpc.Core;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Bson;
using MongoDB.Driver;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AobaServer.Utils;
public static class Extensions
{
public static ObjectId ToObjectId(this string? value)
{
if(value == null)
return ObjectId.Empty;
if(ObjectId.TryParse(value, out ObjectId result))
return result;
return ObjectId.Empty;
}
public static string GetToken(this User user, AuthInfo authInfo)
{
var handler = new JwtSecurityTokenHandler();
var signCreds = new SigningCredentials(new SymmetricSecurityKey(authInfo.SecureKey), SecurityAlgorithms.HmacSha256);
var identity = user.GetIdentity();
var token = handler.CreateEncodedJwt(authInfo.Issuer, authInfo.Audience, identity, notBefore: DateTime.Now, expires: null, issuedAt: DateTime.Now, signCreds);
return token;
}
public static ObjectId GetId(this ClaimsPrincipal user)
{
return user.FindFirstValue(ClaimTypes.NameIdentifier).ToObjectId();
}
public static ObjectId GetUserId(this ServerCallContext context)
{
return context.GetHttpContext().User.GetId();
}
}

View File

@@ -1,68 +1,68 @@
using AobaCore.Models;
using Aoba.RPC;
using MongoDB.Bson;
using Google.Protobuf.WellKnownTypes;
namespace AobaServer.Utils;
public static class ProtoExtensions
{
public static ListResponse ToResponse(this PagedResult<Media> result)
{
var res = new ListResponse()
{
Pagination = result.ToPagination(),
};
res.Items.AddRange(result.Items.Select(i => i.ToMediaModel()));
return res;
}
public static Pagination ToPagination<T>(this PagedResult<T> result)
{
var p =new Pagination()
{
Page = result.Page,
PageSize = result.PageSize,
TotalItems = result.TotalItems,
TotalPages = result.TotalPages,
};
if(result.Query != null)
p.Query = result.Query;
return p;
}
public static MediaResponse ToResponse(this Media? media)
{
if(media == null)
return new MediaResponse() { Empty = new Empty() };
return new MediaResponse()
{
Value = media.ToMediaModel()
};
}
public static MediaModel ToMediaModel(this Media media)
{
return new MediaModel()
{
Ext = media.Ext,
FileName = media.Filename,
Id = media.Id.ToId(),
MediaId = media.MediaId.ToId(),
MediaType = (Aoba.RPC.MediaType)media.MediaType,
Owner = media.Owner.ToId(),
ViewCount = media.ViewCount,
};
}
public static Id ToId(this ObjectId id)
{
return new Id() { Value = id.ToString() };
}
public static ObjectId ToObjectId(this Id id)
{
return id.Value.ToObjectId();
}
}
using AobaCore.Models;
using Aoba.RPC;
using MongoDB.Bson;
using Google.Protobuf.WellKnownTypes;
namespace AobaServer.Utils;
public static class ProtoExtensions
{
public static ListResponse ToResponse(this PagedResult<Media> result)
{
var res = new ListResponse()
{
Pagination = result.ToPagination(),
};
res.Items.AddRange(result.Items.Select(i => i.ToMediaModel()));
return res;
}
public static Pagination ToPagination<T>(this PagedResult<T> result)
{
var p =new Pagination()
{
Page = result.Page,
PageSize = result.PageSize,
TotalItems = result.TotalItems,
TotalPages = result.TotalPages,
};
if(result.Query != null)
p.Query = result.Query;
return p;
}
public static MediaResponse ToResponse(this Media? media)
{
if(media == null)
return new MediaResponse() { Empty = new Empty() };
return new MediaResponse()
{
Value = media.ToMediaModel()
};
}
public static MediaModel ToMediaModel(this Media media)
{
return new MediaModel()
{
Ext = media.Ext,
FileName = media.Filename,
Id = media.Id.ToId(),
MediaId = media.MediaId.ToId(),
MediaType = (Aoba.RPC.MediaType)media.MediaType,
Owner = media.Owner.ToId(),
ViewCount = media.ViewCount,
};
}
public static Id ToId(this ObjectId id)
{
return new Id() { Value = id.ToString() };
}
public static ObjectId ToObjectId(this Id id)
{
return id.Value.ToObjectId();
}
}

View File

@@ -1,9 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"DB_STRING": "mongodb://NinoIna:27017"
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"DB_STRING": "mongodb://NinoIna:27017"
}

View File

@@ -1,11 +1,11 @@
{
"Kestrel": {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
{
"Kestrel": {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
}

View File

@@ -1,31 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35919.96
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AobaServer", "AobaServer\AobaServer.csproj", "{A97400AB-4B57-4074-9A31-8D46A305E633}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AobaCore", "AobaCore\AobaCore.csproj", "{65EEC037-E845-471D-A838-BEEADF781C17}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A97400AB-4B57-4074-9A31-8D46A305E633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Release|Any CPU.Build.0 = Release|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B51CD9E1-22BE-4CDB-82A8-4C6027687C60}
EndGlobalSection
EndGlobal

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35919.96
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AobaServer", "AobaServer\AobaServer.csproj", "{A97400AB-4B57-4074-9A31-8D46A305E633}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AobaCore", "AobaCore\AobaCore.csproj", "{65EEC037-E845-471D-A838-BEEADF781C17}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A97400AB-4B57-4074-9A31-8D46A305E633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A97400AB-4B57-4074-9A31-8D46A305E633}.Release|Any CPU.Build.0 = Release|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65EEC037-E845-471D-A838-BEEADF781C17}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B51CD9E1-22BE-4CDB-82A8-4C6027687C60}
EndGlobalSection
EndGlobal

View File

@@ -1,21 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,9 +1,9 @@
services:
aoba:
build:
context: .
dockerfile: AobaServer/Dockerfile
ports:
- "4321:8080"
environment:
- DB_STRING="mongodb://192.168.86.63:27017"
services:
aoba:
build:
context: .
dockerfile: AobaServer/Dockerfile
ports:
- "4321:8080"
environment:
- DB_STRING="mongodb://192.168.86.63:27017"