From 7ce8ed3afaefe329ed88297d7a5a45743d8a1add Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:34:53 -0500 Subject: [PATCH] chore: initial commit --- .editorconfig | 389 +++++++++++++++++++++++ .gitignore | 490 +++++++++++++++++++++++++++++ LICENSE.md | 19 ++ README.md | 64 ++++ src/ChunkedUploadExample.csproj | 20 ++ src/Program.cs | 286 +++++++++++++++++ src/Properties/launchSettings.json | 23 ++ src/wwwroot/index.html | 41 +++ src/wwwroot/index.js | 240 ++++++++++++++ src/wwwroot/style.css | 112 +++++++ 10 files changed, 1684 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 src/ChunkedUploadExample.csproj create mode 100644 src/Program.cs create mode 100644 src/Properties/launchSettings.json create mode 100644 src/wwwroot/index.html create mode 100644 src/wwwroot/index.js create mode 100644 src/wwwroot/style.css diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ed0be65 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,389 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed016d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,490 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# appsettings +appsettings*.json + +# dotenv files +.env + +# 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/ +[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 +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# 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 +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.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 +# but not directories ending in .e2e +!*.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 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# 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/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# 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 + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Official VS Code C# Dev Kit Extension exclusion +*.lscache + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5b98c14 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2026 Stevan Freeborn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b30cfc9 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Chunked & Resumable File Upload Example + +This is a reference implementation for chunked, parallel, and resumable file uploads using a minimal API and vanilla JavaScript. + +The implementation was developed within the constraints of using on-premise local disk storage hosted behind a reverse proxy with payload size limits that would otherwise prevent handling large file uploads. + +## Architecture + +The storage is designed around direct random-access offset writing. As data chunks arrive, they are written directly into a single `upload.part` file using `FileShare.ReadWrite`. The exact byte position of each chunk is calculated using `offset = index * chunkSize`. This removes double-storage overhead by avoiding temporary file duplication and reassembly on disk. + +This approach makes file assembly basically instant. Finalizing a file takes less than 1 ms and operates with O(1) time complexity because the server only needs to atomically rename `upload.part` to its destination name using `File.Move`. This eliminates the need for background worker services, disk-copying loops, or complex progress-tracking channels between the server and client. + +To guarantee end-to-end data integrity across parallel chunk transmissions, the program uses a two-tiered SHA-256 verification model alongside deterministic session hashing: + +- **Per-Chunk Verification**: On the client, SHA-256 hashes are calculated lazily for each chunk right before transmission using `crypto.subtle.digest`. Upon receipt, the server streams the chunk payload, independently computes its SHA-256 digest, and validates it against the client's hash using constant-time byte comparison (`CryptographicOperations.FixedTimeEquals`). Once validated, the chunk is written to disk alongside a `{index}-{hash}.done` marker file. +- **Composite End-to-End File Verification**: When all chunks finish uploading, the client orders the individual chunk hashes, joins them into a comma-separated string (`hash_0,hash_1,..., hash_n-1`), and computes a final composite SHA-256 hash. When `/Files/{fileId}/complete` is invoked, the server independently recalculates the composite hash from its stored `.done` marker files. The atomic `File.Move` rename executes only if the composite hashes match, guaranteeing that no chunks were corrupted, omitted, or reassembled out of sequence. +- **Deterministic Session Identification**: The session `fileId` is created by hashing the file's primary metadata (`name-size-lastModified`) into a standard GUID string. This ensures that re-selecting an interrupted file produces the exact same session key, allowing the program to query existing `.done` markers and resume uploading without starting over. + +On the client, concurrency is controlled through a worker pool to prevent browser socket exhaustion. The client performs lazy hashing by calculating SHA-256 hashes on-the-fly right before each chunk is transmitted, keeping main thread memory usage low and the UI responsive. + +Once files are successfully uploaded, the program supports immediate media range streaming. The `GET /Files/{fileId}/download` endpoint enables HTTP Range processing, allowing users to stream audio/video or view files directly within browser tabs without downloading the entire file first. + +> [!NOTE] +> The program is packaged as a standalone, single-file native executable using **.NET Native AOT compilation**. All static web assets (HTML, JavaScript, CSS) are embedded directly into the executable via `ManifestEmbeddedFileProvider`. To maintain compatibility with AOT compilation, the program relies on reflection-free `System.Text.Json` source generation. + +## How It Works + +- **Session Initialization (`POST /Files/{fileId}`)**: Client computes a GUID from the file's metadata and requests a session. Returns `200 OK` for new uploads or `409 Conflict` if a session already exists. +- **Resumability Check (`GET /Files/{fileId}/status`)**: Server scans for existing `{index}-{hash}.done` marker files and returns an array of completed chunk indices. +- **Parallel Chunk Uploads (`POST /Files/{fileId}/upload`)**: Worker pool sends chunks concurrently. Chunks are verified against their SHA-256 hash and written asynchronously into `upload.part` at `index * chunkSize`. +- **Completion (`POST /Files/{fileId}/complete`)**: Server validates the composite hash of all completed chunks and renames `upload.part` to the final filename. +- **Retrieval (`GET /Files/{fileId}/download`)**: Serves the finalized file with full HTTP Range request support for playback or viewing. + +## Building and Running + +### Development Mode + +Run the project directly using the .NET SDK: + +```powershell +dotnet run --project .\src\ +``` + +### Publish as a Single-File Native AOT Executable + +Compile the program into a single, self-contained native binary: + +```powershell +# Windows +dotnet publish .\src\ -c Release -r win-x64 +dotnet publish .\src\ -c Release -r win-arm64 + +# Linux +dotnet publish ./src/ -c Release -r linux-x64 +dotnet publish ./src/ -c Release -r linux-arm64 + +# macOS +dotnet publish ./src/ -c Release -r osx-arm64 +dotnet publish ./src/ -c Release -r osx-x64 +``` + +The output binary will be located in: + +`.\src\bin\Release\net11.0\\publish\` diff --git a/src/ChunkedUploadExample.csproj b/src/ChunkedUploadExample.csproj new file mode 100644 index 0000000..6d61fa5 --- /dev/null +++ b/src/ChunkedUploadExample.csproj @@ -0,0 +1,20 @@ + + + + net11.0 + enable + enable + true + true + + + + + + + + + + + + diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..754b8fd --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,286 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions( + options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default) +); + +builder.Services.AddSingleton(); + +builder.Services.AddProblemDetails(); + +var app = builder.Build(); +var fileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); + +app.UseHttpsRedirection(); + +app.UseDefaultFiles(new DefaultFilesOptions +{ + FileProvider = fileProvider, +}); + +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = fileProvider, +}); + +app.UseStatusCodePages(); + +app.MapPost("/Files/{fileId}", (string fileId, FileService fileService) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory)) + { + return Results.Conflict(new SessionResponse(fileId, "Session already exists, resuming upload.")); + } + + Directory.CreateDirectory(fileDirectory); + + return Results.Ok(new SessionResponse(fileId, "New upload session created.")); +}); + +app.MapGet("/Files/{fileId}/status", (string fileId, FileService fileService) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory) is false) + { + return Results.NotFound("Session not found."); + } + + var uploadedChunks = Directory.GetFiles(fileDirectory, "*.done") + .Select(n => + { + var parts = Path.GetFileNameWithoutExtension(n).Split('-'); + return new ChunkStatusInfo(int.Parse(parts[0]), parts[1]); + }) + .OrderBy(c => c.Index) + .ToList(); + + return Results.Ok(new SessionStatusResponse(fileId, uploadedChunks)); +}); + +app.MapPost("/Files/{fileId}/upload", async ( + string fileId, + IFormFile chunk, + [FromForm] int index, + [FromForm] long chunkSize, + [FromForm] string hash, + FileService fileService +) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory) is false) + { + return Results.NotFound("Session not found."); + } + + if (chunk.Length is 0 || chunk.Length > 10_000_000) + { + return Results.BadRequest("Invalid chunk length."); + } + + if (index < 0 || chunkSize <= 0) + { + return Results.BadRequest("Invalid index or chunkSize."); + } + + var markerFilePath = Path.Combine(fileDirectory, $"{index}-{hash}.done"); + + if (File.Exists(markerFilePath)) + { + return Results.Ok(new ChunkUploadResponse(fileId, index, "already_exists")); + } + + using var formStream = chunk.OpenReadStream(); + var hashBytes = await SHA256.HashDataAsync(formStream); + var calculatedHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(calculatedHash), Encoding.UTF8.GetBytes(hash.ToLowerInvariant())) is false) + { + return Results.BadRequest("Chunk hash mismatch."); + } + + var partFilePath = Path.Combine(fileDirectory, "upload.part"); + long byteOffset = index * chunkSize; + + var fileStreamOptions = new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.Write, + Share = FileShare.ReadWrite, + BufferSize = 81920, + Options = FileOptions.Asynchronous + }; + + formStream.Position = 0; + + using var targetStream = new FileStream(partFilePath, fileStreamOptions); + targetStream.Seek(byteOffset, SeekOrigin.Begin); + await formStream.CopyToAsync(targetStream); + + await File.WriteAllTextAsync(markerFilePath, string.Empty); + + return Results.Ok(new ChunkUploadResponse(fileId, index, "ok")); +}); + +app.MapPost("/Files/{fileId}/complete", ( + string fileId, + FileCompleteRequest request, + FileService fileService +) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory) is false) + { + return Results.NotFound(); + } + + var safeFilename = Path.GetFileName(request.Filename); + var partFilePath = Path.Combine(fileDirectory, "upload.part"); + var finalFilePath = Path.Combine(fileDirectory, safeFilename); + + if (File.Exists(partFilePath) is false) + { + return Results.BadRequest("No upload data found."); + } + + var completedChunkHashes = Directory.GetFiles(fileDirectory, "*.done") + .Select(n => + { + var parts = Path.GetFileNameWithoutExtension(n).Split('-'); + return (Index: int.Parse(parts[0]), Hash: parts[1]); + }) + .OrderBy(t => t.Index) + .Select(t => t.Hash); + + var compositeHashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(',', completedChunkHashes))); + var compositeHash = Convert.ToHexString(compositeHashBytes).ToLowerInvariant(); + + if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(compositeHash), Encoding.UTF8.GetBytes(request.CompositeHash.ToLowerInvariant())) is false) + { + return Results.BadRequest("Composite hash mismatch."); + } + + File.Move(partFilePath, finalFilePath, overwrite: true); + + foreach (var marker in Directory.GetFiles(fileDirectory, "*.done")) + { + File.Delete(marker); + } + + return Results.Ok(new CompleteResponse("complete", finalFilePath)); +}); + +app.MapGet("/Files/{fileId}/download", (string fileId, FileService fileService) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory) is false) + { + return Results.NotFound("Session not found."); + } + + var filePath = Directory.GetFiles(fileDirectory) + .FirstOrDefault(f => f.EndsWith(".done") is false && f.EndsWith(".part") is false); + + if (filePath == null || File.Exists(filePath) is false) + { + return Results.NotFound("Assembled file not found."); + } + + var fileName = Path.GetFileName(filePath); + var provider = new FileExtensionContentTypeProvider(); + + if (provider.TryGetContentType(fileName, out var contentType) is false) + { + contentType = "application/octet-stream"; + } + + return Results.File(filePath, contentType, enableRangeProcessing: true); +}); + +app.MapDelete("/Files/{fileId}", (string fileId, FileService fileService) => +{ + if (fileService.IsValidFileId(fileId) is false) + { + return Results.BadRequest("Invalid fileId."); + } + + var fileDirectory = fileService.GetFileDirectory(fileId); + + if (Directory.Exists(fileDirectory) is false) + { + return Results.NotFound(); + } + + Directory.Delete(fileDirectory, true); + + return Results.NoContent(); +}); + +app.Run(); + +record FileCompleteRequest(string Filename, string CompositeHash); +record SessionResponse(string FileId, string Message); +record ChunkStatusInfo(int Index, string Hash); +record SessionStatusResponse(string FileId, List UploadedChunks); +record ChunkUploadResponse(string FileId, int Index, string Status); +record CompleteResponse(string Status, string Path); + +[JsonSerializable(typeof(FileCompleteRequest))] +[JsonSerializable(typeof(SessionResponse))] +[JsonSerializable(typeof(ChunkStatusInfo))] +[JsonSerializable(typeof(SessionStatusResponse))] +[JsonSerializable(typeof(ChunkUploadResponse))] +[JsonSerializable(typeof(CompleteResponse))] +internal partial class AppJsonContext : JsonSerializerContext +{ +} + +internal class FileService +{ + public bool IsValidFileId(string fileId) => Guid.TryParse(fileId, out _); + + public string GetFileDirectory(string fileId) + { + return IsValidFileId(fileId) is false + ? throw new ArgumentException("Invalid file ID", nameof(fileId)) + : Path.Combine(AppContext.BaseDirectory, "Files", fileId); + } +} \ No newline at end of file diff --git a/src/Properties/launchSettings.json b/src/Properties/launchSettings.json new file mode 100644 index 0000000..8c4554f --- /dev/null +++ b/src/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7181;http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/wwwroot/index.html b/src/wwwroot/index.html new file mode 100644 index 0000000..8a292e0 --- /dev/null +++ b/src/wwwroot/index.html @@ -0,0 +1,41 @@ + + + + + + + Chunked Upload Example + + + + + +
+
+
+
+ + +
+
+ +
+
+

