weshare impl

This commit is contained in:
Michal Vrba
2026-09-26 22:17:32 +02:00
commit 95e3a7972d
31 changed files with 2902 additions and 0 deletions
+363
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="WeShare/WeShare.csproj" />
</Solution>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="sk" class="h-full bg-[#070b14]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<title>WeShare — Zdieľame medzi sebou (.NET 10)</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom Theme Styles -->
<link rel="stylesheet" href="css/weshare.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["WeShare.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<body class="h-full text-slate-100 font-sans antialiased select-none bg-[#070b14] overflow-x-hidden">
<Routes />
<ReconnectModal />
<script src="js/weshare-uploader.js"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -0,0 +1,92 @@
@inherits LayoutComponentBase
<div class="min-h-screen flex flex-col relative bg-[#070b14] overflow-hidden text-slate-100">
<!-- Ambient Glowing Connection Mesh Background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden z-0">
<!-- Glowing Orbs -->
<div class="absolute -top-32 -left-32 w-[550px] h-[550px] rounded-full bg-indigo-600/15 blur-[140px] animate-glow"></div>
<div class="absolute top-1/3 -right-32 w-[500px] h-[500px] rounded-full bg-cyan-500/15 blur-[140px] animate-glow" style="animation-delay: 2.5s;"></div>
<div class="absolute -bottom-32 left-1/3 w-[450px] h-[450px] rounded-full bg-purple-600/15 blur-[120px] animate-glow" style="animation-delay: 1.2s;"></div>
<!-- Subtle SVG Connection Waves & Constellations -->
<svg class="absolute inset-0 w-full h-full opacity-25" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="layout-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C084FC" stop-opacity="0.6"/>
<stop offset="50%" stop-color="#38BDF8" stop-opacity="0.8"/>
<stop offset="100%" stop-color="#818CF8" stop-opacity="0.4"/>
</linearGradient>
<filter id="glow-fx" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path d="M-100,220 C300,80 600,420 1200,240 S1800,360 2200,190" fill="none" stroke="url(#layout-grad)" stroke-width="2" filter="url(#glow-fx)" stroke-dasharray="8 6"/>
<path d="M-50,420 C400,560 700,160 1300,380 S1700,140 2100,420" fill="none" stroke="url(#layout-grad)" stroke-width="1.5" opacity="0.6"/>
<circle cx="20%" cy="30%" r="3.5" fill="#38BDF8" opacity="0.7" filter="url(#glow-fx)"/>
<circle cx="80%" cy="65%" r="4" fill="#C084FC" opacity="0.7" filter="url(#glow-fx)"/>
<circle cx="45%" cy="75%" r="2.5" fill="#818CF8" opacity="0.6"/>
<circle cx="85%" cy="25%" r="3" fill="#22D3EE" opacity="0.7"/>
</svg>
</div>
<!-- Header / Navbar -->
<header class="relative z-20 border-b border-white/5 bg-slate-950/40 backdrop-blur-md px-4 lg:px-8 py-3.5">
<div class="max-w-6xl mx-auto flex items-center justify-between">
<a href="/" class="flex items-center gap-3 group">
<!-- Interconnected 'W' Logo -->
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-purple-500 via-indigo-500 to-cyan-400 p-0.5 shadow-lg shadow-indigo-500/20 group-hover:shadow-cyan-500/30 transition duration-300 flex items-center justify-center">
<div class="w-full h-full bg-slate-950 rounded-[10px] flex items-center justify-center">
<svg class="w-6 h-6" viewBox="0 0 32 32" fill="none">
<circle cx="7" cy="8" r="3" fill="#C084FC" />
<circle cx="25" cy="8" r="3" fill="#22D3EE" />
<circle cx="16" cy="19" r="3.5" fill="#818CF8" />
<path d="M7 11L11 25L16 19L21 25L25 11" stroke="url(#w-logo-grad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="w-logo-grad" x1="7" y1="8" x2="25" y2="8" gradientUnits="userSpaceOnUse">
<stop stop-color="#C084FC"/>
<stop offset="0.5" stop-color="#818CF8"/>
<stop offset="1" stop-color="#22D3EE"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
<div>
<span class="font-extrabold text-xl tracking-tight text-white">We<span class="text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400">Share</span></span>
<span class="block text-[10px] text-indigo-300 font-medium tracking-wide">Zdieľame medzi sebou</span>
</div>
</a>
<div class="flex items-center gap-3">
<span class="hidden sm:inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
Limit: 5.0 GB
</span>
<span class="text-xs px-2.5 py-1 rounded-full bg-white/5 text-slate-300 border border-white/10 font-mono">
.NET 10
</span>
</div>
</div>
</header>
<!-- Main Dynamic Content -->
<main class="relative z-10 flex-1 flex flex-col justify-center items-center px-4 py-8 md:py-12">
@Body
</main>
<!-- Footer -->
<footer class="relative z-20 border-t border-white/5 py-4 px-4 text-center text-xs text-slate-500">
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
<span>&copy; @DateTime.UtcNow.Year WeShare — Blesková a bezpečná výmena súborov</span>
<span>Zero-DB Architecture • On-the-Fly ZIP Streaming</span>
</div>
</footer>
</div>
@@ -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;
}
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -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;
}
}
@@ -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();
}
}
+275
View File
@@ -0,0 +1,275 @@
@page "/d/{Id}"
@page "/download/{Id}"
@rendermode InteractiveServer
@inject IShareStorageService StorageService
@inject IPasswordHasher PasswordHasher
@inject NavigationManager NavManager
<PageTitle>WeShare — Stiahnutie súborov</PageTitle>
<div class="w-full max-w-xl mx-auto transition-all duration-300">
@if (isLoading)
{
<div class="glass-panel rounded-3xl p-12 text-center shadow-2xl">
<div class="w-12 h-12 border-4 border-cyan-400 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-sm text-slate-300 font-medium">Načítavam informácie o zdieľaní...</p>
</div>
}
else if (metadata == null)
{
<div class="glass-panel rounded-3xl p-10 text-center shadow-2xl space-y-4">
<div class="w-16 h-16 rounded-3xl bg-amber-500/10 text-amber-400 border border-amber-500/20 mx-auto flex items-center justify-center">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-white">Súbor neexistuje alebo expiroval</h3>
<p class="text-xs text-slate-400 max-w-sm mx-auto">Tento odkaz na stiahnutie už nie je platný, bol vymazaný alebo vypršala jeho platnosť.</p>
<div class="pt-2">
<a href="/" class="px-5 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-semibold transition inline-block">
Prejsť na WeShare &rarr;
</a>
</div>
</div>
}
else if (metadata.IsExpired)
{
<div class="glass-panel rounded-3xl p-10 text-center shadow-2xl space-y-4">
<div class="w-16 h-16 rounded-3xl bg-red-500/10 text-red-400 border border-red-500/20 mx-auto flex items-center justify-center">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-white">Platnosť odkazu vypršala</h3>
<p class="text-xs text-slate-400 max-w-sm mx-auto">Platnosť tohto súboru vypršala @metadata.ExpiresAtUtc?.ToString("d. M. yyyy HH:mm") UTC.</p>
<div class="pt-2">
<a href="/" class="px-5 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-semibold transition inline-block">
Nahrať nový súbor &rarr;
</a>
</div>
</div>
}
else if (metadata.IsPasswordProtected && !isPasswordUnlocked)
{
<!-- Password Prompt -->
<div class="glass-panel rounded-3xl p-8 sm:p-10 shadow-2xl space-y-6 text-center">
<div class="w-16 h-16 rounded-3xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 mx-auto flex items-center justify-center shadow-lg shadow-indigo-500/10">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div>
<h3 class="text-xl font-bold text-white tracking-tight">Tento súbor je chránený heslom</h3>
<p class="text-xs text-slate-400 mt-1">Pre prístup k súborom zadajte heslo od odosielateľa</p>
</div>
<div class="space-y-3">
<input type="password"
@bind="enteredPassword"
@bind:event="oninput"
@onkeyup="HandlePasswordKeyUp"
placeholder="Zadajte heslo pre odomknutie..."
class="w-full bg-slate-900/90 border border-white/15 focus:border-indigo-400 rounded-2xl px-4 py-3 text-sm text-slate-100 text-center tracking-widest placeholder:tracking-normal placeholder:text-slate-600 focus:outline-none" />
@if (!string.IsNullOrEmpty(passwordError))
{
<p class="text-xs text-red-400 font-medium">@passwordError</p>
}
<button @onclick="UnlockWithPassword"
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition">
Odomknúť a zobraziť súbory
</button>
</div>
</div>
}
else
{
<!-- Unlocked Download View -->
<div class="space-y-4">
<!-- Header Info -->
<div class="flex items-center justify-between px-2">
<div>
<span class="text-[11px] uppercase tracking-wider text-cyan-400 font-bold">Prijatý balíček súborov</span>
<h2 class="text-2xl font-black text-white tracking-tight">WeShare Sťahovanie</h2>
</div>
<div class="text-right">
<span class="text-xs text-slate-400 block">Celková veľkosť</span>
<span class="text-sm font-bold text-slate-200">@FormatBytes(metadata.TotalSizeBytes)</span>
</div>
</div>
<div class="glass-panel rounded-3xl p-6 sm:p-8 shadow-2xl space-y-6">
<!-- Meta Banner -->
<div class="glass-subpanel rounded-2xl p-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div>
<div class="font-semibold text-slate-200">@metadata.Files.Count @(metadata.Files.Count == 1 ? "súbor" : "súborov") v balíčku</div>
<div class="text-slate-400 text-[11px]">
@(metadata.ExpiresAtUtc.HasValue ? $"Expiruje: {metadata.ExpiresAtUtc.Value.ToLocalTime():d. M. yyyy HH:mm}" : "Bez expirácie")
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono text-[11px]">
SHA-256 Overené
</span>
</div>
</div>
<!-- Files List -->
<div class="space-y-2 max-h-64 overflow-y-auto">
@foreach (var file in metadata.Files)
{
<div class="glass-subpanel rounded-2xl p-3.5 flex items-center justify-between gap-3 hover:bg-white/5 transition">
<div class="flex items-center gap-3 truncate max-w-[70%]">
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 text-indigo-300 flex items-center justify-center font-bold text-xs shrink-0">
@GetFileExtensionShort(file.OriginalFileName)
</div>
<div class="truncate">
<div class="font-medium text-xs text-slate-200 truncate" title="@file.OriginalFileName">@file.OriginalFileName</div>
<div class="text-[11px] text-slate-400">@FormatBytes(file.SizeBytes)</div>
</div>
</div>
<a href="@GetSingleDownloadUrl(file.Id)"
download="@file.OriginalFileName"
class="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-cyan-300 border border-white/10 text-xs font-semibold transition flex items-center gap-1.5 shrink-0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
<span>Stiahnuť</span>
</a>
</div>
}
</div>
<!-- Big Action: Download All as ZIP (On-The-Fly Streaming) -->
@if (metadata.Files.Count > 1)
{
<a href="@GetZipDownloadUrl()"
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition flex items-center justify-center gap-2 shadow-lg block text-center">
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
<span>Stiahnuť všetko ako ZIP (@FormatBytes(metadata.TotalSizeBytes))</span>
</a>
}
else if (metadata.Files.Count == 1)
{
<a href="@GetSingleDownloadUrl(metadata.Files[0].Id)"
download="@metadata.Files[0].OriginalFileName"
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition flex items-center justify-center gap-2 shadow-lg block text-center">
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
<span>Stiahnuť súbor (@FormatBytes(metadata.TotalSizeBytes))</span>
</a>
}
<div class="pt-2 text-center">
<a href="/" class="text-xs text-slate-400 hover:text-white underline transition">
Chcete poslať vlastné súbory? Otvoriť WeShare &rarr;
</a>
</div>
</div>
</div>
}
</div>
@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]}";
}
}
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}
+458
View File
@@ -0,0 +1,458 @@
@page "/"
@rendermode InteractiveServer
@inject IJSRuntime JS
@inject NavigationManager NavManager
@implements IDisposable
<PageTitle>WeShare — Zdieľame medzi sebou</PageTitle>
<div class="w-full max-w-xl mx-auto transition-all duration-300">
<!-- Top Card Header -->
<div class="flex items-center justify-between mb-4 px-2">
<div>
<h2 class="text-2xl font-black text-white tracking-tight flex items-center gap-2">
Rýchle zdieľanie
</h2>
<p class="text-xs text-indigo-300 font-medium">Potiahnite súbory a pošlite priamy odkaz</p>
</div>
<div class="flex items-center gap-1.5 bg-slate-900/60 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-slate-300">
<svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>Max <strong>5 GB</strong></span>
</div>
</div>
<!-- Main Glass Card Container (Cupertino Liquid) -->
<div class="glass-panel rounded-3xl p-6 sm:p-8 shadow-2xl relative transition-all duration-300">
<!-- ================= STAV 1: DROPZONE & VÝBER SÚBOROV ================= -->
@if (currentState == UiState.SelectFiles)
{
<div class="space-y-5">
<!-- Dropzone Area -->
<div id="weshare-dropzone"
class="dropzone-border rounded-2xl p-8 sm:p-10 text-center cursor-pointer relative bg-white/[0.02] hover:bg-white/[0.04] transition group">
<input type="file" id="weshare-file-input" multiple class="absolute inset-0 opacity-0 cursor-pointer z-10" />
<div class="flex flex-col items-center justify-center gap-3.5 pointer-events-none">
<!-- Cloud/Share Icon with Glow -->
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-cyan-500/20 to-purple-500/20 text-cyan-300 flex items-center justify-center shadow-inner group-hover:scale-105 transition-transform">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
</div>
<div>
<p class="text-base font-semibold text-slate-100 group-hover:text-cyan-300 transition">
Presuňte súbory sem alebo <span class="text-cyan-400 underline decoration-cyan-400/40">vyberte z disku</span>
</p>
<p class="text-xs text-slate-400 mt-1">Podpora viacerých súborov naraz • Až do 5 GB</p>
</div>
</div>
</div>
<!-- Selected Files List -->
@if (selectedFiles.Count > 0)
{
<div class="glass-subpanel rounded-2xl p-4 space-y-2.5 max-h-48 overflow-y-auto">
<div class="flex items-center justify-between text-xs text-slate-400 pb-1 border-b border-white/5">
<span>Vybrané súbory (@selectedFiles.Count):</span>
<span class="font-medium text-slate-200">Spolu: @FormatBytes(TotalSelectedBytes)</span>
</div>
@foreach (var file in selectedFiles)
{
<div class="flex items-center justify-between text-xs py-1 px-2 rounded-lg bg-white/5 hover:bg-white/10 transition">
<div class="flex items-center gap-2 truncate max-w-[80%]">
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="text-slate-200 truncate">@file.Name</span>
<span class="text-slate-500 text-[11px] shrink-0">(@FormatBytes(file.Size))</span>
</div>
<button @onclick="() => RemoveFile(file.FileId)" class="text-slate-500 hover:text-red-400 p-1 transition" title="Odstrániť súbor">
✕
</button>
</div>
}
</div>
}
<!-- Parameters Grid (Expiration & Password) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<!-- Expiration Selection -->
<div class="glass-subpanel rounded-2xl p-3.5 flex flex-col justify-between">
<label class="text-xs font-medium text-slate-300 flex items-center gap-1.5 mb-2">
<svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Platnosť odkazu
</label>
<select @bind="expirationDays" class="bg-slate-900/90 border border-white/10 rounded-xl px-3 py-2 text-xs text-slate-100 focus:outline-none focus:border-cyan-400 cursor-pointer">
<option value="1">1 Deň</option>
<option value="7">7 Dní (odporúčané)</option>
<option value="30">30 Dní (1 mesiac)</option>
<option value="0">Nikdy (bez expirácie)</option>
</select>
</div>
<!-- Password Protection -->
<div class="glass-subpanel rounded-2xl p-3.5 flex flex-col justify-between">
<div class="flex items-center justify-between mb-2">
<label class="text-xs font-medium text-slate-300 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Ochrana heslom
</label>
<input type="checkbox" @bind="isPasswordEnabled" class="rounded bg-slate-900 border-white/20 text-indigo-500 focus:ring-0 cursor-pointer" />
</div>
@if (isPasswordEnabled)
{
<input type="password" @bind="passwordValue" @bind:event="oninput" placeholder="Zadajte heslo..." class="w-full bg-slate-900/90 border border-indigo-500/50 rounded-xl px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:border-indigo-400 placeholder:text-slate-600" />
}
else
{
<span class="text-[11px] text-slate-500">Bez hesla (verejný odkaz)</span>
}
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-300 text-xs flex items-center gap-2">
<span>⚠️ @errorMessage</span>
</div>
}
<!-- Upload Submit Button -->
<button @onclick="InitiateUpload"
disabled="@(selectedFiles.Count == 0)"
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<span>Nahrať @(selectedFiles.Count > 0 ? $"({selectedFiles.Count} súborov • {FormatBytes(TotalSelectedBytes)})" : "súbory")</span>
</button>
</div>
}
<!-- ================= STAV 2: UPLOADING (STREAMING PROGRESS & SENDER-RECEIVER BRIDGE) ================= -->
@if (currentState == UiState.Uploading)
{
<div class="space-y-6 py-2">
<!-- Visual 'We Share Between Us' Connection Stream -->
<div class="glass-subpanel rounded-2xl p-4 flex items-center justify-between relative overflow-hidden">
<!-- Sender Avatar (You) -->
<div class="flex flex-col items-center z-10">
<div class="w-12 h-12 rounded-full p-0.5 bg-gradient-to-tr from-purple-500 to-pink-500 shadow-lg shadow-purple-500/30">
<div class="w-full h-full bg-slate-900 rounded-full flex items-center justify-center text-xs font-bold text-purple-300">
JA
</div>
</div>
<span class="text-[10px] text-purple-300 font-semibold mt-1">Odosielateľ</span>
</div>
<!-- Animated Data Bridge Beam -->
<div class="flex-1 mx-4 flex flex-col items-center relative">
<div class="w-full h-2 bg-slate-950 rounded-full overflow-hidden relative border border-white/5">
<div class="absolute inset-0 bg-gradient-to-r from-purple-500 via-cyan-400 to-indigo-500 rounded-full animate-stream"></div>
</div>
<div class="text-[10px] text-cyan-400 font-mono mt-2 flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-cyan-400 animate-ping"></span>
<span>priamy prenos medzi nami</span>
</div>
</div>
<!-- Receiver Avatar (Recipient) -->
<div class="flex flex-col items-center z-10">
<div class="w-12 h-12 rounded-full p-0.5 bg-gradient-to-tr from-cyan-400 to-indigo-500 shadow-lg shadow-cyan-500/30">
<div class="w-full h-full bg-slate-900 rounded-full flex items-center justify-center text-xs font-bold text-cyan-300">
TY
</div>
</div>
<span class="text-[10px] text-cyan-300 font-semibold mt-1">Príjemca</span>
</div>
</div>
<!-- File Summary & Cancel Button -->
<div class="flex items-center justify-between pb-2 border-b border-white/10">
<div class="truncate max-w-[70%]">
<h4 class="font-semibold text-sm text-slate-100 truncate">@currentUploadFileName</h4>
<p class="text-xs text-slate-400">@FormatBytes(currentUploadedBytes) z @FormatBytes(currentTotalBytes)</p>
</div>
<button @onclick="CancelUpload" class="px-3 py-1.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 text-xs font-semibold transition flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span>Zrušiť</span>
</button>
</div>
<!-- Progress Bar & Metrics -->
<div class="space-y-2.5">
<div class="flex justify-between text-xs">
<span class="text-slate-400">Streamovanie dát...</span>
<span class="text-cyan-400 font-extrabold text-sm">@progressPercent.ToString("0.0")%</span>
</div>
<div class="h-3 w-full bg-slate-950 rounded-full overflow-hidden p-0.5 border border-white/10 shadow-inner">
<div class="h-full bg-gradient-to-r from-purple-500 via-indigo-500 to-cyan-400 rounded-full transition-all duration-200"
style="width: @(progressPercent)%"></div>
</div>
<div class="flex items-center justify-between text-[11px] text-slate-400 pt-1">
<span>Rýchlosť: <strong class="text-slate-200">@currentSpeedMb MB/s</strong></span>
<span>Zostáva: <strong class="text-slate-200">@(etaSeconds > 0 ? $"{etaSeconds} sekúnd" : "dokončuje sa...")</strong></span>
</div>
</div>
</div>
}
<!-- ================= STAV 3: SUCCESS & SHARE LINK ================= -->
@if (currentState == UiState.Completed)
{
<div class="space-y-6 text-center py-2">
<div class="w-16 h-16 rounded-3xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 mx-auto flex items-center justify-center shadow-lg shadow-emerald-500/20">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div>
<h3 class="text-xl font-bold text-white tracking-tight">Súbory sú pripravené na zdieľanie!</h3>
<p class="text-xs text-slate-400 mt-1">
Platnosť: @(expirationDays == 0 ? "Nikdy" : $"{expirationDays} dní") • @(isPasswordEnabled ? "Chránené heslom 🔒" : "Bez hesla 🔓") • @FormatBytes(currentTotalBytes)
</p>
</div>
<!-- Copy Link Input Box -->
<div class="glass-subpanel rounded-2xl p-2 flex items-center gap-2 border border-cyan-500/30 shadow-lg">
<input type="text" readonly value="@completedShareUrl" class="bg-transparent text-xs text-cyan-300 font-mono flex-1 px-3 focus:outline-none select-all truncate" />
<button @onclick="CopyLink" class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition flex items-center gap-1.5 shadow">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<span>@copyButtonLabel</span>
</button>
</div>
<!-- QR Code & Action Links -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 pt-2">
<button @onclick="ToggleQrCode" class="text-xs px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 transition flex items-center gap-1.5">
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M4 8h4m0 0V4m0 4h.01M4 16h4m0 0v4m0-4h.01"></path>
</svg>
<span>@(showQrCode ? "Skryť QR Kód" : "Zobraziť QR Kód")</span>
</button>
<a href="@completedShareUrl" target="_blank" class="text-xs px-3.5 py-2 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/20 transition flex items-center gap-1.5">
<span>Otvoriť náhľad príjemcu</span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</div>
@if (showQrCode && !string.IsNullOrEmpty(completedShareId))
{
<div class="glass-subpanel rounded-2xl p-4 inline-block mx-auto border border-white/10">
<img src="/api/share/@completedShareId/qr" alt="QR Code" class="w-44 h-44 rounded-xl mx-auto bg-white p-2" />
<p class="text-[11px] text-slate-400 mt-2">Naskenujte pre okamžité stiahnutie do mobilu</p>
</div>
}
<div class="pt-2">
<button @onclick="ResetForm" class="text-xs text-slate-400 hover:text-white underline transition">
+ Nahrať ďalší súbor
</button>
</div>
</div>
}
</div>
</div>
@code {
private enum UiState { SelectFiles, Uploading, Completed }
private UiState currentState = UiState.SelectFiles;
private List<FileUploadItemDto> 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<Home>? 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<FileUploadItemDto> 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; }
}
}
+5
View File
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
+6
View File
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
+13
View File
@@ -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
+125
View File
@@ -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();
}
+98
View File
@@ -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;
}
}
+26
View File
@@ -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<ShareFileItem> 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;
}
+40
View File
@@ -0,0 +1,40 @@
namespace WeShare.Models;
public class InitUploadRequest
{
public List<FileUploadItemDto> 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<FileUploadItemDto> 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; }
}
+13
View File
@@ -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; }
}
+48
View File
@@ -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<WeShareOptions>(builder.Configuration.GetSection(WeShareOptions.SectionName));
// Configure Large File form options for chunk transfer
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 50 * 1024 * 1024; // 50MB per chunk form request
});
// Register services
builder.Services.AddSingleton<IPasswordHasher, PasswordHasher>();
builder.Services.AddSingleton<IShareStorageService, ShareStorageService>();
builder.Services.AddHostedService<CleanupBackgroundService>();
// 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<App>()
.AddInteractiveServerRenderMode();
app.Run();
+23
View File
@@ -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"
}
}
}
}
@@ -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<WeShareOptions> _optionsMonitor;
private readonly ILogger<CleanupBackgroundService> _logger;
public CleanupBackgroundService(
IServiceProvider serviceProvider,
IOptionsMonitor<WeShareOptions> optionsMonitor,
ILogger<CleanupBackgroundService> 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<IShareStorageService>();
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.");
}
}
+18
View File
@@ -0,0 +1,18 @@
using WeShare.Models;
namespace WeShare.Services;
public interface IShareStorageService
{
Task<InitUploadResponse> InitializeUploadSessionAsync(InitUploadRequest request);
Task AppendChunkAsync(Guid sessionId, Guid fileId, int chunkIndex, Stream chunkStream);
Task CancelUploadSessionAsync(Guid sessionId);
Task<SharePackageMetadata> CompleteUploadSessionAsync(CompleteUploadDto dto);
Task<SharePackageMetadata?> GetMetadataAsync(string idOrShortId);
Task<Stream?> OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId);
Task StreamPackageAsZipAsync(SharePackageMetadata metadata, Stream outputStream);
Task IncrementDownloadCountAsync(SharePackageMetadata metadata);
Task<int> CleanupExpiredPackagesAsync();
}
+45
View File
@@ -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);
}
}
+418
View File
@@ -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<ShareStorageService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public ShareStorageService(
IOptions<WeShareOptions> options,
IPasswordHasher passwordHasher,
ILogger<ShareStorageService> 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<InitUploadResponse> 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<SharePackageMetadata> 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<SharePackageMetadata?> 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<SharePackageMetadata?> LoadMetadataFileAsync(string metaPath)
{
try
{
await using var stream = File.OpenRead(metaPath);
return await JsonSerializer.DeserializeAsync<SharePackageMetadata>(stream, JsonOptions);
}
catch
{
return null;
}
}
public Task<Stream?> OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId)
{
var fileItem = metadata.Files.FirstOrDefault(f => f.Id == fileId);
if (fileItem == null)
return Task.FromResult<Stream?>(null);
string root = GetRootDirectory();
string filePath = Path.Combine(root, metadata.Id.ToString(), fileItem.StoredFileName);
if (!File.Exists(filePath))
return Task.FromResult<Stream?>(null);
Stream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, useAsync: true);
return Task.FromResult<Stream?>(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<int> 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<byte> 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;
}
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="QRCoder" Version="1.8.0" />
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"WeShare": {
"UploadRootFolder": "Uploads",
"MaxUploadSizeBytes": 5368709120,
"ChunkSizeBytes": 10485760,
"EnableAutoCleanup": true,
"CleanupIntervalMinutes": 15
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+38
View File
@@ -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;
}
+72
View File
@@ -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);
}
+289
View File
@@ -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
};
})();