7 Commits

Author SHA1 Message Date
41aa78b672 added pagination controls
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m33s
2025-12-28 14:29:46 -05:00
21163b277d update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m0s
2025-12-26 17:14:45 -05:00
7a0d3b7f40 merge
Some checks failed
Build and Push Image / build-and-push (push) Failing after 4m5s
2025-12-26 17:03:34 -05:00
6d2b8c77b2 update to dx 0.7.2 2025-12-26 17:03:00 -05:00
8bdd9edbb0 Increase multi-part body limit
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m52s
2025-12-22 15:55:46 +00:00
5e6b0b21a6 fix filename handling
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m48s
2025-12-14 16:22:51 -05:00
19274d444d fix typo 2025-12-14 16:09:11 -05:00
20 changed files with 1913 additions and 545 deletions

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkou code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Build - name: Set up Docker Build

1961
AobaClient/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
dioxus = { version = "0.6.0", features = ["router"] } dioxus = { version = "0.7.2", features = ["router"] }
serde = "1.0.219" serde = "1.0.219"
serde_repr = "0.1.20" serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [ tonic = { version = "*", default-features = false, features = [

View File

@@ -195,3 +195,24 @@ form {
} }
} }
} }
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 5;
padding: 2px;
a {
transition: all 0.25s ease-out;
cursor: pointer;
user-select: none;
padding: 5px 10px;
background-color: $featureColor;
border-radius: 4px;
&:hover {
background-color: $focusColor;
}
}
}

View File

@@ -9,6 +9,8 @@ use crate::{
#[derive(PartialEq, Clone, Props)] #[derive(PartialEq, Clone, Props)]
pub struct MediaGridProps { pub struct MediaGridProps {
pub query: Option<String>, pub query: Option<String>,
pub max_page: Signal<i32>,
pub total_items: Signal<i32>,
#[props(default = Some(1))] #[props(default = Some(1))]
pub page: Option<i32>, pub page: Option<i32>,
#[props(default = Some(100))] #[props(default = Some(100))]
@@ -33,12 +35,13 @@ impl Into<PageFilter> for MediaGridProps {
} }
#[component] #[component]
pub fn MediaGrid(props: MediaGridProps) -> Element { pub fn MediaGrid(mut props: MediaGridProps) -> Element {
let media_result = use_resource(use_reactive!(|(props)| async move { let media_result = use_resource(use_reactive!(|(props)| async move {
let mut client = get_rpc_client(); let mut client = get_rpc_client();
let result = client.list_media(props.into_request()).await; let result = client.list_media(props.into_request()).await;
if let Ok(items) = result { if let Ok(items) = result {
return Ok(items.into_inner()); let res = items.into_inner();
return Ok(res);
} else { } else {
let err = result.err().unwrap(); let err = result.err().unwrap();
let message = err.message(); let message = err.message();
@@ -48,17 +51,24 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
match media_result.cloned() { match media_result.cloned() {
Some(value) => match value { Some(value) => match value {
Ok(result) => rsx! { Ok(result) => {
div { let pagination = result.pagination.unwrap();
class: "mediaGrid", let total_pages = pagination.total_pages;
// oncontextmenu: oncontext, let total_items = pagination.total_items;
{result.items.iter().map(|itm| rsx!{ props.max_page.set(total_pages.max(1));
MediaItem { props.total_items.set(total_items.max(1));
item: itm.clone() return rsx! {
} div {
})}, class: "mediaGrid",
} // oncontextmenu: oncontext,
}, {result.items.iter().map(|itm| rsx!{
MediaItem {
item: itm.clone()
}
})},
}
};
}
Err(msg) => rsx! { Err(msg) => rsx! {
div { div {
class: "mediaGrid", class: "mediaGrid",

View File

@@ -5,6 +5,7 @@ mod media_item;
mod metrics_token; mod metrics_token;
mod navbar; mod navbar;
mod notif; mod notif;
mod pagination;
mod search; mod search;
pub use context_menu::*; pub use context_menu::*;
pub use media_grid::*; pub use media_grid::*;
@@ -12,5 +13,6 @@ pub use media_item::*;
pub use metrics_token::*; pub use metrics_token::*;
pub use navbar::*; pub use navbar::*;
pub use notif::*; pub use notif::*;
pub use pagination::*;
pub use search::*; pub use search::*;
mod icons; mod icons;

View File

@@ -22,7 +22,7 @@ pub fn Navbar() -> Element {
pub fn MainNaviagation() -> Element { pub fn MainNaviagation() -> Element {
rsx! { rsx! {
div { class: "mainNav", div { class: "mainNav",
Link { class: "navItem", to: Route::Home {}, "Home" } Link { class: "navItem", to: Route::Home { }, "Home" }
Link { class: "navItem", to: Route::Settings {}, "Settings" } Link { class: "navItem", to: Route::Settings {}, "Settings" }
} }
} }

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
use dioxus::signals::{Signal, Writable}; use dioxus::signals::{Signal, WritableExt};
use web_sys::window; use web_sys::window;
use crate::rpc::{login, logout}; use crate::rpc::{login, logout};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ public class AobaService(IMongoDatabase db)
var total = await find.CountDocumentsAsync(); var total = await find.CountDocumentsAsync();
page -= 1; page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync(); var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total); return new PagedResult<Media>(items, page, pageSize, (int)total);
} }
public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default) public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default)

View File

@@ -14,7 +14,7 @@ namespace AobaServer.Controllers;
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
{ {
[HttpGet("{id}")] [HttpGet("{id}")]
[HttpGet("{id}/*")] [HttpGet("{id}/{*fn}")]
[ResponseCache(Duration = int.MaxValue)] [ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken) public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
{ {
@@ -29,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
return File(file, mime, true); return File(file, mime, true);
} }
[HttpGet("{id}/dl")]
[HttpGet("{id}/dl/{*fn}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> DownloadMediaAsync(ObjectId id, [FromServices] MongoClient client, [FromQuery] string? fn = null, CancellationToken cancellationToken = default)
{
var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
if (file.HasError)
{
logger.LogError(file.Error.Exception, "Failed to load media stream");
return NotFound();
}
var mime = MimeTypesMap.GetMimeType(file.Value.FileInfo.Filename);
_ = aobaService.IncrementViewCountAsync(id, cancellationToken);
return File(file, mime, fn ?? file.Value.FileInfo.Filename, true);
}
/// <summary> /// <summary>
/// Redirect legacy media urls to the new url /// Redirect legacy media urls to the new url
/// </summary> /// </summary>

View File

@@ -22,12 +22,12 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
# Install `dx` # Install `dx`
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall dioxus-cli@0.6.3 --root /.cargo -y --force RUN cargo binstall dioxus-cli@0.7.2 --root /.cargo -y --force
ENV PATH="/.cargo/bin:$PATH" ENV PATH="/.cargo/bin:$PATH"
ARG VERSION ARG VERSION
ENV APP_VERSION=$VERSION ENV APP_VERSION=$VERSION
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled # Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
RUN dx bundle --platform web RUN dx bundle --release --platform web
# Server Build # Server Build
# This stage is used when running from VS in fast mode (Default for Debug configuration) # This stage is used when running from VS in fast mode (Default for Debug configuration)

View File

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

View File

@@ -54,8 +54,8 @@ message ListResponse {
message Pagination { message Pagination {
int32 page = 1; int32 page = 1;
int32 pageSize = 2; int32 pageSize = 2;
int64 totalPages = 3; int32 totalPages = 3;
int64 totalItems = 4; int32 totalItems = 4;
optional string query = 5; optional string query = 5;
} }