File:

+
+
+
+ + + +
+
+
+
+ + + diff --git a/src/wwwroot/index.js b/src/wwwroot/index.js new file mode 100644 index 0000000..46b3611 --- /dev/null +++ b/src/wwwroot/index.js @@ -0,0 +1,240 @@ +document.addEventListener("DOMContentLoaded", () => { + const fileInputElement = document.getElementById("file"); + const chunksDisplayElement = document.getElementById("chunks-display"); + const fileIdElement = document.getElementById("fileId"); + const fileStatusElement = document + .getElementById("fileStatus") + ?.querySelector(".status"); + const fileRedXElement = document + .getElementById("fileStatus") + ?.querySelector(".red-x"); + const fileCheckmarkElement = document + .getElementById("fileStatus") + ?.querySelector(".checkmark"); + const fileSpinnerElement = document + .getElementById("fileStatus") + ?.querySelector(".spinner"); + + const fileDownloadElement = document.getElementById("fileDownload"); + const downloadLinkElement = document.getElementById("downloadLink"); + + if ( + !fileInputElement || + !chunksDisplayElement || + !fileIdElement || + !fileStatusElement + ) { + throw new Error("Required DOM elements missing."); + } + + fileInputElement.addEventListener("change", async function(e) { + const file = e?.target?.files?.item(0); + + if (!file) { + return; + } + + fileIdElement.innerText = ""; + chunksDisplayElement.innerHTML = ""; + + if (fileDownloadElement) { + fileDownloadElement.classList.add("hidden"); + } + + if (downloadLinkElement) { + downloadLinkElement.href = "#"; + } + + const fileId = await generateDeterministicFileId(file); + fileIdElement.innerText = fileId; + fileSpinnerElement?.classList.remove("hidden"); + fileRedXElement?.classList.add("hidden"); + fileCheckmarkElement?.classList.add("hidden"); + fileStatusElement.innerText = "Checking upload session..."; + + const createFileResponse = await fetch(`/files/${fileId}`, { + method: "POST", + }); + let existingChunksMap = new Map(); + + if (createFileResponse.status === 409 || createFileResponse.ok) { + const statusRes = await fetch(`/Files/${fileId}/status`); + + if (statusRes.ok) { + const statusData = await statusRes.json(); + + for (const item of statusData.uploadedChunks || []) { + existingChunksMap.set(item.index, item.hash); + } + } + } else { + fileSpinnerElement?.classList.add("hidden"); + fileRedXElement?.classList.remove("hidden"); + fileStatusElement.innerText = "Unable to create upload session"; + return; + } + + const CHUNK_SIZE = 8 * 1024 * 1024; + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const chunkHashes = new Array(totalChunks); + const pendingIndices = []; + + for (let index = 0; index < totalChunks; index++) { + const chunkElement = document.createElement("div"); + const chunkId = `${fileId}-${index}`; + const isAlreadyUploaded = existingChunksMap.has(index); + + if (isAlreadyUploaded) { + chunkHashes[index] = existingChunksMap.get(index); + } else { + pendingIndices.push(index); + } + + chunkElement.innerHTML = ` +
+
Chunk ${index}
+
+ +
✅
+
+
${isAlreadyUploaded ? "Resumed (On Disk)" : "In Progress"}
+
+
`; + + chunksDisplayElement.appendChild(chunkElement); + } + + fileStatusElement.innerText = + pendingIndices.length < totalChunks + ? "Resuming upload..." + : "Uploading..."; + + const MAX_CONCURRENCY = 4; + let uploadFailed = false; + + async function worker() { + while (pendingIndices.length > 0 && !uploadFailed) { + const index = pendingIndices.shift(); + const start = index * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const blob = file.slice(start, end); + + const chunkId = `${fileId}-${index}`; + const chunkElement = document.getElementById(chunkId); + const chunkRedX = chunkElement?.querySelector(".red-x"); + const chunkCheckmark = chunkElement?.querySelector(".checkmark"); + const chunkSpinner = chunkElement?.querySelector(".spinner"); + const chunkStatus = chunkElement?.querySelector(".status"); + + try { + const hash = await calculateHash(blob); + chunkHashes[index] = hash; + + const formData = new FormData(); + formData.append("chunk", blob); + formData.append("index", index); + formData.append("chunkSize", CHUNK_SIZE); + formData.append("hash", hash); + + const response = await fetch(`/Files/${fileId}/upload`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Chunk ${index} failed`); + } + + chunkCheckmark?.classList.remove("hidden"); + chunkSpinner?.classList.add("hidden"); + + if (chunkStatus) { + chunkStatus.innerText = "Succeeded"; + } + } catch (err) { + console.err(err); + uploadFailed = true; + + chunkRedX?.classList.remove("hidden"); + chunkSpinner?.classList.add("hidden"); + + if (chunkStatus) { + chunkStatus.innerText = "Failed"; + } + } + } + } + + const workers = Array.from( + { + length: Math.min(MAX_CONCURRENCY, Math.max(1, pendingIndices.length)), + }, + () => worker(), + ); + + await Promise.all(workers); + + if (uploadFailed) { + fileSpinnerElement?.classList.add("hidden"); + fileRedXElement?.classList.remove("hidden"); + fileStatusElement.innerText = + "Upload paused/failed. Select file again to resume."; + return; + } + + const sortedHashes = chunkHashes.join(","); + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(sortedHashes); + const compositeHashBuffer = await crypto.subtle.digest( + "SHA-256", + uint8Array, + ); + const compositeHashHex = convertToHexString(compositeHashBuffer); + + const completeResponse = await fetch(`/Files/${fileId}/complete`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + compositeHash: compositeHashHex, + }), + }); + + if (completeResponse.ok) { + fileSpinnerElement?.classList.add("hidden"); + fileCheckmarkElement?.classList.remove("hidden"); + fileStatusElement.innerText = "File uploaded and assembled successfully"; + + if (downloadLinkElement && fileDownloadElement) { + downloadLinkElement.href = `/Files/${fileId}/download`; + fileDownloadElement.classList.remove("hidden"); + } + } else { + fileSpinnerElement?.classList.add("hidden"); + fileRedXElement?.classList.remove("hidden"); + fileStatusElement.innerText = "File assembly failed"; + } + }); +}); + +async function generateDeterministicFileId(file) { + const signature = `${file.name}-${file.size}-${file.lastModified}`; + const encoder = new TextEncoder(); + const data = encoder.encode(signature); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hex = convertToHexString(hashBuffer); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +async function calculateHash(blob) { + const buffer = await blob.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + return convertToHexString(hashBuffer); +} + +function convertToHexString(buffer) { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .toLowerCase(); +} diff --git a/src/wwwroot/style.css b/src/wwwroot/style.css new file mode 100644 index 0000000..49e0207 --- /dev/null +++ b/src/wwwroot/style.css @@ -0,0 +1,112 @@ +* { + padding: 0; + margin: 0; + box-sizing: border-box; +} + +.hidden { + display: none; +} + +:root { + font-size: 16px; + font-family: Arial, Helvetica, sans-serif; +} + +html, +body { + height: 100%; +} + +body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + overflow: hidden; +} + +main { + display: flex; + justify-content: start; + gap: 1rem; + flex: 1; + width: 100%; + overflow: hidden; +} + +main > * { + flex: 1; + border: 0.25rem solid black; + padding: 0.5rem; +} + +fieldset { + display: flex; + flex-direction: column; + gap: 0.25rem; + border: none; +} + +fieldset label { + font-weight: bold; +} + +.chunks { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#chunks-display { + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow-y: scroll; +} + +.chunk { + display: flex; + justify-content: space-between; + gap: 0.25rem; +} + +.chunk-status { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.spinner { + height: 1rem; + width: 1rem; + border: 2px solid grey; + border-left: 2px solid red; + border-radius: 50%; +} + +.spinner:not(.hidden) { + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + 0% { + rotate: 0; + } + 50% { + rotate: 180deg; + } + 100% { + rotate: 360deg; + }; +} + +#fileStatus { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 0.5rem; +}