Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ The backend proxies every byte. Clients never receive S3 URLs or connect to stor
- Download from `GET /api/documents/{id}`, which returns a file response.

Pictures work the same way. You upload your own with `PUT /api/me/avatar`; plugins store a linked
account's picture through `ILinkedAccounts.SetPictureAsync`. The database holds only a bare blob key, and
account's picture through `ILinkedAccounts.SetPictureAsync` and a school account's own picture through
`ILinkedAccounts.SetSchoolUserPictureAsync`. The database holds only a bare blob key, and
the DTOs carry an API path under `/api/avatars/...`. Every avatar route needs the normal sign-in and
answers only the picture's owner, so clients load pictures with their bearer token. See
[Pictures](setup/configuration.md#pictures).
Expand Down
5 changes: 4 additions & 1 deletion docs/setup/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,16 @@ Document and avatar blobs go to an S3-compatible bucket, which is SeaweedFS in t

Users upload a profile picture with `PUT /api/me/avatar` (multipart; PNG, JPEG, or WebP; 2 MB at most)
and remove it with `DELETE /api/me/avatar`. Plugins store a linked account's picture (the Microsoft
plugin uses the account's own Microsoft photo). The files go to document storage.
plugin uses the account's own Microsoft photo) and a school account's own picture (the Schulnetz plugin
uses the photo on the Lernendenausweis). The files go to document storage. An account row shows the
school account's own picture first, then the linked account's, then the school profile's.

| Route | Answers |
|---|---|
| `GET /api/avatars/users/{applicationUserId}` | The caller's own profile picture. |
| `GET /api/avatars/{schoolUserId}` | The picture of one of the caller's school profiles. |
| `GET /api/avatars/accounts/{linkedAccountId}` | The picture of one of the caller's linked accounts. |
| `GET /api/avatars/school-accounts/{schoolUserId}` | The own picture of one of the caller's school accounts. |

Every route needs the normal sign-in and answers `404` for anyone else's id and for a missing picture.
The DTOs (`profilePictureUrl`, `pictureUrl`) carry the path with a `?v=` value that changes with the
Expand Down
5 changes: 5 additions & 0 deletions src/Schuly.API/Controllers/AvatarsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public async Task<IActionResult> Get(Guid schoolUserId, CancellationToken ct) =>
public async Task<IActionResult> GetAccount(Guid linkedAccountId, CancellationToken ct) =>
Picture(await mediator.Send(new GetLinkedAccountAvatarQuery(linkedAccountId), ct));

/// <summary>The own picture of one of the caller's school accounts (e.g. the student ID card photo).</summary>
[HttpGet("school-accounts/{schoolUserId:guid}")]
public async Task<IActionResult> GetSchoolAccount(Guid schoolUserId, CancellationToken ct) =>
Picture(await mediator.Send(new GetSchoolAccountAvatarQuery(schoolUserId), ct));

/// <summary>The caller's own profile picture.</summary>
[HttpGet("users/{applicationUserId:guid}")]
public async Task<IActionResult> GetUser(Guid applicationUserId, CancellationToken ct) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Schuly.Application.Authorization;
using Schuly.Application.Models;
using Schuly.Infrastructure;
using Schuly.Infrastructure.Services;
using Schuly.Infrastructure.Storage;

namespace Schuly.Application.Queries.Avatar
{
/// <summary>The own picture of one of the caller's school accounts; anyone else's is "not found".</summary>
[AllowAuthenticated]
public record GetSchoolAccountAvatarQuery(Guid SchoolUserId) : IQuery<Result<AvatarDownload>>;

public class GetSchoolAccountAvatarQueryHandler(SchulyDbContext db, IDocumentStorage storage, IUserService userService) : IQueryHandler<GetSchoolAccountAvatarQuery, Result<AvatarDownload>>
{
public async ValueTask<Result<AvatarDownload>> Handle(GetSchoolAccountAvatarQuery query, CancellationToken ct)
{
var userId = await userService.GetCurrentUserIdAsync(ct);
var key = await db.SchoolUsers
.AsNoTracking()
.Where(su => su.Id == query.SchoolUserId && su.ApplicationUserId == userId)
.Select(su => su.AccountPictureKey)
.SingleOrDefaultAsync(ct);

if (!BlobKeys.IsStored(key))
return Result<AvatarDownload>.Failure("No stored picture for this school account");

var stream = await storage.OpenReadAsync(key!, ct);
return Result<AvatarDownload>.Success(new AvatarDownload(stream));
}
}
}
2 changes: 2 additions & 0 deletions src/Schuly.Domain/SchoolUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class SchoolUser : Base
public required string LastName { get; set; }
public required string Email { get; set; }
public string? ProfilePictureUrl { get; set; }
/// <summary>Blob key of the account's own picture set by its plugin (e.g. the student ID card photo); shown only on the owner's account rows.</summary>
public string? AccountPictureKey { get; set; }


public required DateOnly EntryDate { get; set; }
Expand Down
Loading
Loading