From 95e3a7972d259b59f290c87775dd710a65879408 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Sat, 26 Sep 2026 22:17:32 +0200 Subject: [PATCH] weshare impl --- .gitignore | 363 ++++++++++++++ WeShare.slnx | 3 + WeShare/Components/App.razor | 30 ++ WeShare/Components/Layout/MainLayout.razor | 92 ++++ .../Components/Layout/MainLayout.razor.css | 20 + .../Components/Layout/ReconnectModal.razor | 31 ++ .../Layout/ReconnectModal.razor.css | 157 ++++++ .../Components/Layout/ReconnectModal.razor.js | 63 +++ WeShare/Components/Pages/Download.razor | 275 +++++++++++ WeShare/Components/Pages/Error.razor | 36 ++ WeShare/Components/Pages/Home.razor | 458 ++++++++++++++++++ WeShare/Components/Pages/NotFound.razor | 5 + WeShare/Components/Routes.razor | 6 + WeShare/Components/_Imports.razor | 13 + WeShare/Endpoints/DownloadEndpoints.cs | 125 +++++ WeShare/Endpoints/UploadEndpoints.cs | 98 ++++ WeShare/Models/SharePackageMetadata.cs | 26 + WeShare/Models/UploadDtos.cs | 40 ++ WeShare/Models/WeShareOptions.cs | 13 + WeShare/Program.cs | 48 ++ WeShare/Properties/launchSettings.json | 23 + WeShare/Services/CleanupBackgroundService.cs | 59 +++ WeShare/Services/IShareStorageService.cs | 18 + WeShare/Services/PasswordHasher.cs | 45 ++ WeShare/Services/ShareStorageService.cs | 418 ++++++++++++++++ WeShare/WeShare.csproj | 14 + WeShare/appsettings.Development.json | 8 + WeShare/appsettings.json | 16 + WeShare/wwwroot/app.css | 38 ++ WeShare/wwwroot/css/weshare.css | 72 +++ WeShare/wwwroot/js/weshare-uploader.js | 289 +++++++++++ 31 files changed, 2902 insertions(+) create mode 100644 .gitignore create mode 100644 WeShare.slnx create mode 100644 WeShare/Components/App.razor create mode 100644 WeShare/Components/Layout/MainLayout.razor create mode 100644 WeShare/Components/Layout/MainLayout.razor.css create mode 100644 WeShare/Components/Layout/ReconnectModal.razor create mode 100644 WeShare/Components/Layout/ReconnectModal.razor.css create mode 100644 WeShare/Components/Layout/ReconnectModal.razor.js create mode 100644 WeShare/Components/Pages/Download.razor create mode 100644 WeShare/Components/Pages/Error.razor create mode 100644 WeShare/Components/Pages/Home.razor create mode 100644 WeShare/Components/Pages/NotFound.razor create mode 100644 WeShare/Components/Routes.razor create mode 100644 WeShare/Components/_Imports.razor create mode 100644 WeShare/Endpoints/DownloadEndpoints.cs create mode 100644 WeShare/Endpoints/UploadEndpoints.cs create mode 100644 WeShare/Models/SharePackageMetadata.cs create mode 100644 WeShare/Models/UploadDtos.cs create mode 100644 WeShare/Models/WeShareOptions.cs create mode 100644 WeShare/Program.cs create mode 100644 WeShare/Properties/launchSettings.json create mode 100644 WeShare/Services/CleanupBackgroundService.cs create mode 100644 WeShare/Services/IShareStorageService.cs create mode 100644 WeShare/Services/PasswordHasher.cs create mode 100644 WeShare/Services/ShareStorageService.cs create mode 100644 WeShare/WeShare.csproj create mode 100644 WeShare/appsettings.Development.json create mode 100644 WeShare/appsettings.json create mode 100644 WeShare/wwwroot/app.css create mode 100644 WeShare/wwwroot/css/weshare.css create mode 100644 WeShare/wwwroot/js/weshare-uploader.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9491a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,363 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/WeShare.slnx b/WeShare.slnx new file mode 100644 index 0000000..2ceeae0 --- /dev/null +++ b/WeShare.slnx @@ -0,0 +1,3 @@ + + + diff --git a/WeShare/Components/App.razor b/WeShare/Components/App.razor new file mode 100644 index 0000000..7924ed3 --- /dev/null +++ b/WeShare/Components/App.razor @@ -0,0 +1,30 @@ + + + + + + + + WeShare — Zdieľame medzi sebou (.NET 10) + + + + + + + + + + + + + + + + + + + + + + diff --git a/WeShare/Components/Layout/MainLayout.razor b/WeShare/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..52fbd8a --- /dev/null +++ b/WeShare/Components/Layout/MainLayout.razor @@ -0,0 +1,92 @@ +@inherits LayoutComponentBase + +
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + +
+ @Body +
+ + +
+
+ © @DateTime.UtcNow.Year WeShare — Blesková a bezpečná výmena súborov + Zero-DB Architecture • On-the-Fly ZIP Streaming +
+
+ +
diff --git a/WeShare/Components/Layout/MainLayout.razor.css b/WeShare/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..60cec92 --- /dev/null +++ b/WeShare/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/WeShare/Components/Layout/ReconnectModal.razor b/WeShare/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..e740b0c --- /dev/null +++ b/WeShare/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/WeShare/Components/Layout/ReconnectModal.razor.css b/WeShare/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/WeShare/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/WeShare/Components/Layout/ReconnectModal.razor.js b/WeShare/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/WeShare/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/WeShare/Components/Pages/Download.razor b/WeShare/Components/Pages/Download.razor new file mode 100644 index 0000000..7d99fcd --- /dev/null +++ b/WeShare/Components/Pages/Download.razor @@ -0,0 +1,275 @@ +@page "/d/{Id}" +@page "/download/{Id}" +@rendermode InteractiveServer +@inject IShareStorageService StorageService +@inject IPasswordHasher PasswordHasher +@inject NavigationManager NavManager + +WeShare — Stiahnutie súborov + +
+ + @if (isLoading) + { +
+
+

Načítavam informácie o zdieľaní...

+
+ } + else if (metadata == null) + { +
+
+ + + +
+

Súbor neexistuje alebo expiroval

+

Tento odkaz na stiahnutie už nie je platný, bol vymazaný alebo vypršala jeho platnosť.

+ +
+ } + else if (metadata.IsExpired) + { +
+
+ + + +
+

Platnosť odkazu vypršala

+

Platnosť tohto súboru vypršala @metadata.ExpiresAtUtc?.ToString("d. M. yyyy HH:mm") UTC.

+ +
+ } + else if (metadata.IsPasswordProtected && !isPasswordUnlocked) + { + +
+
+ + + +
+ +
+

Tento súbor je chránený heslom

+

Pre prístup k súborom zadajte heslo od odosielateľa

+
+ +
+ + + @if (!string.IsNullOrEmpty(passwordError)) + { +

@passwordError

+ } + + +
+
+ } + else + { + +
+ +
+
+ Prijatý balíček súborov +

WeShare Sťahovanie

+
+
+ Celková veľkosť + @FormatBytes(metadata.TotalSizeBytes) +
+
+ +
+ +
+
+
+ + + +
+
+
@metadata.Files.Count @(metadata.Files.Count == 1 ? "súbor" : "súborov") v balíčku
+
+ @(metadata.ExpiresAtUtc.HasValue ? $"Expiruje: {metadata.ExpiresAtUtc.Value.ToLocalTime():d. M. yyyy HH:mm}" : "Bez expirácie") +
+
+
+ +
+ + SHA-256 Overené + +
+
+ + +
+ @foreach (var file in metadata.Files) + { +
+
+
+ @GetFileExtensionShort(file.OriginalFileName) +
+
+
@file.OriginalFileName
+
@FormatBytes(file.SizeBytes)
+
+
+ + + + + + Stiahnuť + +
+ } +
+ + + @if (metadata.Files.Count > 1) + { + + + + + Stiahnuť všetko ako ZIP (@FormatBytes(metadata.TotalSizeBytes)) + + } + else if (metadata.Files.Count == 1) + { + + + + + Stiahnuť súbor (@FormatBytes(metadata.TotalSizeBytes)) + + } + + +
+
+ } + +
+ +@code { + [Parameter] + public string? Id { get; set; } + + private bool isLoading = true; + private SharePackageMetadata? metadata; + private bool isPasswordUnlocked = false; + private string enteredPassword = string.Empty; + private string passwordError = string.Empty; + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrWhiteSpace(Id)) + { + metadata = await StorageService.GetMetadataAsync(Id); + if (metadata != null && !metadata.IsPasswordProtected) + { + isPasswordUnlocked = true; + } + } + isLoading = false; + } + + private void HandlePasswordKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + UnlockWithPassword(); + } + } + + private void UnlockWithPassword() + { + if (metadata == null) return; + + if (string.IsNullOrWhiteSpace(enteredPassword)) + { + passwordError = "Prosím zadajte heslo."; + return; + } + + if (PasswordHasher.VerifyPassword(enteredPassword, metadata.PasswordHash!)) + { + isPasswordUnlocked = true; + passwordError = string.Empty; + } + else + { + passwordError = "Nesprávne heslo. Skúste znova."; + } + } + + private string GetSingleDownloadUrl(Guid fileId) + { + string pwdParam = metadata?.IsPasswordProtected == true && !string.IsNullOrEmpty(enteredPassword) + ? $"?pwd={Uri.EscapeDataString(enteredPassword)}" + : ""; + return $"/api/download/{metadata?.Id}/file/{fileId}{pwdParam}"; + } + + private string GetZipDownloadUrl() + { + string pwdParam = metadata?.IsPasswordProtected == true && !string.IsNullOrEmpty(enteredPassword) + ? $"?pwd={Uri.EscapeDataString(enteredPassword)}" + : ""; + return $"/api/download/{metadata?.Id}/zip{pwdParam}"; + } + + private static string GetFileExtensionShort(string filename) + { + var ext = Path.GetExtension(filename).TrimStart('.').ToUpperInvariant(); + return string.IsNullOrWhiteSpace(ext) ? "FILE" : (ext.Length > 4 ? ext[..4] : ext); + } + + private static string FormatBytes(long bytes) + { + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int counter = 0; + decimal number = bytes; + while (Math.Round(number / 1024) >= 1) + { + number /= 1024; + counter++; + } + return $"{number:n1} {suffixes[counter]}"; + } +} diff --git a/WeShare/Components/Pages/Error.razor b/WeShare/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/WeShare/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/WeShare/Components/Pages/Home.razor b/WeShare/Components/Pages/Home.razor new file mode 100644 index 0000000..8043abc --- /dev/null +++ b/WeShare/Components/Pages/Home.razor @@ -0,0 +1,458 @@ +@page "/" +@rendermode InteractiveServer +@inject IJSRuntime JS +@inject NavigationManager NavManager +@implements IDisposable + +WeShare — Zdieľame medzi sebou + +
+ + +
+
+

