Commit: 4f24c31
Parent: 2fdc179

Add upload functionality to admin page

Mårten Åsberg committed on 2026-06-29 at 16:30
.containerfile +1 -1
diff --git a/.containerfile b/.containerfile
index 6d4a22b..0ecce4d 100644
@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0.301 AS build
WORKDIR /app
MatDenDagen/Components/Layout/AdminLayout.razor +1 -0
diff --git a/MatDenDagen/Components/Layout/AdminLayout.razor b/MatDenDagen/Components/Layout/AdminLayout.razor
index 8234b03..9f9df5f 100644
@@ -20,6 +20,7 @@
<li><a href="/admin/questions">Frågor</a></li>
<li><a href="/admin/participants">Deltagare</a></li>
<li><a href="/admin/date">Datum</a></li>
<li><a href="/admin/kokbok">Kokböcker</a></li>
<li><a href="/admin/export">Exportera</a></li>
<li><a href="/admin/logout">Logga ut</a></li>
}
MatDenDagen/Components/Pages/Admin/Kokbok.razor +52 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Kokbok.razor b/MatDenDagen/Components/Pages/Admin/Kokbok.razor
new file mode 100644
index 0000000..caf83d2
@@ -0,0 +1,52 @@
@page "/admin/kokbok"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Admin")]
@layout AdminLayout
<h1>Kokböcker</h1>
@if (success)
{
<p>Kokboken laddades upp!</p>
}
@if (errorMessage is not null)
{
<p>@errorMessage</p>
}
@if (cookbooks.Count > 0)
{
<ul>
@foreach (var cookbook in cookbooks)
{
<li>
<a href="/kokbok/@cookbook.Id">@cookbook.Id</a> — @cookbook.FileName
</li>
}
</ul>
}
else
{
<p>Inga kokböcker uppladdade ännu.</p>
}
<hr />
<form method="post" enctype="multipart/form-data" @formname="UploadCookbook">
<p>
<label>
<span>ID:</span>
<input type="text" name="id" required pattern="[a-zA-Z0-9][a-zA-Z0-9_-]*" title="Bokstäver, siffror, bindestreck och understreck" />
</label>
</p>
<p>
<label>
<span>PDF:</span>
<input type="file" name="file" accept="application/pdf,.pdf" required />
</label>
</p>
<p>
<button type="submit">Ladda upp</button>
</p>
</form>
MatDenDagen/Components/Pages/Admin/Kokbok.razor.cs +84 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Kokbok.razor.cs b/MatDenDagen/Components/Pages/Admin/Kokbok.razor.cs
new file mode 100644
index 0000000..054ed55
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MatDenDagen.Infrastructure.Storage.BlobStorage;
using MatDenDagen.Infrastructure.Storage.Database;
using MatDenDagen.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace MatDenDagen.Components.Pages.Admin;
public partial class Kokbok(BlobStorageService blobService, QuestionnaireContext questionnaireContext)
{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private bool success;
private string? errorMessage;
private List<Cookbook> cookbooks = [];
protected override async Task OnInitializedAsync()
{
var cancellationToken = HttpContext?.RequestAborted ?? CancellationToken.None;
cookbooks = await questionnaireContext.Cookbooks.OrderBy(c => c.Id).ToListAsync(cancellationToken);
if (HttpContext?.Request.Method is not "POST")
return;
var form = HttpContext.Request.Form;
var id = form["id"].FirstOrDefault()?.Trim();
if (id is null || !validId.IsMatch(id))
{
errorMessage = "Ogiltigt ID. Använd bokstäver, siffror, bindestreck och understreck.";
return;
}
var file = form.Files.GetFile("file");
if (file is null || file.Length == 0)
{
errorMessage = "Ingen PDF-fil vald.";
return;
}
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
errorMessage = "Endast PDF-filer är tillåtna.";
return;
}
await using var stream = file.OpenReadStream();
var result = await blobService.SaveBlob(stream, cancellationToken);
var existing = await questionnaireContext.Cookbooks.FindAsync([id], cancellationToken);
if (existing is not null)
{
blobService.DeleteBlob(existing.BlobId);
existing.BlobId = result.Id;
existing.FileName = file.FileName;
}
else
{
questionnaireContext.Cookbooks.Add(
new Cookbook
{
Id = id,
BlobId = result.Id,
FileName = file.FileName,
}
);
}
await questionnaireContext.SaveChangesAsync(cancellationToken);
cookbooks = await questionnaireContext.Cookbooks.OrderBy(c => c.Id).ToListAsync(cancellationToken);
success = true;
}
[GeneratedRegex(@"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")]
private static partial Regex validId { get; }
}
MatDenDagen/Components/Pages/Admin/Kokbok.razor.css +42 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Kokbok.razor.css b/MatDenDagen/Components/Pages/Admin/Kokbok.razor.css
new file mode 100644
index 0000000..310c125
@@ -0,0 +1,42 @@
h1 {
color: var(--wood-dark);
margin-bottom: 1.5em;
padding-bottom: 0.5em;
border-bottom: 2px solid var(--wood-medium);
}
ul {
list-style: none;
padding: 0;
}
li {
margin-bottom: 0.5em;
}
a {
color: var(--wood-dark);
font-weight: 600;
}
form label {
display: block;
margin-bottom: 1em;
}
form label span {
display: block;
font-weight: 600;
margin-bottom: 0.25em;
}
button {
padding: 0.6em 1.2em;
background-color: var(--wood-light);
background-image: var(--wood-grain);
color: var(--paper-color);
border: 2px solid var(--wood-dark);
border-radius: var(--border-radius);
font-weight: 600;
cursor: pointer;
}
MatDenDagen/Infrastructure/Storage/BlobStorage/BlobStorageService.cs +10 -0
diff --git a/MatDenDagen/Infrastructure/Storage/BlobStorage/BlobStorageService.cs b/MatDenDagen/Infrastructure/Storage/BlobStorage/BlobStorageService.cs
index 87bab08..6be3bf3 100644
@@ -48,6 +48,16 @@ public sealed partial class BlobStorageService(
: null;
}
public bool DeleteBlob(string id)
{
var path = Path.Join(blobDirectory, id);
if (!File.Exists(path))
return false;
File.Delete(path);
return true;
}
[LoggerMessage(LogLevel.Debug, "Creating new file {Id}.")]
private partial void LogCreatingFile(string id);
MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.Designer.cs +196 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.Designer.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.Designer.cs
new file mode 100644
index 0000000..eb9e38f
@@ -0,0 +1,196 @@
// <auto-generated />
using System;
using MatDenDagen.Infrastructure.Storage.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
{
[DbContext(typeof(QuestionnaireContext))]
[Migration("20260629120000_AddCookbooks")]
partial class AddCookbooks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("MatDenDagen.Models.Answer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("QuestionId")
.HasColumnType("TEXT");
b.Property<Guid?>("SubmissionId")
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("QuestionId");
b.HasIndex("SubmissionId");
b.ToTable("Answer");
});
modelBuilder.Entity("MatDenDagen.Models.Config", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Configs");
});
modelBuilder.Entity("MatDenDagen.Models.Cookbook", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("BlobId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Cookbooks");
});
modelBuilder.Entity("MatDenDagen.Models.Participant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("NotificationMinutesOffset")
.HasColumnType("INTEGER");
b.Property<string>("PhoneNumber")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PhoneNumber")
.IsUnique();
b.ToTable("Participants");
});
modelBuilder.Entity("MatDenDagen.Models.Question", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Questions");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("Participant")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Participant");
b.ToTable("Submissions");
});
modelBuilder.Entity("MatDenDagen.Models.Upload", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid?>("SubmissionId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SubmissionId");
b.ToTable("Upload");
});
modelBuilder.Entity("MatDenDagen.Models.Answer", b =>
{
b.HasOne("MatDenDagen.Models.Question", null)
.WithMany()
.HasForeignKey("QuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MatDenDagen.Models.Submission", null)
.WithMany("Answers")
.HasForeignKey("SubmissionId");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.HasOne("MatDenDagen.Models.Participant", null)
.WithMany()
.HasForeignKey("Participant")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MatDenDagen.Models.Upload", b =>
{
b.HasOne("MatDenDagen.Models.Submission", null)
.WithMany("Uploads")
.HasForeignKey("SubmissionId");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.Navigation("Answers");
b.Navigation("Uploads");
});
#pragma warning restore 612, 618
}
}
}
MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.cs +34 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260629120000_AddCookbooks.cs
new file mode 100644
index 0000000..18da6ca
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
{
/// <inheritdoc />
public partial class AddCookbooks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Cookbooks",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
BlobId = table.Column<string>(type: "TEXT", nullable: false),
FileName = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_Cookbooks", x => x.Id);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Cookbooks");
}
}
}
MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs +18 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs
index 356c324..77da454 100644
@@ -56,6 +56,24 @@ namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
b.ToTable("Configs");
});
modelBuilder.Entity("MatDenDagen.Models.Cookbook", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("BlobId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Cookbooks");
});
modelBuilder.Entity("MatDenDagen.Models.Participant", b =>
{
b.Property<Guid>("Id")
MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs +6 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs b/MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs
index 249343a..64202a9 100644
@@ -9,6 +9,7 @@ public sealed class QuestionnaireContext(DbContextOptions<QuestionnaireContext>
public required DbSet<Submission> Submissions { get; init; }
public required DbSet<Participant> Participants { get; init; }
public required DbSet<Config> Configs { get; init; }
public required DbSet<Cookbook> Cookbooks { get; init; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -39,5 +40,10 @@ public sealed class QuestionnaireContext(DbContextOptions<QuestionnaireContext>
var configBuilder = modelBuilder.Entity<Config>();
configBuilder.HasKey(c => c.Key);
configBuilder.Property(c => c.Value);
var cookbookBuilder = modelBuilder.Entity<Cookbook>();
cookbookBuilder.HasKey(c => c.Id);
cookbookBuilder.Property(c => c.BlobId);
cookbookBuilder.Property(c => c.FileName);
}
}
MatDenDagen/MatDenDagen.csproj +2 -1
diff --git a/MatDenDagen/MatDenDagen.csproj b/MatDenDagen/MatDenDagen.csproj
index 5d6dc35..e9abbaa 100644
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>MatDenDagen</RootNamespace>
<AssemblyName>MatDenDagen</AssemblyName>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<UserSecretsId>214edf80-b742-4ea6-ad1a-0e3182790250</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\sms.proto" GrpcServices="Client" />
MatDenDagen/Models/Cookbook.cs +8 -0
diff --git a/MatDenDagen/Models/Cookbook.cs b/MatDenDagen/Models/Cookbook.cs
new file mode 100644
index 0000000..8975419
@@ -0,0 +1,8 @@
namespace MatDenDagen.Models;
public sealed class Cookbook
{
public required string Id { get; set; }
public required string BlobId { get; set; }
public required string FileName { get; set; }
}
MatDenDagen/Program.cs +24 -0
diff --git a/MatDenDagen/Program.cs b/MatDenDagen/Program.cs
index 467ff72..1fe6c01 100644
@@ -2,12 +2,15 @@ using System.Threading;
using MatDenDagen;
using MatDenDagen.Components;
using MatDenDagen.Infrastructure.Storage;
using MatDenDagen.Infrastructure.Storage.BlobStorage;
using MatDenDagen.Infrastructure.Storage.Database;
using MatDenDagen.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
@@ -61,4 +64,25 @@ app.MapGet(
)
.RequireAuthorization(p => p.RequireRole("Admin"));
app.MapGet(
"/kokbok/{id}",
async (
string id,
[FromServices] QuestionnaireContext questionnaireContext,
[FromServices] BlobStorageService blobStorageService,
CancellationToken cancellationToken
) =>
{
var cookbook = await questionnaireContext.Cookbooks.FindAsync([id], cancellationToken);
if (cookbook is null)
return Results.NotFound();
var stream = blobStorageService.GetBlob(cookbook.BlobId);
if (stream is null)
return Results.NotFound();
return Results.File(stream, "application/pdf");
}
);
app.Run();
MatDenDagen/packages.lock.json +3 -3
diff --git a/MatDenDagen/packages.lock.json b/MatDenDagen/packages.lock.json
index 090796d..04453fe 100644
@@ -37,9 +37,9 @@
},
"Microsoft.AspNetCore.App.Internal.Assets": {
"type": "Direct",
"requested": "[10.0.8, )",
"resolved": "10.0.8",
"contentHash": "v6KocthydFtUHBIRI29YsOHKOgsqcQubkNywVOxiYr4GSDSv+XO/AeyMJywlX+1tGUTGdydu/X7V+Tzeq4SsoA=="
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "E9Wp/LPKAYkGOVBv4lt5U5TnUA/7pov7QZAwF3eI64kK8AAXqkPDwuadEOwpL1WXEfgecYm0fccluvABp32D8g=="
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
global.json +2 -2
diff --git a/global.json b/global.json
index e98911e..f70a78b 100644
@@ -1,9 +1,9 @@
{
"sdk": {
"version": "10.0.300",
"version": "10.0.301",
"rollForward": "disable"
},
"msbuild-sdks": {
"Microsoft.NET.Sdk.Web": "10.0.300"
"Microsoft.NET.Sdk.Web": "10.0.301"
}
}