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
19 changes: 19 additions & 0 deletions Ticky.Base/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,32 @@ public static class Limits
public const int DEBOUNCE_TIME_IN_MS = 1000;
}

public static class Attachments
{
public const string AVIF_FILE_EXTENSION = ".avif";
public const string AVIF_CONTENT_TYPE = "image/avif";

public static readonly HashSet<string> IMAGE_FILE_EXTENSIONS =
new(StringComparer.OrdinalIgnoreCase)
{
AVIF_FILE_EXTENSION,
".bmp",
".gif",
".jpeg",
".jpg",
".png",
".webp"
};
}

public static class Mappings
{
public const string LOGIN_PATH = "/auth/login";
public const string LOGOUT_PATH = "/auth/logout";
public const string BOARD_PATH = "/boards";
public const string ATTACHMENTS_API_PATH = "/api/attachments";
public const string ATTACHMENTS_DOWNLOAD_PATH = ATTACHMENTS_API_PATH + "/download";
public const string ATTACHMENTS_PREVIEW_PATH = ATTACHMENTS_API_PATH + "/preview";
}

public static class Policies
Expand Down
10 changes: 7 additions & 3 deletions Ticky.Internal/Helpers/AttachmentHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

public static class AttachmentHelper
{
public static bool IsImage(Attachment attachment)
{
var extension = Path.GetExtension(attachment.OriginalName);
return Constants.Attachments.IMAGE_FILE_EXTENSIONS.Contains(extension);
}

public static string GetFileTypeFromAttachment(Attachment attachment)
{
if (attachment.OriginalName.Contains(".doc"))
return "DOC";
else if (
attachment.OriginalName.Contains(".jpg") || attachment.OriginalName.Contains(".png")
)
else if (IsImage(attachment))
return "Image";
else if (attachment.OriginalName.Contains(".pdf"))
return "PDF";
Expand Down
67 changes: 53 additions & 14 deletions Ticky.Web/Components/Dialogs/EditCardModal.razor
Original file line number Diff line number Diff line change
Expand Up @@ -265,27 +265,61 @@
<hr />
<section class="flex flex-col gap-4">
<h5 class="text-xs font-normal text-card-heading select-none">ATTACHMENTS</h5>
<label class="add h-min w-full rounded-lg px-5 py-5 select-none" for="file-upload">
<label class="add h-min w-full rounded-xl border border-dashed border-add-outline px-5 py-5 select-none" for="file-upload">
<i class="fa fa-file-arrow-down text-3xl" title="Upload attachment"></i>
<label>Upload attachment</label>
<span>Upload attachments</span>
<InputFile class="hidden" multiple id="file-upload" OnChange="OnFileChanged"/>
</label>
<div class="flex flex-row flex-wrap items-center justify-start gap-2">
@foreach(var attachment in _card.Attachments)

@{
var imageAttachments = _card.Attachments.Where(AttachmentHelper.IsImage).ToList();
var fileAttachments = _card.Attachments.Where(x => !AttachmentHelper.IsImage(x)).ToList();
}

@if (imageAttachments.Count > 0)
{
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach (var attachment in imageAttachments)
{
<article @key="attachment.Id" class="group relative overflow-hidden rounded-xl bg-search-bar shadow-sm outline-1 outline-add-outline transition-all hover:shadow-md hover:outline-add-outline-hover @(imageAttachments.Count == 1 ? "sm:col-span-2" : string.Empty)">
<button type="button" class="block w-full cursor-pointer overflow-hidden bg-search-bar text-left" aria-label="Download @attachment.OriginalName" @onclick="() => DownloadAttachment(attachment)">
<img class="w-full object-cover transition-transform duration-300 group-hover:scale-[1.02] @(imageAttachments.Count == 1 ? "max-h-96 min-h-56" : "h-48")"
src="@GetAttachmentPreviewUrl(attachment)"
alt="@attachment.OriginalName"
loading="lazy" />
Comment thread
dkorecko marked this conversation as resolved.
<span class="absolute inset-x-0 bottom-0 flex items-end bg-gradient-to-t from-black/80 via-black/30 to-transparent px-3 pb-3 pt-10 text-sm font-medium text-white">
<span class="truncate pr-8">@attachment.OriginalName</span>
</span>
</button>
<button type="button" class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white opacity-100 backdrop-blur-sm transition hover:bg-red-button sm:opacity-0 sm:group-hover:opacity-100 sm:focus:opacity-100"
title="Delete attachment"
aria-label="Delete @attachment.OriginalName"
@onclick="() => DeleteAttachment(attachment)"
@onclick:stopPropagation="true">
<i class="fa fa-trash-can"></i>
</button>
</article>
}
</div>
}

@if (fileAttachments.Count > 0)
{
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@foreach (var attachment in fileAttachments)
{
<div class="flex w-min cursor-pointer flex-row items-center justify-center gap-2 rounded-lg px-2 py-2 outline-2 outline-add-outline transition-all ease-in-out select-none hover:outline-add-outline-hover"
<div @key="attachment.Id" class="flex min-w-0 cursor-pointer flex-row items-center gap-3 rounded-xl bg-search-bar px-3 py-3 outline-1 outline-add-outline transition-all ease-in-out select-none hover:outline-add-outline-hover"
@onclick="() => DownloadAttachment(attachment)">
<div class="h-8 w-8 bg-cover bg-center" style="background-image: url('./images/icons/@AttachmentHelper.GetImageNameFromAttachment(attachment)');" />
<div class="flex flex-col text-xs font-normal">
<label class="cursor-pointer font-medium">@attachment.OriginalName</label>
<div class="flex flex-row gap-2">
<label class="cursor-pointer text-add-outline">@AttachmentHelper.GetFileTypeFromAttachment(attachment)</label>
<label class="ml-auto cursor-pointer text-red-button hover:text-red-button-hover" @onclick="() => DeleteAttachment(attachment)" @onclick:stopPropagation=true><i class="fa fa-trash-can" title="Delete attachment"></i></label>
</div>
<div class="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" style="background-image: url('./images/icons/@AttachmentHelper.GetImageNameFromAttachment(attachment)');"></div>
<div class="flex min-w-0 flex-1 flex-col text-xs font-normal">
<span class="truncate cursor-pointer font-medium" title="@attachment.OriginalName">@attachment.OriginalName</span>
<span class="cursor-pointer text-add-outline">@AttachmentHelper.GetFileTypeFromAttachment(attachment)</span>
</div>
<button type="button" class="ml-auto shrink-0 p-2 text-red-button hover:text-red-button-hover" aria-label="Delete @attachment.OriginalName" @onclick="() => DeleteAttachment(attachment)" @onclick:stopPropagation="true"><i class="fa fa-trash-can" title="Delete attachment"></i></button>
</div>
}
</div>
</div>
}
</section>
}
else if (_tab == 1)
Expand Down Expand Up @@ -874,6 +908,11 @@
await _js.InvokeVoidAsync("triggerFileDownload", attachment.OriginalName, $"{Constants.Mappings.ATTACHMENTS_DOWNLOAD_PATH}/{attachment.FileName}");
}

