6 Commits

Author SHA1 Message Date
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
63e2f9f791 update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m41s
2025-12-14 16:07:53 -05:00
8964d1c069 testing using libvips 2025-08-17 14:57:21 -04:00
8808126905 testing using ffmpeg to generate thumbnails for avif 2025-08-17 03:16:19 -04:00
7 changed files with 284 additions and 169 deletions

View File

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

View File

@@ -40,6 +40,11 @@ public class AobaService(IMongoDatabase db)
return new PagedResult<Media>(items, page, pageSize, total);
}
public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default)
{
var filter = Builders<Media>.Filter.Eq(m => m.Ext, ext);
return await _media.Find(filter).ToListAsync();
}
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
{
@@ -53,6 +58,13 @@ public class AobaService(IMongoDatabase db)
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
}
public async Task RemoveThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var upate = Builders<Media>.Update.Unset(m => m.Thumbnails[size]);
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
}
public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);

View File

@@ -16,6 +16,7 @@ using SixLabors.ImageSharp.Processing;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
@@ -27,6 +28,28 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
public async Task<Error?> DeleteThumbnailAsync(ObjectId mediaId, ThumbnailSize size)
{
var thumbId = await aobaService.GetThumbnailIdAsync(mediaId, size);
if (thumbId == default)
return null;
try
{
await _gridfs.DeleteAsync(thumbId);
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (GridFSFileNotFoundException)
{
//Ignore if the file was not found (somehow already deleted)
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (Exception e)
{
return new ExceptionError(e);
}
return null;
}
/// <summary>
///
/// </summary>
@@ -36,6 +59,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
/// <returns></returns>
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
if (existingThumb != null)
return existingThumb;
@@ -132,7 +156,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
return result;
}
public Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
public static Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
@@ -166,6 +190,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
}
}
public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
var inFilePath = $"/tmp/{fn}.avif";
var outFilePath = $"/tmp/{fn}.webp";
using var source = new FileStream(inFilePath, FileMode.CreateNew);
data.CopyTo(source);
source.Flush();
source.Dispose();
data.Dispose();
try
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "vips",
Arguments = $"smartcrop \"{inFilePath}\" \"{outFilePath}\"[Q=75] {w} {w}",
WorkingDirectory = "/tmp"
});
if (process == null)
return new Error("Failed to run vips command");
process.WaitForExit();
if (process.ExitCode != 0)
return new Error("Failed to convert");
var output = new MemoryStream();
using var oFile = File.OpenRead(outFilePath);
oFile.CopyTo(output);
output.Position = 0;
return output;
}
catch(Exception ex)
{
return ex;
}
finally
{
File.Delete(inFilePath);
File.Delete(outFilePath);
}
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
return new NotImplementedException();

View File

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

View File

@@ -22,7 +22,7 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
# Install `dx`
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall dioxus-cli --root /.cargo -y --force
RUN cargo binstall dioxus-cli@0.6.3 --root /.cargo -y --force
ENV PATH="/.cargo/bin:$PATH"
ARG VERSION
ENV APP_VERSION=$VERSION
@@ -32,7 +32,7 @@ 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
RUN apt-get update && apt-get install -y ffmpeg
RUN apt-get update && apt-get install -y ffmpeg #libvips libvips-tools
USER $APP_UID
WORKDIR /app
EXPOSE 8080

View File

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

View File

@@ -0,0 +1,19 @@
using AobaCore.Services;
namespace AobaServer.Services;
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
foreach (var item in mediaItems)
{
foreach (var size in item.Thumbnails.Keys)
{
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
}
}
}
}