diff --git a/AobaCore/AobaService.cs b/AobaCore/AobaService.cs index 05a41ad..8be06cf 100644 --- a/AobaCore/AobaService.cs +++ b/AobaCore/AobaService.cs @@ -1,7 +1,5 @@ using AobaV2.Models; -using MaybeError; - using MongoDB.Bson; using MongoDB.Driver; @@ -16,4 +14,9 @@ public class AobaService(IMongoDatabase db) { return await _media.Find(m => m.Id == id).FirstOrDefaultAsync(); } + + public Task AddMediaAsync(Media media) + { + return _media.InsertOneAsync(media); + } } diff --git a/AobaCore/Extensions.cs b/AobaCore/Extensions.cs index e02fd05..84c8dd9 100644 --- a/AobaCore/Extensions.cs +++ b/AobaCore/Extensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +global using MaybeError; +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; diff --git a/AobaCore/MediaService.cs b/AobaCore/MediaService.cs new file mode 100644 index 0000000..2d87df3 --- /dev/null +++ b/AobaCore/MediaService.cs @@ -0,0 +1,25 @@ +using AobaV2.Models; + +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.GridFS; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AobaCore; +public class MediaService(IMongoDatabase db, AobaService aobaService) +{ + private readonly GridFSBucket _gridFs = new(db); + + public async Task> UploadMediaAsync(Stream data, string filename, ObjectId owner, CancellationToken cancellationToken = default) + { + var fileId = await _gridFs.UploadFromStreamAsync(filename, data, cancellationToken: cancellationToken); + var media = new Media(fileId, filename, owner); + await aobaService.AddMediaAsync(media); + return media; + } +} diff --git a/AobaCore/Models/Media.cs b/AobaCore/Models/Media.cs index 839cd8a..0c140a6 100644 --- a/AobaCore/Models/Media.cs +++ b/AobaCore/Models/Media.cs @@ -9,9 +9,9 @@ public class Media [BsonId] public ObjectId Id { get; set; } public ObjectId MediaId { get; set; } - public required string Filename { get; set; } + public string Filename { get; set; } public MediaType MediaType { get; set; } - public required string Ext { get; set; } + public string Ext { get; set; } public int ViewCount { get; set; } public ObjectId Owner { get; set; } @@ -49,6 +49,23 @@ public class Media { ".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(); + } + public string GetMediaUrl() { return this switch diff --git a/AobaV2/AobaAuthenticationHandler.cs b/AobaV2/AobaAuthenticationHandler.cs new file mode 100644 index 0000000..7cbbfe4 --- /dev/null +++ b/AobaV2/AobaAuthenticationHandler.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Text.Encodings.Web; + +namespace AobaV2; + +internal class AobaAuthenticationHandler : AuthenticationHandler +{ + public AobaAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + throw new System.NotImplementedException(); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + //Don't challenge API requests + if (OriginalPath.StartsWithSegments("/api")) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.BodyWriter.Complete(); + return Task.CompletedTask; + } + //Redirect to login page + Response.Redirect($"/auth/login?ReturnUrl={Uri.EscapeDataString(OriginalPath)}"); + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + //Don't show error page for api requests + if (OriginalPath.StartsWithSegments("/api")) + { + Response.StatusCode = StatusCodes.Status403Forbidden; + Response.BodyWriter.Complete(); + return Task.CompletedTask; + } + //Show Error page + Response.Redirect($"/error/{StatusCodes.Status403Forbidden}"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/AobaV2/AobaV2.csproj b/AobaV2/AobaV2.csproj index 623884d..e9ce578 100644 --- a/AobaV2/AobaV2.csproj +++ b/AobaV2/AobaV2.csproj @@ -18,6 +18,7 @@ + diff --git a/AobaV2/AuthInfo.cs b/AobaV2/AuthInfo.cs new file mode 100644 index 0000000..4125d4c --- /dev/null +++ b/AobaV2/AuthInfo.cs @@ -0,0 +1,74 @@ +using MongoDB.Bson.IO; +using System.Security.Cryptography; +using System.Text.Json; + +namespace AobaV2; + +public class AuthInfo +{ + public required string Issuer; + public required string Audience; + public required byte[] SecureKey; + + /// + /// Save this auth into in a json format to the sepcified file + /// + /// File path + /// + public AuthInfo Save(string path) + { + File.WriteAllText(path, JsonSerializer.Serialize(this)); + return this; + } + + /// + /// Generate a new Auth Info with newly generated keys + /// + /// + /// + /// + public static AuthInfo Create(string issuer, string audience) + { + var auth = new AuthInfo + { + Issuer = issuer, + Audience = audience, + SecureKey = GenetateJWTKey() + }; + return auth; + } + + /// + /// Load auth info from a json file + /// + /// File path + /// + internal static AuthInfo? Load(string path) + { + return JsonSerializer.Deserialize(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; + } + + /// + /// Generate a new key for use by JWT + /// + /// + public static byte[] GenetateJWTKey(int size = 64) + { + var key = new byte[size]; + RandomNumberGenerator.Fill(key); + return key; + } +} \ No newline at end of file diff --git a/AobaV2/Controllers/Api/AuthApi.cs b/AobaV2/Controllers/Api/AuthApi.cs new file mode 100644 index 0000000..2e2928d --- /dev/null +++ b/AobaV2/Controllers/Api/AuthApi.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace AobaV2.Controllers.Api; + +[Route("/api/auth")] +public class AuthApi: ControllerBase +{ + [HttpGet("login")] + public async Task LoginAsync() + { + throw new NotImplementedException(); + } + + [HttpGet("register")] + public async Task LoginAsync() + { + throw new NotImplementedException(); + } +} diff --git a/AobaV2/Controllers/AuthController.cs b/AobaV2/Controllers/AuthController.cs new file mode 100644 index 0000000..86fb5a5 --- /dev/null +++ b/AobaV2/Controllers/AuthController.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AobaV2.Controllers; + +[AllowAnonymous] +[Route("auth")] +public class AuthController : Controller +{ + [HttpGet("login")] + public IActionResult Login([FromQuery] string returnUrl) + { + ViewData["returnUrl"] = returnUrl; + return View(); + } + + [HttpGet("register/{token}")] + public IActionResult Register(string token) + { + + return View(token); + } +} diff --git a/AobaV2/Controllers/MediaController.cs b/AobaV2/Controllers/MediaController.cs index b616889..72ce986 100644 --- a/AobaV2/Controllers/MediaController.cs +++ b/AobaV2/Controllers/MediaController.cs @@ -1,10 +1,28 @@ -using Microsoft.AspNetCore.Mvc; +using AobaCore; + +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Bson; +using MongoDB.Driver; namespace AobaV2.Controllers; -public class MediaController : Controller + +[Route("/m")] +public class MediaController(MediaService media) : Controller { - public IActionResult Index() + [HttpGet("{id}")] + public IActionResult Media(ObjectId id) { + meda return View(); } + + [HttpGet("/i/{id}/{*rest}")] + public async Task LegacyRedirectAsync(ObjectId id, string rest, [FromServices] AobaService aoba) + { + var media = await aoba.GetMediaAsync(id); + if (media == null) + return NotFound(); + return LocalRedirectPermanent($"/m/{media.Id}/{rest}"); + } } diff --git a/AobaV2/Models/BsonIdModelBinderProvider.cs b/AobaV2/Models/BsonIdModelBinderProvider.cs new file mode 100644 index 0000000..888ce45 --- /dev/null +++ b/AobaV2/Models/BsonIdModelBinderProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using MongoDB.Bson; + +namespace AobaV2.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; + } +} diff --git a/AobaV2/Program.cs b/AobaV2/Program.cs index fcf29d8..278a37b 100644 --- a/AobaV2/Program.cs +++ b/AobaV2/Program.cs @@ -1,17 +1,73 @@ using AobaCore; +using AobaV2; +using AobaV2.Models; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http.Features; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); // Add services to the container. #if DEBUG - builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation(); + builder.Services + .AddControllersWithViews(opt => opt.ModelBinderProviders.Add(new BsonIdModelBinderProvider())) + .AddRazorRuntimeCompilation(); builder.Services.AddSassCompiler(); #else builder.Services.AddControllersWithViews(); #endif +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.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = "Aoba"; +}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => //Bearer auth +{ + options.TokenValidationParameters = validationParams; + options.Events = new JwtBearerEvents + { + OnMessageReceived = ctx => //Retreive token from cookie if not found in headers + { + if (string.IsNullOrWhiteSpace(ctx.Token)) + ctx.Token = ctx.Request.Cookies["token"]; + if (string.IsNullOrWhiteSpace(ctx.Token)) + ctx.Token = ctx.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); + 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("Aoba", cfg => { }); + builder.Services.AddAoba(); builder.Services.Configure(opt => { @@ -47,6 +103,9 @@ app.Use((c, n) => app.UseHttpsRedirection(); app.UseRouting(); + + +app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); diff --git a/AobaV2/Views/Auth/Login.cshtml b/AobaV2/Views/Auth/Login.cshtml new file mode 100644 index 0000000..e1dd794 --- /dev/null +++ b/AobaV2/Views/Auth/Login.cshtml @@ -0,0 +1,5 @@ +@* + For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 +*@ +@{ +} diff --git a/AobaV2/Views/Auth/Register.cshtml b/AobaV2/Views/Auth/Register.cshtml new file mode 100644 index 0000000..e1dd794 --- /dev/null +++ b/AobaV2/Views/Auth/Register.cshtml @@ -0,0 +1,5 @@ +@* + For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 +*@ +@{ +}