private static string GetAttachmentPreviewUrl(Attachment attachment)
{
return $"{Constants.Mappings.ATTACHMENTS_PREVIEW_PATH}/{Uri.EscapeDataString(attachment.FileName)}";
}

private async Task DeleteAttachment(Attachment attachment)
{
if(_card is null)
Expand Down Expand Up @@ -1297,4 +1336,4 @@
await db.SaveChangesAsync();
await UpdateCard();
}
}
}
48 changes: 47 additions & 1 deletion Ticky.Web/Controllers/AttachmentsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,53 @@ public async Task<IActionResult> Download(string fileName)
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while downloading attachment {FileName}", decodedFileName);
_logger.LogError(ex, "Error while downloading an attachment");
return StatusCode(StatusCodes.Status500InternalServerError);
}
}

[HttpGet("preview/{*fileName}")]
public async Task<IActionResult> Preview(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return BadRequest();

var decodedFileName = WebUtility.UrlDecode(fileName);
string[] forbiddenChars = ["..", "/", "\\", "\n", "\r"];

if (forbiddenChars.Any(decodedFileName.Contains))
return BadRequest();

try
{
using var db = _dbContextFactory.CreateDbContext();
var attachment = await db.Attachments.FirstOrDefaultAsync(x =>
x.FileName == decodedFileName
);

if (attachment is null || !AttachmentHelper.IsImage(attachment))
return NotFound();

var absolutePath = Path.GetFullPath(
Path.Combine(Constants.SAVE_UPLOADED_FILES_PATH, attachment.FileName)
);

if (!System.IO.File.Exists(absolutePath))
return NotFound();

var contentTypeProvider =
new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
contentTypeProvider.Mappings[Constants.Attachments.AVIF_FILE_EXTENSION] =
Constants.Attachments.AVIF_CONTENT_TYPE;

if (!contentTypeProvider.TryGetContentType(attachment.OriginalName, out var contentType))
return NotFound();

return PhysicalFile(absolutePath, contentType, enableRangeProcessing: true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while previewing an attachment");
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
Expand Down
Loading
Loading