Skip to content

Commit ecc90be

Browse files
raboergoomens
andauthored
DN-3376 feat: file upload (#14)
* DN-3376 feat: file upload * Added delete and some refactoring * Persit file changes correctly * Fixed file upload and delete * Fxied deletion of file * Made URL and Verbs to more restful * Added get file and some refactorings * Some small refactorings * Added support for tokenbased artifact auth * Small refactor --------- Co-authored-by: Gerrit Oomens <g.oomens@uva.nl>
1 parent 5c7f848 commit ecc90be

25 files changed

+646
-418
lines changed

UvA.Workflow.Api/Actions/ActionsController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class ActionsController(
1010
RightsService rightsService,
1111
TriggerService triggerService,
1212
ContextService contextService,
13-
WorkflowInstanceDtoService dtoService) : ApiControllerBase
13+
WorkflowInstanceDtoFactory workflowInstanceDtoFactory) : ApiControllerBase
1414
{
1515
[HttpPost]
1616
public async Task<ActionResult<ExecuteActionPayloadDto>> ExecuteAction([FromBody] ExecuteActionInputDto input, CancellationToken ct)
@@ -43,7 +43,7 @@ public async Task<ActionResult<ExecuteActionPayloadDto>> ExecuteAction([FromBody
4343

4444
return Ok(new ExecuteActionPayloadDto(
4545
input.Type,
46-
input.Type == ActionType.DeleteInstance ? null : await dtoService.Create(instance, ct)
46+
input.Type == ActionType.DeleteInstance ? null : await workflowInstanceDtoFactory.Create(instance, ct)
4747
));
4848
}
4949
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Text;
3+
using Microsoft.IdentityModel.Tokens;
4+
using UvA.Workflow.Persistence;
5+
6+
namespace UvA.Workflow.Api.Infrastructure;
7+
8+
public class ArtifactTokenService(IConfiguration config)
9+
{
10+
private const string ResourceType = "artefact";
11+
private const string TokenIssuer = "workflow";
12+
private readonly SymmetricSecurityKey signingKey = new(Encoding.ASCII.GetBytes(config["FileKey"]!));
13+
14+
public string CreateAccessToken(ArtifactInfo artifactInfo)
15+
{
16+
var claims = new Dictionary<string, object>
17+
{
18+
["id"] = artifactInfo.Id.ToString(),
19+
["type"] = ResourceType
20+
};
21+
22+
var handler = new JwtSecurityTokenHandler();
23+
return handler.CreateEncodedJwt(new SecurityTokenDescriptor
24+
{
25+
Expires = DateTime.UtcNow.AddHours(1),
26+
Issuer = TokenIssuer,
27+
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512Signature),
28+
Claims = claims
29+
});
30+
}
31+
32+
public async Task<bool> ValidateAccessToken(string artifactId, string token)
33+
{
34+
if (string.IsNullOrWhiteSpace(token) ||
35+
string.IsNullOrWhiteSpace(artifactId))
36+
return false;
37+
var handler = new JwtSecurityTokenHandler();
38+
var result = await handler.ValidateTokenAsync(token, new TokenValidationParameters
39+
{
40+
IssuerSigningKey = signingKey,
41+
ValidateIssuer = true,
42+
ValidateAudience = false,
43+
ValidIssuer = TokenIssuer
44+
});
45+
return result.IsValid
46+
&& result.Claims["id"]?.ToString() == artifactId
47+
&& result.Claims["type"]?.ToString() == ResourceType;
48+
}
49+
}

UvA.Workflow.Api/Infrastructure/GlobalExceptionHandler.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ public async ValueTask<bool> TryHandleAsync(
1919

2020
var (statusCode, code, message) = exception switch
2121
{
22+
EntityNotFoundException enf => (HttpStatusCode.NotFound, enf.Code, enf.Message),
23+
ForbiddenWorkflowActionException fwae => (HttpStatusCode.Forbidden, fwae.Code, fwae.Message),
24+
InvalidWorkflowStateException iwse => (HttpStatusCode.UnprocessableEntity, iwse.Code, iwse.Message),
2225
WorkflowException wfe => (HttpStatusCode.InternalServerError, wfe.Code, wfe.Message),
2326
KeyNotFoundException => (HttpStatusCode.NotFound, "NotFound", "Resource not found"),
2427
ArgumentException => (HttpStatusCode.BadRequest, "InvalidInput", "Invalid input provided"),

UvA.Workflow.Api/Infrastructure/ServiceCollectionExtentions.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
using UvA.Workflow.Api.Submissions;
2+
using UvA.Workflow.Api.Submissions.Dtos;
13
using UvA.Workflow.Infrastructure.Database;
2-
using UvA.Workflow.Infrastructure.Persistence;
4+
using UvA.Workflow.Persistence;
5+
using UvA.Workflow.Submissions;
36

47
namespace UvA.Workflow.Api.Infrastructure;
58

@@ -28,8 +31,12 @@ public static IServiceCollection AddWorkflow(this IServiceCollection services, I
2831
services.AddScoped<IUserService, MockUserService>();
2932
services.AddSingleton<ModelService>();
3033

31-
services.AddScoped<FileClient>();
32-
services.AddScoped<FileService>();
34+
services.AddScoped<ArtifactService>();
35+
services.AddScoped<AnswerService>();
36+
services.AddScoped<SubmissionService>();
37+
services.AddScoped<ArtifactTokenService>();
38+
services.AddScoped<SubmissionDtoFactory>();
39+
services.AddScoped<AnswerDtoFactory>();
3340

3441
services.AddScoped<ContextService>();
3542
services.AddScoped<InstanceService>();

UvA.Workflow.Api/Program.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Serilog;
33
using UvA.Workflow.Api.Infrastructure;
44
using UvA.Workflow.Api.WorkflowInstances;
5+
using UvA.Workflow.Api.WorkflowInstances.Dtos;
56

67
string corsPolicyName = "_CorsPolicy";
78

@@ -25,12 +26,14 @@
2526

2627
var config = builder.Configuration;
2728
config.AddJsonFile("appsettings.local.json", true, true);
28-
2929
builder.Services.AddWorkflow(config);
30-
builder.Services.AddScoped<WorkflowInstanceDtoService>();
30+
builder.Services.AddScoped<WorkflowInstanceDtoFactory>();
3131
builder.Services
3232
.AddControllers()
33-
.AddJsonOptions(opts => opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
33+
.AddJsonOptions(opts =>
34+
{
35+
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
36+
});
3437
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
3538
builder.Services.AddProblemDetails();
3639

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using UvA.Workflow.Api.Infrastructure;
2+
using UvA.Workflow.Api.Submissions.Dtos;
3+
using UvA.Workflow.Infrastructure;
4+
using UvA.Workflow.Submissions;
5+
6+
namespace UvA.Workflow.Api.Submissions;
7+
8+
public class AnswersController(AnswerService answerService, RightsService rightsService, ArtifactTokenService artifactTokenService, SubmissionDtoFactory submissionDtoFactory) : ApiControllerBase
9+
{
10+
[HttpPost("{instanceId}/{submissionId}/{questionName}")]
11+
public async Task<ActionResult<SaveAnswerResponse>> SaveAnswer(string instanceId, string submissionId,string questionName,
12+
[FromBody] AnswerInput input, CancellationToken ct)
13+
{
14+
var context = await answerService.GetQuestionContext(instanceId, submissionId, questionName, ct);
15+
await EnsureAuthorizedToEdit(context);
16+
var answers = await answerService.SaveAnswer(context, input.Value, ct);
17+
var updatedSubmission = submissionDtoFactory.Create(context.Instance,context.Form, context.Submission);
18+
return Ok(new SaveAnswerResponse(true, answers, updatedSubmission));
19+
}
20+
21+
[HttpPost("{instanceId}/{submissionId}/{questionName}/artifacts")]
22+
[Consumes("multipart/form-data")]
23+
[Produces("application/json")]
24+
public async Task<ActionResult<SaveAnswerResponse>> SaveAnswerFile(string instanceId, string submissionId, string questionName,
25+
[FromForm] SaveAnswerFileRequest request, CancellationToken ct)
26+
{
27+
var context = await answerService.GetQuestionContext(instanceId, submissionId, questionName, ct);
28+
await EnsureAuthorizedToEdit(context);
29+
await using var contents = request.File.OpenReadStream();
30+
await answerService.SaveArtifact(context, request.File.FileName, contents, ct);
31+
return Ok(new SaveAnswerFileResponse(true));
32+
}
33+
34+
[HttpDelete("{instanceId}/{submissionId}/{questionName}/artifacts/{artifactId}")]
35+
public async Task<IActionResult> DeleteAnswerFile(string instanceId, string submissionId, string questionName, string artifactId, CancellationToken ct)
36+
{
37+
var context = await answerService.GetQuestionContext(instanceId, submissionId, questionName, ct);
38+
await EnsureAuthorizedToEdit(context);
39+
await answerService.DeleteArtifact(context, artifactId, ct);
40+
return Ok(new SaveAnswerFileResponse(true));
41+
}
42+
43+
[HttpGet("{instanceId}/{submissionId}/{questionName}/artifacts/{artifactId}")]
44+
public async Task<IActionResult> GetAnswerFile(string instanceId, string submissionId, string questionName,
45+
string artifactId,[FromQuery] string token, CancellationToken ct)
46+
{
47+
if (!await artifactTokenService.ValidateAccessToken(artifactId, token))
48+
{
49+
await Task.Delay(TimeSpan.FromMilliseconds(100), ct);
50+
return Unauthorized();
51+
}
52+
53+
var context = await answerService.GetQuestionContext(instanceId, submissionId, questionName, ct);
54+
var file = await answerService.GetArtifact(context, artifactId, ct);
55+
if(file == null) return NotFound();
56+
return File(file.Content, "application/pdf", file.Info.Name);
57+
}
58+
59+
private async Task EnsureAuthorizedToEdit(QuestionContext context)
60+
{
61+
var action = context.Submission?.Date == null ? RoleAction.Submit : RoleAction.Edit;
62+
if (!await rightsService.Can(context.Instance, action, context.Form.Name))
63+
throw new ForbiddenWorkflowActionException(context.Instance.Id, action, context.Form.Name);
64+
}
65+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using UvA.Workflow.Api.Infrastructure;
4+
using UvA.Workflow.Submissions;
5+
6+
namespace UvA.Workflow.Api.Submissions.Dtos;
7+
8+
public record ArtifactReference(string Id, string Name, string AccessToken);
9+
10+
public record AnswerDto(
11+
string Id,
12+
string QuestionName,
13+
string FormName,
14+
string EntityType,
15+
bool IsVisible,
16+
BilingualString? ValidationError = null,
17+
JsonElement? Value = null,
18+
ArtifactReference[]? Files = null,
19+
string[]? VisibleChoices = null
20+
);
21+
22+
public class AnswerDtoFactory(ArtifactTokenService artifactTokenService)
23+
{
24+
public AnswerDto Create(Answer answer)
25+
{
26+
ArtifactReference[]? files = null;
27+
if (answer.Files != null && answer.Files.Length != 0)
28+
{
29+
files = answer.Files
30+
.Select(f => new ArtifactReference(f.Id.ToString(), f.Name,WebUtility.UrlEncode(artifactTokenService.CreateAccessToken(f))))
31+
.ToArray();
32+
}
33+
return new AnswerDto(answer.Id, answer.QuestionName, answer.FormName, answer.EntityType, answer.IsVisible, answer.ValidationError, answer.Value, files, answer.VisibleChoices);
34+
}
35+
36+
}
Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
namespace UvA.Workflow.Api.Submissions.Dtos;
2-
3-
public record FileUpload(
4-
string FileName,
5-
string ContentBase64);
1+
using UvA.Workflow.Submissions;
62

7-
public record SaveAnswerRequest(
8-
string InstanceId,
9-
string SubmissionId,
10-
AnswerInput Answer);
3+
namespace UvA.Workflow.Api.Submissions.Dtos;
114

125
public record SaveAnswerResponse(
136
bool Success,
147
Answer[] Answers,
158
SubmissionDto Submission,
16-
string? ErrorMessage = null);
9+
string? ErrorMessage = null);
10+
11+
public class SaveAnswerFileRequest
12+
{
13+
[FromForm]
14+
public required IFormFile File { get; set; }
15+
}
16+
17+
public record SaveAnswerFileResponse(
18+
bool Success,
19+
string? ErrorMessage = null);

0 commit comments

Comments
 (0)