+ Rýchle zdieľanie +

+

Potiahnite súbory a pošlite priamy odkaz

+
+ +
+ + + + Max 5 GB +
+
+ + +
+ + + @if (currentState == UiState.SelectFiles) + { +
+ +
+ + +
+ +
+ + + +
+
+

+ Presuňte súbory sem alebo vyberte z disku +

+

Podpora viacerých súborov naraz • Až do 5 GB

+
+
+
+ + + @if (selectedFiles.Count > 0) + { +
+
+ Vybrané súbory (@selectedFiles.Count): + Spolu: @FormatBytes(TotalSelectedBytes) +
+ @foreach (var file in selectedFiles) + { +
+
+ + + + @file.Name + (@FormatBytes(file.Size)) +
+ +
+ } +
+ } + + +
+ +
+ + +
+ + +
+
+ + +
+ + @if (isPasswordEnabled) + { + + } + else + { + Bez hesla (verejný odkaz) + } +
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + + +
+ } + + + @if (currentState == UiState.Uploading) + { +
+ +
+ +
+
+
+ JA +
+
+ Odosielateľ +
+ + +
+
+
+
+
+ + priamy prenos medzi nami +
+
+ + +
+
+
+ TY +
+
+ Príjemca +
+
+ + +
+
+

@currentUploadFileName

+

@FormatBytes(currentUploadedBytes) z @FormatBytes(currentTotalBytes)

+
+ + +
+ + +
+
+ Streamovanie dát... + @progressPercent.ToString("0.0")% +
+ +
+
+
+ +
+ Rýchlosť: @currentSpeedMb MB/s + Zostáva: @(etaSeconds > 0 ? $"{etaSeconds} sekúnd" : "dokončuje sa...") +
+
+
+ } + + + @if (currentState == UiState.Completed) + { +
+
+ + + +
+ +
+

Súbory sú pripravené na zdieľanie!

+

+ Platnosť: @(expirationDays == 0 ? "Nikdy" : $"{expirationDays} dní") • @(isPasswordEnabled ? "Chránené heslom 🔒" : "Bez hesla 🔓") • @FormatBytes(currentTotalBytes) +

+
+ + +
+ + +
+ + +
+ + + + Otvoriť náhľad príjemcu + + + + +
+ + @if (showQrCode && !string.IsNullOrEmpty(completedShareId)) + { +
+ QR Code +

Naskenujte pre okamžité stiahnutie do mobilu

+
+ } + +
+ +
+
+ } + +
+ +
+ +@code { + private enum UiState { SelectFiles, Uploading, Completed } + private UiState currentState = UiState.SelectFiles; + + private List selectedFiles = new(); + private int expirationDays = 7; + private bool isPasswordEnabled = false; + private string passwordValue = string.Empty; + private string errorMessage = string.Empty; + + // Upload Progress State + private double progressPercent = 0; + private double currentSpeedMb = 0; + private int etaSeconds = 0; + private long currentUploadedBytes = 0; + private long currentTotalBytes = 0; + private string currentUploadFileName = string.Empty; + + // Completed State + private string completedShareUrl = string.Empty; + private string completedShareId = string.Empty; + private string copyButtonLabel = "Kopírovať"; + private bool showQrCode = false; + + private DotNetObjectReference? dotNetRef; + + private long TotalSelectedBytes => selectedFiles.Sum(f => f.Size); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + dotNetRef = DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("WeShareUploader.registerDotNet", dotNetRef); + await JS.InvokeVoidAsync("WeShareUploader.initDropzone", "weshare-dropzone", "weshare-file-input"); + } + } + + [JSInvokable] + public void OnFilesSelected(List files) + { + selectedFiles.AddRange(files); + errorMessage = string.Empty; + StateHasChanged(); + } + + private async Task RemoveFile(string fileId) + { + selectedFiles.RemoveAll(f => f.FileId == fileId); + await JS.InvokeVoidAsync("WeShareUploader.removeSelectedFile", fileId); + } + + private async Task InitiateUpload() + { + if (selectedFiles.Count == 0) return; + + errorMessage = string.Empty; + progressPercent = 0; + currentSpeedMb = 0; + etaSeconds = 0; + currentUploadedBytes = 0; + currentTotalBytes = TotalSelectedBytes; + currentUploadFileName = selectedFiles[0].Name; + + currentState = UiState.Uploading; + StateHasChanged(); + + string? pass = isPasswordEnabled && !string.IsNullOrWhiteSpace(passwordValue) ? passwordValue : null; + await JS.InvokeVoidAsync("WeShareUploader.startUpload", expirationDays, pass); + } + + [JSInvokable] + public void OnUploadProgress(UploadProgressDto progress) + { + progressPercent = progress.Percent; + currentSpeedMb = progress.SpeedMb; + etaSeconds = progress.EtaSeconds; + currentUploadedBytes = progress.UploadedBytes; + currentTotalBytes = progress.TotalBytes; + if (!string.IsNullOrEmpty(progress.CurrentFileName)) + { + currentUploadFileName = progress.CurrentFileName; + } + StateHasChanged(); + } + + [JSInvokable] + public void OnUploadCompleted(CompleteUploadResult result) + { + if (result.Success) + { + completedShareUrl = result.ShareUrl ?? ""; + completedShareId = result.ShareId ?? ""; + currentState = UiState.Completed; + copyButtonLabel = "Kopírovať"; + showQrCode = false; + } + else + { + currentState = UiState.SelectFiles; + errorMessage = result.Error ?? "Nastala neočakávaná chyba."; + } + StateHasChanged(); + } + + [JSInvokable] + public void OnUploadCancelled() + { + currentState = UiState.SelectFiles; + StateHasChanged(); + } + + [JSInvokable] + public void OnUploadError(string error) + { + currentState = UiState.SelectFiles; + errorMessage = error; + StateHasChanged(); + } + + private async Task CancelUpload() + { + await JS.InvokeVoidAsync("WeShareUploader.cancelUpload"); + } + + private async Task CopyLink() + { + if (!string.IsNullOrEmpty(completedShareUrl)) + { + await JS.InvokeVoidAsync("WeShareUploader.copyToClipboard", completedShareUrl); + copyButtonLabel = "Skopírované! ✓"; + StateHasChanged(); + } + } + + private void ToggleQrCode() + { + showQrCode = !showQrCode; + } + + private async Task ResetForm() + { + selectedFiles.Clear(); + await JS.InvokeVoidAsync("WeShareUploader.clearSelectedFiles"); + currentState = UiState.SelectFiles; + passwordValue = string.Empty; + isPasswordEnabled = false; + errorMessage = string.Empty; + showQrCode = false; + } + + private static string FormatBytes(long bytes) + { + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int counter = 0; + decimal number = bytes; + while (Math.Round(number / 1024) >= 1) + { + number /= 1024; + counter++; + } + return $"{number:n1} {suffixes[counter]}"; + } + + public void Dispose() + { + dotNetRef?.Dispose(); + } + + public class UploadProgressDto + { + public double Percent { get; set; } + public double SpeedMb { get; set; } + public int EtaSeconds { get; set; } + public long UploadedBytes { get; set; } + public long TotalBytes { get; set; } + public string? CurrentFileName { get; set; } + } +} diff --git a/WeShare/Components/Pages/NotFound.razor b/WeShare/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/WeShare/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/WeShare/Components/Routes.razor b/WeShare/Components/Routes.razor new file mode 100644 index 0000000..105855d --- /dev/null +++ b/WeShare/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/WeShare/Components/_Imports.razor b/WeShare/Components/_Imports.razor new file mode 100644 index 0000000..ce8d0ae --- /dev/null +++ b/WeShare/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using WeShare +@using WeShare.Components +@using WeShare.Components.Layout +@using WeShare.Models +@using WeShare.Services diff --git a/WeShare/Endpoints/DownloadEndpoints.cs b/WeShare/Endpoints/DownloadEndpoints.cs new file mode 100644 index 0000000..ec927b5 --- /dev/null +++ b/WeShare/Endpoints/DownloadEndpoints.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Mvc; +using QRCoder; +using WeShare.Models; +using WeShare.Services; + +namespace WeShare.Endpoints; + +public static class DownloadEndpoints +{ + public static IEndpointRouteBuilder MapDownloadEndpoints(this IEndpointRouteBuilder app) + { + // 1. Download single file + app.MapGet("/api/download/{id}/file/{fileId:guid}", async ( + string id, + Guid fileId, + [FromQuery] string? pwd, + IShareStorageService storageService, + IPasswordHasher passwordHasher, + HttpResponse response) => + { + var metadata = await storageService.GetMetadataAsync(id); + if (metadata == null) + { + return Results.NotFound(new { error = "Share package not found or expired." }); + } + + if (metadata.IsExpired) + { + return Results.Problem(statusCode: StatusCodes.Status410Gone, detail: "This share package has expired."); + } + + if (metadata.IsPasswordProtected) + { + if (string.IsNullOrWhiteSpace(pwd) || !_passwordHasher.VerifyPassword(pwd, metadata.PasswordHash!)) + { + return Results.Unauthorized(); + } + } + + var fileItem = metadata.Files.FirstOrDefault(f => f.Id == fileId); + if (fileItem == null) + { + return Results.NotFound(new { error = "File not found in this package." }); + } + + var stream = await storageService.OpenFileReadStreamAsync(metadata, fileId); + if (stream == null) + { + return Results.NotFound(new { error = "File content not found on disk." }); + } + + await storageService.IncrementDownloadCountAsync(metadata); + + return Results.File( + fileStream: stream, + contentType: fileItem.ContentType, + fileDownloadName: fileItem.OriginalFileName, + enableRangeProcessing: true); + }); + + // 2. Download all files as on-the-fly ZIP stream + app.MapGet("/api/download/{id}/zip", async ( + string id, + [FromQuery] string? pwd, + IShareStorageService storageService, + IPasswordHasher passwordHasher, + HttpContext httpContext) => + { + var metadata = await storageService.GetMetadataAsync(id); + if (metadata == null) + { + return Results.NotFound(new { error = "Share package not found or expired." }); + } + + if (metadata.IsExpired) + { + return Results.Problem(statusCode: StatusCodes.Status410Gone, detail: "This share package has expired."); + } + + if (metadata.IsPasswordProtected) + { + if (string.IsNullOrWhiteSpace(pwd) || !_passwordHasher.VerifyPassword(pwd, metadata.PasswordHash!)) + { + return Results.Unauthorized(); + } + } + + string zipFileName = $"WeShare_{metadata.ShortId}.zip"; + httpContext.Response.ContentType = "application/zip"; + httpContext.Response.Headers.Append("Content-Disposition", $"attachment; filename=\"{zipFileName}\""); + + await storageService.StreamPackageAsZipAsync(metadata, httpContext.Response.Body); + await storageService.IncrementDownloadCountAsync(metadata); + + return Results.Empty; + }); + + // 3. Generate QR Code for Share Link + app.MapGet("/api/share/{id}/qr", async ( + string id, + IShareStorageService storageService, + HttpRequest request) => + { + var metadata = await storageService.GetMetadataAsync(id); + if (metadata == null) + { + return Results.NotFound(); + } + + string baseUrl = $"{request.Scheme}://{request.Host}"; + string shareUrl = $"{baseUrl}/d/{metadata.ShortId}"; + + using var qrGenerator = new QRCodeGenerator(); + using var qrCodeData = qrGenerator.CreateQrCode(shareUrl, QRCodeGenerator.ECCLevel.Q); + using var qrCode = new PngByteQRCode(qrCodeData); + byte[] qrCodeImage = qrCode.GetGraphic(20); + + return Results.File(qrCodeImage, "image/png"); + }); + + return app; + } + + private static IPasswordHasher _passwordHasher = new PasswordHasher(); +} diff --git a/WeShare/Endpoints/UploadEndpoints.cs b/WeShare/Endpoints/UploadEndpoints.cs new file mode 100644 index 0000000..d5e51f0 --- /dev/null +++ b/WeShare/Endpoints/UploadEndpoints.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Mvc; +using WeShare.Models; +using WeShare.Services; + +namespace WeShare.Endpoints; + +public static class UploadEndpoints +{ + public static IEndpointRouteBuilder MapUploadEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/upload"); + + // 1. Init upload session + group.MapPost("/init", async ( + [FromBody] InitUploadRequest request, + IShareStorageService storageService) => + { + if (request.Files.Count == 0) + { + return Results.BadRequest(new { error = "No files specified." }); + } + + var response = await storageService.InitializeUploadSessionAsync(request); + return Results.Ok(response); + }).DisableAntiforgery(); + + // 2. Upload individual chunk + group.MapPost("/chunk", async ( + HttpRequest request, + IShareStorageService storageService) => + { + if (!request.HasFormContentType) + { + return Results.BadRequest(new { error = "Form-data content type required." }); + } + + var form = await request.ReadFormAsync(); + if (!Guid.TryParse(form["sessionId"], out var sessionId) || + !Guid.TryParse(form["fileId"], out var fileId) || + !int.TryParse(form["chunkIndex"], out var chunkIndex)) + { + return Results.BadRequest(new { error = "Invalid upload parameters (sessionId, fileId, chunkIndex)." }); + } + + var file = form.Files.GetFile("chunk"); + if (file == null || file.Length == 0) + { + return Results.BadRequest(new { error = "Empty chunk payload." }); + } + + await using var stream = file.OpenReadStream(); + await storageService.AppendChunkAsync(sessionId, fileId, chunkIndex, stream); + + return Results.Ok(new { success = true, chunkIndex }); + }).DisableAntiforgery(); + + // 3. Complete upload + group.MapPost("/complete", async ( + [FromBody] CompleteUploadDto dto, + IShareStorageService storageService, + HttpRequest request) => + { + try + { + var metadata = await storageService.CompleteUploadSessionAsync(dto); + string baseUrl = $"{request.Scheme}://{request.Host}"; + string shareUrl = $"{baseUrl}/d/{metadata.ShortId}"; + + return Results.Ok(new CompleteUploadResult + { + Success = true, + ShareId = metadata.Id.ToString(), + ShortId = metadata.ShortId, + ShareUrl = shareUrl + }); + } + catch (Exception ex) + { + return Results.BadRequest(new CompleteUploadResult + { + Success = false, + Error = ex.Message + }); + } + }).DisableAntiforgery(); + + // 4. Cancel upload + group.MapPost("/cancel/{sessionId:guid}", async ( + Guid sessionId, + IShareStorageService storageService) => + { + await storageService.CancelUploadSessionAsync(sessionId); + return Results.Ok(new { success = true, message = "Session cancelled." }); + }).DisableAntiforgery(); + + return app; + } +} diff --git a/WeShare/Models/SharePackageMetadata.cs b/WeShare/Models/SharePackageMetadata.cs new file mode 100644 index 0000000..2ed46a9 --- /dev/null +++ b/WeShare/Models/SharePackageMetadata.cs @@ -0,0 +1,26 @@ +namespace WeShare.Models; + +public class SharePackageMetadata +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ShortId { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime? ExpiresAtUtc { get; set; } + public bool IsPasswordProtected { get; set; } + public string? PasswordHash { get; set; } + public int DownloadCount { get; set; } + public long TotalSizeBytes { get; set; } + public List Files { get; set; } = new(); + + public bool IsExpired => ExpiresAtUtc.HasValue && ExpiresAtUtc.Value < DateTime.UtcNow; +} + +public class ShareFileItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string OriginalFileName { get; set; } = string.Empty; + public string StoredFileName { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public string ContentType { get; set; } = "application/octet-stream"; + public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/WeShare/Models/UploadDtos.cs b/WeShare/Models/UploadDtos.cs new file mode 100644 index 0000000..a4184b0 --- /dev/null +++ b/WeShare/Models/UploadDtos.cs @@ -0,0 +1,40 @@ +namespace WeShare.Models; + +public class InitUploadRequest +{ + public List Files { get; set; } = new(); + public int ExpirationDays { get; set; } = 7; // 0 = never, 1, 7, 30 + public string? Password { get; set; } +} + +public class FileUploadItemDto +{ + public string FileId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public long Size { get; set; } + public string Type { get; set; } = "application/octet-stream"; +} + +public class InitUploadResponse +{ + public Guid SessionId { get; set; } + public long ChunkSize { get; set; } + public string Message { get; set; } = "Upload session initialized"; +} + +public class CompleteUploadDto +{ + public Guid SessionId { get; set; } + public int ExpirationDays { get; set; } = 7; + public string? Password { get; set; } + public List Files { get; set; } = new(); +} + +public class CompleteUploadResult +{ + public bool Success { get; set; } + public string? ShareId { get; set; } + public string? ShortId { get; set; } + public string? ShareUrl { get; set; } + public string? Error { get; set; } +} diff --git a/WeShare/Models/WeShareOptions.cs b/WeShare/Models/WeShareOptions.cs new file mode 100644 index 0000000..0244d35 --- /dev/null +++ b/WeShare/Models/WeShareOptions.cs @@ -0,0 +1,13 @@ +namespace WeShare.Models; + +public class WeShareOptions +{ + public const string SectionName = "WeShare"; + + public string UploadRootFolder { get; set; } = "Uploads"; + public long MaxUploadSizeBytes { get; set; } = 5368709120; // 5 GB default + public long ChunkSizeBytes { get; set; } = 10485760; // 10 MB chunk default + public bool EnableAutoCleanup { get; set; } = true; + public int CleanupIntervalMinutes { get; set; } = 15; + public string? BaseUrl { get; set; } +} diff --git a/WeShare/Program.cs b/WeShare/Program.cs new file mode 100644 index 0000000..dc1b7b7 --- /dev/null +++ b/WeShare/Program.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http.Features; +using WeShare.Components; +using WeShare.Endpoints; +using WeShare.Models; +using WeShare.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Configure options +builder.Services.Configure(builder.Configuration.GetSection(WeShareOptions.SectionName)); + +// Configure Large File form options for chunk transfer +builder.Services.Configure(options => +{ + options.MultipartBodyLengthLimit = 50 * 1024 * 1024; // 50MB per chunk form request +}); + +// Register services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +// Add Blazor InteractiveServer +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.UseStaticFiles(); + +// Map Minimal API Endpoints +app.MapUploadEndpoints(); +app.MapDownloadEndpoints(); + +// Map Blazor Components +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/WeShare/Properties/launchSettings.json b/WeShare/Properties/launchSettings.json new file mode 100644 index 0000000..744bb63 --- /dev/null +++ b/WeShare/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5139", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7227;http://localhost:5139", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/WeShare/Services/CleanupBackgroundService.cs b/WeShare/Services/CleanupBackgroundService.cs new file mode 100644 index 0000000..aa31c51 --- /dev/null +++ b/WeShare/Services/CleanupBackgroundService.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; +using WeShare.Models; + +namespace WeShare.Services; + +public class CleanupBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + + public CleanupBackgroundService( + IServiceProvider serviceProvider, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + _serviceProvider = serviceProvider; + _optionsMonitor = optionsMonitor; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("WeShare CleanupBackgroundService started."); + + while (!stoppingToken.IsCancellationRequested) + { + var options = _optionsMonitor.CurrentValue; + int intervalMinutes = Math.Max(1, options.CleanupIntervalMinutes); + + try + { + if (options.EnableAutoCleanup) + { + using var scope = _serviceProvider.CreateScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + int cleaned = await storageService.CleanupExpiredPackagesAsync(); + if (cleaned > 0) + { + _logger.LogInformation("Cleanup worker completed cycle: {Count} packages deleted.", cleaned); + } + } + else + { + _logger.LogDebug("AutoCleanup is disabled in appsettings."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during WeShare storage cleanup cycle."); + } + + await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken); + } + + _logger.LogInformation("WeShare CleanupBackgroundService stopped."); + } +} diff --git a/WeShare/Services/IShareStorageService.cs b/WeShare/Services/IShareStorageService.cs new file mode 100644 index 0000000..e632c2e --- /dev/null +++ b/WeShare/Services/IShareStorageService.cs @@ -0,0 +1,18 @@ +using WeShare.Models; + +namespace WeShare.Services; + +public interface IShareStorageService +{ + Task InitializeUploadSessionAsync(InitUploadRequest request); + Task AppendChunkAsync(Guid sessionId, Guid fileId, int chunkIndex, Stream chunkStream); + Task CancelUploadSessionAsync(Guid sessionId); + Task CompleteUploadSessionAsync(CompleteUploadDto dto); + + Task GetMetadataAsync(string idOrShortId); + Task OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId); + Task StreamPackageAsZipAsync(SharePackageMetadata metadata, Stream outputStream); + + Task IncrementDownloadCountAsync(SharePackageMetadata metadata); + Task CleanupExpiredPackagesAsync(); +} diff --git a/WeShare/Services/PasswordHasher.cs b/WeShare/Services/PasswordHasher.cs new file mode 100644 index 0000000..60020f8 --- /dev/null +++ b/WeShare/Services/PasswordHasher.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; + +namespace WeShare.Services; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string storedHash); +} + +public class PasswordHasher : IPasswordHasher +{ + private const int SaltSize = 16; // 128 bit + private const int KeySize = 32; // 256 bit + private const int Iterations = 100_000; + private static readonly HashAlgorithmName Algorithm = HashAlgorithmName.SHA256; + + public string HashPassword(string password) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algorithm, KeySize); + + return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + public bool VerifyPassword(string password, string storedHash) + { + if (string.IsNullOrWhiteSpace(storedHash) || string.IsNullOrWhiteSpace(password)) + return false; + + string[] parts = storedHash.Split('.'); + if (parts.Length != 3) + return false; + + if (!int.TryParse(parts[0], out int iterations)) + return false; + + byte[] salt = Convert.FromBase64String(parts[1]); + byte[] expectedHash = Convert.FromBase64String(parts[2]); + + byte[] actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algorithm, expectedHash.Length); + + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } +} diff --git a/WeShare/Services/ShareStorageService.cs b/WeShare/Services/ShareStorageService.cs new file mode 100644 index 0000000..7c506e6 --- /dev/null +++ b/WeShare/Services/ShareStorageService.cs @@ -0,0 +1,418 @@ +using System.Buffers.Text; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Options; +using WeShare.Models; + +namespace WeShare.Services; + +public class ShareStorageService : IShareStorageService +{ + private readonly WeShareOptions _options; + private readonly IPasswordHasher _passwordHasher; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public ShareStorageService( + IOptions options, + IPasswordHasher passwordHasher, + ILogger logger) + { + _options = options.Value; + _passwordHasher = passwordHasher; + _logger = logger; + + EnsureDirectoryExists(GetRootDirectory()); + EnsureDirectoryExists(GetTempDirectory()); + } + + private string GetRootDirectory() + { + string root = _options.UploadRootFolder; + if (!Path.IsPathRooted(root)) + { + root = Path.Combine(AppContext.BaseDirectory, root); + } + return root; + } + + private string GetTempDirectory() => Path.Combine(GetRootDirectory(), "_temp"); + + private void EnsureDirectoryExists(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + public Task InitializeUploadSessionAsync(InitUploadRequest request) + { + var sessionId = Guid.NewGuid(); + string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString()); + EnsureDirectoryExists(sessionDir); + + _logger.LogInformation("Initialized upload session {SessionId} for {FileCount} files", sessionId, request.Files.Count); + + return Task.FromResult(new InitUploadResponse + { + SessionId = sessionId, + ChunkSize = _options.ChunkSizeBytes, + Message = "Upload session initialized" + }); + } + + public async Task AppendChunkAsync(Guid sessionId, Guid fileId, int chunkIndex, Stream chunkStream) + { + string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString()); + if (!Directory.Exists(sessionDir)) + { + EnsureDirectoryExists(sessionDir); + } + + string tempFilePath = Path.Combine(sessionDir, $"{fileId}.tmp"); + + // Append chunk + await using var fileStream = new FileStream( + tempFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.None, + bufferSize: 64 * 1024, + useAsync: true); + + await chunkStream.CopyToAsync(fileStream); + } + + public Task CancelUploadSessionAsync(Guid sessionId) + { + string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString()); + if (Directory.Exists(sessionDir)) + { + try + { + Directory.Delete(sessionDir, recursive: true); + _logger.LogInformation("Cancelled and deleted temp upload session {SessionId}", sessionId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete cancelled session folder {SessionDir}", sessionDir); + } + } + return Task.CompletedTask; + } + + public async Task CompleteUploadSessionAsync(CompleteUploadDto dto) + { + string tempSessionDir = Path.Combine(GetTempDirectory(), dto.SessionId.ToString()); + if (!Directory.Exists(tempSessionDir)) + { + throw new DirectoryNotFoundException($"Upload session {dto.SessionId} was not found or expired."); + } + + var shareId = Guid.NewGuid(); + string shortId = EncodeShortGuid(shareId); + string packageDir = Path.Combine(GetRootDirectory(), shareId.ToString()); + EnsureDirectoryExists(packageDir); + + var metadata = new SharePackageMetadata + { + Id = shareId, + ShortId = shortId, + CreatedAtUtc = DateTime.UtcNow, + ExpiresAtUtc = dto.ExpirationDays > 0 ? DateTime.UtcNow.AddDays(dto.ExpirationDays) : null, + IsPasswordProtected = !string.IsNullOrWhiteSpace(dto.Password), + PasswordHash = !string.IsNullOrWhiteSpace(dto.Password) ? _passwordHasher.HashPassword(dto.Password) : null, + DownloadCount = 0 + }; + + long totalBytes = 0; + + foreach (var fileDto in dto.Files) + { + if (!Guid.TryParse(fileDto.FileId, out var fileGuid)) + { + fileGuid = Guid.NewGuid(); + } + + string tempFile = Path.Combine(tempSessionDir, $"{fileDto.FileId}.tmp"); + string safeFileName = Path.GetFileName(fileDto.Name); + if (string.IsNullOrWhiteSpace(safeFileName)) + { + safeFileName = $"file_{fileGuid}"; + } + + // Ensure unique stored file name inside the package directory + string targetPath = Path.Combine(packageDir, safeFileName); + if (File.Exists(targetPath)) + { + targetPath = Path.Combine(packageDir, $"{fileGuid}_{safeFileName}"); + safeFileName = Path.GetFileName(targetPath); + } + + if (File.Exists(tempFile)) + { + File.Move(tempFile, targetPath, overwrite: true); + var fi = new FileInfo(targetPath); + totalBytes += fi.Length; + + metadata.Files.Add(new ShareFileItem + { + Id = fileGuid, + OriginalFileName = fileDto.Name, + StoredFileName = safeFileName, + SizeBytes = fi.Length, + ContentType = string.IsNullOrWhiteSpace(fileDto.Type) ? "application/octet-stream" : fileDto.Type, + UploadedAtUtc = DateTime.UtcNow + }); + } + } + + metadata.TotalSizeBytes = totalBytes; + + // Save metadata.json + string metadataPath = Path.Combine(packageDir, "metadata.json"); + await using (var metaStream = File.Create(metadataPath)) + { + await JsonSerializer.SerializeAsync(metaStream, metadata, JsonOptions); + } + + // Clean up temp session directory + try + { + Directory.Delete(tempSessionDir, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove temp directory for session {SessionId}", dto.SessionId); + } + + _logger.LogInformation("Completed share package {ShareId} with {FileCount} files ({TotalBytes} bytes)", + shareId, metadata.Files.Count, totalBytes); + + return metadata; + } + + public async Task GetMetadataAsync(string idOrShortId) + { + if (string.IsNullOrWhiteSpace(idOrShortId)) + return null; + + string root = GetRootDirectory(); + + // 1. Try if idOrShortId is a direct GUID + if (Guid.TryParse(idOrShortId, out var directGuid)) + { + string guidDir = Path.Combine(root, directGuid.ToString()); + string metaFile = Path.Combine(guidDir, "metadata.json"); + if (File.Exists(metaFile)) + { + return await LoadMetadataFileAsync(metaFile); + } + } + + // 2. Try decode as ShortGuid + if (TryDecodeShortGuid(idOrShortId, out var decodedGuid)) + { + string decodedDir = Path.Combine(root, decodedGuid.ToString()); + string metaFile = Path.Combine(decodedDir, "metadata.json"); + if (File.Exists(metaFile)) + { + return await LoadMetadataFileAsync(metaFile); + } + } + + // 3. Fallback scan of directories (Zero DB scan) + foreach (var dir in Directory.GetDirectories(root)) + { + if (Path.GetFileName(dir).Equals("_temp", StringComparison.OrdinalIgnoreCase)) + continue; + + string metaFile = Path.Combine(dir, "metadata.json"); + if (File.Exists(metaFile)) + { + var meta = await LoadMetadataFileAsync(metaFile); + if (meta != null && (meta.ShortId == idOrShortId || meta.Id.ToString("N") == idOrShortId)) + { + return meta; + } + } + } + + return null; + } + + private static async Task LoadMetadataFileAsync(string metaPath) + { + try + { + await using var stream = File.OpenRead(metaPath); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions); + } + catch + { + return null; + } + } + + public Task OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId) + { + var fileItem = metadata.Files.FirstOrDefault(f => f.Id == fileId); + if (fileItem == null) + return Task.FromResult(null); + + string root = GetRootDirectory(); + string filePath = Path.Combine(root, metadata.Id.ToString(), fileItem.StoredFileName); + + if (!File.Exists(filePath)) + return Task.FromResult(null); + + Stream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, useAsync: true); + return Task.FromResult(stream); + } + + public async Task StreamPackageAsZipAsync(SharePackageMetadata metadata, Stream outputStream) + { + string root = GetRootDirectory(); + string packageDir = Path.Combine(root, metadata.Id.ToString()); + + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + + foreach (var file in metadata.Files) + { + string filePath = Path.Combine(packageDir, file.StoredFileName); + if (!File.Exists(filePath)) + continue; + + var entry = zipArchive.CreateEntry(file.OriginalFileName, CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, useAsync: true); + await fileStream.CopyToAsync(entryStream); + } + } + + public async Task IncrementDownloadCountAsync(SharePackageMetadata metadata) + { + string metaPath = Path.Combine(GetRootDirectory(), metadata.Id.ToString(), "metadata.json"); + if (File.Exists(metaPath)) + { + metadata.DownloadCount++; + try + { + await using var stream = File.Create(metaPath); + await JsonSerializer.SerializeAsync(stream, metadata, JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update download count for {Id}", metadata.Id); + } + } + } + + public async Task CleanupExpiredPackagesAsync() + { + if (!_options.EnableAutoCleanup) + { + _logger.LogInformation("Auto cleanup is disabled in configuration."); + return 0; + } + + int cleaned = 0; + string root = GetRootDirectory(); + + // 1. Clean expired packages + foreach (var dir in Directory.GetDirectories(root)) + { + string dirName = Path.GetFileName(dir); + if (dirName.Equals("_temp", StringComparison.OrdinalIgnoreCase)) + continue; + + string metaPath = Path.Combine(dir, "metadata.json"); + if (File.Exists(metaPath)) + { + var meta = await LoadMetadataFileAsync(metaPath); + if (meta != null && meta.IsExpired) + { + try + { + Directory.Delete(dir, recursive: true); + cleaned++; + _logger.LogInformation("Cleaned expired package {Id} (Expired at: {ExpiresAt})", meta.Id, meta.ExpiresAtUtc); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete expired directory {Dir}", dir); + } + } + } + } + + // 2. Clean old orphaned _temp sessions (older than 4 hours) + string tempRoot = GetTempDirectory(); + if (Directory.Exists(tempRoot)) + { + foreach (var tempDir in Directory.GetDirectories(tempRoot)) + { + try + { + var info = new DirectoryInfo(tempDir); + if (DateTime.UtcNow - info.CreationTimeUtc > TimeSpan.FromHours(4)) + { + Directory.Delete(tempDir, recursive: true); + _logger.LogInformation("Cleaned orphaned temp session {TempDir}", tempDir); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clean orphaned temp directory {TempDir}", tempDir); + } + } + } + + return cleaned; + } + + public static string EncodeShortGuid(Guid guid) + { + Span bytes = stackalloc byte[16]; + guid.TryWriteBytes(bytes); + return Convert.ToBase64String(bytes) + .Replace('/', '_') + .Replace('+', '-') + .TrimEnd('='); + } + + public static bool TryDecodeShortGuid(string shortGuid, out Guid guid) + { + guid = Guid.Empty; + if (string.IsNullOrWhiteSpace(shortGuid) || shortGuid.Length < 20 || shortGuid.Length > 24) + return false; + + string base64 = shortGuid.Replace('_', '/').Replace('-', '+'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + + try + { + byte[] bytes = Convert.FromBase64String(base64); + if (bytes.Length == 16) + { + guid = new Guid(bytes); + return true; + } + } + catch + { + // invalid + } + return false; + } +} diff --git a/WeShare/WeShare.csproj b/WeShare/WeShare.csproj new file mode 100644 index 0000000..cc6566d --- /dev/null +++ b/WeShare/WeShare.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/WeShare/appsettings.Development.json b/WeShare/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/WeShare/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/WeShare/appsettings.json b/WeShare/appsettings.json new file mode 100644 index 0000000..cfccf3d --- /dev/null +++ b/WeShare/appsettings.json @@ -0,0 +1,16 @@ +{ + "WeShare": { + "UploadRootFolder": "Uploads", + "MaxUploadSizeBytes": 5368709120, + "ChunkSizeBytes": 10485760, + "EnableAutoCleanup": true, + "CleanupIntervalMinutes": 15 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/WeShare/wwwroot/app.css b/WeShare/wwwroot/app.css new file mode 100644 index 0000000..5388357 --- /dev/null +++ b/WeShare/wwwroot/app.css @@ -0,0 +1,38 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/WeShare/wwwroot/css/weshare.css b/WeShare/wwwroot/css/weshare.css new file mode 100644 index 0000000..1938aec --- /dev/null +++ b/WeShare/wwwroot/css/weshare.css @@ -0,0 +1,72 @@ +/* WeShare Cupertino Liquid Theme & Visual Effects */ +@keyframes pulseGlow { + 0%, 100% { opacity: 0.45; transform: scale(1); } + 50% { opacity: 0.8; transform: scale(1.06); } +} + +@keyframes streamMove { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.animate-glow { + animation: pulseGlow 5s infinite ease-in-out; +} + +.animate-stream { + animation: streamMove 2s infinite linear; +} + +/* Glassmorphism Card (Cupertino Liquid) */ +.glass-panel { + background: rgba(15, 23, 42, 0.65); + backdrop-filter: blur(36px); + -webkit-backdrop-filter: blur(36px); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.15); +} + +.glass-subpanel { + background: rgba(10, 15, 29, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Dropzone States */ +.dropzone-border { + border: 2px dashed rgba(255, 255, 255, 0.2); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.dropzone-border:hover, .drag-active { + border-color: #38bdf8 !important; + background-color: rgba(56, 189, 248, 0.08) !important; + transform: translateY(-1px); +} + +/* Glow Buttons */ +.btn-primary-glow { + background: linear-gradient(135deg, #06b6d4 0%, #6366f1 50%, #a855f7 100%); + box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.4); + transition: all 0.25s ease; +} + +.btn-primary-glow:hover { + box-shadow: 0 15px 30px -5px rgba(6, 182, 212, 0.5); + transform: translateY(-1px); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.5); +} +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.3); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.5); +} diff --git a/WeShare/wwwroot/js/weshare-uploader.js b/WeShare/wwwroot/js/weshare-uploader.js new file mode 100644 index 0000000..9be88b3 --- /dev/null +++ b/WeShare/wwwroot/js/weshare-uploader.js @@ -0,0 +1,289 @@ +// WeShare Chunked Streaming Uploader (Up to 5GB support) +window.WeShareUploader = (function () { + let activeSessionId = null; + let isCancelled = false; + let currentXhr = null; + let dotNetHelper = null; + + function registerDotNet(helper) { + dotNetHelper = helper; + } + + function initDropzone(dropzoneElementId, fileInputElementId) { + const dropzone = document.getElementById(dropzoneElementId); + const fileInput = document.getElementById(fileInputElementId); + + if (!dropzone) return; + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropzone.addEventListener(eventName, preventDefaults, false); + document.body.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + dropzone.addEventListener(eventName, () => dropzone.classList.add('drag-active'), false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + dropzone.addEventListener(eventName, () => dropzone.classList.remove('drag-active'), false); + }); + + dropzone.addEventListener('drop', (e) => { + const dt = e.dataTransfer; + const files = dt.files; + handleSelectedFiles(files); + }); + + if (fileInput) { + fileInput.addEventListener('change', (e) => { + handleSelectedFiles(e.target.files); + }); + } + } + + let selectedFilesStore = []; + + function handleSelectedFiles(files) { + if (!files || files.length === 0) return; + + const fileItems = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = generateUUID(); + selectedFilesStore.push({ id: fileId, file: file }); + fileItems.push({ + fileId: fileId, + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream' + }); + } + + if (dotNetHelper) { + dotNetHelper.invokeMethodAsync('OnFilesSelected', fileItems); + } + } + + function removeSelectedFile(fileId) { + selectedFilesStore = selectedFilesStore.filter(f => f.id !== fileId); + } + + function clearSelectedFiles() { + selectedFilesStore = []; + } + + async function startUpload(expirationDays, password) { + if (selectedFilesStore.length === 0) { + alert("Prosím vyberte aspoň jeden súbor."); + return; + } + + isCancelled = false; + + // 1. Initialize upload session + const initPayload = { + files: selectedFilesStore.map(f => ({ + fileId: f.id, + name: f.file.name, + size: f.file.size, + type: f.file.type || 'application/octet-stream' + })), + expirationDays: expirationDays, + password: password + }; + + let initRes; + try { + const response = await fetch('/api/upload/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(initPayload) + }); + if (!response.ok) throw new Error("Chyba inicializácie nahrávania."); + initRes = await response.json(); + } catch (err) { + if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', err.message); + return; + } + + activeSessionId = initRes.sessionId; + const chunkSize = initRes.chunkSize || (10 * 1024 * 1024); // 10MB chunk + + const totalBytesAllFiles = selectedFilesStore.reduce((acc, f) => acc + f.file.size, 0); + let uploadedBytesAllFiles = 0; + const startTime = Date.now(); + let lastSpeedSampleTime = startTime; + let lastSampleUploadedBytes = 0; + let currentSpeedMb = 0; + + // 2. Upload chunks for each file + for (let fIdx = 0; fIdx < selectedFilesStore.length; fIdx++) { + if (isCancelled) break; + + const item = selectedFilesStore[fIdx]; + const file = item.file; + const fileId = item.id; + const totalChunks = Math.ceil(file.size / chunkSize); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + if (isCancelled) break; + + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunkBlob = file.slice(start, end); + const chunkBytes = end - start; + + const formData = new FormData(); + formData.append('sessionId', activeSessionId); + formData.append('fileId', fileId); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('chunk', chunkBlob, file.name); + + try { + await uploadChunk(formData); + } catch (err) { + if (isCancelled) return; + if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', "Chyba prenosu: " + err.message); + return; + } + + uploadedBytesAllFiles += chunkBytes; + + // Calculate speed & ETA + const now = Date.now(); + const elapsedSinceSample = (now - lastSpeedSampleTime) / 1000; + if (elapsedSinceSample >= 0.5 || chunkIndex === totalChunks - 1) { + const bytesSinceSample = uploadedBytesAllFiles - lastSampleUploadedBytes; + currentSpeedMb = ((bytesSinceSample / (1024 * 1024)) / elapsedSinceSample).toFixed(1); + lastSpeedSampleTime = now; + lastSampleUploadedBytes = uploadedBytesAllFiles; + } + + const percent = Math.min(100, (uploadedBytesAllFiles / totalBytesAllFiles) * 100); + const totalElapsedSec = (now - startTime) / 1000; + const avgSpeedBytesPerSec = uploadedBytesAllFiles / totalElapsedSec; + const remainingBytes = totalBytesAllFiles - uploadedBytesAllFiles; + const etaSeconds = avgSpeedBytesPerSec > 0 ? Math.round(remainingBytes / avgSpeedBytesPerSec) : 0; + + if (dotNetHelper && !isCancelled) { + dotNetHelper.invokeMethodAsync('OnUploadProgress', { + percent: percent, + speedMb: parseFloat(currentSpeedMb) || 0, + etaSeconds: etaSeconds, + uploadedBytes: uploadedBytesAllFiles, + totalBytes: totalBytesAllFiles, + currentFileName: file.name + }); + } + } + } + + if (isCancelled) { + return; + } + + // 3. Complete Upload + const completePayload = { + sessionId: activeSessionId, + expirationDays: expirationDays, + password: password, + files: initPayload.files + }; + + try { + const compRes = await fetch('/api/upload/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(completePayload) + }); + const result = await compRes.json(); + if (result.success && dotNetHelper) { + dotNetHelper.invokeMethodAsync('OnUploadCompleted', result); + clearSelectedFiles(); + } else if (dotNetHelper) { + dotNetHelper.invokeMethodAsync('OnUploadError', result.error || 'Dokončenie zlyhalo.'); + } + } catch (err) { + if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', err.message); + } + } + + function uploadChunk(formData) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + currentXhr = xhr; + xhr.open('POST', '/api/upload/chunk', true); + + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.responseText); + } else { + reject(new Error(`Server vrátil kód ${xhr.status}`)); + } + }; + + xhr.onerror = function () { + reject(new Error("Zlyhalo sieťové spojenie so serverom.")); + }; + + xhr.send(formData); + }); + } + + function cancelUpload() { + isCancelled = true; + if (currentXhr) { + try { currentXhr.abort(); } catch (e) { } + } + + if (activeSessionId) { + fetch(`/api/upload/cancel/${activeSessionId}`, { method: 'POST' }).catch(() => { }); + activeSessionId = null; + } + + if (dotNetHelper) { + dotNetHelper.invokeMethodAsync('OnUploadCancelled'); + } + } + + function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } else { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + return new Promise((res, rej) => { + document.execCommand('copy') ? res() : rej(); + textArea.remove(); + }); + } + } + + function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + return { + registerDotNet, + initDropzone, + removeSelectedFile, + clearSelectedFiles, + startUpload, + cancelUpload, + copyToClipboard + }; +})();