appendix: 23 Code-Ausschnitte aus dem Houston-Repository
Pin nixpkgs to the revision used by itc.componentware: the channel version ships a latexminted that crashes on Python 3.14, which made every listing fail and sent latexmk into an endless error prompt. Also force nonstopmode/halt-on-error so a broken listing can never loop again.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
// Houston/Auth/HoustonRoles.cs
|
||||
public static readonly string DocumentsView = "Documents.View";
|
||||
|
||||
// Houston/Program.cs
|
||||
var viewDocumentsPolicy = "CanViewDocuments";
|
||||
|
||||
builder.Services.AddAuthorization(o =>
|
||||
{
|
||||
o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
|
||||
// ...
|
||||
o.AddPolicy(viewDocumentsPolicy, policy => policy.RequireRole(HoustonRoles.DocumentsView));
|
||||
});
|
||||
|
||||
builder.Services.AddRazorPages(o =>
|
||||
{
|
||||
// ...
|
||||
o.Conventions.AllowAnonymousToPage("/Document/Share");
|
||||
o.Conventions.AuthorizeFolder("/Document", viewDocumentsPolicy);
|
||||
o.Conventions.AuthorizePage("/Documents", viewDocumentsPolicy);
|
||||
});
|
||||
|
||||
// Houston/Services registrations
|
||||
builder.Services.AddOptions<DocumentsSettings>().BindConfiguration("Documents");
|
||||
builder.Services.AddDocumentsClient();
|
||||
builder.Services.AddScoped<DocumentsService>();
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Houston.Model.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Public URL id of a customer document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The identifier used in URLs (e.g. <c>/document/{id}/download</c> or <c>/document/{id}/share</c>)
|
||||
/// is the URL base64 encoding of the document path <em>without</em> the customer/organization
|
||||
/// folder. For an object stored at <c>OrgX/TypeY/FileZ.pdf</c> the id therefore encodes
|
||||
/// <c>TypeY/FileZ.pdf</c>. The organization part is never part of the id, so it can only ever be
|
||||
/// derived server side from the authenticated user.
|
||||
/// </remarks>
|
||||
public readonly record struct DocumentId(string Value)
|
||||
{
|
||||
public static DocumentId Encode(DocumentRelativePath relativePath)
|
||||
=> new(WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(relativePath.ToString())));
|
||||
|
||||
public bool TryDecode([NotNullWhen(true)] out DocumentRelativePath? relativePath)
|
||||
{
|
||||
relativePath = null;
|
||||
|
||||
if (String.IsNullOrEmpty(Value))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Value));
|
||||
return DocumentRelativePath.TryFromS3Key(decoded, out relativePath);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
relativePath = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
|
||||
namespace Houston.Model.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Full S3 object key of a customer document, including the customer/organization prefix.
|
||||
/// </summary>
|
||||
public readonly record struct DocumentKey(OrgSlug Org, DocumentType? Type, DocumentName Name)
|
||||
{
|
||||
public static bool TryFromS3Key(string key, [NotNullWhen(true)] out DocumentKey? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
if (key.EndsWith('/')) return false;
|
||||
|
||||
var parts = key.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Contains("..")) return false;
|
||||
|
||||
result = parts switch
|
||||
{
|
||||
[var org, var name] when OrgSlug.TryFromFolderName(org, out var slug)
|
||||
=> new(slug.Value, null, new(name)),
|
||||
[var org, var type, var name] when OrgSlug.TryFromFolderName(org, out var slug)
|
||||
=> new(slug.Value, DocumentTypeExtensions.ParseStorageFolderName(type), new(name)),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
StringBuilder result = new();
|
||||
result.Append(Org.ToString());
|
||||
if (Type is { } type) result.Append(type.StorageFolderName).Append('/');
|
||||
result.Append(Name);
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Houston.Model.Documents;
|
||||
|
||||
public readonly record struct DocumentName(string Value)
|
||||
{
|
||||
public bool Matches(string pattern)
|
||||
{
|
||||
return Value.Contains(pattern, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this document is a PDF and therefore eligible for the inline modal preview.
|
||||
/// </summary>
|
||||
public bool IsPdf => Value.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an external document link (a .URL file).
|
||||
/// </summary>
|
||||
public bool IsLink => Value.EndsWith(".url", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// File display name.
|
||||
/// </summary>
|
||||
public string DisplayName => IsLink ? Value[..^".url".Length] : Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
private async IAsyncEnumerable<DocumentKey> SearchDocumentsAsync(
|
||||
OrgSlug org,
|
||||
string? searchTerm,
|
||||
DocumentTypeSet documentTypes,
|
||||
bool includeUntypedDocuments,
|
||||
DocumentRelativePath? startAfter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var query = searchTerm?.Trim();
|
||||
|
||||
await foreach (var doc in ListDocumentsAsync(org, startAfter, cancellationToken))
|
||||
{
|
||||
if (!doc.Type.MatchesFilter(documentTypes, includeUntypedDocuments))
|
||||
continue;
|
||||
|
||||
if (!String.IsNullOrEmpty(query) && !doc.Name.Matches(query))
|
||||
continue;
|
||||
|
||||
yield return doc;
|
||||
}
|
||||
}
|
||||
|
||||
// Houston/Model/Documents/DocumentName.cs
|
||||
public bool Matches(string pattern)
|
||||
{
|
||||
return Value.Contains(pattern, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
// Houston/Model/Documents/DocumentType.cs
|
||||
public bool MatchesFilter(DocumentTypeSet documentTypes, bool includeUntypedDocuments)
|
||||
{
|
||||
if (documentTypes.IsEmpty && !includeUntypedDocuments)
|
||||
return true;
|
||||
|
||||
if (documentType is null)
|
||||
return includeUntypedDocuments;
|
||||
|
||||
return documentTypes.Types.Contains(documentType.Value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Globalization;
|
||||
using Houston.Resources;
|
||||
|
||||
namespace Houston.Model.Documents;
|
||||
|
||||
public enum DocumentType
|
||||
{
|
||||
ServiceProtocol,
|
||||
AcceptanceDocuments,
|
||||
SlaReportsTicketProcessing,
|
||||
MonitoringReports,
|
||||
SecurityAssessments,
|
||||
BillingData,
|
||||
ContractDocuments,
|
||||
}
|
||||
|
||||
public static class DocumentTypeExtensions
|
||||
{
|
||||
public const string OtherDocumentsDisplayName = "Sonstige Dokumente";
|
||||
public const string OtherDocumentsValue = "OtherDocuments";
|
||||
|
||||
public static string? FromStorageFolderName(string storageFolderName) =>
|
||||
APITranslation.ResourceManager.GetString(storageFolderName, CultureInfo.InvariantCulture);
|
||||
|
||||
public static DocumentType? ParseStorageFolderName(string storageFolderName)
|
||||
{
|
||||
var type = FromStorageFolderName(storageFolderName);
|
||||
if (!Enum.TryParse<DocumentType>(type, ignoreCase: true, out var variant))
|
||||
return null;
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
extension(DocumentType documentType)
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the S3 subfolder that holds documents of this type, i.e. the German translation.
|
||||
/// </summary>
|
||||
public string StorageFolderName =>
|
||||
APITranslation.ResourceManager.GetString(documentType.ToString(), CultureInfo.InvariantCulture)
|
||||
?? throw new NotImplementedException($"Missing translation for DocumentType '{documentType}'.");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<tbody>
|
||||
@foreach (var file in Model.Files)
|
||||
{
|
||||
<tr class="document-row" id="@Documents.DocumentViewId(file.Id)">
|
||||
<td>
|
||||
@if (Documents.IsDownloadable(file.Name))
|
||||
{
|
||||
<div class="form-check mb-0">
|
||||
<input type="checkbox" name="selected" value="@file.Id"
|
||||
class="form-check-input js-document-select"
|
||||
aria-label="@file.Name.DisplayName auswählen"/>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<i class="doc-type-icon bx-sm @file.IconClass" title="@file.Type.DisplayName"></i>
|
||||
</td>
|
||||
<td>@file.Name.DisplayName</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex">
|
||||
@if (file.Name.IsPdf)
|
||||
{
|
||||
<button type="button"
|
||||
class="btn btn-icon btn-text-secondary rounded-pill js-document-preview"
|
||||
data-preview-url="@Model.DocumentPreviewUrl(file.Id)"
|
||||
data-document-name="@file.Name.DisplayName"
|
||||
title="@file.Name.DisplayName als Vorschau öffnen"
|
||||
aria-label="@file.Name.DisplayName als Vorschau öffnen">
|
||||
<i class="bx bx-show-alt"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="btn btn-icon rounded-pill invisible" aria-hidden="true">
|
||||
<i class="bx bx-show-alt"></i>
|
||||
</span>
|
||||
}
|
||||
<button type="button"
|
||||
class="btn btn-icon btn-text-secondary rounded-pill js-document-share"
|
||||
data-share-url="@Model.DocumentShareUrl(file.Id)"
|
||||
title="Link zu @file.Name.DisplayName kopieren"
|
||||
aria-label="Link zu @file.Name.DisplayName kopieren">
|
||||
<i class="bx bx-share-alt"></i>
|
||||
</button>
|
||||
@if (file.Name.IsLink)
|
||||
{
|
||||
<a class="btn btn-icon btn-text-secondary rounded-pill"
|
||||
asp-page="/Document/Download" asp-route-id="@file.Id"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
title="@file.Name.DisplayName in neuem Tab öffnen" aria-label="@file.Name.DisplayName in neuem Tab öffnen">
|
||||
<i class="bx bx-download"></i>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-icon btn-text-secondary rounded-pill"
|
||||
asp-page="/Document/Download" asp-route-id="@file.Id"
|
||||
title="@file.Name.DisplayName herunterladen" aria-label="@file.Name.DisplayName herunterladen">
|
||||
<i class="bx bx-download"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Houston.Services;
|
||||
|
||||
public class IniParser
|
||||
{
|
||||
public async Task<IniDocument> ParseAsync(TextReader reader, CancellationToken cancellationToken)
|
||||
{
|
||||
var sections = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
sections[String.Empty] = current;
|
||||
|
||||
while (await reader.ReadLineAsync(cancellationToken) is { } line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip blank lines and comments
|
||||
if (trimmed.Length == 0 || trimmed[0] is ';' or '#')
|
||||
continue;
|
||||
|
||||
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
|
||||
{
|
||||
var name = trimmed[1..^1].Trim();
|
||||
if (!sections.TryGetValue(name, out current!))
|
||||
sections[name] = current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = trimmed.IndexOf('=');
|
||||
if (separator < 0)
|
||||
continue;
|
||||
|
||||
var key = trimmed[..separator].Trim();
|
||||
if (key.Length == 0)
|
||||
continue;
|
||||
|
||||
// Last value wins for duplicate keys within a section.
|
||||
current[key] = trimmed[(separator + 1)..].Trim();
|
||||
}
|
||||
|
||||
return new(sections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses INI content from a string.
|
||||
/// </summary>
|
||||
public Task<IniDocument> ParseAsync(string content, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StringReader(content);
|
||||
return ParseAsync(reader, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
private async IAsyncEnumerable<DocumentKey> ListDocumentsAsync(
|
||||
OrgSlug org,
|
||||
DocumentRelativePath? startAfter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var isFirstRequest = true;
|
||||
var requestBuilder = () =>
|
||||
{
|
||||
var r = new ListObjectsV2Request
|
||||
{
|
||||
Prefix = org.ToString(),
|
||||
StartAfter = isFirstRequest && startAfter is { } doc
|
||||
? new DocumentKey(org, doc.Type, doc.Name).ToString()
|
||||
: null,
|
||||
};
|
||||
|
||||
isFirstRequest = false;
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
await foreach (var obj in EnumerateObjectsAsync(requestBuilder, cancellationToken))
|
||||
if (DocumentKey.TryFromS3Key(obj.Key, out var key))
|
||||
yield return key.Value;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
|
||||
namespace Houston.Model.Documents;
|
||||
|
||||
public readonly record struct OrgSlug
|
||||
{
|
||||
public const int MaxLength = 128;
|
||||
|
||||
private const string CharactersToReplace = @"/\{}^%`][""><~#|";
|
||||
|
||||
private const char ReplacementCharacter = '-';
|
||||
|
||||
/// <summary>
|
||||
/// Characters that must not start or end a folder name, because they are either invisible or
|
||||
/// break path semantics on the client side.
|
||||
/// </summary>
|
||||
private static readonly char[] UntrimmableEdgeCharacters = [' ', '.', ReplacementCharacter];
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
private OrgSlug(string value) => Value = value;
|
||||
|
||||
public static bool TryFromS3Key(string key, [NotNullWhen(true)] out OrgSlug? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
if (!key.EndsWith('/')) return false;
|
||||
|
||||
var parts = key.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return parts is [var name] && TryFromFolderName(name, out result);
|
||||
}
|
||||
|
||||
public static bool TryFromFolderName(string name, [NotNullWhen(true)] out OrgSlug? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
if (String.IsNullOrWhiteSpace(name)) return false;
|
||||
if (name.Contains('/')) return false;
|
||||
if (name == "..") return false;
|
||||
|
||||
result = new OrgSlug(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical folder name for an organization.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>false</c> if the name contains nothing that survives the normalization, in which case
|
||||
/// there is no folder name Houston could safely claim.
|
||||
/// </returns>
|
||||
public static bool TryFrom(OrgName name, [NotNullWhen(true)] out OrgSlug? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
var slug = Normalize(name.Value, MaxLength);
|
||||
if (slug.Length == 0)
|
||||
return false;
|
||||
|
||||
result = new OrgSlug(slug);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryFrom(OrgName name, OrgId id, [NotNullWhen(true)] out OrgSlug? result)
|
||||
{
|
||||
result = null;
|
||||
|
||||
var normalizedId = Normalize(id.Value, MaxLength);
|
||||
if (normalizedId.Length == 0)
|
||||
return false;
|
||||
|
||||
// The id is what makes the folder name unique, so the name is what gets truncated.
|
||||
var suffix = $" ({normalizedId})";
|
||||
if (suffix.Length >= MaxLength)
|
||||
return false;
|
||||
|
||||
var slug = Normalize(name.Value, MaxLength - suffix.Length);
|
||||
|
||||
result = new OrgSlug(slug.Length == 0 ? normalizedId : slug + suffix);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, int maxLength)
|
||||
{
|
||||
var builder = new StringBuilder(Math.Min(value.Length, maxLength));
|
||||
|
||||
foreach (var character in value.Trim().TakeWhile(c => builder.Length != maxLength))
|
||||
{
|
||||
builder.Append(Char.IsControl(character) || CharactersToReplace.Contains(character)
|
||||
? ReplacementCharacter
|
||||
: character);
|
||||
}
|
||||
|
||||
// Truncating can uncover a trailing space or dot, so trimming happens afterwards.
|
||||
return builder.ToString().Trim().Trim(UntrimmableEdgeCharacters).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S3 prefix of the folder, including the trailing slash.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
// A default(OrgSlug) would resolve to the bucket root and expose every customer's
|
||||
// documents, so it must never be used as a prefix.
|
||||
if (String.IsNullOrEmpty(Value))
|
||||
throw new InvalidOperationException("An uninitialized OrgSlug cannot be used as an S3 prefix.");
|
||||
|
||||
return Value + '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
private async IAsyncEnumerable<ListObjectsV2Response> EnumerateResponsesAsync(
|
||||
Func<ListObjectsV2Request> requestBuilder,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
string? continuationToken = null;
|
||||
do
|
||||
{
|
||||
var request = requestBuilder();
|
||||
request.ContinuationToken = continuationToken;
|
||||
request.BucketName = _settings.S3.Bucket;
|
||||
|
||||
var response = await s3.ListObjectsV2Async(request, cancellationToken);
|
||||
yield return response;
|
||||
|
||||
continuationToken = response.IsTruncated == true ? response.NextContinuationToken : null;
|
||||
} while (continuationToken is not null);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<S3Object> EnumerateObjectsAsync(
|
||||
Func<ListObjectsV2Request> requestBuilder,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var response in EnumerateResponsesAsync(requestBuilder, cancellationToken))
|
||||
{
|
||||
foreach (var obj in response.S3Objects ?? [])
|
||||
yield return obj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
private async Task<string?> GetPreSignedUrlAsync(DocumentKey key, bool inline, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await ObjectExistsAsync(key, cancellationToken))
|
||||
return null;
|
||||
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = _settings.S3.Bucket,
|
||||
Key = key.ToString(),
|
||||
Verb = HttpVerb.GET,
|
||||
Expires = DateTime.UtcNow.Add(DownloadUrlLifetime),
|
||||
ResponseHeaderOverrides =
|
||||
{
|
||||
ContentDisposition = inline
|
||||
? InlineContentDisposition(key.Name.ToString())
|
||||
: AttachmentContentDisposition(key.Name.ToString()),
|
||||
},
|
||||
};
|
||||
|
||||
return await s3.GetPreSignedURLAsync(request);
|
||||
}
|
||||
|
||||
private static string AttachmentContentDisposition(string fileName)
|
||||
{
|
||||
var header = new ContentDispositionHeaderValue("attachment");
|
||||
header.SetHttpFileName(fileName);
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c>Content-Disposition</c> header that lets the browser render the document inline
|
||||
/// while preserving the file name, using RFC 5987 encoding so non ASCII file names survive.
|
||||
/// </summary>
|
||||
private static string InlineContentDisposition(string fileName)
|
||||
{
|
||||
var asciiFallback = new string(fileName.Select(c => c is >= ' ' and < (char)127 and not '"' ? c : '_').ToArray());
|
||||
var encoded = Uri.EscapeDataString(fileName);
|
||||
return $"inline; filename=\"{asciiFallback}\"; filename*=UTF-8''{encoded}";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// <summary>
|
||||
/// todo: this function can currently cause dataloss. see #10070
|
||||
/// </summary>
|
||||
/// <returns>success</returns>
|
||||
private async Task<bool> RenameCustomerFolderAsync(
|
||||
OrgSlug source, OrgSlug destination, OrgId owner, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source == destination)
|
||||
return true;
|
||||
|
||||
if (!await CanClaimCustomerFolderAsync(destination, owner, cancellationToken))
|
||||
return false;
|
||||
|
||||
var sourcePrefix = source.ToString();
|
||||
var destinationPrefix = destination.ToString();
|
||||
|
||||
var sourceKeys = await ListKeysAsync(sourcePrefix, cancellationToken);
|
||||
|
||||
// The marker of the customer folder carries the efecte-org-id, so it is copied last and
|
||||
// deleted first: the destination only becomes resolvable once it is complete. Everything
|
||||
// below the customer folder keeps its relative key, including subfolders Houston does not
|
||||
// know, so no document ever ends up somewhere else.
|
||||
var documentKeys = sourceKeys.Where(key => key != sourcePrefix).ToList();
|
||||
var markerKeys = sourceKeys.Where(key => key == sourcePrefix).ToList();
|
||||
var copiedKeys = new List<string>(sourceKeys.Count);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var key in documentKeys.Concat(markerKeys))
|
||||
{
|
||||
var destinationKey = destinationPrefix + key[sourcePrefix.Length..];
|
||||
await CopyObjectAsync(key, destinationKey, cancellationToken);
|
||||
copiedKeys.Add(destinationKey);
|
||||
}
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
ExceptionlessClient.Default.SubmitLog(
|
||||
$"Could not move customer folder '{source}' to '{destination}': {exception.Message}. "
|
||||
+ "The folder stays where it is.", LogLevel.Error);
|
||||
|
||||
// Leftover copies would block every later attempt, because the destination is only
|
||||
// claimed while it is empty. Cleaning them up keeps the move retryable.
|
||||
await TryDeleteObjectsAsync(copiedKeys, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
// From here on the destination is authoritative. A failure while deleting only leaves
|
||||
// garbage behind, the customer already sees all of his documents under the new name.
|
||||
await TryDeleteObjectsAsync(documentKeys, cancellationToken);
|
||||
await TryDeleteObjectsAsync(markerKeys, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
private async Task<OrgSlug?> GetCustomerFolderAsync(OrgName name, OrgId id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OrgSlug.TryFrom(name, out var canonicalSlug) || !OrgSlug.TryFrom(name, id, out var collisionSlug))
|
||||
{
|
||||
ExceptionlessClient.Default.SubmitLog($"Efecte name '{name}' of organization {id} does not yield a usable folder name.", LogLevel.Warn);
|
||||
return null;
|
||||
}
|
||||
|
||||
var (prettySlug, uniqueSlug) = (canonicalSlug.Value, collisionSlug.Value);
|
||||
|
||||
// Fast path: pretty slug exists & meta-id matches => return slug(name)
|
||||
var prettyState = await InspectCustomerFolderAsync(prettySlug, id, cancellationToken);
|
||||
if (prettyState is CustomerFolderState.Owned)
|
||||
return prettySlug;
|
||||
|
||||
// Conflict resolution: pretty slug exists & meta-id does not match => return slug(name,id)
|
||||
var uniqueState = await InspectCustomerFolderAsync(uniqueSlug, id, cancellationToken);
|
||||
if (uniqueState is CustomerFolderState.Owned)
|
||||
return uniqueSlug;
|
||||
|
||||
// State cleanup:
|
||||
// Either the folder does not have the name it should have or doesn't exist, so the bucket has to be scanned. And
|
||||
// if necessary and possible, renamed such that the next lookup hits one of the expected slugs.
|
||||
|
||||
OrgSlug? availableSlug =
|
||||
prettyState is CustomerFolderState.Missing ? prettySlug
|
||||
: uniqueState is CustomerFolderState.Missing ? uniqueSlug
|
||||
: null;
|
||||
|
||||
var currentSlug = await SearchCustomerPrefixAsync(id, cancellationToken);
|
||||
|
||||
if (currentSlug is null)
|
||||
{
|
||||
if (availableSlug is not { } available)
|
||||
{
|
||||
ExceptionlessClient.Default.SubmitLog(
|
||||
$"Organization {id} has no customer folder and neither "
|
||||
+ $"'{prettySlug}' nor '{uniqueSlug}' is available.", LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await CreateCustomerFolderAsync(available, id, cancellationToken))
|
||||
return null;
|
||||
|
||||
if (await InspectCustomerFolderAsync(available, id, cancellationToken) is not CustomerFolderState.Owned)
|
||||
{
|
||||
ExceptionlessClient.Default.SubmitLog(
|
||||
$"Organization {id} has no customer folder and "
|
||||
+ $"'{available}' could not be created.", LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
if (availableSlug is null || availableSlug.Value == currentSlug.Value)
|
||||
return currentSlug;
|
||||
|
||||
// A failed move leaves every document under the name it already had, so that name is what
|
||||
// gets handed back. The next lookup simply tries the move again.
|
||||
return await RenameCustomerFolderAsync(currentSlug.Value, availableSlug.Value, id, cancellationToken)
|
||||
? availableSlug
|
||||
: currentSlug;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Houston.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Houston.Extensions;
|
||||
|
||||
public static class DocumentsExtensions
|
||||
{
|
||||
extension(IServiceCollection services)
|
||||
{
|
||||
public IServiceCollection AddDocumentsClient()
|
||||
{
|
||||
return services.AddKeyedScoped<IAmazonS3>("Documents", (services, _) =>
|
||||
{
|
||||
var settings = services.GetRequiredService<IOptions<DocumentsSettings>>();
|
||||
var client = new AmazonS3Client(
|
||||
new BasicAWSCredentials(settings.Value.S3.Key, settings.Value.S3.Secret),
|
||||
new AmazonS3Config { ServiceURL = settings.Value.S3.Endpoint });
|
||||
|
||||
// The SDK percent-encodes the separators in x-amz-copy-source ("bucket%2Fkey%2Fdoc.pdf").
|
||||
// AWS decodes that again, our S3 splits bucket and key on the first literal slash and ends up
|
||||
// without a key ("Invalid copy source object key"), so the separators are restored here. The
|
||||
// event runs before the signer, hence the corrected value is the one that gets signed.
|
||||
client.BeforeRequestEvent += (_, args) =>
|
||||
{
|
||||
if (args is WebServiceRequestEventArgs { Headers: { } headers }
|
||||
&& headers.TryGetValue("x-amz-copy-source", out var copySource)
|
||||
&& copySource.Contains("%2F", StringComparison.Ordinal))
|
||||
{
|
||||
headers["x-amz-copy-source"] = copySource.Replace("%2F", "/", StringComparison.Ordinal);
|
||||
}
|
||||
};
|
||||
|
||||
return client;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Houston.Settings;
|
||||
|
||||
public class DocumentsSettings
|
||||
{
|
||||
public required S3Settings S3 { get; set; }
|
||||
}
|
||||
|
||||
namespace Houston.Settings;
|
||||
|
||||
public class S3Settings
|
||||
{
|
||||
public required string Endpoint { get; set; }
|
||||
public required string Key { get; set; }
|
||||
public required string Secret { get; set; }
|
||||
public required string Bucket { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Houston.Model.Documents;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Houston.Pages.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous landing page for shared document links. It exposes only metadata derived from the
|
||||
/// document id and never fetches or renders file contents.
|
||||
/// </summary>
|
||||
public class Share : PageModel
|
||||
{
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string Id { get; set; } = String.Empty;
|
||||
|
||||
public string Title { get; private set; } = "Dokument";
|
||||
|
||||
public string ImageUrl { get; private set; } = String.Empty;
|
||||
|
||||
public string ExplorerUrl { get; private set; } = String.Empty;
|
||||
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
var docId = new DocumentId(Id);
|
||||
|
||||
if (!docId.TryDecode(out var relativePath))
|
||||
return NotFound();
|
||||
|
||||
var type = relativePath.Value.Type;
|
||||
var name = relativePath.Value.Name;
|
||||
var iconPath = type.PreviewPath;
|
||||
|
||||
Title = name.DisplayName;
|
||||
ImageUrl = AbsoluteUrl(iconPath);
|
||||
ExplorerUrl = AbsoluteUrl("/documents", BuildExplorerQuery(type, name), $"#{Documents.DocumentViewId(docId)}");
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
private static QueryString BuildExplorerQuery(DocumentType? type, DocumentName name)
|
||||
{
|
||||
return new QueryBuilder {
|
||||
{ "q", name.DisplayName },
|
||||
{ "types", type.Name }
|
||||
}.ToQueryString();
|
||||
}
|
||||
|
||||
private string AbsoluteUrl(string path, QueryString query = default, string? fragment = null)
|
||||
{
|
||||
return UriHelper.BuildAbsolute(
|
||||
Request.Scheme,
|
||||
Request.Host,
|
||||
Request.PathBase,
|
||||
path,
|
||||
query,
|
||||
fragment: fragment is null ? default : new FragmentString(fragment)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[Theory]
|
||||
[InlineData("../Kunde2/secret.pdf")]
|
||||
[InlineData("sub/../../Kunde2/secret.pdf")]
|
||||
public void DocumentIdTryParseRejectsPathTraversal(string relativePath)
|
||||
{
|
||||
Assert.False(new DocumentId(UnsafeDocumentIdValue(relativePath)).TryDecode(out _));
|
||||
}
|
||||
|
||||
|
||||
// Test-Hilfsfunktion: erzeugt bewusst eine ID, die die Validierung umgeht.
|
||||
private static string UnsafeDocumentIdValue(string relativePath)
|
||||
=> WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(relativePath));
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
/// <summary>
|
||||
/// A bucket without a single object: every lookup answers with a 404 and every listing is empty
|
||||
/// until a test puts something in via <see cref="GiveFolder"/>, <see cref="GiveTopLevelFolders"/>
|
||||
/// or <see cref="GiveObjects"/>.
|
||||
/// </summary>
|
||||
private static IAmazonS3 EmptyBucket()
|
||||
{
|
||||
var s3 = Substitute.For<IAmazonS3>();
|
||||
|
||||
// Objects written during the test become visible to later lookups, just like in a real bucket.
|
||||
var writtenObjects = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
s3.GetObjectMetadataAsync(Arg.Any<GetObjectMetadataRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
var key = call.Arg<GetObjectMetadataRequest>().Key;
|
||||
|
||||
if (!writtenObjects.TryGetValue(key, out var owner))
|
||||
throw new AmazonS3Exception("not found") { StatusCode = System.Net.HttpStatusCode.NotFound };
|
||||
|
||||
return owner is null ? new GetObjectMetadataResponse() : MetadataResponse(owner);
|
||||
});
|
||||
|
||||
s3.ListObjectsV2Async(Arg.Any<ListObjectsV2Request>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => new ListObjectsV2Response { IsTruncated = false });
|
||||
|
||||
s3.CopyObjectAsync(Arg.Any<CopyObjectRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => new CopyObjectResponse());
|
||||
|
||||
s3.PutObjectAsync(Arg.Any<PutObjectRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
var request = call.Arg<PutObjectRequest>();
|
||||
writtenObjects[request.Key] = request.Metadata["efecte-org-id"];
|
||||
return new PutObjectResponse();
|
||||
});
|
||||
|
||||
s3.DeleteObjectsAsync(Arg.Any<DeleteObjectsRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => new DeleteObjectsResponse());
|
||||
|
||||
return s3;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.doc-type-icon {
|
||||
display: inline-flex;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@each $name in (
|
||||
'service-protokoll',
|
||||
'abnahme-dokumente',
|
||||
'sla-reports',
|
||||
'monitoring-reports',
|
||||
'security-assessments',
|
||||
'abrechnungsdaten',
|
||||
'vertragsunterlagen',
|
||||
'default',
|
||||
'ms-sharepoint',
|
||||
'ms-onedrive',
|
||||
'ms-teams',
|
||||
) {
|
||||
.doc-type-icon-#{$name} {
|
||||
background-color: currentColor;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('/img/document-types/icons/#{$name}.svg');
|
||||
@@ -0,0 +1,43 @@
|
||||
public async Task<string?> TryGetUrlFileIconClassAsync(DocumentKey doc, CancellationToken cancellationToken)
|
||||
{
|
||||
var ini = await TryReadUrlFileAsync(doc, cancellationToken);
|
||||
var iconFile = ini?.GetValue("InternetShortcut", "IconFile");
|
||||
|
||||
return String.IsNullOrEmpty(iconFile) ? null : iconFile;
|
||||
}
|
||||
|
||||
private async Task<string?> TryGetUrlFileTargetAsync(DocumentKey doc, CancellationToken cancellationToken)
|
||||
{
|
||||
var ini = await TryReadUrlFileAsync(doc, cancellationToken);
|
||||
var target = ini?.GetValue("InternetShortcut", "URL");
|
||||
|
||||
if (String.IsNullOrWhiteSpace(target))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(target, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
if (!(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
return null;
|
||||
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private async Task<IniDocument?> TryReadUrlFileAsync(DocumentKey doc, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await s3.GetObjectAsync(new GetObjectRequest
|
||||
{
|
||||
BucketName = _settings.S3.Bucket,
|
||||
Key = doc.ToString(),
|
||||
}, cancellationToken);
|
||||
|
||||
using var reader = new StreamReader(response.ResponseStream);
|
||||
return await iniParser.ParseAsync(reader, cancellationToken);
|
||||
}
|
||||
catch (AmazonS3Exception exception) when (exception.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <summary>
|
||||
/// Streams the prepared documents into a ZIP archive written directly onto <paramref name="destination"/>
|
||||
/// </summary>
|
||||
public async Task WriteZipArchiveAsync(
|
||||
OrgSlug orgSlug, IEnumerable<DocumentRelativePath> documents,
|
||||
Stream destination, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var zip = await ZipArchive.CreateAsync(
|
||||
destination, ZipArchiveMode.Create, leaveOpen: true,
|
||||
entryNameEncoding: null, cancellationToken);
|
||||
|
||||
foreach (var path in documents)
|
||||
{
|
||||
var key = new DocumentKey(orgSlug, path.Type, path.Name);
|
||||
|
||||
using var response = await TryGetObjectAsync(new GetObjectRequest
|
||||
{
|
||||
BucketName = _settings.S3.Bucket,
|
||||
Key = key.ToString(),
|
||||
}, cancellationToken);
|
||||
|
||||
if (response is null)
|
||||
continue;
|
||||
|
||||
var entry = zip.CreateEntry(path.ToString(), CompressionLevel.Optimal);
|
||||
await using var entryStream = await entry.OpenAsync(cancellationToken);
|
||||
await response.ResponseStream.CopyToAsync(entryStream, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GetObjectResponse?> TryGetObjectAsync(GetObjectRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await s3.GetObjectAsync(request, cancellationToken);
|
||||
}
|
||||
catch (AmazonS3Exception exception) when (exception.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
public async Task<IActionResult> OnPostDownloadZipAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OrgName.TryCreate(User.GetCompanyName(), out var orgName))
|
||||
return NotFound();
|
||||
|
||||
var fallback = RedirectToPage(new
|
||||
{
|
||||
q = Search,
|
||||
types = TypeFilterSelection,
|
||||
pageSize = PageSize,
|
||||
pageToken = PageToken,
|
||||
});
|
||||
|
||||
if (SelectedDocuments is null || SelectedDocuments.Count == 0)
|
||||
return fallback;
|
||||
|
||||
var relativePaths = SelectedDocuments
|
||||
.SelectWhere<string, DocumentRelativePath>(x => new DocumentId(x).TryDecode(out var y) ? y.Value : null)
|
||||
.Where(x => IsDownloadable(x.Name))
|
||||
.ToList();
|
||||
|
||||
if (relativePaths.Count == 0)
|
||||
return fallback;
|
||||
|
||||
var orgSlug = await documents.ResolveCustomerPrefixAsync(OrgId, orgName.Value, cancellationToken);
|
||||
if (orgSlug is null)
|
||||
return fallback;
|
||||
|
||||
// Zipping compresses each entry with a DeflateStream, which flushes its buffers synchronously
|
||||
// when the entry is closed. Kestrel forbids synchronous response writes by default, so we opt
|
||||
// in for this streamed response only. Nothing large is written synchronously - the document
|
||||
// payloads are still copied with async IO.
|
||||
var bodyControl = HttpContext.Features.Get<IHttpBodyControlFeature>();
|
||||
bodyControl?.AllowSynchronousIO = true;
|
||||
|
||||
Response.ContentType = "application/zip";
|
||||
Response.Headers.ContentDisposition = $"attachment; filename=\"{DocumentsService.ZipDownloadFileName}\"";
|
||||
|
||||
await documents.WriteZipArchiveAsync(orgSlug.Value, relativePaths, Response.Body, cancellationToken);
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
Reference in New Issue
Block a user