commit c85dd2845f100d10e1ff5ad6a8461c42158f048e Author: Mike Nolan Date: Mon Jul 22 22:49:40 2024 -0500 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a58f1d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,486 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# 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 +*.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 + +# 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 + +# 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/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/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/master/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 + +data/ \ No newline at end of file diff --git a/.woodpecker/.build.yaml b/.woodpecker/.build.yaml new file mode 100644 index 0000000..6ff2d1e --- /dev/null +++ b/.woodpecker/.build.yaml @@ -0,0 +1,10 @@ +steps: + - name: build + image: mcr.microsoft.com/dotnet/sdk:8.0 + when: + event: push + branch: master + commands: + - apt update -y + - apt install -y make zip tar + - make test \ No newline at end of file diff --git a/.woodpecker/.deploy.yaml b/.woodpecker/.deploy.yaml new file mode 100644 index 0000000..c48ef09 --- /dev/null +++ b/.woodpecker/.deploy.yaml @@ -0,0 +1,13 @@ +steps: + - name: deploy + image: mcr.microsoft.com/dotnet/sdk:8.0 + when: + event: tag + branch: master + commands: + - apt update -y + - apt install -y make zip tar + - dotnet workload install wasm-tools + - bash publish.sh + volumes: + - /mnt/20TB/Artifacts:/deploy_dir \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..46ff6af --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,675 @@ +# GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands \`show w' and \`show c' should show the +appropriate parts of the General Public License. Of course, your +program's commands might be different; for a GUI interface, you would +use an "about box". + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU GPL, see . + +The GNU General Public License does not permit incorporating your +program into proprietary programs. If your program is a subroutine +library, you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +GNU Lesser General Public License instead of this License. But first, +please read . \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dedc77 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +all: linux-server linux-client win-server win-client mac-server mac-client +win-server: obj/server-windows/x86 obj/server-windows/x64 obj/server-windows/arm64 +win-client: obj/client-windows/x86 obj/client-windows/x64 obj/client-windows/arm64 +mac-server: obj/server-mac/x64 obj/server-mac/arm64 +mac-client: obj/client-mac/x64 obj/client-mac/arm64 +linux-server: obj/server-linux/x64 obj/server-linux/arm obj/server-linux/arm64 +linux-client: obj/client-linux/x64 obj/client-linux/arm obj/client-linux/arm64 + +blazor: + mkdir -p obj/wwwroot + cd TessesDedupWeb && dotnet publish -o ../obj/ -c Release + +obj/server-linux/%: blazor + rm -rf $@/data/wwwroot || true + mkdir -p $@ || true && cd "TessesDedupServer" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r linux-$(notdir $@) + mkdir -p $@/data/ + cp -r obj/wwwroot $@/data/wwwroot + mkdir -p publish/server + cd $@ && tar cvzf ../../../publish/server/tessesbackup-linux-server-$(notdir $@).tar.gz . + +obj/client-linux/%: + mkdir -p $@ || true && cd "TessesDedupClient" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r linux-$(notdir $@) + mkdir -p publish/client + cd $@ && tar cvzf ../../../publish/client/tessesbackup-linux-client-$(notdir $@).tar.gz . + + +obj/server-mac/%: blazor + rm -rf $@/data/wwwroot || true + mkdir -p $@ || true && cd "TessesDedupServer" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r osx-$(notdir $@) + mkdir -p $@/data/ + cp -r obj/wwwroot $@/data/wwwroot + mkdir -p publish/server + cd $@ && tar cvzf ../../../publish/server/tessesbackup-macos-server-$(notdir $@).tar.gz . + +obj/client-mac/%: + mkdir -p $@ || true && cd "TessesDedupClient" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r osx-$(notdir $@) + mkdir -p publish/client + cd $@ && tar cvzf ../../../publish/client/tessesbackup-macos-client-$(notdir $@).tar.gz . + +obj/server-windows/%: blazor + rm -rf $@/data/wwwroot || true + mkdir -p $@ || true && cd "TessesDedupServer" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r win-$(notdir $@) + mkdir -p $@/data/ + cp -r obj/wwwroot $@/data/wwwroot + mkdir -p publish/server + cd $@ && zip -r ../data.zip . && mv ../data.zip ../../../publish/server/tessesbackup-windows-server-$(notdir $@).zip + +obj/client-windows/%: + mkdir -p $@ || true && cd "TessesDedupClient" && dotnet publish -c Release -o ../$@ --self-contained -p:PublishReadyToRun=true -p:PublishSingleFile=true -r win-$(notdir $@) + mkdir -p publish/client + cd $@ && zip -r ../data.zip . && mv ../data.zip ../../../publish/client/tessesbackup-windows-client-$(notdir $@).zip + +test: + cd TessesDedupWeb && dotnet build + cd TessesDedupServer && dotnet build + cd TessesDedupClient && dotnet build \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4715679 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Tesses Backup + +> I am too lazy right now to properly do this + +> TODO: make this GPLv3 compliant + +## Installing Linux (Client) (assumes x86_64) +```bash + $ wget "https://downloads.tesses.net/artifacts/tesses50/tesses-backup/latest/client/tessesbackup-linux-client-x64.tar.gz" + $ tar xvzf "tessesbackup-linux-client-x64.tar.gz" + $ ./TessesDedupClient --help + $ echo "export PATH=\"\$PATH:$PWD\"" >> ~/.bashrc +``` + +## Installing Linux (Server) (assumes x86_64) +```bash + $ wget "https://downloads.tesses.net/artifacts/tesses50/tesses-backup/latest/server/tessesbackup-linux-server-x64.tar.gz" + $ tar xvzf "tessesbackup-linux-server-x64.tar.gz" + $ ./TessesDedupServer +``` \ No newline at end of file diff --git a/TessesDedup/AccessKey.cs b/TessesDedup/AccessKey.cs new file mode 100644 index 0000000..165cc8b --- /dev/null +++ b/TessesDedup/AccessKey.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace TessesDedup +{ + public class AccessKey + { + [JsonProperty("id")] + public long Id {get;set;} + [JsonIgnore] + public string Key {get;set;}=""; + + [JsonIgnore] + public long UserId {get;set;} + + [JsonProperty("device_name")] + public string DeviceName {get;set;}=""; + + [JsonProperty("creation_date")] + public DateTime CreationDate {get;set;} + + public void NewKey() + { + byte[] bytes=new byte[32]; + using(var rnd = RandomNumberGenerator.Create()) + rnd.GetBytes(bytes); + Key = Convert.ToBase64String(bytes); + } + } +} \ No newline at end of file diff --git a/TessesDedup/Account.cs b/TessesDedup/Account.cs new file mode 100644 index 0000000..db84c77 --- /dev/null +++ b/TessesDedup/Account.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace TessesDedup +{ + public class Account + { + [JsonProperty("id")] + public long Id {get;set;} + + [JsonProperty("username")] + public string Username {get;set;} + + [JsonIgnore] + public string PasswordHash {get;set;} + + [JsonIgnore] + public string PasswordSalt {get;set;} + + public void NewSalt() + { + byte[] bytes=new byte[64]; + using(var rnd = RandomNumberGenerator.Create()) + rnd.GetBytes(bytes); + PasswordSalt = Convert.ToBase64String(bytes); + } + public string GetPasswordHash(string password) + { + string pass = $"{password}{PasswordSalt}"; + using(var sha256Managed = SHA512.Create()) + { + return Convert.ToBase64String(sha256Managed.ComputeHash(Encoding.UTF8.GetBytes(pass))); + } + } + public bool PasswordCorrect(string password) + { + string hash=GetPasswordHash(password); + + return PasswordHash == hash ; + } + + } +} \ No newline at end of file diff --git a/TessesDedup/Backup.cs b/TessesDedup/Backup.cs new file mode 100644 index 0000000..f9cb5a8 --- /dev/null +++ b/TessesDedup/Backup.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Tesses.VirtualFilesystem; + +namespace TessesDedup +{ + public class Backup + { + [JsonProperty("id")] + public long Id {get;set;} + [JsonProperty("account_id")] + public long AccountId {get;set;} + [JsonProperty("root")] + public FilesystemEntry Root {get;set;} + [JsonProperty("device_name")] + public string DeviceName {get;set;}=""; + [JsonProperty("tag")] + public string Tag {get;set;}=""; + [JsonProperty("creation_date")] + + public DateTime CreationDate {get;set;} + + + public Backup WithoutRoot() + { + Backup b=new Backup(); + b.AccountId = AccountId; + b.Id = Id; + b.CreationDate = CreationDate; + b.DeviceName = DeviceName; + b.Tag = Tag; + b.Root=null; + return b; + } + + public Backup WithoutHashes() + { + Backup b=new Backup(); + b.AccountId = AccountId; + b.Id = Id; + b.CreationDate = CreationDate; + b.DeviceName = DeviceName; + b.Tag = Tag; + b.Root=Root.WithoutHashes(); + return b; + } + + + + public FilesystemEntry GetEntryFromPath(UnixPath path) + { + + if(path.Path == "/") return Root; + + var gefp=GetEntryFromPath(path.Parent); + + if(gefp.Type != FilesystemEntryType.Dir) throw new DirectoryNotFoundException(path.Parent.Path); + + foreach(var item in gefp.Entries) + { + if(item.Name == path.Name) + { + switch(item.Type) + { + case FilesystemEntryType.Dir: + case FilesystemEntryType.File: + return item; + case FilesystemEntryType.Symlink: + { + if(item.PointsTo.StartsWith("/")) + { + UnixPath _path = path.Parent; + foreach(var _item in item.PointsTo.Split('/')) + { + + if(_item == "..") + { + _path = _path.Parent; + } + else if(_item != ".") + { + _path /= _item; + } + } + return GetEntryFromPath(_path); + } + else + { + UnixPath _path = Special.Root; + foreach(var _item in item.PointsTo.Split('/')) + { + + if(_item == "..") + { + _path = _path.Parent; + } + else if(_item != ".") + { + _path /= _item; + } + } + return GetEntryFromPath(_path); + } + } + } + } + } + + throw new FileNotFoundException(path.Path); + } + } + +} \ No newline at end of file diff --git a/TessesDedup/Class1.cs b/TessesDedup/Class1.cs new file mode 100644 index 0000000..e878f83 --- /dev/null +++ b/TessesDedup/Class1.cs @@ -0,0 +1,724 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using LiteDB; +using Tesses.VirtualFilesystem; +using Tesses.VirtualFilesystem.Extensions; +using Tesses.WebServer; +using System.Xml; +using System.Text; +using System.Web; + +namespace TessesDedup +{ + + public class Dedup + { + public class HttpDirectoryServer : Server + { + Dedup dedup; + public HttpDirectoryServer(Dedup dedup) + { + this.dedup=dedup; + } + public override async Task GetAsync(ServerContext ctx) + { + if (!ctx.RequestHeaders.TryGetFirst("Authorization", out var value)){ + ctx.ResponseHeaders.Add("WWW-Authenticate", "Basic realm=\"Webdav realm\""); + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized"); + return; + } + string[] array = value.Split(' '); + string[] array2 = Encoding.UTF8.GetString(Convert.FromBase64String(array[1])).Split(new char[1] { ':' }, 2); + string usern = array2[0]; + Account user=null; + + bool accessKeyMode=false; + if(usern == "$access_key") + accessKeyMode=true; + else + + using(var db = dedup.Database) + { + + user = db.Accounts.FindOne(e=>e.Username == usern ); + + } + + if(user == null && !accessKeyMode) { + ctx.ResponseHeaders.Add("WWW-Authenticate", "Basic realm=\"Webdav realm\""); + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized"); + return; + } + if(!accessKeyMode && !user.PasswordCorrect(array2[1])) { + ctx.ResponseHeaders.Add("WWW-Authenticate", "Basic realm=\"Webdav realm\""); + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized"); + return; + } + + if(accessKeyMode) + { + using(var db = dedup.Database) + { + string key = array2[1]; + var ak = db.AccessKeys.FindOne(e=>e.Key == key); + if(ak != null) + { + user = db.Accounts.FindOne(e=>e.Id == ak.UserId); + if(user == null) + { + ctx.ResponseHeaders.Add("WWW-Authenticate", "Basic realm=\"Webdav realm\""); + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized"); + return; + } + } + else + { + ctx.ResponseHeaders.Add("WWW-Authenticate", "Basic realm=\"Webdav realm\""); + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized"); + return; + } + } + } + + StringBuilder builder=new StringBuilder($"Index of {HttpUtility.HtmlEncode(HttpUtility.UrlDecode(ctx.UrlPath))}

Index of {HttpUtility.HtmlEncode(HttpUtility.UrlDecode(ctx.UrlPath))}


../\n");
+                bool isDir=true;
+                    
+                void Insert(string path,bool isDir=true)
+                {
+                    builder.Append($"{HttpUtility.HtmlEncode(Path.GetFileName(path.TrimEnd('/')))}{(isDir?"/":"")}\n");
+                }
+
+                if(ctx.UrlPath == "/")
+                {
+                    
+                    List deviceNames = new List();
+                    using(var db = dedup.Database)
+                    foreach(var item in db.Backups.Find(e=>e.AccountId == user.Id))
+                    {
+                                if(!deviceNames.Contains(item.DeviceName))
+                                deviceNames.Add(item.DeviceName);
+                    }
+
+                    foreach(var dev in deviceNames)
+                    {
+                        Insert($"{dev}/");
+                    }
+                }
+                else
+                {
+                    string[] path = ctx.UrlPath.Split(new char[]{'/'},StringSplitOptions.RemoveEmptyEntries);
+                    if(path.Length == 1)
+                    {
+                         List backups = new List();
+                            using(var db = dedup.Database)
+                            foreach(var item in db.Backups.Find(e=>e.AccountId == user.Id))
+                            {
+                                string backupName = $"{item.Tag} ({item.CreationDate.ToString("yyyyMMdd_HHmmss")})";
+                                if(item.DeviceName == path[0] && !backups.Contains(backupName))
+                                {
+                                    backups.Add(backupName);
+                                }
+
+                            }
+
+                            foreach(var dev in backups)
+                            {
+                                Insert($"{dev}/");
+                            }
+                    }
+                    else {
+                        var name = HttpUtility.UrlDecode(path[1]);
+                        
+                        using(var db = dedup.Database)
+                        foreach(var item in db.Backups.Find(e=>e.AccountId == user.Id))
+                        {
+                                string backupName = $"{item.Tag} ({item.CreationDate.ToString("yyyyMMdd_HHmmss")})";
+                                if(item.DeviceName == path[0] && name == backupName)
+                                {
+                                    UnixPath upath = Special.Root;
+                                    for(int i = 2;i
"); + await ctx.SendTextAsync(builder.ToString()); + } + } + } + public IServer Server {get;} + public DedupStorage Storage {get;} + public DatabaseHandler Database => new DatabaseHandler(_db()); + + private Func _db; + + + + public class DatabaseHandler : IDisposable + { + public ILiteCollection Backups => Database.GetCollection("backups"); + + public ILiteCollection Accounts => Database.GetCollection("accounts"); + + public ILiteCollection AccessKeys=>Database.GetCollection("accesskeys"); + public ILiteDatabase Database {get;} + public DatabaseHandler(ILiteDatabase db) + { + Database = db; + } + + public void Dispose() + { + Database.Dispose(); + } + } + + RouteServer routeServer; + IVirtualFilesystem fs; + public Dedup(IServer www,IVirtualFilesystem storage,Func db) + { + _db = db; + fs = storage; + Storage = new DedupStorage(storage); + + MountableServer mnt=new MountableServer(www); + HttpDirectoryServer webdav=new HttpDirectoryServer(this); + + + + mnt.Mount("/data/",webdav); + routeServer=new RouteServer(mnt); + Server = routeServer; + + routeServer.Add("/api/v1/Block",HasBlockAsync,"HEAD"); + + routeServer.Add("/api/v1/Block",PutBlockAsync,"PUT"); + + routeServer.Add("/api/v1/Backup",GetBackupAsync,"GET"); + routeServer.Add("/api/v1/Backup",PutBackupAsync,"PUT"); + + routeServer.Add("/api/v1/Login",LoginAsync,"POST"); + routeServer.Add("/api/v1/Logout",LogoutAsync,"POST"); + routeServer.Add("/api/v1/Download",DownloadAsync,"GET"); + routeServer.Add("/api/v1/Download",DownloadAsync,"HEAD"); + + routeServer.Add("/api/v1/AccessKey",AccessKeyAsync,"GET"); + routeServer.Add("/api/v1/AccessKey",AccessKeyDeleteAsync,"DELETE"); + + routeServer.Add("/api/v1/Registered",RegisteredAsync,"GET"); + + routeServer.Add("/api/v1/Stats",StatsAsync,"GET"); + } + + private async Task AccessKeyDeleteAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + string key = GetAuthorizationKey(ctx); + + using(var db = Database){ + var ak=db.AccessKeys.FindOne(e=>e.Key == key); + if(ctx.QueryParams.TryGetFirstInt64("id",out var id)) + { + var item=db.AccessKeys.FindById(id); + if(item != null && item.UserId == ak.UserId) + { + if(item.Id != ak.Id) + { + db.AccessKeys.Delete(id); + await ctx.SendJsonAsync(new{success=true,reason="Success, it got deleted"}); + } + else { + await ctx.SendJsonAsync(new{success=false,reason="Cannot delete this accesskey, must use Logout"}); + } + } + else + { + await ctx.SendJsonAsync(new{success=false,reason="Either accesskey does not exist or it is not yours"}); + } + } + } + } + + private async Task AccessKeyAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + + string key = GetAuthorizationKey(ctx); + + using(var db = Database) + { + var ak=db.AccessKeys.FindOne(e=>e.Key == key); + await ctx.SendJsonAsync(db.AccessKeys.Find(e=>e.UserId == ak.UserId && e.Id != ak.Id).ToList()); + } + } + + private string BytesToUnited(long sz) + { + if(sz == 1) return "1 byte"; + if(sz < 1024) return $"{sz} bytes"; + if(sz < Math.Pow(1024,2)) return $"{sz / 1024} kiB"; + if(sz < Math.Pow(1024,3)) return $"{sz / (int)Math.Pow(1024,2)} MiB"; + if(sz < Math.Pow(1024,4)) return $"{sz / (long)Math.Pow(1024,3)} GiB"; + return $"{sz / (long)Math.Pow(1024,4)} TiB"; + } + private async Task StatsAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + long blocks = 0; + await foreach(var dir in fs.EnumerateDirectoriesAsync(Special.Root)) + { + await foreach(var dir2 in fs.EnumerateDirectoriesAsync(dir)) + { + await foreach(var file in fs.EnumerateFilesAsync(dir2)) + { + blocks++; + } + + } + + } + long bytes = blocks*DedupStorage.BlockLength; + using(var db = Database) + await ctx.SendJsonAsync(new{blocks,bytes,backups=db.Backups.Count(),label=BytesToUnited(bytes)}); + } + + public bool Registered() + { + using(var db = Database) + return db.Accounts.FindById(1) != null && !fs.FileExists(Special.Root/"ResetPassword.txt"); + } + private async Task RegisteredAsync(ServerContext ctx) + { + await ctx.SendJsonAsync(Registered()); + } + + private async Task DownloadAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + if(ctx.QueryParams.TryGetFirstInt64("id",out var id) && ctx.QueryParams.TryGetFirst("path",out var path)) + { + UnixPath upath = path; + Backup bkp; + using(var db = Database) + bkp=db.Backups.FindById(id); + if(bkp != null) + { + try + { + var ent=bkp.GetEntryFromPath(upath); + ctx.WithFileName(ent.Name,true); + await ctx.SendStreamAsync(Storage.OpenRead(ent),HeyRed.Mime.MimeTypesMap.GetMimeType(ent.Name)); + }catch(DirectoryNotFoundException) + { + ctx.StatusCode = 404; + await ctx.SendTextAsync($"Could not find directory {upath.Parent.Path}\r\n","text/plain"); + } + catch (FileNotFoundException) + { + ctx.StatusCode = 404; + await ctx.SendTextAsync($"Could not find file {upath.Path}\r\n","text/plain"); + } + } + else + { + ctx.StatusCode=404; + await ctx.SendTextAsync("Backup not found\r\n"); + } + } + } + + private async Task LogoutAsync(ServerContext ctx) + { + if(!ctx.RequestHeaders.ContainsKey("Content-Type")) + ctx.RequestHeaders.Add("Content-Type","application/x-www-form-urlencoded"); + + ctx.ParseBody(); + var key = GetAuthorizationKey(ctx); + if(!string.IsNullOrWhiteSpace(key)) + { + + using(var db = Database){ + var item = db.AccessKeys.FindOne(e=>e.Key ==key); + if(item != null) + db.AccessKeys.Delete(item.Id); + } + } + ctx.StatusCode = 204; + await ctx.WriteHeadersAsync(); + } + + private async Task LoginAsync(ServerContext ctx) + { + if(!ctx.RequestHeaders.ContainsKey("Content-Type")) + ctx.RequestHeaders.Add("Content-Type","application/x-www-form-urlencoded"); + ctx.ParseBody(); + if(ctx.QueryParams.TryGetFirst("username",out var username) && ctx.QueryParams.TryGetFirst("password",out var password) && ctx.QueryParams.TryGetFirst("device_name",out var device_name)) + { + if(Registered()) + { + Account user; + using(var db = Database) + user=db.Accounts.FindOne(e=>e.Username == username); + if(user != null) + { + if(user.PasswordCorrect(password)) + { + AccessKey ak = new AccessKey(); + ak.NewKey(); + ak.Id = 0; + ak.UserId = user.Id; + ak.DeviceName = device_name; + ak.CreationDate=DateTime.Now; + using(var db = Database) + db.AccessKeys.Insert(ak); + + await ctx.SendJsonAsync(new{success=true, key=ak.Key}); + } + else + { + await ctx.SendJsonAsync(new {success=false}); + } + } + else + { + await ctx.SendJsonAsync(new {success=false}); + } + } + else + { + if(await fs.FileExistsAsync(Special.Root/"ResetPassword.txt")) await fs.DeleteFileAsync(Special.Root/"ResetPassword.txt"); + Account user; + using(var db = Database) + user = db.Accounts.FindById(1); + if(user != null) + { + user.Username = username; + user.NewSalt(); + + user.PasswordHash=user.GetPasswordHash(password); + user.Username = username; + using(var db = Database) + db.Accounts.Update(user); + } + else + { + Account acnt = new Account(); + acnt.Id = 1; + acnt.NewSalt(); + acnt.PasswordHash=acnt.GetPasswordHash(password); + acnt.Username = username; + using(var db = Database) + db.Accounts.Insert(acnt); + } + + AccessKey ak = new AccessKey(); + ak.NewKey(); + ak.Id = 0; + ak.UserId = 1; + ak.DeviceName = device_name; + using(var db = Database) + db.AccessKeys.Insert(ak); + + await ctx.SendJsonAsync(new{success=true, key=ak.Key}); + } + } + } + + private async Task PutBackupAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + var item = await ctx.ReadJsonAsync(); + item.Id = 0; + string key = GetAuthorizationKey(ctx); + AccessKey ak; + using(var db = Database) + ak=db.AccessKeys.FindOne(e=>e.Key == key); + item.AccountId = ak.UserId; + item.DeviceName = ak.DeviceName; + using(var db = Database) + db.Backups.Insert(item); + ctx.StatusCode=204; + await ctx.WriteHeadersAsync(); + } + private string GetAuthorizationKey(ServerContext ctx) + { + string key=""; + if(ctx.RequestHeaders.TryGetFirst("Authorization",out var auth)) + { + if(auth.StartsWith("Bearer ")) + { + key = auth.Substring(7); + } + } + if(ctx.RequestHeaders.TryGetFirst("X-Access-Key",out var ak)) + { + key = ak; + } + if(ctx.QueryParams.TryGetFirst("access_key",out var access_key)) + { + key=access_key; + } + return key; + } + private long GetAccountId(ServerContext ctx) + { + string key = GetAuthorizationKey(ctx); + + using(var db = Database) + { + var ak=db.AccessKeys.FindOne(e=>e.Key == key); + if(ak != null) return ak.UserId; + } + return -1; + } + + private async Task GetBackupAsync(ServerContext ctx) + { + + if(await Unauthenticated(ctx)) return; + + bool noHashes=ctx.QueryParams.GetFirstBoolean("noHashes"); + + if(ctx.QueryParams.TryGetFirstInt64("id",out var id)) + { + Backup item; + using(var db = Database) + item = db.Backups.FindById(id); + if(item != null) + + await ctx.SendJsonAsync(noHashes ? item.WithoutHashes() : item); + else + { + ctx.StatusCode=404; + await ctx.SendNotFoundAsync(); + } + } + else + { + List backups=new List(); + using(var db = Database) + foreach(var item in db.Backups.FindAll()) + { + backups.Add(item.WithoutRoot()); + } + await ctx.SendJsonAsync(backups); + } + } + + private async Task PutBlockAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + var vals=await ctx.ReadBytesAsync(); + await ctx.SendTextAsync(Storage.WriteBlock(vals),"text/plain"); + } + + private async Task HasBlockAsync(ServerContext ctx) + { + if(await Unauthenticated(ctx)) return; + if(ctx.QueryParams.TryGetFirst("hash",out var hash)) + { + ctx.StatusCode = Storage.HasBlock(hash) ? 200 : 404; + await ctx.WriteHeadersAsync(); + } + } + private async Task Unauthenticated(ServerContext ctx) + { + if(GetAccountId(ctx) == -1) + { + ctx.StatusCode = 401; + await ctx.SendTextAsync("Unauthorized use /api/v1/Login to Authenticate\r\nand use access_key query param or Authorization: Bearer YOURKEY on this endpoint\r\n","text/plain"); + return true; + } + return false; + } + } + + public class DedupStorage + { + private class DedupStream : Stream + { + private DedupStorage dedupStorage; + private FilesystemEntry fsE; + + public DedupStream(DedupStorage dedupStorage, FilesystemEntry fsE) + { + this.dedupStorage = dedupStorage; + this.fsE = fsE; + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => fsE.Length; + + public override long Position { get;set; } + + public override void Flush() + { + + } + + public override int Read(byte[] buffer, int offset, int count) + { + int total_read = Math.Min(count,buffer.Length-offset); + long end = Position + total_read; + if(end > Length) {end = Length; total_read = (int)(Length-Position);} + + int read = 0; + while(Position < end) + { + //get current hash for Position + int currentFile = (int)(Position / BlockLength); + + //get current offset (in file) for Position + int fileOffset = (int)(Position % BlockLength); + + int toReadFromFile = BlockLength - fileOffset; + + toReadFromFile = Math.Min(total_read-read,toReadFromFile); + + var blk=dedupStorage.ReadBlock(fsE.Hashes[currentFile]); + Array.Copy(blk,fileOffset,buffer,offset+read,toReadFromFile); + read+=toReadFromFile; + Position+=toReadFromFile; + } + return read; + } + + public override long Seek(long offset, SeekOrigin origin) + { + switch(origin) + { + case SeekOrigin.Begin: + Position = offset; + break; + case SeekOrigin.Current: + Position += offset; + break; + case SeekOrigin.End: + Position = Length - offset; + break; + } + return Position; + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + } + + + public Stream OpenRead(FilesystemEntry fsE) + { + return new DedupStream(this,fsE); + } + public DedupStorage(IVirtualFilesystem fs) + { + this.vfs = fs; + } + + public const int BlockReadSize = 1024; + public const int BlockReads=4096; + public const int BlockLength = BlockReadSize*BlockReads; + IVirtualFilesystem vfs; + public UnixPath GetHashPath(string hash) + { + if(hash.Length != 128) return Special.Root; + string hashSlice1 = hash.Substring(0,2); + string hashSlice2 = hash.Substring(2,2); + string hashRest = hash.Substring(4,124); + return Special.Root / hashSlice1 / hashSlice2 / hashRest; + } + Mutex mtx=new Mutex(); + public bool HasBlock(string hash) + { + if(hash.Length != 128) return false; + + return vfs.FileExists(GetHashPath(hash)); + } + internal static string Sha512Hash(byte[] data) + { + using(var sha512=SHA512.Create()) + { + sha512.Initialize(); + byte[] hash=sha512.ComputeHash(data); + return BitConverter.ToString(hash).ToLower().Replace("-",""); + } + } + public string WriteBlock(byte[] data) + { + if(data.Length != BlockLength) + mtx.WaitOne(); + string hash=Sha512Hash(data); + if(!HasBlock(hash)) + { + string hashSlice1 = hash.Substring(0,2); + string hashSlice2 = hash.Substring(2,2); + vfs.CreateDirectory(Special.Root / hashSlice1); + vfs.CreateDirectory(Special.Root / hashSlice1 / hashSlice2); + vfs.WriteAllBytes(GetHashPath(hash),data); + } + mtx.ReleaseMutex(); + return hash; + } + + public byte[] ReadBlock(string hash) + { + if(HasBlock(hash)) + { + return vfs.ReadAllBytes(GetHashPath(hash)); + } + return new byte[0]; + } + } +} diff --git a/TessesDedup/Client.cs b/TessesDedup/Client.cs new file mode 100644 index 0000000..caa8c50 --- /dev/null +++ b/TessesDedup/Client.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LiteDB; +using Newtonsoft.Json; +using ShellProgressBar; +using Tesses.VirtualFilesystem; +using Tesses.VirtualFilesystem.Extensions; +using Tesses.WebServer; +using System.Net; +namespace TessesDedup +{ + public class DedupClient : IDisposable + { + HttpClient clt; + string url; + bool ownClient; + public bool ShowProgress {get;set;} + public DedupClient(HttpClient client, string url,bool ownClient=true,bool showProgress=true) + { + ShowProgress = showProgress; + this.clt = client; + this.url = url.TrimEnd('/'); + this.ownClient = ownClient; + } + public DedupClient(string url,bool showProgress=true) : this(new HttpClient(),url,true,showProgress) + { + + } + public async Task LogoutAsync(string key) + { + Dictionary dictionary= new Dictionary + { + { "key", key } + }; + + FormUrlEncodedContent formUrlEncodedContent=new FormUrlEncodedContent(dictionary); + using(var resp=await clt.PostAsync($"{url}/api/v1/Logout",formUrlEncodedContent)) + { + + } + } + public async Task LoginAsync(string username,string password, string device_name) + { + Dictionary dictionary= new Dictionary + { + { "username", username }, + { "password", password }, + { "device_name", device_name } + }; + + FormUrlEncodedContent formUrlEncodedContent=new FormUrlEncodedContent(dictionary); + using(var resp=await clt.PostAsync($"{url}/api/v1/Login",formUrlEncodedContent)) + { + if(resp.IsSuccessStatusCode) + { + return JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + } + else + { + return new LoginResult(); + } + } + } + + + private async Task ReadAsync(Stream strm, byte[] buffer,CancellationToken token=default) + { + Array.Clear(buffer,0,buffer.Length); + int totalRead = 0; + while(totalRead < buffer.Length) + { + int read = Math.Min(buffer.Length-totalRead,DedupStorage.BlockReadSize); + if(read == 0) + { + break; + } + read=await strm.ReadAsync(buffer,totalRead,read,token); + if(token.IsCancellationRequested) return 0; + if(read == 0) + { + break; + } + totalRead+=read; + } + return totalRead; + } + private async Task HasBlockAsync(string authkey,string hash,CancellationToken token=default) + { + HttpRequestMessage request=new HttpRequestMessage(HttpMethod.Head,$"{url}/api/v1/Block?hash={hash}"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",authkey); + using(var req=await clt.SendAsync(request,token)) + return req.StatusCode == System.Net.HttpStatusCode.OK; + } + private async Task WriteBlockAsync(string authkey,string hash,byte[] buffer,CancellationToken token=default) + { + HttpRequestMessage request=new HttpRequestMessage(HttpMethod.Put,$"{url}/api/v1/Block"); + request.Content = new ByteArrayContent(buffer); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",authkey); + using(var req=await clt.SendAsync(request,token)) + { + if(req.StatusCode == System.Net.HttpStatusCode.OK) + { + string server_hash=await req.Content.ReadAsStringAsync(); + if(server_hash != hash) + throw new BackupFailedException(server_hash,hash); + } + } + } + public async Task BackupAsync(string authkey, IVirtualFilesystem fs,string tag,CancellationToken token=default) + { + var dt = DateTime.Now; + var fse=await BackupPathAsync(authkey,fs,Special.Root,token); + if(token.IsCancellationRequested) return false; + Backup bkp=new Backup(); + bkp.Id = 0; + bkp.Tag = tag; + bkp.CreationDate = dt; + bkp.Root = fse; + + await WriteBackupAsync(authkey,bkp,token); + if(token.IsCancellationRequested) return false; + return true; + } + + private async Task WriteBackupAsync(string authkey, Backup bkp,CancellationToken token=default) + { + var str=JsonConvert.SerializeObject(bkp); + HttpRequestMessage request=new HttpRequestMessage(HttpMethod.Put,$"{url}/api/v1/Backup"); + request.Content = new StringContent(str,Encoding.UTF8,"application/json"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",authkey); + + using(var c=await clt.SendAsync(request,token)) + { + c.EnsureSuccessStatusCode(); + } + + } + + private async Task BackupPathAsync(string authkey,IVirtualFilesystem fs,UnixPath path,CancellationToken token=default) + { + if(await fs.SymlinkExistsAsync(path)) + { + return new FilesystemEntry(){Name = path.Name, Type = FilesystemEntryType.Symlink, PointsTo = (await fs.ReadLinkAsync(path,token)).Path}; + } + else if(await fs.DirectoryExistsAsync(path)) + { + FilesystemEntry entry=new FilesystemEntry(); + entry.Name = path.Name; + entry.Type = FilesystemEntryType.Dir; + await foreach(var ent in fs.EnumerateFileSystemEntriesAsync(path)) + { + if(token.IsCancellationRequested) return entry; + entry.Entries.Add(await BackupPathAsync(authkey,fs,ent,token)); + } + return entry; + } + else if(await fs.FileExistsAsync(path)) + { + FilesystemEntry entry = new FilesystemEntry(); + entry.Name = path.Name; + entry.Type = FilesystemEntryType.File; + entry.Length = 0; + entry.Hashes=new List(); + + using(var strm = await fs.OpenReadAsync(path)) + { + if(strm.CanSeek) entry.Length = strm.Length; + await foreach(var hash in BackupFileAsync($"/{string.Join("/",path.Parts)}",authkey,strm,(len)=>entry.Length=len,token)) + { + if(token.IsCancellationRequested) return entry; + entry.Hashes.Add(hash); + } + } + + return entry; + } + else + { + return new FilesystemEntry(){Length=0, Type = FilesystemEntryType.File}; + } + } + + public async IAsyncEnumerable ListBackupsAsync(string authkey) + { + HttpRequestMessage request=new HttpRequestMessage(HttpMethod.Get,$"{url}/api/v1/Backup"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",authkey); + var resp=await clt.SendAsync(request); + resp.EnsureSuccessStatusCode(); + foreach(var item in JsonConvert.DeserializeObject>(await resp.Content.ReadAsStringAsync())) + { + if(item != null) yield return item; + } + } + public async Task GetBackup(string authkey,long id,bool noHashes=true) + { + HttpRequestMessage request=new HttpRequestMessage(HttpMethod.Get,$"{url}/api/v1/Backup?id={id}&noHashes={noHashes}"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",authkey); + var resp=await clt.SendAsync(request); + resp.EnsureSuccessStatusCode(); + var bkp=JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + return bkp; + } + private async Task RestoreAsync(string key,long id,FilesystemEntry entry,UnixPath srcPath,IVirtualFilesystem fs,UnixPath destPath,CancellationToken token=default) + { + if(entry.Type == FilesystemEntryType.Dir) + { + await fs.CreateDirectoryAsync(destPath,token); + foreach(var ent in entry.Entries) + { + if(token.IsCancellationRequested) return; + await RestoreAsync(key,id,ent,srcPath/ent.Name,fs,destPath/ent.Name,token); + } + } + else if(entry.Type == FilesystemEntryType.File) + { + + using(var file = await fs.OpenAsync(destPath,FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None)) + { + ProgressBar bar=null; + IProgress p=null; + if(ShowProgress && file.CanSeek) + { + var options = new ProgressBarOptions + { + ProgressCharacter = '─', + ProgressBarOnBottom = true + }; + bar=new ProgressBar(10000,$"Restoring file {destPath.Path}"); + p = bar.AsProgress(); + }else if(ShowProgress) + { + Console.WriteLine("Stream is not seekable"); + } + + await RestoreFileAsync(key,id,srcPath.Path,file,new Progress(e=>{ + if(ShowProgress && file.CanSeek) + { + try{p.Report((double)e/(double)file.Length);} + catch(Exception ex) + { + _=ex; + } + + } + }),token); + + if(ShowProgress && file.CanSeek) + { + bar.Dispose(); + } + + + } + + } + else if(entry.Type == FilesystemEntryType.Symlink) { + await fs.CreateSymlinkAsync(new UnixPath(entry.PointsTo),destPath,token); + } + } + + public async Task RestoreAsync(string key,long id,UnixPath srcPath,IVirtualFilesystem fs,UnixPath destPath,CancellationToken token=default) + { + var bkp=await GetBackup(key,id); + await RestoreAsync(key,id,bkp.GetEntryFromPath(srcPath),srcPath,fs,destPath,token); + } + private async Task RestoreFileAsync(string key,long id, string path,Stream strm,IProgress progress=null,CancellationToken token=default) + { + var _path = $"{url}/api/v1/Download?id={id}&access_key={WebUtility.UrlEncode(key)}&id={id}&path={WebUtility.UrlEncode(path)}"; + byte[] buffer = new byte[1024]; + + long offset=0; + + HttpRequestMessage requestMessage=new HttpRequestMessage(HttpMethod.Get,_path); + + if(strm.CanSeek && strm.Length > 0) + { + strm.Position = strm.Length; + offset=strm.Position; + + requestMessage.Headers.Range=new System.Net.Http.Headers.RangeHeaderValue(strm.Length,null); + } + if(token.IsCancellationRequested) return; + using(var req = await clt.SendAsync(requestMessage,token)) + { + if(token.IsCancellationRequested) return; + if(req.StatusCode == System.Net.HttpStatusCode.RequestedRangeNotSatisfiable) return; + req.EnsureSuccessStatusCode(); + int read = 0; + using(var srcStrm = await req.Content.ReadAsStreamAsync()) + do { + read = await srcStrm.ReadAsync(buffer,0,buffer.Length,token); + if(token.IsCancellationRequested) return; + await strm.WriteAsync(buffer,0,read,token); + if(token.IsCancellationRequested) return; + offset += read; + progress?.Report(offset); + } while(read > 0); + } + } + private async IAsyncEnumerable BackupFileAsync(string name,string authkey,Stream file,Action length,[EnumeratorCancellation]CancellationToken token=default) + { + if(file.CanSeek && file.Length == 0) yield break; + byte[] buffer = new byte[DedupStorage.BlockLength]; + int read=0; + long totalLength=0; + ProgressBar bar=null; + IProgress p=null; + if(ShowProgress && file.CanSeek) + { + var options = new ProgressBarOptions + { + ProgressCharacter = '─', + ProgressBarOnBottom = true + }; + bar=new ProgressBar(10000,$"Backing up file {name}"); + p = bar.AsProgress(); + } + else if(ShowProgress) + { + Console.WriteLine("Stream is not seekable"); + } + do{ + read=await ReadAsync(file,buffer,token); + if(read == 0) yield break; + if(token.IsCancellationRequested) yield break; + string hash=DedupStorage.Sha512Hash(buffer); + yield return hash; + if(!await HasBlockAsync(authkey,hash,token)) + { + await WriteBlockAsync(authkey,hash,buffer,token); + if(token.IsCancellationRequested) yield break; + } + + totalLength += read; + + if(ShowProgress) + { + if(file.CanSeek) + { + double progress = totalLength / (double)file.Length; + p?.Report(progress); + } + } + + + } while(read == buffer.Length); + + if(ShowProgress) + bar.Dispose(); + + if(!file.CanSeek) + length(totalLength); + } + + public void Dispose() + { + if(ownClient) + this.clt.Dispose(); + } + } + + [Serializable] + internal class BackupFailedException : Exception + { + + public BackupFailedException() + { + } + + public BackupFailedException(string message) : base(message) + { + } + + public BackupFailedException(string server_hash, string hash) : base($"The server hash \"{server_hash}\" does not match client hash \"{hash}\"") + { + + } + + public BackupFailedException(string message, Exception innerException) : base(message, innerException) + { + } + + protected BackupFailedException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } + public class LoginResult +{ + [JsonProperty("success")] + public bool Success {get;set;}=false; + + [JsonProperty("key")] + public string Key {get;set;}=""; +} +} \ No newline at end of file diff --git a/TessesDedup/FilesystemEntry.cs b/TessesDedup/FilesystemEntry.cs new file mode 100644 index 0000000..1b0a85f --- /dev/null +++ b/TessesDedup/FilesystemEntry.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Tesses.VirtualFilesystem; +using Zio; + +namespace TessesDedup +{ + public class FilesystemEntry + { + [JsonProperty("name")] + public string Name {get;set;}=""; + + [JsonProperty("type")] + [JsonConverter(typeof(StringEnumConverter),typeof(CamelCaseNamingStrategy))] + + public FilesystemEntryType Type {get;set;} = FilesystemEntryType.Dir; + + [JsonProperty("entries")] + public List Entries {get;set;}=new List(); + + [JsonProperty("hashes")] + public List Hashes {get;set;}=new List(); + [JsonProperty("length")] + public long Length {get;set;} + + [JsonProperty("points_to")] + public string PointsTo {get;set;} + + public FilesystemEntry WithoutHashes() + { + if(Type == FilesystemEntryType.Dir) + { + FilesystemEntry ent=new FilesystemEntry(); + ent.Name = Name; + ent.Type = FilesystemEntryType.Dir; + foreach(var item in Entries) + { + ent.Entries.Add(item.WithoutHashes()); + } + return ent; + } + else if(Type == FilesystemEntryType.File) + { + FilesystemEntry ent=new FilesystemEntry(); + ent.Length = Length; + ent.Name = Name; + ent.Type = FilesystemEntryType.File; + return ent; + } + + return this; + } + + } + + public enum FilesystemEntryType + { + Dir, + File, + + Symlink + } +} \ No newline at end of file diff --git a/TessesDedup/TessesDedup.csproj b/TessesDedup/TessesDedup.csproj new file mode 100644 index 0000000..617aefd --- /dev/null +++ b/TessesDedup/TessesDedup.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + 8.0 + + + + + + + + + + + + + + + + + diff --git a/TessesDedupClient/Program.cs b/TessesDedupClient/Program.cs new file mode 100644 index 0000000..1fbba0e --- /dev/null +++ b/TessesDedupClient/Program.cs @@ -0,0 +1,617 @@ +using System.Diagnostics; +using System.Net; +using Newtonsoft.Json; +using Tesses.VirtualFilesystem; +using Tesses.VirtualFilesystem.Filesystems; +using TessesDedup; + +string verb = ""; +List> newArgs=new List>(); +List positionalArguments =new List(); + +string endpoint = Environment.GetEnvironmentVariable("TBKP_ENDPOINT") ?? ""; +string accessKey = Environment.GetEnvironmentVariable("TBKP_ACCESS_KEY") ?? ""; +bool env_set = !string.IsNullOrWhiteSpace(endpoint) && !string.IsNullOrWhiteSpace(accessKey); +string GetLoginPath() +{ + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData,Environment.SpecialFolderOption.Create),"tbkp_login.json"); +} +if(!env_set) +{ + string conf = GetLoginPath(); + if(File.Exists(conf)) + { + var res=JsonConvert.DeserializeObject(File.ReadAllText(conf)); + if(res != null) + { + endpoint = res.Endpoint; + accessKey = res.AccessKey; + } + } +} + + + +IEnumerable GetArgsViaKey(string key) +{ + foreach(var item in newArgs) + { + if(item.Key == key) + yield return item.Value; + } +} + +bool TryGetFirst(string key,out string val) +{ + foreach(var item in GetArgsViaKey(key)) + { + val = item; + return true; + } + + val=""; + return false; +} + +string GetOrAsk(string prompt,bool password,params string[] keys) +{ + + foreach(var item in keys) + { + if(TryGetFirst(item,out var str)) + { + return str; + } + } + while(true) + { + if(password) + { + string res = ReadLine.ReadPassword($"{prompt}: "); + if(!string.IsNullOrWhiteSpace(res)) + return res; + } + else{ + string res = ReadLine.Read($"{prompt}: "); + if(!string.IsNullOrWhiteSpace(res)) + return res; + } + } +} +string GetOrAskWithDefault(string prompt,string defaultValue,params string[] keys) +{ + + foreach(var item in keys) + { + if(TryGetFirst(item,out var str)) + { + return str; + } + } + while(true) + { + + ReadLine.AddHistory(new string[]{defaultValue}); + string res = ReadLine.Read($"{prompt}: "); + if(!string.IsNullOrWhiteSpace(res)) + return res; + } +} + +if(args.Length > 0) +{ + if(args[0] == "--help") + { + PrintHelp(); + return; + } + else + { + verb = args[0]; + bool mustBePositionalArgs=false; + for(int i = 1;i(key,value)); + } + } + else + { + positionalArguments.Add(args[i]); + } + } + } +} +else +{ + PrintHelp(); + return; +} + +void PrintHelp(string? page=null) +{ + if(string.IsNullOrWhiteSpace(page)) + { + Console.WriteLine("tbkp"); + Console.WriteLine("VERBS:"); + Console.WriteLine("\tlogin\tLogin to backup server"); + Console.WriteLine("\tlogout\tLogout from backup server"); + Console.WriteLine("\tmount\tMount backups to filesystem"); + Console.WriteLine("\tunmount\tUnmount backups to filesystem"); + Console.WriteLine("\tbackup\tCreate backup"); + Console.WriteLine("\tbackupex\tCreate backup with multiple mounts uses Zio C# Library: https://github.com/xoofx/zio"); + Console.WriteLine("\trestore\tRestore backup with optional subentry"); + Console.WriteLine("\tlist\tList backups"); + Console.WriteLine("TIPS:"); + Console.WriteLine("\tFlags always require a value (except for --help) so if you want to set a boolean to true type --flagName true or --flagName false for false"); + Console.WriteLine("\tSet environment variables TBKP_ENDPOINT and TBKP_ACCESS_KEY whereever configuration is unavailable"); + + } + else + { + switch(page) + { + case "login": + Console.WriteLine("tbkp login [options]"); + Console.WriteLine("FLAGS:"); + Console.WriteLine("-u,--user\tUsername"); + Console.WriteLine("-p,--pass\tPassword"); + Console.WriteLine("-e,--url\tThe Url to the server"); + Console.WriteLine("-d,--deviceName\tThe Device Name"); + Console.WriteLine("-a,--justAccessKey true\tusing --justAccessKey true will print created access key to console and wont save to file"); + Console.WriteLine(); + Console.WriteLine("NOTE: If the flags are not set (other than --justAccessKey true), we will ask for credentials"); + break; + case "logout": + Console.WriteLine("tbkp logout"); + Console.WriteLine("This is a verb without flags or arguments"); + break; + case "mount": + Console.WriteLine("tbkp mount [path]"); + Console.WriteLine("ARGS:"); + Console.WriteLine("path (optional):\tMount to this path on your computer"); + Console.WriteLine(); + Console.WriteLine($"NOTE: If path is not specified, \"{DefaultTBKP()}\" will be assumed"); + Console.WriteLine("NOTE: httpdirfs is required, debian: sudo apt install httpdirfs, others: https://github.com/fangfufu/httpdirfs"); + break; + case "unmount": + Console.WriteLine("tbkp unmount [path]"); + Console.WriteLine("ARGS:"); + Console.WriteLine("path (optional):\tunmount from this path on your computer"); + Console.WriteLine(); + Console.WriteLine($"NOTE: If path is not specified, \"{DefaultTBKP()}\" will be assumed"); + break; + case "backup": + Console.WriteLine("tbkp backup [options] path"); + Console.WriteLine("ARGS:"); + Console.WriteLine("path (optional):\tfolder to backup (defaults to current directory)"); + Console.WriteLine(); + Console.WriteLine("FLAGS:"); + Console.WriteLine("-t, --tag:\tTag of backup (defaults to \"default\")"); + Console.WriteLine("-s,--silent true\tusing --silent true will disable progress"); + break; + case "backupex": + Console.WriteLine("tbkp backupex [options] root"); + Console.WriteLine("ARGS:"); + Console.WriteLine("root (optional):\troot folder to backup"); + Console.WriteLine(); + Console.WriteLine("FLAGS:"); + Console.WriteLine("-t, --tag:\tTag of backup (defaults to \"default\")"); + Console.WriteLine("-v, --volume:\tMount folders in backup like bind mounts in docker, -v /Path/On/Host:/Path/In/Backup"); + Console.WriteLine("-s,--silent true\tusing --silent true will disable progress"); + Console.WriteLine(); + Console.WriteLine("NOTE: On windows using -v command you must replace C:\\YourPath\\ with \\con\\C\\YourPath\\ due to the colon"); + Console.WriteLine("NOTE: On windows using -v command you can use / rather than \\ except for the \\con\\ part"); + break; + case "list": + Console.WriteLine("tbkp list [options] /path/to/dir"); + Console.WriteLine("ARGS:"); + Console.WriteLine("/path/to/dir (optional):\tfolder to enumerate in backup"); + Console.WriteLine(); + Console.WriteLine("FLAGS:"); + Console.WriteLine("-b, --backupId\t: The backup number (without this, it lists the backups)"); + break; + case "restore": + Console.WriteLine("tbkp list [options] /path/to/output"); + Console.WriteLine("ARGS:"); + Console.WriteLine("/path/to/output:\tthe path to restore to, for files this will be the filename, for directory this will be the folder that contains the folder in backup's files"); + Console.WriteLine(); + Console.WriteLine("FLAGS:"); + Console.WriteLine("-b, --backupId\t: The backup number"); + Console.WriteLine("-p, --path\t: The path inside backup (defaults to / and can be file, but if file the /path/to/output must not be a directory)"); + Console.WriteLine("-s,--silent true\tusing --silent true will disable progress"); + + + break; + } + } +} + +void Mount(string path) +{ + Directory.CreateDirectory(path); + MountIntern(path); + +} +void Mount2() +{ + string p = DefaultTBKP(); + string file = Path.Combine(p,"readme.txt"); + if(File.Exists(file)) + { + MountIntern(p); + } + else + { + Console.WriteLine("Already mounted"); + } +} +void MountIntern(string path) +{ + using(Process p = new Process()) + { + p.StartInfo.FileName = Find("httpdirfs"); + p.StartInfo.ArgumentList.Add("-o"); + p.StartInfo.ArgumentList.Add("nonempty"); + p.StartInfo.ArgumentList.Add("-u"); + p.StartInfo.ArgumentList.Add("$access_key"); + p.StartInfo.ArgumentList.Add("-p"); + p.StartInfo.ArgumentList.Add(accessKey); + p.StartInfo.ArgumentList.Add($"{endpoint.TrimEnd('/')}/data/"); + p.StartInfo.ArgumentList.Add(path); + + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput=true; + p.StartInfo.RedirectStandardError=true; + + p.Start(); + } +} +void Unmount2() +{ + string p = DefaultTBKP(); + string file = Path.Combine(p,"readme.txt"); + if(File.Exists(file)) + { + Console.WriteLine("Filesystem not mounted"); + } + else + { + Umount(p); + } +} + +void Umount(string path) +{ + using(Process p = new Process()) + { + p.StartInfo.FileName = Find(Environment.OSVersion.Platform == PlatformID.Win32NT ? "mountvol" : "umount"); + if(Environment.OSVersion.Platform == PlatformID.Win32NT) + { + p.StartInfo.ArgumentList.Add(path); + p.StartInfo.ArgumentList.Add("/d"); + } + else + { + p.StartInfo.ArgumentList.Add(path); + } + if(p.Start()) { + p.WaitForExit(); + } + } +} + +string Find(string v) +{ + string[] path = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator); + string[] pathext = (Environment.GetEnvironmentVariable("PATHEXT") ?? "").Split(Path.PathSeparator); + foreach(var dir in path) + { + string _vp = Path.Combine(dir,v); + if(File.Exists(_vp)) return _vp; + foreach(var ext in pathext) + { + string p=$"{_vp}{ext}"; + if(File.Exists(p)) return p; + } + + } + return v; +} + +string DefaultTBKP() +{ + var dir= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments,Environment.SpecialFolderOption.Create),"Tesses Backups"); + if(!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir,"readme.txt"),"Don't Delete this file, unless you delete the folder it is in.\nAlso don't put any files in this directory as the tool mounts here\n"); + + } + return dir; +} + +switch (verb) +{ + case "login": + { + if(env_set) { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("ERROR: TBKP_ENDPOINT and TBKP_ACCESS_KEY are set"); + Console.ResetColor(); + return; + } + string url=GetOrAsk("Endpoint (Server Url)",false,"url","e"); + string username=GetOrAsk("Username",false,"user","u"); + string password=GetOrAsk("Password",true,"pass","p"); + string deviceName=GetOrAskWithDefault("Device Name (press up for hostname)",Dns.GetHostName(),"deviceName","d"); + + using DedupClient client=new DedupClient(url); + var res=await client.LoginAsync(username,password,deviceName); + + if(res.Success) + { + if((TryGetFirst("justAccessKey",out var akV) || TryGetFirst("a",out akV)) && akV == "true") + { + Console.WriteLine($"Your access key is: {res.Key}"); + } + else + { + LoginConf conf = new LoginConf(); + conf.AccessKey = res.Key; + conf.Endpoint = url; + File.WriteAllText(GetLoginPath(),JsonConvert.SerializeObject(conf)); + Console.WriteLine("Logged in successfully"); + } + } + else + { + Console.WriteLine("Can't login, invalid password or something like that"); + } + + } + break; + case "logout": + { + using DedupClient client=new DedupClient(endpoint); + await client.LogoutAsync(endpoint); + if(!env_set) + { + File.Delete(GetLoginPath()); + } + } + break; + case "mount": + { + if(positionalArguments.Count > 0) + { + Mount(positionalArguments[0]); + } + else + { + Mount2(); + } + } + break; + case "unmount": + { + if(positionalArguments.Count > 0) + { + Umount(positionalArguments[0]); + } + else + { + Unmount2(); + } + } + break; + case "restore": + { + bool silent = false; + + if(TryGetFirst("s",out var sltStr) || TryGetFirst("silent",out sltStr)) + { + silent = sltStr=="true"; + } + + if(positionalArguments.Count > 0) + { + if((TryGetFirst("backupId",out var backupId) || TryGetFirst("b",out backupId)) && long.TryParse(backupId,out var id)) + { + string path = "/"; + if(TryGetFirst("p",out var p) || TryGetFirst("path",out p)) + path = p; + + //we need to restore backup + // public async Task RestoreAsync(string key,long id,UnixPath srcPath,IVirtualFilesystem fs,UnixPath destPath,CancellationToken token=default) + + using var ddc = new DedupClient(endpoint,!silent); + LocalFileSystem fs = new LocalFileSystem(); + await ddc.RestoreAsync(accessKey,id,new UnixPath(path),fs,UnixPath.FromLocal(Path.GetFullPath(positionalArguments[0]))); + } + } + } + break; + case "list": + { + if(positionalArguments.Count > 0) + { + if((TryGetFirst("backupId",out var backupId) || TryGetFirst("b",out backupId)) && long.TryParse(backupId,out var id)) + { + using DedupClient client = new DedupClient(endpoint); + var bkp=await client.GetBackup(accessKey,id); + foreach(var item in bkp.GetEntryFromPath(new UnixPath(positionalArguments[0])).Entries) + { + Console.WriteLine($"[{item.Type.ToString().ToUpper()}] {item.Name}"); + } + } + else + { + Console.WriteLine("Use \"--backupId n\" from this list in the square brackets"); + using DedupClient client = new DedupClient(endpoint); + await foreach(var item in client.ListBackupsAsync(accessKey)) + { + Console.WriteLine($"[{item.Id}] {item.DeviceName} - {item.Tag} ({item.CreationDate.ToString("G")})"); + } + } + } + else + { + if((TryGetFirst("backupId",out var backupId) || TryGetFirst("b",out backupId)) && long.TryParse(backupId,out var id)) + { + + using DedupClient client = new DedupClient(endpoint); + var bkp=await client.GetBackup(accessKey,id); + foreach(var item in bkp.Root.Entries) + { + Console.WriteLine($"[{item.Type.ToString().ToUpper()}] {item.Name}"); + } + } + else + { + using DedupClient client = new DedupClient(endpoint); + await foreach(var item in client.ListBackupsAsync(accessKey)) + { + Console.WriteLine($"[{item.Id}] {item.DeviceName} - {item.Tag} ({item.CreationDate.ToString("G")})"); + } + } + } + } + break; + case "backup": + { + bool silent = false; + + if(TryGetFirst("s",out var sltStr) || TryGetFirst("silent",out sltStr)) + { + silent = sltStr=="true"; + } + string tag="default"; + if(TryGetFirst("t",out var t) || TryGetFirst("tag",out t)) + { + tag = t; + } + UnixPath path = Special.CurDir; + if(positionalArguments.Count > 0) + { + path = new UnixPath(Path.GetFullPath(positionalArguments[0])); + } + + LocalFileSystem fs=new LocalFileSystem(); + + + DedupClient client=new DedupClient(endpoint,!silent); + await client.BackupAsync(accessKey,fs.GetSubdirFilesystem(path),tag); + + } + break; + case "backupex": + { + bool silent = false; + + if(TryGetFirst("s",out var sltStr) || TryGetFirst("silent",out sltStr)) + { + silent = sltStr=="true"; + } + string tag="default"; + if(TryGetFirst("t",out var t) || TryGetFirst("tag",out t)) + { + tag = t; + } + LocalFileSystem fs=new LocalFileSystem(); + ZioMountableWrapper wrapper; + if(positionalArguments.Count > 0) + { + var p = new UnixPath(Path.GetFullPath(positionalArguments[0])); + wrapper=ZioMountableWrapper.Create(fs.GetSubdirFilesystem(p),true); + } + else + { + wrapper=ZioMountableWrapper.Create(true); + } + + foreach(var bind in GetArgsViaKey("v")) + { + var _bind=bind.Split(new char[]{':'}); + + + if(_bind.Length == 2) + { + + if(_bind[0].StartsWith("\\con\\")) + { + string[] path=_bind[0].Replace("\\con\\","").Split(new char[]{'\\'},2,StringSplitOptions.RemoveEmptyEntries); + + _bind[0] = $"{path[0]}:\\"; + if(path.Length > 1) + { + _bind[0] += path[1]; + } + } + wrapper.Mount(UnixPath.FromLocal(_bind[1]),fs.GetSubdirFilesystem(new UnixPath(UnixPath.FromLocal(_bind[0])))); + } + + } + + foreach(var bind in GetArgsViaKey("volume")) + { + var _bind=bind.Split(new char[]{':'}); + + + if(_bind.Length == 2) + { + + if(_bind[0].StartsWith("\\con\\")) + { + string[] path=_bind[0].Replace("\\con\\","").Split(new char[]{'\\'},2,StringSplitOptions.RemoveEmptyEntries); + + _bind[0] = $"{path[0]}:\\"; + if(path.Length > 1) + { + _bind[0] += path[1]; + } + } + wrapper.Mount(UnixPath.FromLocal(_bind[1]),fs.GetSubdirFilesystem(new UnixPath(UnixPath.FromLocal(_bind[0])))); + } + + // \con\C\Users\ + } + + + + using DedupClient client=new DedupClient(endpoint,!silent); + await client.BackupAsync(accessKey,wrapper,tag); + + } + break; +} + + + + + + + +internal class LoginConf +{ + [JsonProperty("endpoint")] + public string Endpoint {get;set;}=""; + [JsonProperty("access_key")] + public string AccessKey {get;set;}=""; +} \ No newline at end of file diff --git a/TessesDedupClient/TessesDedupClient.csproj b/TessesDedupClient/TessesDedupClient.csproj new file mode 100644 index 0000000..13792ab --- /dev/null +++ b/TessesDedupClient/TessesDedupClient.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/TessesDedupServer/Program.cs b/TessesDedupServer/Program.cs new file mode 100644 index 0000000..cfd9938 --- /dev/null +++ b/TessesDedupServer/Program.cs @@ -0,0 +1,14 @@ +using TessesDedup; +using Tesses.WebServer; +using Tesses.VirtualFilesystem.Filesystems; +using Tesses.VirtualFilesystem; +using LiteDB; + +Directory.CreateDirectory("data/storage"); +Directory.CreateDirectory("data/wwwroot"); + +LocalFileSystem fs = new LocalFileSystem(); + + +Dedup dedup = new Dedup(new StaticServer("data/wwwroot"){ RedirectToRootInsteadOfNotFound=true},fs.GetSubdirFilesystem(Special.CurDir / "data" / "storage"),()=>new LiteDatabase("data/backups.db")); +dedup.Server.StartServer(3255); diff --git a/TessesDedupServer/TessesDedupServer.csproj b/TessesDedupServer/TessesDedupServer.csproj new file mode 100644 index 0000000..9b693ff --- /dev/null +++ b/TessesDedupServer/TessesDedupServer.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/TessesDedupWeb/App.razor b/TessesDedupWeb/App.razor new file mode 100644 index 0000000..623580d --- /dev/null +++ b/TessesDedupWeb/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/TessesDedupWeb/MainLayout.razor b/TessesDedupWeb/MainLayout.razor new file mode 100644 index 0000000..0478d96 --- /dev/null +++ b/TessesDedupWeb/MainLayout.razor @@ -0,0 +1,55 @@ +@inherits LayoutComponentBase + + + + + + + +
+ @Body +
\ No newline at end of file diff --git a/TessesDedupWeb/Pages/AccessKeys.razor b/TessesDedupWeb/Pages/AccessKeys.razor new file mode 100644 index 0000000..7ffcd4b --- /dev/null +++ b/TessesDedupWeb/Pages/AccessKeys.razor @@ -0,0 +1,98 @@ +@page "/access_keys" +@inject DedupClient Client; +@inject Blazored.LocalStorage.ILocalStorageService localStorage; +@inject IJSRuntime jsRt; + + + +
+
+
+
This Browser
+ +
+ add New Access Key edit Edit +
+@if(Ready) +{ + @foreach(var item in Items) + { + +
+ +
+
+
@item.DeviceName
+ +
+ +
+ + } +} +
+ + +@code { + public List Items {get;set;}=new List(); + + public bool Ready {get;set;}=false; + + string token=""; + + public async Task LogoutAsync() + { + if(!await jsRt.InvokeAsync("confirm","Are you sure you want to to logout?")) return; + + try{ + await Client.LogoutAsync(token); + }catch(Exception ex) + { + Console.WriteLine(ex); + _=ex; + } + + try{ + await localStorage.RemoveItemAsync("token"); + Items.Clear(); + StateHasChanged(); + }catch(Exception ex) + { + _=ex; + } + } + + public async Task DeleteAsync(AccessKey ak) + { + if(!await jsRt.InvokeAsync("confirm",$"Are you sure you want to delete the access key {ak.DeviceName} created {ak.Created.Humanize()}?")) return; + + var res=await Client.AccessKeyDeleteAsync(token,ak.Id); + + if(res.Success) + { + Items.Remove(ak); + StateHasChanged(); + } + else + { + //error + } + } + + protected override async Task OnInitializedAsync() + { + var _token = await localStorage.GetItemAsStringAsync("token"); + if(!string.IsNullOrWhiteSpace(_token)) + { + token = _token; + Items.Clear(); + try{ + await foreach(var item in Client.GetAccessKeysAsync(_token)) + { + Items.Add(item); + } + }catch(Exception ex){_=ex;} + Ready=true; + } + } +} diff --git a/TessesDedupWeb/Pages/BackupView.razor b/TessesDedupWeb/Pages/BackupView.razor new file mode 100644 index 0000000..38c436c --- /dev/null +++ b/TessesDedupWeb/Pages/BackupView.razor @@ -0,0 +1,144 @@ +@page "/backups/{id:long}" +@inject NavigationManager NavManager; +@inject DedupClient Client; +@inject Blazored.LocalStorage.ILocalStorageService localStorage; + +@if(IsReady) +{ + if(IsDir) + { + if(ParentPath == "/backups") + { + Up @Path + }else{ + Up @Path + } +
+ foreach(var item in Entries) + { + if(item.PointsTo.StartsWith("/api/v1/Download")) + { + + @item.Type +
+
@item.Name
+
+ +
+ }else{ + + @item.Type +
+
@item.Name
+
+ +
+ } + } + } +} + +@code { + [Parameter] + public long Id {get;set;} + + + public string Path {get;set;}=""; + + public bool IsReady {get;set;}=false; + + public bool IsDir {get;set;}=false; + + public string Url {get;set;}=""; + + public string ParentPath {get;set;}=""; + + public List Entries {get;set;}=new List(); + string token=""; + + public void HandlePage(string path) + { + + + if(!string.IsNullOrWhiteSpace(path)) + { + UnixPath unixPath = path; + + var p=bkp.GetEntryFromPath(unixPath); + if(unixPath.Path == "/" || unixPath.Path.Length == 0) + ParentPath="/backups"; + else + ParentPath = $"/backups/{Id}?path={WebUtility.UrlEncode(unixPath.Parent.Path)}"; + + if(p.Type == "dir") + { + IsDir=true; + foreach(var item in p.Entries) + { + FilesystemEntry ent = new FilesystemEntry(); + ent.Name = item.Name; + if(item.Type == "dir") + { + ent.PointsTo = $"/backups/{Id}?path={WebUtility.UrlEncode((unixPath/item.Name).Path)}"; + ent.Type = "folder"; + } + else if(item.Type == "file") + { + ent.PointsTo = $"/api/v1/Download?access_key={WebUtility.UrlEncode(token)}&id={Id}&path={WebUtility.UrlEncode((unixPath/item.Name).Path)}"; + ent.Type = "draft"; + } + else + { + var follow = bkp.GetEntryFromPath(unixPath/item.Name); + if(follow.Type == "dir") + { + ent.PointsTo = $"/backups/{Id}?path={WebUtility.UrlEncode((unixPath/item.Name).Path)}"; + } + else + { + ent.PointsTo = $"/api/v1/Download?access_key={WebUtility.UrlEncode(token)}&id={Id}&path={WebUtility.UrlEncode((unixPath/item.Name).Path)}"; + } + ent.Type = "link"; + } + Entries.Add(ent); + + } + + } + IsReady=true; + } + + } + Backup bkp=new Backup(); + + + + private void Navmgr(string _url) + { + Entries.Clear(); + IsReady=false; + NavManager.NavigateTo(_url); + var url=NavManager.ToAbsoluteUri(_url); + if(QueryHelpers.ParseQuery(url.Query).TryGetValue("path",out var value)) + { + string? path = value; + if(!string.IsNullOrWhiteSpace(path)) + { + HandlePage(path); + } + } + StateHasChanged(); + } + + protected override async Task OnInitializedAsync() + { + var _token = await localStorage.GetItemAsStringAsync("token"); + if(!string.IsNullOrWhiteSpace(_token)) + { + token = _token; + bkp = await Client.GetBackupAsync(token,Id); + Navmgr(NavManager.Uri); + } + } + +} diff --git a/TessesDedupWeb/Pages/Backups.razor b/TessesDedupWeb/Pages/Backups.razor new file mode 100644 index 0000000..656814d --- /dev/null +++ b/TessesDedupWeb/Pages/Backups.razor @@ -0,0 +1,49 @@ +@page "/backups" +@inject DedupClient Client; +@inject Blazored.LocalStorage.ILocalStorageService localStorage; + +@if(Ready) +{ + var i = 0; + +} + +@code { + public bool Ready {get;set;}=false; + + public List Items {get;set;}=new List(); + + protected override async Task OnInitializedAsync() + { + var loggedIn = await localStorage.ContainKeyAsync("token"); + if(loggedIn) + { + var token=await localStorage.GetItemAsStringAsync("token"); + await foreach(var item in Client.GetBackupsAsync(token ?? "")) + { + Items.Add(item); + } + Ready=true; + + } + } +} \ No newline at end of file diff --git a/TessesDedupWeb/Pages/EditAccessKey.razor b/TessesDedupWeb/Pages/EditAccessKey.razor new file mode 100644 index 0000000..ad15741 --- /dev/null +++ b/TessesDedupWeb/Pages/EditAccessKey.razor @@ -0,0 +1,75 @@ +@page "/access_keys/edit" +@inject Blazored.LocalStorage.ILocalStorageService localStorage; +@inject IJSRuntime jsRt; +@inject HttpClient client; + + +
+ + +
+ +
+ +
+ +
+
+
+

To Install

+ + $ sudo apt install httpdirfs + +
+
+

To Mount

+ + $ mkdir "~/BackupsMount"
+ $ httpdirfs -u "\$access_key" -p "@_AccessKey" "@WebUrl" "~/BackupsMount" +
+
+
+

To Unmount

+ + $ umount "~/BackupsMount" + +
+
+

To Unmount (if busy and you are impatient)

+ + $ umount -l "~/BackupsMount" + +
+ +
+
+ + +Done +@code { + public string _AccessKey {get;set;}=""; + + public string WebUrl => $"{(client.BaseAddress?.ToString() ?? "").TrimEnd('/')}/data/"; + + public async Task SaveAsync() + { + if(string.IsNullOrWhiteSpace(_AccessKey)) + { + if(await localStorage.ContainKeyAsync("token")) + await localStorage.RemoveItemAsync("token"); + } + else{ + await localStorage.SetItemAsStringAsync("token",_AccessKey); + } + await jsRt.InvokeVoidAsync("alert","Saved"); + } + + protected override async Task OnInitializedAsync() + { + _AccessKey = await localStorage.GetItemAsStringAsync("token") ?? ""; + + } +} \ No newline at end of file diff --git a/TessesDedupWeb/Pages/Index.razor b/TessesDedupWeb/Pages/Index.razor new file mode 100644 index 0000000..3cbe871 --- /dev/null +++ b/TessesDedupWeb/Pages/Index.razor @@ -0,0 +1,176 @@ +@page "/" +@inject DedupClient Client; +@inject Blazored.LocalStorage.ILocalStorageService localStorage; +@inject IJSRuntime jsRt; +
@Error
+ +@if(Ready) +{ + @if(LoggedIn) + { +

Backups: @Stats.Backups

+

Blocks: @Stats.Blocks

+

Size: @Stats.Label

+ +
+
+ +
+ +
+
+
+

To Install

+ + $ sudo apt install httpdirfs + +
+
+

To Mount

+ + $ mkdir "~/BackupsMount"
+ $ httpdirfs -u "\$access_key" -p "@_AccessKey" "@Client.DataPath" "~/BackupsMount" +
+
+
+

To Unmount

+ + $ umount "~/BackupsMount" + +
+
+

To Unmount (if busy and you are impatient)

+ + $ umount -l "~/BackupsMount" + +
+ +
+ } + else + { + @if(Registered) + { +

Login

+
+ + +
+
+ + +
+
+ + +
+ + } + else + { +

Register

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + } + } +} + + +@code { + public async Task SplashErrorAsync(string error) + { + Error = error; + await jsRt.InvokeVoidAsync("ui","#sb_error"); + } + public Stats Stats {get;set;}=new Stats(); + public bool Ready {get;set;}=false; + public bool Registered {get;set;}=false; + + public bool LoggedIn {get;set;}=false; + + + public string Username {get;set;}=""; + public string Password {get;set;}=""; + + public string ConfirmPassword {get;set;}=""; + + public string Error {get;set;}=""; + + public string DeviceName {get;set;}="Browser"; + + public string _AccessKey {get;set;}=""; + + + protected override async Task OnInitializedAsync() + { + LoggedIn = await localStorage.ContainKeyAsync("token"); + if(LoggedIn) + { + var token=await localStorage.GetItemAsStringAsync("token"); + try{ + _AccessKey = token ?? ""; + Stats = await Client.GetStatsAsync(_AccessKey); + }catch(System.Net.Http.HttpRequestException ex) + { + _=ex; + LoggedIn=false; + } + } + Registered = await Client.IsRegisteredAsync(); + Ready = true; + } + public async Task LoginAsync() + { + var res=await Client.LoginAsync(Username,Password,DeviceName); + if(res.Success) + { + await localStorage.SetItemAsStringAsync("token",res.Key); + Registered=true; + LoggedIn=true; + _AccessKey = res.Key; + Stats = await Client.GetStatsAsync(res.Key); + } + else + { + await SplashErrorAsync("Incorrect username or password"); + } + } + public async Task RegisterAsync() + { + if(Password != ConfirmPassword) + { + await SplashErrorAsync("Passwords do not match"); + return; + } + var res=await Client.LoginAsync(Username,Password,DeviceName); + if(res.Success) + { + await localStorage.SetItemAsStringAsync("token",res.Key); + Stats = await Client.GetStatsAsync(res.Key); + Registered=true; + LoggedIn=true; + } + else + { + await SplashErrorAsync("Incorrect username or password"); + } + } +} \ No newline at end of file diff --git a/TessesDedupWeb/Pages/NewAccessKey.razor b/TessesDedupWeb/Pages/NewAccessKey.razor new file mode 100644 index 0000000..e3bd174 --- /dev/null +++ b/TessesDedupWeb/Pages/NewAccessKey.razor @@ -0,0 +1,53 @@ +@page "/access_keys/new" +@inject Blazored.LocalStorage.ILocalStorageService localStorage; +@inject DedupClient Client; +@inject IJSRuntime jsRt; + +
Incorrect username or password
+ +

Note: you won't be able to access the key again, unless you have access to the database.

+ +

Create access key

+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + +
+ +Done + +@code { + public string Username {get;set;}=""; + public string Password {get;set;}=""; + + public string DeviceName {get;set;}=$"New Access Key {DateTime.Now.ToShortDateString()} {DateTime.Now.ToShortTimeString()}"; + + public string _AccessKey {get;set;}=""; + + public async Task LoginAsync() + { + var res=await Client.LoginAsync(Username,Password,DeviceName); + if(res.Success) + { + _AccessKey=res.Key; + } + else + { + await jsRt.InvokeVoidAsync("ui","#sb_error"); + } + } +} \ No newline at end of file diff --git a/TessesDedupWeb/Program.cs b/TessesDedupWeb/Program.cs new file mode 100644 index 0000000..c7b22a1 --- /dev/null +++ b/TessesDedupWeb/Program.cs @@ -0,0 +1,15 @@ +using Blazored.LocalStorage; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using TessesDedupWeb; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); +HttpClient client = new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }; +builder.Services.AddScoped(sp => client ); +builder.Services.AddBlazoredLocalStorage(); + +builder.Services.AddScoped(sp=>new DedupClient(client)); + +await builder.Build().RunAsync(); diff --git a/TessesDedupWeb/Properties/launchSettings.json b/TessesDedupWeb/Properties/launchSettings.json new file mode 100644 index 0000000..66905c0 --- /dev/null +++ b/TessesDedupWeb/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "iisSettings": { + "iisExpress": { + "applicationUrl": "http://localhost:35148", + "sslPort": 44359 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5286", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7166;http://localhost:5286", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TessesDedupWeb/Services/DedupClient.cs b/TessesDedupWeb/Services/DedupClient.cs new file mode 100644 index 0000000..4e25c42 --- /dev/null +++ b/TessesDedupWeb/Services/DedupClient.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using TessesDedupWeb; +using Tesses.VirtualFilesystem; +public class DedupClient +{ + public DedupClient(HttpClient client) + { + this.client = client; + } + + public string DataPath=>$"{(client.BaseAddress?.ToString() ?? "").TrimEnd('/')}/data/"; + HttpClient client; + + public async Task IsRegisteredAsync() + { + return await client.GetStringAsync("/api/v1/Registered") == "true"; + } + + public async Task GetStatsAsync(string key) + { + //"blocks":0,"bytes":0,"backups":0,"label":"0 bytes" + + + var res= await client.GetFromJsonAsync($"/api/v1/Stats?access_key={WebUtility.UrlEncode(key)}"); + if(res != null) return res; + + return new Stats(); + + } + public async Task LogoutAsync(string key) + { + Dictionary dictionary= new Dictionary + { + { "access_key", key } + }; + + FormUrlEncodedContent formUrlEncodedContent=new FormUrlEncodedContent(dictionary); + using(var resp=await client.PostAsync("/api/v1/Logout",formUrlEncodedContent)) + { + + } + } + public async Task AccessKeyDeleteAsync(string key, long id) + { + var res= await client.DeleteFromJsonAsync($"/api/v1/AccessKey?id={id}&access_key={WebUtility.UrlEncode(key)}"); + return res ?? new AccessKeyDeleteReason(); + } + public async IAsyncEnumerable GetAccessKeysAsync(string key) + { + var res= await client.GetFromJsonAsync>($"/api/v1/AccessKey?access_key={WebUtility.UrlEncode(key)}"); + if(res != null) { + foreach(var item in res) + { + yield return item; + } + } + } + public async Task GetBackupAsync(string key,long id) + { + var res= await client.GetFromJsonAsync($"/api/v1/Backup?id={id}&access_key={WebUtility.UrlEncode(key)}&noHashes=true"); + return res ?? new Backup(); + } + + + + public async IAsyncEnumerable GetBackupsAsync(string key) + { + var res= await client.GetFromJsonAsync>($"/api/v1/Backup?access_key={WebUtility.UrlEncode(key)}"); + if(res != null) { + foreach(var item in res) + { + yield return item; + } + } + + } + public async Task LoginAsync(string username,string password, string device_name) + { + Dictionary dictionary= new Dictionary + { + { "username", username }, + { "password", password }, + { "device_name", device_name } + }; + + FormUrlEncodedContent formUrlEncodedContent=new FormUrlEncodedContent(dictionary); + using(var resp=await client.PostAsync("/api/v1/Login",formUrlEncodedContent)) + { + if(resp.IsSuccessStatusCode) + { + return await resp.Content.ReadFromJsonAsync() ?? new LoginResult(); + } + else + { + return new LoginResult(); + } + } + } +} + +public class AccessKeyDeleteReason +{ + [JsonPropertyName("success")] + public bool Success {get;set;}=false; + [JsonPropertyName("reason")] + public string Reason {get;set;}="The response was null"; +} + +public class AccessKey +{ + [JsonPropertyName("creation_date")] + public DateTime Created {get;set;} + [JsonPropertyName("id")] + public long Id {get;set;} + + + [JsonPropertyName("device_name")] + public string DeviceName {get;set;}=""; +} + +public class Stats +{ + [JsonPropertyName("blocks")] + public long Blocks {get;set;}=0; + + [JsonPropertyName("bytes")] + public long Bytes {get;set;}=0; + + [JsonPropertyName("backups")] + public long Backups {get;set;}=0; + + [JsonPropertyName("label")] + public string Label {get;set;}=""; +} + +public class LoginResult +{ + [JsonPropertyName("success")] + public bool Success {get;set;}=false; + + [JsonPropertyName("key")] + public string Key {get;set;}=""; +} + + public class Backup + { + [JsonPropertyName("id")] + public long Id {get;set;} + [JsonPropertyName("account_id")] + public long AccountId {get;set;} + [JsonPropertyName("root")] + public FilesystemEntry? Root {get;set;} + [JsonPropertyName("device_name")] + public string DeviceName {get;set;}=""; + [JsonPropertyName("tag")] + public string Tag {get;set;}=""; + [JsonPropertyName("creation_date")] + + public DateTime CreationDate {get;set;} + + + public Backup WithoutRoot() + { + Backup b=new Backup(); + b.AccountId = AccountId; + b.Id = Id; + b.CreationDate = CreationDate; + b.DeviceName = DeviceName; + b.Tag = Tag; + b.Root=null; + return b; + } + + public FilesystemEntry GetEntryFromPath(UnixPath path) + { + + if(path.Path == "/" || path.Path.Length == 0) return Root ?? new FilesystemEntry(); + + var gefp=GetEntryFromPath(path.Parent); + + if(gefp.Type != "dir") throw new DirectoryNotFoundException(path.Parent.Path); + + foreach(var item in gefp.Entries) + { + if(item.Name == path.Name) + { + switch(item.Type) + { + case "dir": + case "file": + return item; + case "symlink": + { + if(item.PointsTo.StartsWith("/")) + { + UnixPath _path = path.Parent; + foreach(var _item in item.PointsTo.Split('/')) + { + + if(_item == "..") + { + _path = _path.Parent; + } + else if(_item != ".") + { + _path /= _item; + } + } + return GetEntryFromPath(_path); + } + else + { + UnixPath _path = Special.Root; + foreach(var _item in item.PointsTo.Split('/')) + { + + if(_item == "..") + { + _path = _path.Parent; + } + else if(_item != ".") + { + _path /= _item; + } + } + return GetEntryFromPath(_path); + } + } + } + } + } + + throw new FileNotFoundException(path.Path); + } + } + + public class FilesystemEntry + { + [JsonPropertyName("name")] + public string Name {get;set;}=""; + + [JsonPropertyName("type")] + + + public string Type {get;set;}=""; + + [JsonPropertyName("entries")] + public List Entries {get;set;}=new List(); + + [JsonPropertyName("hashes")] + public List Hashes {get;set;}=new List(); + [JsonPropertyName("length")] + public long Length {get;set;} + + [JsonPropertyName("points_to")] + public string PointsTo {get;set;}=""; + + + } + + \ No newline at end of file diff --git a/TessesDedupWeb/TessesDedupWeb.csproj b/TessesDedupWeb/TessesDedupWeb.csproj new file mode 100644 index 0000000..b6dd5d6 --- /dev/null +++ b/TessesDedupWeb/TessesDedupWeb.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/TessesDedupWeb/_Imports.razor b/TessesDedupWeb/_Imports.razor new file mode 100644 index 0000000..b03d0a1 --- /dev/null +++ b/TessesDedupWeb/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using TessesDedupWeb +@using Humanizer +@using Microsoft.AspNetCore.WebUtilities; +@using System.Net +@using Tesses.VirtualFilesystem; \ No newline at end of file diff --git a/TessesDedupWeb/wwwroot/css/app.css b/TessesDedupWeb/wwwroot/css/app.css new file mode 100644 index 0000000..ffcb043 --- /dev/null +++ b/TessesDedupWeb/wwwroot/css/app.css @@ -0,0 +1,32 @@ +h1:focus { + outline: none; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + 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; + } + +.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." + } diff --git a/TessesDedupWeb/wwwroot/css/beer.min.css b/TessesDedupWeb/wwwroot/css/beer.min.css new file mode 100644 index 0000000..52ef648 --- /dev/null +++ b/TessesDedupWeb/wwwroot/css/beer.min.css @@ -0,0 +1 @@ +:root{--size: 16px;--font: Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif;--font-icon: "Material Symbols Outlined";--speed1: .1s;--speed2: .2s;--speed3: .3s;--speed4: .4s}:root,body.light{--primary: #6750a4;--on-primary: #ffffff;--primary-container: #e9ddff;--on-primary-container: #22005d;--secondary: #625b71;--on-secondary: #ffffff;--secondary-container: #e8def8;--on-secondary-container: #1e192b;--tertiary: #7e5260;--on-tertiary: #ffffff;--tertiary-container: #ffd9e3;--on-tertiary-container: #31101d;--error: #ba1a1a;--on-error: #ffffff;--error-container: #ffdad6;--on-error-container: #410002;--background: #fffbff;--on-background: #1c1b1e;--surface: #fdf8fd;--on-surface: #1c1b1e;--surface-variant: #e7e0eb;--on-surface-variant: #49454e;--outline: #7a757f;--outline-variant: #cac4cf;--shadow: #000000;--scrim: #000000;--inverse-surface: #313033;--inverse-on-surface: #f4eff4;--inverse-primary: #cfbcff;--surface-dim: #ddd8dd;--surface-bright: #fdf8fd;--surface-container-lowest: #ffffff;--surface-container-low: #f7f2f7;--surface-container: #f2ecf1;--surface-container-high: #ece7eb;--surface-container-highest: #e6e1e6;--overlay: rgb(0 0 0 / .5);--active: rgb(0 0 0 / .1);--elevate1: 0 .125rem .125rem 0 rgb(0 0 0 / .32);--elevate2: 0 .25rem .5rem 0 rgb(0 0 0 / .4);--elevate3: 0 .375rem .75rem 0 rgb(0 0 0 / .48)}body.dark{--primary: #cfbcff;--on-primary: #381e72;--primary-container: #4f378a;--on-primary-container: #e9ddff;--secondary: #cbc2db;--on-secondary: #332d41;--secondary-container: #4a4458;--on-secondary-container: #e8def8;--tertiary: #efb8c8;--on-tertiary: #4a2532;--tertiary-container: #633b48;--on-tertiary-container: #ffd9e3;--error: #ffb4ab;--on-error: #690005;--error-container: #93000a;--on-error-container: #ffb4ab;--background: #1c1b1e;--on-background: #e6e1e6;--surface: #141316;--on-surface: #e6e1e6;--surface-variant: #49454e;--on-surface-variant: #cac4cf;--outline: #948f99;--outline-variant: #49454e;--shadow: #000000;--scrim: #000000;--inverse-surface: #e6e1e6;--inverse-on-surface: #313033;--inverse-primary: #6750a4;--surface-dim: #141316;--surface-bright: #3a383c;--surface-container-lowest: #0f0e11;--surface-container-low: #1c1b1e;--surface-container: #201f22;--surface-container-high: #2b292d;--surface-container-highest: #363438;--overlay: rgb(0 0 0 / .5);--active: rgb(255 255 255 / .2);--elevate1: 0 .125rem .125rem 0 rgb(0 0 0 / .32);--elevate2: 0 .25rem .5rem 0 rgb(0 0 0 / .4);--elevate3: 0 .375rem .75rem 0 rgb(0 0 0 / .48)}@font-face{font-family:Material Symbols Outlined;font-style:normal;font-weight:400;font-display:block;src:url(material-symbols-outlined.woff2) format("woff2"),url(https://cdn.jsdelivr.net/npm/beercss@3.5.8/dist/cdn/material-symbols-outlined.woff2) format("woff2")}@font-face{font-family:Material Symbols Rounded;font-style:normal;font-weight:400;font-display:block;src:url(material-symbols-rounded.woff2) format("woff2"),url(https://cdn.jsdelivr.net/npm/beercss@3.5.8/dist/cdn/material-symbols-rounded.woff2) format("woff2")}@font-face{font-family:Material Symbols Sharp;font-style:normal;font-weight:400;font-display:block;src:url(material-symbols-sharp.woff2) format("woff2"),url(https://cdn.jsdelivr.net/npm/beercss@3.5.8/dist/cdn/material-symbols-sharp.woff2) format("woff2")}*{-webkit-tap-highlight-color:transparent;position:relative;vertical-align:middle;color:inherit;margin:0;padding:0;border-radius:inherit;box-sizing:border-box}body{color:var(--on-surface);background-color:var(--surface);overflow-x:hidden}label{font-size:.75rem;vertical-align:baseline}a,b,i,span,strong{vertical-align:bottom}a,button,.button{cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;border:none;font-family:inherit;outline:inherit;justify-content:center}a,button,.button,i,label{user-select:none}body ::-webkit-scrollbar,body ::-webkit-scrollbar-thumb,body ::-webkit-scrollbar-button{background:none;inline-size:.4rem;block-size:.4rem}body :is(:hover,:focus)::-webkit-scrollbar-thumb{background:var(--outline);border-radius:1rem}pre,code{direction:ltr}.primary{background-color:var(--primary)!important;color:var(--on-primary)!important}.primary-text{color:var(--primary)!important}.primary-border{border-color:var(--primary)!important}.primary-container{background-color:var(--primary-container)!important;color:var(--on-primary-container)!important}.secondary{background-color:var(--secondary)!important;color:var(--on-secondary)!important}.secondary-text{color:var(--secondary)!important}.secondary-border{border-color:var(--secondary)!important}.secondary-container{background-color:var(--secondary-container)!important;color:var(--on-secondary-container)!important}.tertiary{background-color:var(--tertiary)!important;color:var(--on-tertiary)!important}.tertiary-text{color:var(--tertiary)!important}.tertiary-border{border-color:var(--tertiary)!important}.tertiary-container{background-color:var(--tertiary-container)!important;color:var(--on-tertiary-container)!important}.error{background-color:var(--error)!important;color:var(--on-error)!important}.error-text{color:var(--error)!important}.error-border{border-color:var(--error)!important}.error-container{background-color:var(--error-container)!important;color:var(--on-error-container)!important}.background{background-color:var(--background)!important;color:var(--on-background)!important}.surface,.surface-dim,.surface-bright,.surface-container-lowest,.surface-container-low,.surface-container,.surface-container-high,.surface-container-highest{background-color:var(--surface)!important;color:var(--on-surface)!important}.surface-variant{background-color:var(--surface-variant)!important;color:var(--on-surface-variant)!important}.inverse-surface{background-color:var(--inverse-surface);color:var(--inverse-on-surface)}.inverse-primary{background-color:var(--inverse-primary);color:var(--primary)}.inverse-primary-text{color:var(--inverse-primary)!important}.inverse-primary-border{border-color:var(--inverse-primary)!important}.surface-dim{background-color:var(--surface-dim)!important}.surface-bright{background-color:var(--surface-bright)!important}.surface-container-lowest{background-color:var(--surface-container-lowest)!important}.surface-container{background-color:var(--surface-container)!important}.surface-container-high{background-color:var(--surface-container-high)!important}.surface-container-highest{background-color:var(--surface-container-highest)!important}.surface-container-low{background-color:var(--surface-container-low)!important}.black{background-color:#000!important}.black-border{border-color:#000!important}.black-text{color:#000!important}.white{background-color:#fff!important}.white-border{border-color:#fff!important}.white-text{color:#fff!important}.transparent{background-color:transparent!important;box-shadow:none!important;color:inherit!important}.transparent-border{border-color:transparent!important}.transparent-text{color:transparent!important}.fill:not(i){background-color:var(--surface-variant)!important;color:var(--on-surface-variant)!important}.middle-align{display:flex;align-items:center!important}.bottom-align{display:flex;align-items:flex-end!important}.top-align{display:flex;align-items:flex-start!important}.left-align{text-align:start;justify-content:flex-start!important}.right-align{text-align:end;justify-content:flex-end!important}.center-align{text-align:center;justify-content:center!important}.red,.red6{background-color:#f44336!important}.red-border{border-color:#f44336!important}.red-text{color:#f44336!important}.red1{background-color:#ffebee!important}.red2{background-color:#ffcdd2!important}.red3{background-color:#ef9a9a!important}.red4{background-color:#e57373!important}.red5{background-color:#ef5350!important}.red7{background-color:#e53935!important}.red8{background-color:#d32f2f!important}.red9{background-color:#c62828!important}.red10{background-color:#b71c1c!important}.pink,.pink6{background-color:#e91e63!important}.pink-border{border-color:#e91e63!important}.pink-text{color:#e91e63!important}.pink1{background-color:#fce4ec!important}.pink2{background-color:#f8bbd0!important}.pink3{background-color:#f48fb1!important}.pink4{background-color:#f06292!important}.pink5{background-color:#ec407a!important}.pink7{background-color:#d81b60!important}.pink8{background-color:#c2185b!important}.pink9{background-color:#ad1457!important}.pink10{background-color:#880e4f!important}.purple,.purple6{background-color:#9c27b0!important}.purple-border{border-color:#9c27b0!important}.purple-text{color:#9c27b0!important}.purple1{background-color:#f3e5f5!important}.purple2{background-color:#e1bee7!important}.purple3{background-color:#ce93d8!important}.purple4{background-color:#ba68c8!important}.purple5{background-color:#ab47bc!important}.purple7{background-color:#8e24aa!important}.purple8{background-color:#7b1fa2!important}.purple9{background-color:#6a1b9a!important}.purple10{background-color:#4a148c!important}.deep-purple,.deep-purple6{background-color:#673ab7!important}.deep-purple-border{border-color:#673ab7!important}.deep-purple-text{color:#673ab7!important}.deep-purple1{background-color:#ede7f6!important}.deep-purple2{background-color:#d1c4e9!important}.deep-purple3{background-color:#b39ddb!important}.deep-purple4{background-color:#9575cd!important}.deep-purple5{background-color:#7e57c2!important}.deep-purple7{background-color:#5e35b1!important}.deep-purple8{background-color:#512da8!important}.deep-purple9{background-color:#4527a0!important}.deep-purple10{background-color:#311b92!important}.indigo,.indigo6{background-color:#3f51b5!important}.indigo-border{border-color:#3f51b5!important}.indigo-text{color:#3f51b5!important}.indigo1{background-color:#e8eaf6!important}.indigo2{background-color:#c5cae9!important}.indigo3{background-color:#9fa8da!important}.indigo4{background-color:#7986cb!important}.indigo5{background-color:#5c6bc0!important}.indigo7{background-color:#3949ab!important}.indigo8{background-color:#303f9f!important}.indigo9{background-color:#283593!important}.indigo10{background-color:#1a237e!important}.blue,.blue6{background-color:#2196f3!important}.blue-border{border-color:#2196f3!important}.blue-text{color:#2196f3!important}.blue1{background-color:#e3f2fd!important}.blue2{background-color:#bbdefb!important}.blue3{background-color:#90caf9!important}.blue4{background-color:#64b5f6!important}.blue5{background-color:#42a5f5!important}.blue7{background-color:#1e88e5!important}.blue8{background-color:#1976d2!important}.blue9{background-color:#1565c0!important}.blue10{background-color:#0d47a1!important}.light-blue,.light-blue6{background-color:#03a9f4!important}.light-blue-border{border-color:#03a9f4!important}.light-blue-text{color:#03a9f4!important}.light-blue1{background-color:#e1f5fe!important}.light-blue2{background-color:#b3e5fc!important}.light-blue3{background-color:#81d4fa!important}.light-blue4{background-color:#4fc3f7!important}.light-blue5{background-color:#29b6f6!important}.light-blue7{background-color:#039be5!important}.light-blue8{background-color:#0288d1!important}.light-blue9{background-color:#0277bd!important}.light-blue10{background-color:#01579b!important}.cyan,.cyan6{background-color:#00bcd4!important}.cyan-border{border-color:#00bcd4!important}.cyan-text{color:#00bcd4!important}.cyan1{background-color:#e0f7fa!important}.cyan2{background-color:#b2ebf2!important}.cyan3{background-color:#80deea!important}.cyan4{background-color:#4dd0e1!important}.cyan5{background-color:#26c6da!important}.cyan7{background-color:#00acc1!important}.cyan8{background-color:#0097a7!important}.cyan9{background-color:#00838f!important}.cyan10{background-color:#006064!important}.teal,.teal6{background-color:#009688!important}.teal-border{border-color:#009688!important}.teal-text{color:#009688!important}.teal1{background-color:#e0f2f1!important}.teal2{background-color:#b2dfdb!important}.teal3{background-color:#80cbc4!important}.teal4{background-color:#4db6ac!important}.teal5{background-color:#26a69a!important}.teal7{background-color:#00897b!important}.teal8{background-color:#00796b!important}.teal9{background-color:#00695c!important}.teal10{background-color:#004d40!important}.green,.green6{background-color:#4caf50!important}.green-border{border-color:#4caf50!important}.green-text{color:#4caf50!important}.green1{background-color:#e8f5e9!important}.green2{background-color:#c8e6c9!important}.green3{background-color:#a5d6a7!important}.green4{background-color:#81c784!important}.green5{background-color:#66bb6a!important}.green7{background-color:#43a047!important}.green8{background-color:#388e3c!important}.green9{background-color:#2e7d32!important}.green10{background-color:#1b5e20!important}.light-green,.light-green6{background-color:#8bc34a!important}.light-green-border{border-color:#8bc34a!important}.light-green-text{color:#8bc34a!important}.light-green1{background-color:#f1f8e9!important}.light-green2{background-color:#dcedc8!important}.light-green3{background-color:#c5e1a5!important}.light-green4{background-color:#aed581!important}.light-green5{background-color:#9ccc65!important}.light-green7{background-color:#7cb342!important}.light-green8{background-color:#689f38!important}.light-green9{background-color:#558b2f!important}.light-green10{background-color:#33691e!important}.lime,.lime6{background-color:#cddc39!important}.lime-border{border-color:#cddc39!important}.lime-text{color:#cddc39!important}.lime1{background-color:#f9fbe7!important}.lime2{background-color:#f0f4c3!important}.lime3{background-color:#e6ee9c!important}.lime4{background-color:#dce775!important}.lime5{background-color:#d4e157!important}.lime7{background-color:#c0ca33!important}.lime8{background-color:#afb42b!important}.lime9{background-color:#9e9d24!important}.lime10{background-color:#827717!important}.yellow,.yellow6{background-color:#ffeb3b!important}.yellow-border{border-color:#ffeb3b!important}.yellow-text{color:#ffeb3b!important}.yellow1{background-color:#fffde7!important}.yellow2{background-color:#fff9c4!important}.yellow3{background-color:#fff59d!important}.yellow4{background-color:#fff176!important}.yellow5{background-color:#ffee58!important}.yellow7{background-color:#fdd835!important}.yellow8{background-color:#fbc02d!important}.yellow9{background-color:#f9a825!important}.yellow10{background-color:#f57f17!important}.amber,.amber6{background-color:#ffc107!important}.amber-border{border-color:#ffc107!important}.amber-text{color:#ffc107!important}.amber1{background-color:#fff8e1!important}.amber2{background-color:#ffecb3!important}.amber3{background-color:#ffe082!important}.amber4{background-color:#ffd54f!important}.amber5{background-color:#ffca28!important}.amber7{background-color:#ffb300!important}.amber8{background-color:#ffa000!important}.amber9{background-color:#ff8f00!important}.amber10{background-color:#ff6f00!important}.orange,.orange6{background-color:#ff9800!important}.orange-border{border-color:#ff9800!important}.orange-text{color:#ff9800!important}.orange1{background-color:#fff3e0!important}.orange2{background-color:#ffe0b2!important}.orange3{background-color:#ffcc80!important}.orange4{background-color:#ffb74d!important}.orange5{background-color:#ffa726!important}.orange7{background-color:#fb8c00!important}.orange8{background-color:#f57c00!important}.orange9{background-color:#ef6c00!important}.orange10{background-color:#e65100!important}.deep-orange,.deep-orange6{background-color:#ff5722!important}.deep-orange-border{border-color:#ff5722!important}.deep-orange-text{color:#ff5722!important}.deep-orange1{background-color:#fbe9e7!important}.deep-orange2{background-color:#ffccbc!important}.deep-orange3{background-color:#ffab91!important}.deep-orange4{background-color:#ff8a65!important}.deep-orange5{background-color:#ff7043!important}.deep-orange7{background-color:#f4511e!important}.deep-orange8{background-color:#e64a19!important}.deep-orange9{background-color:#d84315!important}.deep-orange10{background-color:#bf360c!important}.brown,.brown6{background-color:#795548!important}.brown-border{border-color:#795548!important}.brown-text{color:#795548!important}.brown1{background-color:#efebe9!important}.brown2{background-color:#d7ccc8!important}.brown3{background-color:#bcaaa4!important}.brown4{background-color:#a1887f!important}.brown5{background-color:#8d6e63!important}.brown7{background-color:#6d4c41!important}.brown8{background-color:#5d4037!important}.brown9{background-color:#4e342e!important}.brown10{background-color:#3e2723!important}.blue-grey,.blue-grey6{background-color:#607d8b!important}.blue-grey-border{border-color:#607d8b!important}.blue-grey-text{color:#607d8b!important}.blue-grey1{background-color:#eceff1!important}.blue-grey2{background-color:#cfd8dc!important}.blue-grey3{background-color:#b0bec5!important}.blue-grey4{background-color:#90a4ae!important}.blue-grey5{background-color:#78909c!important}.blue-grey7{background-color:#546e7a!important}.blue-grey8{background-color:#455a64!important}.blue-grey9{background-color:#37474f!important}.blue-grey10{background-color:#263238!important}.grey,.grey6{background-color:#9e9e9e!important}.grey-border{border-color:#9e9e9e!important}.grey-text{color:#9e9e9e!important}.grey1{background-color:#fafafa!important}.grey2{background-color:#f5f5f5!important}.grey3{background-color:#eee!important}.grey4{background-color:#e0e0e0!important}.grey5{background-color:#bdbdbd!important}.grey7{background-color:#757575!important}.grey8{background-color:#616161!important}.grey9{background-color:#424242!important}.grey10{background-color:#212121!important}.horizontal{display:inline-flex;flex-direction:row!important;gap:1rem;inline-size:auto!important;max-inline-size:none!important}.horizontal>*{margin-block:0!important}.vertical{display:flex;flex-direction:column!important}:is(a,button,.button,.chip).vertical{display:inline-flex;gap:.25rem;block-size:auto!important;max-block-size:none!important;padding-block:.5rem}.vertical>*{margin-inline:0!important}[class*=divider]{min-inline-size:1.5rem;min-block-size:auto;block-size:.0625rem;background-color:var(--outline-variant);display:block}[class*=divider]+*{margin:0!important}.medium-divider{margin:1rem 0!important}.large-divider{margin:1.5rem 0!important}.small-divider{margin:.5rem 0!important}.divider.vertical{min-inline-size:auto;min-block-size:1.5rem;inline-size:.0625rem}.no-elevate{box-shadow:none!important}.small-elevate,.elevate{box-shadow:var(--elevate1)!important}.medium-elevate{box-shadow:var(--elevate2)!important}.large-elevate{box-shadow:var(--elevate3)!important}.round{border-radius:var(---round)}.small-round,.medium-round,.large-round{border-radius:var(---round)!important}.top-round,.bottom-round,.left-round,.right-round,.medium-round,.round{---round: 2rem}.small-round{---round: .5rem}.large-round{---round: 3.5rem}.no-round,.square,.top-round,.bottom-round,.left-round,.right-round{border-radius:0!important}.top-round{border-start-start-radius:var(---round)!important;border-start-end-radius:var(---round)!important}.bottom-round{border-end-end-radius:var(---round)!important;border-end-start-radius:var(---round)!important}.left-round{border-start-start-radius:var(---round)!important;border-end-start-radius:var(---round)!important}.right-round{border-start-end-radius:var(---round)!important;border-end-end-radius:var(---round)!important}.circle{border-radius:50%}:is(button,.button,.chip).circle{border-radius:2.5rem}:is(.circle,.square):not(i,img,video,svg),:is(.circle,.square).chip.medium{block-size:2.5rem;inline-size:2.5rem;padding:0}:is(.circle,.square)>span{display:none}:is(.circle,.square).small:not(i,img,video,svg),:is(.circle,.square).chip{block-size:2rem;inline-size:2rem}:is(.circle,.square).large:not(i,img,video,svg){block-size:3rem;inline-size:3rem}:is(.circle,.square).extra:not(i,img,video,svg){block-size:3.5rem;inline-size:3.5rem}:is(.circle,.square).round{border-radius:1rem!important}.border:not(table,.field){box-sizing:border-box;border:.0625rem solid var(--outline);background-color:transparent;box-shadow:none}.no-border{border-color:transparent!important}:is(nav,.row,dialog.max,header.fixed,footer.fixed,menu > a,menu.max,table,.tabs):not(.round){border-radius:0}[class*=margin]:not(.left-margin,.right-margin,.top-margin,.bottom-margin,.horizontal-margin,.vertical-margin){margin:var(---margin)!important}[class*=margin]{---margin: 1rem}.no-margin{---margin: 0}.auto-margin{---margin: auto}.tiny-margin{---margin: .25rem}.small-margin{---margin: .5rem}.large-margin{---margin: 1.5rem}.left-margin,.horizontal-margin{margin-inline-start:var(---margin)!important}.right-margin,.horizontal-margin{margin-inline-end:var(---margin)!important}.top-margin,.vertical-margin{margin-block-start:var(---margin)!important}.bottom-margin,.vertical-margin{margin-block-end:var(---margin)!important}.no-opacity{opacity:1!important}.opacity{opacity:0!important}.small-opacity{opacity:.75!important}.medium-opacity{opacity:.5!important}.large-opacity{opacity:.25!important}[class*=padding]:not(.left-padding,.right-padding,.top-padding,.bottom-padding,.horizontal-padding,.vertical-padding){padding:var(---padding)!important}[class*=padding]{---padding: 1rem}.no-padding{---padding: 0}.tiny-padding{---padding: .25rem}.small-padding{---padding: .5rem}.large-padding{---padding: 1.5rem}.left-padding,.horizontal-padding{padding-inline-start:var(---padding)!important}.right-padding,.horizontal-padding{padding-inline-end:var(---padding)!important}.top-padding,.vertical-padding{padding-block-start:var(---padding)!important}.bottom-padding,.vertical-padding{padding-block-end:var(---padding)!important}.front{z-index:10!important}.back{z-index:-10!important}.left{inset-inline-start:0}.right{inset-inline-end:0}.top{inset-block-start:0}.bottom{inset-block-end:0}.center{inset-inline-start:50%;transform:translate(-50%)}[dir=rtl] .center{transform:translate(50%)}.middle{inset-block-start:50%;transform:translateY(-50%)}.middle.center{transform:translate(-50%,-50%)}[dir=rtl] .middle.center{transform:translate(50%,-50%)}.scroll{overflow:auto}.no-scroll{overflow:hidden}[class*=width]{max-inline-size:100%}.auto-width{inline-size:auto}.small-width{inline-size:12rem!important}.medium-width{inline-size:24rem!important}.large-width{inline-size:36rem!important}.auto-height{block-size:auto}.small-height{block-size:12rem!important}.medium-height{block-size:24rem!important}.large-height{block-size:36rem!important}.wrap{display:block;white-space:normal}.no-wrap:not(menu){display:flex;white-space:nowrap}.tiny-space:not(nav,ol,ul,.row,.grid,table,.tooltip){block-size:.5rem}:is(.space,.small-space):not(nav,ol,ul,.row,.grid,table,.tooltip){block-size:1rem}.medium-space:not(nav,ol,ul,.row,.grid,table,.tooltip){block-size:2rem}.large-space:not(nav,ol,ul,.row,.grid,table,.tooltip){block-size:3rem}.responsive{inline-size:-webkit-fill-available;inline-size:-moz-available}@media only screen and (max-width: 600px){.m:not(.s),.l:not(.s),.m.l:not(.s){display:none}}@media only screen and (min-width: 601px) and (max-width: 992px){.s:not(.m),.l:not(.m),.s.l:not(.m){display:none}}@media only screen and (min-width: 993px){.m:not(.l),.s:not(.l),.m.s:not(.l){display:none}}html{font-size:var(--size)}body{font-family:var(--font);font-size:.875rem;line-height:1.5rem;letter-spacing:.0313rem}h1,h2,h3,h4,h5,h6{font-weight:400;display:flex;align-items:center;line-height:normal}*+:is(h1,h2,h3,h4,h5,h6){margin-block-start:1rem}h1{font-size:3.5625rem}h2{font-size:2.8125rem}h3{font-size:2.25rem}h4{font-size:2rem}h5{font-size:1.75rem}h6{font-size:1.5rem}h1.small{font-size:3.0625rem}h2.small{font-size:2.3125rem}h3.small{font-size:1.75rem}h4.small{font-size:1.5rem}h5.small{font-size:1.25rem}h6.small{font-size:1rem}h1.large{font-size:4.0625rem}h2.large{font-size:3.3125rem}h3.large{font-size:2.75rem}h4.large{font-size:2.5rem}h5.large{font-size:2.25rem}h6.large{font-size:2rem}.link{color:var(--primary)!important}.inverse-link{color:var(--inverse-primary)!important}.truncate{overflow:hidden;white-space:nowrap!important;text-overflow:ellipsis;flex:inherit}.truncate>*{white-space:nowrap!important}.small-text{font-size:.75rem}.medium-text{font-size:.875rem}.large-text{font-size:1rem}.upper{text-transform:uppercase}.lower{text-transform:lowercase}.capitalize{text-transform:capitalize}.bold{font-weight:700}.overline{text-decoration:line-through}.underline{text-decoration:underline}.italic{font-style:italic}p{margin:.5rem 0}.no-line{line-height:normal}.tiny-line{line-height:1.25rem}.small-line{line-height:1.5rem}.medium-line{line-height:1.75rem}.large-line{line-height:2rem}.extra-line{line-height:2.25rem}.wave:after,.chip:after,.wave.light:after,:is(.button,button):after{content:"";position:absolute;inset:0;z-index:1;border-radius:inherit;inline-size:100%;block-size:100%;background-position:center;background-image:radial-gradient(circle,rgb(255 255 255 / .4) 1%,transparent 1%);opacity:0;transition:none}.wave.dark:after,.wave.row:after,.chip:after,:is(.button,button).border:after,:is(.button,button).transparent:after{background-image:radial-gradient(circle,rgb(150 150 150 / .2) 1%,transparent 1%)}:is(.wave,.chip,.button,button):is(:focus-visible,:hover):after{background-size:15000%;opacity:1;transition:background-size var(--speed2) linear}:is(.wave,.chip,.button,button):active:after{background-size:5000%;opacity:0;transition:none}.no-wave:after,.no-wave:is(:hover,:active):after{display:none}.badge{display:inline-flex;align-items:center;justify-content:center;position:absolute;font-size:.6875rem;text-transform:none;z-index:2;padding:0 .25rem;min-block-size:1rem;min-inline-size:1rem;background-color:var(--error);color:var(--on-error);line-height:normal;border-radius:1rem;inset:50% auto auto 50%;transform:translateY(-100%);font-family:var(--font)}.badge.top{transform:translate(-50%,-100%)}.badge.bottom{transform:translate(-50%)}.badge.left{transform:translate(-100%,-50%)}.badge.right{transform:translateY(-50%)}.badge.top.left{transform:translate(-100%,-100%)}.badge.bottom.left{transform:translate(-100%)}.badge.top.right{transform:translateY(-100%)}.badge.bottom.right{transform:translate(0)}.badge.border{border-color:var(--error);color:var(--error);background-color:var(--surface)}.badge:is(.circle,.square){text-align:center;inline-size:auto;block-size:auto;padding:0 .25rem;border-radius:1rem}.badge.square{border-radius:0}.badge.min>*{display:none}.badge.min{clip-path:circle(18.75% at 50% 50%)}nav:is(.left,.right,.top,.bottom)>a>.badge,nav:is(.left,.right,.top,.bottom)>:is(ol,ul)>li>a>.badge{inset:1rem auto auto 50%}.badge.none{inset:auto!important;transform:none;position:relative;margin:0 .125rem}:is(button,.button,.chip)>.badge.none{margin:0 -.5rem}.button,button{box-sizing:content-box;display:inline-flex;align-items:center;justify-content:center;block-size:2.5rem;min-inline-size:2.5rem;font-size:.875rem;font-weight:500;color:var(--on-primary);padding:0 1.5rem;background-color:var(--primary);margin:0 .5rem;border-radius:1.25rem;transition:transform var(--speed3),border-radius var(--speed3),padding var(--speed3);user-select:none;gap:1rem;line-height:normal}:is(button,.button).small{block-size:2rem;min-inline-size:2rem;font-size:.875rem;border-radius:1rem}:is(button,.button).large{block-size:3rem;min-inline-size:3rem;border-radius:1.5rem}:is(.button,button):is(.extra,.extend){block-size:3.5rem;min-inline-size:3.5rem;font-size:1rem;border-radius:1.75rem}:is(button,.button).border{border-color:var(--outline);color:var(--primary)}:is(button,.button):not(.border,.chip):hover{box-shadow:var(--elevate1)}.extend>span{display:none}.extend:is(:hover,.active){inline-size:auto;padding:0 1.5rem}.extend:is(:hover,.active)>i+span{display:inherit;margin-inline-start:1.5rem}.extend:is(:hover,.active)>:is(img,svg)+span{display:inherit;margin-inline-start:2.5rem}:is(.button,button):is([disabled]){opacity:.5;cursor:not-allowed}:is(.button):is([disabled]){pointer-events:none}:is(.button,button):is([disabled]):before,:is(.button,button):is([disabled]):after{display:none}:is(.button,button).fill{background-color:var(--secondary-container)!important;color:var(--on-secondary-container)!important}:is(.button,button).vertical{border-radius:2rem}article{box-shadow:var(--elevate1);background-color:var(--surface-container-low);color:var(--on-surface);padding:1rem;border-radius:.75rem;display:block;transition:transform var(--speed3),border-radius var(--speed3),padding var(--speed3)}*+article{margin-block-start:1rem}article.small{block-size:12rem}article.medium{block-size:20rem}article.large{block-size:32rem}.chip{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;block-size:2rem;min-inline-size:2rem;font-size:.875rem;font-weight:500;background-color:transparent;border:.0625rem solid var(--outline);color:var(--on-surface-variant);padding:0 1rem;margin:0 .5rem;text-transform:none;border-radius:.5rem;transition:transform var(--speed3),border-radius var(--speed3),padding var(--speed3);user-select:none;gap:1rem;line-height:normal}.chip.fill:hover{box-shadow:var(--elevate1)}.chip.medium{block-size:2.5rem;min-inline-size:2.5rem}.chip.large{block-size:3rem;min-inline-size:3rem}.chip.fill{background-color:var(--secondary-container)!important;border:none}.chip.round.small{border-radius:1rem}.chip.round{border-radius:1.25rem}.chip.round.large{border-radius:1.5rem}main.responsive{flex:1;padding:.5rem;overflow-x:hidden}:is(main,header,footer,section).responsive{max-inline-size:75rem;margin:0 auto}:is(main,header,footer,section).responsive.max{max-inline-size:100%}*:has(> main.responsive){display:flex;flex-direction:column;min-block-size:100vh}*:has(> nav.bottom:not(.s,.m,.l)){padding-block-end:5rem}*:has(> nav.top:not(.s,.m,.l)){padding-block-start:5rem}*:has(> nav.left:not(.s,.m,.l)){padding-inline-start:5rem}*:has(> nav.right:not(.s,.m,.l)){padding-inline-end:5rem}*:has(> nav.drawer.left:not(.s,.m,.l)){padding-inline-start:20rem}*:has(> nav.drawer.right:not(.s,.m,.l)){padding-inline-end:20rem}nav.top:not(.s,.m,.l)~header.fixed{inset-block-start:5rem}nav.bottom:not(.s,.m,.l)~footer.fixed{inset-block-end:5rem}@media only screen and (max-width: 600px){*:has(> nav.s.bottom){padding-block-end:5rem}*:has(> nav.s.top){padding-block-start:5rem}*:has(> nav.s.left){padding-inline-start:5rem}*:has(> nav.s.right){padding-inline-end:5rem}*:has(> nav.s.drawer.left){padding-inline-start:20rem}*:has(> nav.s.drawer.right){padding-inline-end:20rem}nav.s.top~header.fixed{inset-block-start:5rem}nav.s.bottom~footer.fixed{inset-block-end:5rem}}@media only screen and (min-width: 601px) and (max-width: 992px){*:has(> nav.m.bottom){padding-block-end:5rem}*:has(> nav.m.top){padding-block-start:5rem}*:has(> nav.m.left){padding-inline-start:5rem}*:has(> nav.m.right){padding-inline-end:5rem}*:has(> nav.m.drawer.left){padding-inline-start:20rem}*:has(> nav.m.drawer.right){padding-inline-end:20rem}nav.m.top~header.fixed{inset-block-start:5rem}nav.m.bottom~footer.fixed{inset-block-end:5rem}}@media only screen and (min-width: 993px){*:has(> nav.l.bottom){padding-block-end:5rem}*:has(> nav.l.top){padding-block-start:5rem}*:has(> nav.l.left){padding-inline-start:5rem}*:has(> nav.l.right){padding-inline-end:5rem}*:has(> nav.l.drawer.left){padding-inline-start:20rem}*:has(> nav.l.drawer.right){padding-inline-end:20rem}nav.l.top~header.fixed{inset-block-start:5rem}nav.l.bottom~footer.fixed{inset-block-end:5rem}}@media only screen and (max-width: 600px){main.responsive{padding-inline:.5rem}}dialog{display:block;border:none;opacity:0;visibility:hidden;position:fixed;box-shadow:var(--elevate2);color:var(--on-surface);background-color:var(--surface-container-high);padding:1.5rem;z-index:100;inset:10% auto auto 50%;min-inline-size:20rem;max-inline-size:100%;max-block-size:80%;overflow-x:hidden;overflow-y:auto;transition:all var(--speed3),0s background-color;border-radius:1.75rem;transform:translate(-50%,-4rem)}dialog::backdrop{display:none}dialog.small{inline-size:25%;block-size:25%}dialog.medium{inline-size:50%;block-size:50%}dialog.large{inline-size:75%;block-size:75%}dialog:is(.active,[open]){opacity:1;visibility:visible;transform:translate(-50%)}dialog.top{opacity:1;padding:1rem;inset:0 auto auto 0;block-size:auto;inline-size:100%;min-inline-size:auto;max-block-size:100%;transform:translateY(-100%);border-radius:0 0 1rem 1rem}[dir=rtl] dialog.right,dialog.left{opacity:1;padding:1rem;inset:0 auto auto 0;inline-size:auto;block-size:100%;max-block-size:100%;border-radius:0 1rem 1rem 0;background-color:var(--surface);transform:translate(-100%)}[dir=rtl] dialog.left,dialog.right{opacity:1;padding:1rem;inset:0 0 auto auto;inline-size:auto;block-size:100%;max-block-size:100%;border-radius:1rem 0 0 1rem;background-color:var(--surface);transform:translate(100%)}dialog.bottom{opacity:1;padding:1rem;inset:auto auto 0 0;block-size:auto;inline-size:100%;min-inline-size:auto;max-block-size:100%;transform:translateY(100%);border-radius:1rem 1rem 0 0}dialog.max{inset:0 auto auto 0;inline-size:100%;block-size:100%;max-inline-size:100%;max-block-size:100%;transform:translateY(4rem);background-color:var(--surface)}dialog:is(.active,[open]):is(.left,.right,.top,.bottom,.max){transform:translate(0)}dialog.small:is(.left,.right){inline-size:20rem}dialog.medium:is(.left,.right){inline-size:32rem}dialog.large:is(.left,.right){inline-size:44rem}dialog.small:is(.top,.bottom){block-size:16rem}dialog.medium:is(.top,.bottom){block-size:24rem}dialog.large:is(.top,.bottom){block-size:32rem}dialog>a.row:is(:hover,.active){background-color:var(--secondary-container)}dialog>.row{padding:.75rem}summary.none{list-style-type:none}summary.none::-webkit-details-marker{display:none}summary{cursor:pointer}summary:focus{outline:none}.field{---size: 3rem;---start: 1.2rem;block-size:var(---size);margin-block-end:2rem}*+.field{margin-block-start:1rem}.grid>*>.field{margin-block-end:1rem}.grid>*>.field+.field{margin-block-start:2rem}.grid.no-space>*>.field+.field{margin-block-start:1rem}.grid.medium-space>*>.field+.field{margin-block-start:2.5rem}.grid.large-space>*>.field+.field{margin-block-start:3rem}.field.small{---size: 2.5rem;---start: 1rem}.field.large{---size: 3.5rem;---start: 1.4rem}.field.extra{---size: 4rem;---start: 1.6rem}.field{border-radius:.25rem .25rem 0 0}.field.border{border-radius:.25rem}.field.round.small{border-radius:1.25rem}.field.round{border-radius:1.5rem}.field.round.large{border-radius:1.75rem}.field.round.extra{border-radius:2rem}.field>:is(i,img,svg,progress,a:not(.helper,.error)){position:absolute;inset:50% auto auto auto;transform:translateY(-50%);cursor:pointer;z-index:0;inline-size:1.5rem;block-size:1.5rem}.field>:is(i,img,svg,progress,a:not(.helper,.error)),[dir=rtl] .field>:is(i,img,svg,progress,a:not(.helper,.error)):first-child{inset:50% 1rem auto auto}.field>:is(i,img,svg,progress,a:not(.helper,.error)):first-child,[dir=rtl] .field>:is(i,img,svg,progress,a:not(.helper,.error)){inset:50% auto auto 1rem}.field.invalid>i{color:var(--error)}.field>progress.circle{inset-block-start:calc(50% - .75rem)!important;border-width:.1875rem}.field>a:not(.helper,.error){z-index:10}.field>a>:is(i,img,svg,progress,a:not(.helper,.error)){inline-size:1.5rem;block-size:1.5rem}.field>:is(input,textarea,select){all:unset;position:relative;display:flex;align-items:center;box-sizing:border-box;border-radius:inherit;border:.0625rem solid transparent;padding:0 .9375rem;font-family:inherit;font-size:1rem;inline-size:100%;block-size:100%;outline:none;z-index:1;background:none;resize:none}.field>:is(input,textarea,select):focus{border:.125rem solid transparent;padding:0 .875rem}.field.min>textarea{overflow:hidden;position:absolute;inset:0}input[type=file],input[type=color],:not(.field)>input[type^=date],:not(.field)>input[type^=time],input::-webkit-calendar-picker-indicator{opacity:0;position:absolute;inset:0;inline-size:100%;block-size:100%;margin:0;padding:0;border:0;outline:0;z-index:2!important}input::-webkit-search-decoration,input::-webkit-search-cancel-button,input::-webkit-search-results-button,input::-webkit-search-results-decoration,input::-webkit-inner-spin-button,input::-webkit-outer-spin-button{display:none}input[type=number]{appearance:textfield}.field.border>:is(input,textarea,select){border-color:var(--outline)}.field.border>:is(input,textarea,select):focus{border-color:var(--primary)}.field.round>:is(input,textarea,select){padding-inline:1.4376rem}.field.round>:is(input,textarea,select):focus{padding-inline:1.375rem}.field.prefix>:is(input,textarea,select){padding-inline-start:2.9375rem}.field.prefix>.slider{margin-inline-start:3.5rem}.field.prefix>:is(input,textarea,select):focus{padding-inline-start:2.875rem}.field.suffix>:is(input,textarea,select){padding-inline-end:2.9375rem}.field.suffix>.slider{margin-inline-end:3.5rem}.field.suffix>:is(input,textarea,select):focus{padding-inline-end:2.875rem}.field:not(.border,.round)>:is(input,textarea,select){border-block-end-color:var(--outline)}.field:not(.border,.round)>:is(input,textarea,select):focus{border-block-end-color:var(--primary)}.field.round:not(.border,.fill)>:is(input,textarea,select),.field.round:not(.border)>:is(input,textarea,select):focus{box-shadow:var(--elevate1)}.field.round:not(.border,.fill)>:is(input,textarea,select):focus{box-shadow:var(--elevate2)}.field.invalid:not(.border,.round)>:is(input,textarea,select),.field.invalid:not(.border,.round)>:is(input,textarea,select):focus{border-block-end-color:var(--error)}.field.invalid.border>:is(input,textarea,select),.field.invalid.border>:is(input,textarea,select):focus{border-color:var(--error)}.field:has(> :disabled){opacity:.5;cursor:not-allowed}.field>:disabled{cursor:not-allowed}.field.textarea.small:not(.min){---size: 4.5rem}.field.textarea:not(.min){---size: 5.5rem}.field.textarea.large:not(.min){---size: 6.5rem}.field.textarea.extra:not(.min){---size: 7.5rem}.field>select>option{background-color:var(--surface-container);color:var(--on-surface)}.field.label>:is(input,select){padding-block-start:1rem}.field.label.border:not(.fill)>:is(input,select){padding-block-start:0}.field>textarea{padding-block-start:var(---start)}.field>textarea:focus{padding-block-start:calc(var(---start) - .06rem)}.field:not(.label)>textarea,.field.border.label:not(.fill)>textarea{padding-block-start:calc(var(---start) - .5rem)}.field:not(.label)>textarea:focus,.field.border.label:not(.fill)>textarea:focus{padding-block-start:calc(var(---start) - .56rem)}.field.label>label{position:absolute;inset:-.5rem auto auto 1rem;display:flex;inline-size:calc(100% - 5rem);block-size:4rem;line-height:4rem;font-size:1rem;transition:all .2s;gap:.25rem;white-space:nowrap}[dir=rtl] .field.label>label{inset:-.5rem 1rem auto auto}.field.label.small>label{block-size:3.5rem;line-height:3.5rem}.field.label.large>label{block-size:4.5rem;line-height:4.5rem}.field.label.extra>label{block-size:5rem;line-height:5rem}.field.label.border.prefix:not(.fill)>:is(label.active,:focus + label,[placeholder]:not(:placeholder-shown) + label,select + label){inset-inline-start:1rem}.field.label.round>label,.field.label.border.prefix.round:not(.fill)>:is(label.active,:focus + label,[placeholder]:not(:placeholder-shown) + label,select + label){inset-inline-start:1.5rem}.field.label.prefix>label{inset-inline-start:3rem}.field.label>:is(label.active,:focus + label,[placeholder]:not(:placeholder-shown) + label,select + label){block-size:2.5rem;line-height:2.5rem;font-size:.75rem}.field.label.border:not(.fill)>:is(label.active,:focus + label,[placeholder]:not(:placeholder-shown) + label,select + label){block-size:1rem;line-height:1rem}.field.label.border:not(.fill)>label:after{content:"";display:block;margin-block-start:.5rem;border-block-start:.0625rem solid var(--outline);block-size:1rem;transition:none;flex:auto}.field.label.border:not(.fill)>:focus+label:after{border-block-start:.125rem solid var(--primary)}.field.label.border:not(.fill)>:is(input,textarea):is(:focus,[placeholder]:not(:placeholder-shown),.active),.field.label.border:not(.fill)>select{clip-path:polygon(-2% -2%,.75rem -2%,.75rem .5rem,calc(100% - 5rem) .5rem,calc(100% - 5rem) -2%,102% -2%,102% 102%,-2% 102%)}[dir=rtl] .field.label.border:not(.fill)>:is(input,textarea):is(:focus,[placeholder]:not(:placeholder-shown),.active),[dir=rtl] .field.label.border:not(.fill)>select{clip-path:polygon(-2% -2%,5rem -2%,5rem .5rem,calc(100% - .75rem) .5rem,calc(100% - .75rem) -2%,102% -2%,102% 102%,-2% 102%)}.field.label.border.round:not(.fill)>:is(input,textarea):is(:focus,[placeholder]:not(:placeholder-shown),.active),.field.label.border.round:not(.fill)>select{clip-path:polygon(-2% -2%,1.25rem -2%,1.25rem .5rem,calc(100% - 5rem) .5rem,calc(100% - 5rem) -2%,102% -2%,102% 102%,-2% 102%)}[dir=rtl] .field.label.border.round:not(.fill)>:is(input,textarea):is(:focus,[placeholder]:not(:placeholder-shown),.active),[dir=rtl] .field.label.border.round:not(.fill)>select{clip-path:polygon(-2% -2%,5rem -2%,5rem .5rem,calc(100% - 1.25rem) .5rem,calc(100% - 1.25rem) -2%,102% -2%,102% 102%,-2% 102%)}.field.label>:focus+label{color:var(--primary)}.field.label.invalid>label,.field.label.invalid>label:after{color:var(--error)!important;border-color:var(--error)!important}.field.label>label>a{block-size:inherit;line-height:inherit;inline-size:1rem}.field.label>label>a>:is(i,img,svg){block-size:1rem;line-height:1rem;inline-size:1rem;font-size:1rem}.field>:is(.helper,.error){position:absolute;inset:auto auto 0 1rem;transform:translateY(100%);font-size:.75rem;background:none!important;padding-block-start:.125rem}[dir=rtl] .field>:is(.helper,.error){inset:auto 1rem 0 auto}a.helper{color:var(--primary)}.field>.error{color:var(--error)!important}.field.round>:is(.helper,.error){inset-inline-start:1.5rem}.field.invalid>.helper{display:none}table td>.field{margin:0}.grid{---gap: 1rem;display:grid;grid-template-columns:repeat(12,calc(8.33% - var(---gap) + (var(---gap) / 12)));gap:var(---gap)}*+.grid{margin-block-start:1rem}.grid.no-space{---gap: 0rem}.grid.medium-space{---gap: 1.5rem}.grid.large-space{---gap: 2rem}.s1{grid-area:auto/span 1}.s2{grid-area:auto/span 2}.s3{grid-area:auto/span 3}.s4{grid-area:auto/span 4}.s5{grid-area:auto/span 5}.s6{grid-area:auto/span 6}.s7{grid-area:auto/span 7}.s8{grid-area:auto/span 8}.s9{grid-area:auto/span 9}.s10{grid-area:auto/span 10}.s11{grid-area:auto/span 11}.s12{grid-area:auto/span 12}@media only screen and (min-width: 601px){.m1{grid-area:auto/span 1}.m2{grid-area:auto/span 2}.m3{grid-area:auto/span 3}.m4{grid-area:auto/span 4}.m5{grid-area:auto/span 5}.m6{grid-area:auto/span 6}.m7{grid-area:auto/span 7}.m8{grid-area:auto/span 8}.m9{grid-area:auto/span 9}.m10{grid-area:auto/span 10}.m11{grid-area:auto/span 11}.m12{grid-area:auto/span 12}}@media only screen and (min-width: 993px){.l1{grid-area:auto/span 1}.l2{grid-area:auto/span 2}.l3{grid-area:auto/span 3}.l4{grid-area:auto/span 4}.l5{grid-area:auto/span 5}.l6{grid-area:auto/span 6}.l7{grid-area:auto/span 7}.l8{grid-area:auto/span 8}.l9{grid-area:auto/span 9}.l10{grid-area:auto/span 10}.l11{grid-area:auto/span 11}.l12{grid-area:auto/span 12}}i{---size: 1.5rem;font-family:var(--font-icon);font-weight:400;font-style:normal;font-size:var(---size);letter-spacing:normal;text-transform:none;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;word-wrap:normal;direction:ltr;font-feature-settings:"liga";-webkit-font-smoothing:antialiased;vertical-align:middle;text-align:center;overflow:hidden;inline-size:var(---size);min-inline-size:var(---size);block-size:var(---size);min-block-size:var(---size);box-sizing:content-box;line-height:normal}i.tiny{---size: 1rem}.chip>i,i.small{---size: 1.25rem}i.medium{---size: 1.5rem}i.large{---size: 1.75rem}i.extra{---size: 2rem}i.fill,a.row:is(:hover,:focus)>i,.transparent:is(:hover,:focus)>i{font-variation-settings:"FILL" 1}i>:is(img,svg){inline-size:100%;block-size:100%;background-size:100%;border-radius:inherit;position:absolute;inset:0 auto auto 0;padding:inherit}i[class*=fa-]{font-size:calc(var(---size) * .85);line-height:normal;block-size:auto;min-block-size:auto}.absolute{position:absolute}.fixed{position:fixed}:is(.absolute,.fixed).left.right{inline-size:auto}:is(.absolute,.fixed).left.right.small{block-size:20rem}:is(.absolute,.fixed).left.right.medium{block-size:28rem}:is(.absolute,.fixed).left.right.large{block-size:44rem}:is(.absolute,.fixed).top.bottom.small{inline-size:20rem}:is(.absolute,.fixed).top.bottom.medium{inline-size:28rem}:is(.absolute,.fixed).top.bottom.large{inline-size:44rem}header,footer{display:flex;justify-content:center;flex-direction:column;min-block-size:4rem;padding:0 1rem;background-color:var(--surface-container)}main~footer{min-block-size:5rem}:is(header,footer).fixed.responsive{z-index:12}:is(header,footer,menu > *).fixed{position:sticky;inset:0;z-index:11;background-color:inherit}:is(dialog,menu,nav,article)>:is(header,footer){background-color:inherit;padding:0}:is(dialog,article,[class*=padding])>:is(header,footer).fixed{---translateY: 1rem;transform:translateY(var(---translateY))}:is(dialog,article,[class*=padding])>header.fixed{transform:translateY(calc(-1 * var(---translateY)))}.no-padding>:is(header,footer).fixed{transform:none}.small-padding>:is(header,footer).fixed{---translateY: .5rem}:is(.large-padding,dialog:not(.left,.right,.top,.bottom))>:is(header,footer).fixed{---translateY: 1.5rem}svg{fill:currentcolor}:is(img,svg,video):is(.small,.medium,.large,.tiny,.extra,.round,.circle,.responsive){object-fit:cover;object-position:center;transition:transform var(--speed3),border-radius var(--speed3),padding var(--speed3);block-size:3rem;inline-size:3rem}:is(img,svg,video).round{border-radius:.5rem}:is(img,svg,video).tiny{block-size:2rem;inline-size:2rem}:is(img,svg,video).small{block-size:2.5rem;inline-size:2.5rem}:is(img,svg,video).large{block-size:3.5rem;inline-size:3.5rem}:is(img,svg,video).extra{block-size:4rem;inline-size:4rem}:is(img,svg,video).responsive{inline-size:100%;block-size:100%;margin:0 auto}:is(button,.button,.chip):not(.transparent)>.responsive{border:.25rem solid transparent}:is(button.small,.button.small,.chip)>.responsive{inline-size:2rem}:is(button,.button,.chip.medium)>.responsive{inline-size:2.5rem}:is(button,.button,.chip).large>.responsive{inline-size:3rem}:is(button,.button,.chip).extra>.responsive{inline-size:3.5rem}:is(img,svg,video).responsive.tiny{inline-size:100%;block-size:4rem}:is(img,svg,video).responsive.small{inline-size:100%;block-size:8rem}:is(img,svg,video).responsive.medium{inline-size:100%;block-size:12rem}:is(img,svg,video).responsive.large{inline-size:100%;block-size:16rem}:is(img,svg,video).responsive.extra{inline-size:100%;block-size:20rem}:is(img,svg,video).responsive.round{border-radius:2rem}:is(img,svg,video).empty-state{max-inline-size:100%;inline-size:24rem}:is(button,.button,.chip,.field)>:is(img,svg):not(.responsive,.tiny,.small,.medium,.large,.extra),:is(.tabs) :is(img,svg):not(.responsive,.tiny,.small,.medium,.large,.extra){min-inline-size:1.5rem;max-inline-size:1.5rem;min-block-size:1.5rem;max-block-size:1.5rem}:is(button,.button,.chip)>:is(i,img,svg),:is(button,.button,.chip)>.responsive{margin:0 -.5rem}:is(button,.button)>.responsive{margin-inline-start:-1.5rem}:is(button,.button)>span+.responsive{margin-inline-start:-.5rem;margin-inline-end:-1.5rem}.chip>.responsive{margin-inline-start:-1rem}.chip>span+.responsive{margin-inline-start:-.5rem;margin-inline-end:-1rem}:is(.circle,.square)>.responsive{margin:0}.extend>:is(.responsive,i){margin:0;position:absolute;inset-inline:1rem;z-index:1}.extend>.responsive{inset-inline:0;inline-size:3.5rem}.extend.border>.responsive{inline-size:3.375rem}menu>li{all:unset}menu{opacity:0;visibility:hidden;position:absolute;box-shadow:var(--elevate2);background-color:var(--surface-container);z-index:11;inset:auto auto 0 0;inline-size:100%;max-block-size:50vh;max-inline-size:none!important;overflow-x:hidden;overflow-y:auto;font-size:.875rem;font-weight:400;text-transform:none;color:var(--on-surface);line-height:normal;text-align:start;border-radius:.25rem;transform:scale(.8) translateY(120%);transition:all var(--speed2),0s background-color}[dir=rtl] menu{inset:auto 0 0 auto}menu.no-wrap{inline-size:max-content;white-space:nowrap!important}menu.active,menu:not([data-ui]):active,:not([data-ui]):focus-within>menu{opacity:1;visibility:visible;transform:scale(1) translateY(100%)}menu *{white-space:inherit!important}menu>a,menu>li>a{padding:.5rem 1rem;min-block-size:3rem;flex:1}menu>a:not(.row),menu>li>a:not(.row){display:flex;flex-direction:column;align-items:flex-start}menu>a:is(:hover,:focus,.active),menu>li>a:is(:hover,:focus,.active){background-color:var(--active)}menu.min{inset:0 0 auto 0;transform:none!important;border-radius:inherit}[dir=rtl] menu.min.right,menu.min.left{inset:0 0 auto auto}[dir=rtl] menu.min.left,menu.min.right{inset:0 auto auto 0}menu.max{position:fixed;inset:0;block-size:100%;max-block-size:none;min-block-size:auto;z-index:100;transform:none!important}menu.no-wrap:is(.min,.max){min-inline-size:16rem}[dir=rtl] menu.right,menu.left{inset:auto 0 0 auto}[dir=rtl] menu.left,menu.right{inset:auto auto 0 0}nav>:is(ol,ul),nav>:is(ol,ul)>li{all:unset}nav,.row,a.row,nav.drawer>:is(a,label),nav.drawer>:is(ol,ul)>li>:is(a,label){display:flex;align-items:center;align-self:normal;text-align:start;justify-content:flex-start;white-space:nowrap;gap:1rem;margin:0}:is(nav,.row,.max)>:only-child,nav>:is(ol,ul)>li>:only-child{margin:0}*+:is(nav,.row){margin-block-start:1rem}:is(nav,.row)>*{margin:0;white-space:normal;flex:none}:is(nav,.row).no-space{gap:0}:is(nav,.row).no-space>.border+.border{border-inline-start:0}:is(nav,.row).medium-space{gap:1.5rem}:is(nav,.row).large-space{gap:2rem}:is(nav,.row)>.max,:is(nav,.row)>:is(ol,ul)>.max,nav.drawer>:is(a,label)>.max,nav.drawer>:is(ol,ul)>li>:is(a,label)>.max{flex:1}:is(nav,.row).wrap{display:flex;flex-wrap:wrap}:is(header,footer)>:is(nav,.row){min-block-size:inherit}:is(nav,.row)>.border.no-margin+.border.no-margin{border-inline-start:0}nav:is(.left,.right,.top,.bottom){border:0;position:fixed;color:var(--on-surface);transform:none;z-index:100;block-size:auto;inline-size:auto;text-align:center;padding:.5rem;margin:0;inset:0}nav:is(.left,.right){inline-size:5rem;justify-content:flex-start;flex-direction:column;background-color:var(--surface)}nav:is(.top,.bottom){block-size:5rem;justify-content:center;flex-direction:row;background-color:var(--surface-container)}nav.top{inset-block-end:auto}nav.left{inset-inline-end:auto}nav.right{inset-inline-start:auto}nav.bottom{inset-block-start:auto}nav.drawer{flex-direction:column;align-items:normal;inline-size:20rem;gap:0;padding:.5rem 1rem}nav.drawer:is(.min,.max){inline-size:auto}nav.drawer.max{inline-size:100%}:is(nav,.row)>header{background-color:inherit}nav:is(.left,.right)>header{transform:translateY(-.5rem)}nav.drawer>header{transform:translateY(-.75rem);min-block-size:4.5rem;align-self:stretch}nav.drawer>:is(a,label),nav.drawer>:is(ol,ul)>li>:is(a,label),a.row.wave{padding:.75rem;font-size:inherit}nav.drawer>a,nav.drawer>:is(ol,ul)>li>a{border-radius:2rem}nav.drawer>a:is(:hover,.active),nav.drawer>:is(ol,ul)>li>a:is(:hover,.active){background-color:var(--secondary-container)}nav.drawer>a:is(:hover,:focus,.active)>i,nav.drawer>:is(ol,ul)>li>a:is(:hover,:focus,.active)>i{font-variation-settings:"FILL" 1}nav>:is(ol,ul){all:inherit;flex:auto}nav:not(.left,.right,.bottom,.top)>:is(ol,ul){padding:0}nav:is(.left,.right,.top,.bottom):not(.drawer)>a:not(.button,.chip),nav:is(.left,.right,.top,.bottom):not(.drawer)>:is(ol,ul)>li>a:not(.button,.chip){align-self:center;display:flex;flex-direction:column}nav:is(.top,.bottom):not(.drawer)>a:not(.button,.chip),nav:is(.top,.bottom):not(.drawer)>:is(ol,ul)>li>a:not(.button,.chip){inline-size:3.5rem}nav:is(.left,.right,.top,.bottom):not(.drawer)>a:not(.button,.chip)>i,nav:is(.left,.right,.top,.bottom):not(.drawer)>:is(ol,ul)>li>a:not(.button,.chip)>i{padding:.25rem;border-radius:2rem;transition:padding var(--speed1) linear;margin:0 auto}nav:is(.left,.right,.top,.bottom):not(.drawer)>a:not(.button,.chip):is(:hover,:focus,.active)>i,nav:is(.left,.right,.top,.bottom):not(.drawer)>:is(ol,ul)>li>a:not(.button,.chip):is(:hover,:focus,.active)>i{background-color:var(--secondary-container);color:var(--on-secondary-container);padding:.25rem 1rem;font-variation-settings:"FILL" 1}:is(nav,.row):is(.left-align,.top-align,.vertical){justify-content:flex-start}:is(nav,.row):is(.right-align,.bottom-align){justify-content:flex-end}:is(nav,.row):is(.center-align,.middle-align){justify-content:center}:is(nav,.row):is(.left-align,.top-align,.vertical).vertical{align-items:flex-start}:is(nav,.row):is(.right-align,.bottom-align).vertical{align-items:flex-end}:is(nav,.row):is(.center-align,.middle-align).vertical{align-items:center}:is(.drawer,.vertical)>:is(li,[class*=divider]):not(.vertical),:is(.drawer,.vertical)>:is(ol,ul)>li:not(.vertical){align-self:stretch}nav:not(.left,.right)>.space{inline-size:.5rem}nav:not(.left,.right)>.medium-space{inline-size:1rem}nav:not(.left,.right)>.large-space{inline-size:1.5rem}@media only screen and (max-width: 600px){nav.top,nav.bottom{justify-content:space-around}}.overlay{opacity:0;visibility:hidden;position:fixed;inset:0 auto auto 0;inline-size:100%;block-size:100%;color:var(--on-surface);background-color:var(--overlay);z-index:100;transition:all var(--speed3),0s background-color}nav>.overlay{z-index:0}.overlay.active{opacity:1;visibility:visible}.page,:is(.page,dialog):not(.active) .page.active{---transform: translate(0, 0);opacity:0;position:absolute;display:none}.page.active{opacity:1;position:inherit;display:inherit;animation:var(--speed4) to-page ease}.page.active.top{---transform: translate(0, -4rem)}.page.active.bottom{---transform: translate(0, 4rem)}.page.active.left{---transform: translate(-4rem, 0)}.page.active.right{---transform: translate(4rem, 0)}@keyframes to-page{0%{opacity:0;transform:var(---transform)}to{opacity:1;transform:translate(0)}}progress{position:relative;inline-size:100%;block-size:.5rem;color:var(--primary);background:var(--primary-container);border-radius:1rem;flex:none;border:none;overflow:hidden;writing-mode:horizontal-tb;direction:ltr}progress.small{inline-size:4rem}progress.medium{inline-size:8rem}progress.large{inline-size:12rem}progress:not(.circle,[value]):after{content:"";position:absolute;inset:0;inline-size:100%;block-size:100%;clip-path:none;background:currentcolor;animation:1.6s to-linear ease infinite}progress:not(.circle,[value])::-moz-progress-bar{animation:1.6s to-linear ease infinite}progress:not(.circle,[value])::-webkit-progress-value{animation:1.6s to-linear ease infinite}progress::-webkit-progress-bar{background:none}progress::-webkit-progress-value{background:currentcolor}progress::-moz-progress-bar{background:currentcolor}progress.circle{display:inline-block;inline-size:2.5rem;block-size:2.5rem;border-radius:50%;border-width:.3rem;border-style:solid;border-color:currentcolor;animation:1.6s to-circular linear infinite;background:none;flex:none}progress.circle::-moz-progress-bar{background:none}progress.circle.small{inline-size:1.5rem;block-size:1.5rem;border-width:.2rem}progress.circle.large{inline-size:3.5rem;block-size:3.5rem;border-width:.4rem}:is(nav,.row,.field)>progress:not(.circle,.small,.medium,.large){flex:auto}progress.max{display:unset;position:absolute;inline-size:100%!important;block-size:100%!important;color:var(--active);background:none;inset:0;border-radius:inherit;animation:none;writing-mode:horizontal-tb}progress:is(.horizontal,.vertical,.max){display:unset;inline-size:100%!important}progress.vertical{writing-mode:vertical-lr}progress.max.vertical{transform:rotate(-180deg)}:has(> progress)>:not(progress){z-index:1}@supports (-moz-appearance:none){progress.max.vertical{transform:none}}@keyframes to-linear{0%{margin-inline-start:0%;inline-size:0%}50%{margin-inline-start:0%;inline-size:100%}to{margin-inline-start:100%;inline-size:0%}}@keyframes to-circular{0%{transform:rotate(0);clip-path:polygon(50% 50%,0% 0%,50% 0%,50% 0%,50% 0%,50% 0%,50% 0%,50% 0%,50% 0%)}20%{clip-path:polygon(50% 50%,0% 0%,50% 0%,100% 0%,100% 0%,100% 0%,100% 0%,100% 0%,100% 0%)}30%{clip-path:polygon(50% 50%,0% 0%,50% 0%,100% 0%,100% 50%,100% 50%,100% 50%,100% 50%,100% 50%)}40%{clip-path:polygon(50% 50%,0% 0%,50% 0%,100% 0%,100% 50%,100% 100%,100% 100%,100% 100%,100% 100%)}50%{clip-path:polygon(50% 50%,50% 0%,50% 0%,100% 0%,100% 50%,100% 100%,50% 100%,50% 100%,50% 100%)}60%{clip-path:polygon(50% 50%,100% 50%,100% 50%,100% 50%,100% 50%,100% 100%,50% 100%,0% 100%,0% 100%)}70%{clip-path:polygon(50% 50%,50% 100%,50% 100%,50% 100%,50% 100%,50% 100%,50% 100%,0% 100%,0% 50%)}80%{clip-path:polygon(50% 50%,0% 100%,0% 100%,0% 100%,0% 100%,0% 100%,0% 100%,0% 100%,0% 50%)}90%{transform:rotate(360deg);clip-path:polygon(50% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%)}to{clip-path:polygon(50% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%,0% 50%)}}.checkbox,.radio,.switch{direction:ltr;inline-size:auto;block-size:auto;line-height:normal;white-space:nowrap;cursor:pointer;display:inline-flex;align-items:center}:is(.checkbox,.radio)>input{inline-size:1.5rem;block-size:1.5rem;opacity:0}.switch>input{inline-size:3.25rem;block-size:2rem;opacity:0}:is(.checkbox,.radio,.switch)>span{display:inline-flex;align-items:center;color:var(--on-surface);font-size:.875rem}:is(.checkbox,.radio)>span:not(:empty){padding-inline-start:.25rem}:is(.checkbox,.radio,.switch)>span:before,.icon>span>i{font-family:var(--font-icon);font-weight:400;font-style:normal;font-size:1.5rem;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;font-feature-settings:"liga";-webkit-font-smoothing:antialiased;vertical-align:middle;text-align:center;overflow:hidden;inline-size:1.5rem;block-size:1.5rem;box-sizing:border-box;margin:0 auto;outline:none;color:var(--primary);position:absolute;inset:auto auto auto -1.5rem;background-color:transparent;border-radius:50%;user-select:none;z-index:1;box-shadow:0 0 0 0 var(--active);transition:all var(--speed1)}.switch>span:before,.switch.icon>span>i{position:absolute;inset:50% auto auto 0;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;transition:all var(--speed2);font-size:1rem;user-select:none;min-inline-size:auto;content:"";color:var(--surface-variant);background-color:var(--outline)}.switch>span:before,.switch.icon>span>i{transform:translate(-3rem,-50%) scale(.6)}.switch.icon>span>i{transform:translate(-3rem,-50%) scale(1)}.checkbox>span:before{content:"check_box_outline_blank"}.checkbox>input:checked+span:before{content:"check_box";font-variation-settings:"FILL" 1}.checkbox>input:indeterminate+span:before{content:"indeterminate_check_box"}.radio>span:before{content:"radio_button_unchecked"}.radio>input:checked+span:before{content:"radio_button_checked"}:is(.radio,.checkbox,.switch).icon>span:before{content:""!important;font-variation-settings:unset!important}:is(.checkbox,.radio)>input:not(:disabled):is(:focus,:hover)+span:before{background-color:var(--active);box-shadow:0 0 0 .5rem var(--active)}.switch>input:not(:disabled):is(:focus,:hover)+span:before,.switch.icon>input:not(:disabled):is(:focus,:hover)+span>i{box-shadow:0 0 0 .5rem var(--active)}:is(.checkbox,.radio)>input:checked+span:before,:is(.checkbox,.radio).icon>input:checked+span>i{color:var(--primary)}.icon>input:checked+span>i:first-child,.icon>span>i:last-child{opacity:0}.icon>input:checked+span>i:last-child,.icon>span>i:first-child{opacity:1}.switch>input:checked+span:after{border:none;background-color:var(--primary)}.switch>input:checked+span:before,.switch.icon>input:checked+span>i{content:"check";color:var(--primary);background-color:var(--on-primary)}.switch>input:checked+span:before,.switch.icon>input:checked+span>i{transform:translate(-1.75rem,-50%) scale(1)}:is(.checkbox,.radio,.switch)>input:disabled+span{opacity:.5;cursor:not-allowed}.switch>span:after{content:"";position:absolute;inset:50% auto auto 0;background-color:var(--active);border:.125rem solid var(--outline);box-sizing:border-box;inline-size:3.25rem;block-size:2rem;border-radius:2rem}.switch>span:after{transform:translate(-3.25rem,-50%)}.field>:is(nav,.row){flex-grow:1;padding:0 1rem}.field.round>:is(nav,.row){flex-grow:1;padding:0 1.5rem}[dir=rtl] .switch{transform:scale(-1)}[dir=rtl] .switch>span:before,[dir=rtl] .switch.icon>span>i{transform:translate(-3rem,-50%) scale(-.6)}[dir=rtl] .switch.icon>span>i{transform:translate(-3rem,-50%) scale(-1)}[dir=rtl] .switch>input:checked+span:before,[dir=rtl] .switch.icon>input:checked+span>i{transform:translate(-1.75rem,-50%) scale(-1)}.slider{---start: 0%;---end: 0%;---value1: "";---value2: "";display:flex;align-items:center!important;inline-size:auto;block-size:1.25rem;margin:1.125rem;flex:none;direction:ltr}[dir=rtl] .slider{transform:scaleX(-1)}.slider.vertical{flex-direction:row!important;margin:.5rem auto!important;padding:50% 0;transform:rotate(-90deg);inline-size:100%}.slider.small{inline-size:4rem}.slider.medium{inline-size:8rem}.slider.large{inline-size:12rem}.slider>input{appearance:none;box-shadow:none;border:none;outline:none;pointer-events:none;inline-size:100%;block-size:1rem;background:none;z-index:1;padding:0;margin:0}.slider>input:only-of-type{pointer-events:all}.slider>input+input{position:absolute}.slider>input::-webkit-slider-thumb{appearance:none;box-shadow:none;border:none;outline:none;pointer-events:all;block-size:2.75rem;inline-size:.25rem;border-radius:.25rem;background:var(--primary);cursor:grab;margin:0}.slider>input::-webkit-slider-thumb:active{cursor:grabbing}.slider>input::-moz-range-thumb{appearance:none;box-shadow:none;border:none;outline:none;pointer-events:all;block-size:2.75rem;inline-size:.25rem;border-radius:.25rem;background:var(--primary);cursor:grab;margin:0}.slider>input::-moz-range-thumb:active{cursor:grabbing}.slider>input:not(:disabled):is(:focus)::-webkit-slider-thumb{transform:scaleX(.6)}.slider>input:not(:disabled):is(:focus)::-moz-range-thumb{transform:scaleX(.6)}.slider>input:disabled{cursor:not-allowed;opacity:1}.slider>input:disabled::-webkit-slider-thumb{background:#9E9E9E;cursor:not-allowed}.slider>input:disabled::-moz-range-thumb{background:#9E9E9E;cursor:not-allowed}.slider>input:disabled~span{background:#9E9E9E}.slider>span{position:absolute;block-size:1rem;border-radius:1rem 0 0 1rem;background:var(--primary);z-index:0;inset:calc(50% - .5rem) var(---end) auto var(---start);clip-path:polygon(0 0,calc(100% - .5rem) 0,calc(100% - .5rem) 100%,0 100%)}.slider>input+input~span{border-radius:0;clip-path:polygon(.5rem 0,max(.5rem,calc(100% - .5rem)) 0,max(.5rem,calc(100% - .5rem)) 100%,.5rem 100%)}.field>.slider{inline-size:100%}.slider:before{content:"";position:absolute;inline-size:100%;block-size:1rem;border-radius:1rem;background:var(--primary-container);clip-path:polygon(calc(var(---start) - .5rem) 0,0 0,0 100%,calc(var(---start) - .5rem) 100%,calc(var(---start) - .5rem) 0,calc(100% - var(---end) + .5rem) 0,100% 0,100% 100%,calc(100% - var(---end) + .5rem) 100%,calc(100% - var(---end) + .5rem) 0)}.slider:has(> [disabled]):before{background:var(--active)}.slider>.tooltip{visibility:hidden!important;opacity:0!important;inset:0 auto auto calc(100% - var(---end));border-radius:2rem;transition:top var(--speed2) ease,opacity var(--speed2) ease;transform:translate(-50%,-50%)!important;padding:.75rem 1rem}[dir=rtl] .slider>.tooltip{transform:translate(-50%,-50%) scaleX(-1)!important}.slider>.tooltip+.tooltip{inset:.25rem calc(100% - var(---start)) auto auto;transform:translate(50%,-50%)!important}[dir=rtl] .slider>.tooltip+.tooltip{transform:translate(50%,-50%) scaleX(-1)!important}.slider>.tooltip:before{content:var(---value1)}.slider>.tooltip+.tooltip:before{content:var(---value2)}.slider>:focus~.tooltip{inset-block-start:-1rem!important;opacity:1!important;visibility:visible!important}.slider.vertical>.tooltip{display:none}:is(nav,.row,.field)>.slider:not(.circle,.small,.medium,.large){flex:auto}.slider.max,.slider.max.vertical,.slider.max>input,.slider.max.vertical>input{all:unset;margin:0!important;position:absolute;color:var(--primary);inset:0;border-radius:inherit;overflow:hidden;z-index:2;cursor:grab;inline-size:100%;block-size:100%}.slider.max:before{display:none}.slider.max.vertical>input{writing-mode:vertical-lr;transform:rotate(-180deg)}.slider.max>input::-webkit-slider-thumb{opacity:0;inline-size:1rem;block-size:100vh;transform:none!important}.slider.max>input::-moz-range-thumb{opacity:0;inline-size:1rem;block-size:100vh;transform:none!important}.slider.max>span{block-size:auto!important;inset:0 var(---end) 0 var(---start);clip-path:none;background:currentcolor;border-radius:0}.slider.max.vertical>span{inset:var(---end) 0 var(---start) 0}@media (hover: none){.slider>:hover~.tooltip{inset-block-start:-1rem!important;opacity:1!important;visibility:visible!important}}.snackbar{---transform-start: translate(-50%, 1rem);---transform-end: translate(-50%, 0);position:fixed;inset:auto auto 6rem 50%;inline-size:80%;block-size:auto;z-index:200;visibility:hidden;display:flex;box-shadow:var(--elevate2);color:var(--inverse-on-surface);background-color:var(--inverse-surface);padding:1rem;opacity:1;cursor:pointer;text-align:start;align-items:center;border-radius:.25rem;gap:.5rem;transform:var(---transform-end)}.snackbar.top{inset:6rem auto auto 50%}.snackbar.active{visibility:visible;animation:var(--speed2) to-snackbar}.snackbar.active.top{---transform-end: translate(-50%, -1rem)}.snackbar>.max{flex:auto}@keyframes to-snackbar{0%{opacity:0;transform:var(---transform-start)}to{opacity:1;transform:var(---transform-end)}}@media only screen and (min-width: 993px){.snackbar{inline-size:40%}}table{---stripes: rgb(0 0 0 / .05);inline-size:100%;border-spacing:0;font-size:.875rem;color:var(--on-surface);text-align:start;background-color:var(--surface)}.dark table{---stripes: rgb(255 255 255 / .05)}table :is(thead,tbody,tfoot,tr,th){background-color:inherit}:is(th,td){inline-size:auto;max-inline-size:1rem;text-align:inherit;padding:.5rem}:is(th,td)>*{vertical-align:middle}table.border>tbody>tr:not(:last-child)>td,thead>tr>th{border-block-end:.0625rem solid var(--outline)}tfoot>tr>th{border-block-start:.0625rem solid var(--outline)}table.stripes>tbody>tr:nth-child(odd){background-color:var(---stripes)}table.no-space :is(th,td){padding:0}table.medium-space :is(th,td){padding:.75rem}table.large-space :is(th,td){padding:1rem}table>.fixed,th.fixed{position:sticky;z-index:1;inset-block-start:0}tfoot.fixed,tfoot th.fixed{inset-block-end:0}:is(td,th).min{inline-size:0;max-inline-size:0%;white-space:nowrap}.tabs{display:flex;white-space:nowrap;border-block-end:.0625rem solid var(--surface-variant)}.tabs.min{padding:0 1rem;gap:2rem}.tabs:not(.left-align,.right-align,.center-align){justify-content:space-around}*+.tabs{margin-block-start:1rem}.tabs>a{display:flex;font-size:.875rem;font-weight:500;color:var(--on-surface-variant);padding:.5rem 1rem;border-block-end:.125rem solid transparent;text-align:center;min-block-size:3rem;inline-size:100%;gap:.25rem}.tabs.min>a{inline-size:auto;padding:.5rem 0}.tabs.small>a{min-block-size:2rem}.tabs.large>a{min-block-size:4rem}.tabs>a.active{color:var(--primary);border-block-end:.125rem solid var(--primary)}.tabs>a.active>i{color:var(--primary)}.tabs:is(.left-align,.center-align,.right-align)>a{inline-size:auto}.tooltip{---space: -.5rem;visibility:hidden;display:flex;align-items:center;justify-content:center;gap:.5rem;background-color:var(--inverse-surface);color:var(--inverse-on-surface);font-size:.75rem;text-align:center;border-radius:.25rem;padding:.5rem;position:absolute;z-index:3;inset:0 auto auto 50%;inline-size:auto;white-space:nowrap;font-weight:500;opacity:0;transition:all var(--speed2);line-height:normal;transform:translate(-50%,-100%) scale(.9)}.tooltip.left{inset:50% auto auto 0;transform:translate(-100%,-50%) scale(.9)}.tooltip.right{inset:50% 0 auto auto;transform:translate(100%,-50%) scale(.9)}.tooltip.bottom{inset:auto auto 0 50%;transform:translate(-50%,100%) scale(.9)}.tooltip.small{inline-size:8rem;white-space:normal}.tooltip.medium{inline-size:12rem;white-space:normal}.tooltip.large{inline-size:16rem;white-space:normal}:hover>.tooltip{visibility:visible;opacity:1;transform:translate(-50%,-100%) scale(1)}:hover>.tooltip.left{transform:translate(-100%,-50%) scale(1)}:hover>.tooltip.right{transform:translate(100%,-50%) scale(1)}:hover>.tooltip.bottom{transform:translate(-50%,100%) scale(1)}.tooltip.no-space{---space: 0}.tooltip.medium-space{---space: -1rem}.tooltip.large-space{---space: -1.5rem}.tooltip:not(.left,.right,.bottom){margin-block-start:var(---space)!important}.tooltip.left,.tooltip.right{margin-inline:var(---space)!important}.tooltip.bottom{margin-block-end:var(---space)!important}menu:active~.tooltip,:is(button,.button):focus>menu~.tooltip,.field>:focus~menu~.tooltip{visibility:hidden}.slider>.tooltip{---space: -1.25rem}.slider.vertical>.tooltip{---space: -.75rem}.slider.vertical>.tooltip:is(.left,.right){---space: -.5rem}.tooltip.max{display:block;font-size:inherit;white-space:normal;text-align:start;inline-size:20rem;border-radius:.5rem;padding:1rem;box-shadow:var(--elevate2)}[class*=blur],[class*=blur].light{---blur: 1rem;-webkit-backdrop-filter:blur(var(---blur));backdrop-filter:blur(var(---blur));color:var(--on-surface);background-color:#ffffff80}.dark [class*=blur],[class*=blur].dark{background-color:#00000080}.small-blur{---blur: .5rem}.large-blur{---blur: 1.5rem}.shadow{background-color:#00000050}:is(.left-shadow,.right-shadow,.top-shadow,.bottom-shadow){background-color:transparent!important}.left-shadow{background-image:linear-gradient(to right,black,transparent)}.right-shadow{background-image:linear-gradient(to left,black,transparent)}.bottom-shadow{background-image:linear-gradient(to top,black,transparent)}.top-shadow{background-image:linear-gradient(to bottom,black,transparent)} diff --git a/TessesDedupWeb/wwwroot/index.html b/TessesDedupWeb/wwwroot/index.html new file mode 100644 index 0000000..1a1ff44 --- /dev/null +++ b/TessesDedupWeb/wwwroot/index.html @@ -0,0 +1,27 @@ + + + + + + Tesses Dedup + + + + + + + + +
Loading...
+ +
+ An unhandled error has occurred. + Reload or Logout + 🗙 +
+ + + + + diff --git a/TessesDedupWeb/wwwroot/js/beer.min.js b/TessesDedupWeb/wwwroot/js/beer.min.js new file mode 100644 index 0000000..7f63d05 --- /dev/null +++ b/TessesDedupWeb/wwwroot/js/beer.min.js @@ -0,0 +1,3 @@ +let m,S,M,P;const d={light:"",dark:""},j=[];async function U(t){return await new Promise(e=>setTimeout(e,t))}function X(){return"fxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,t=>{const e=Math.random()*16|0;return(t==="x"?e:e&3|8).toString(16)})}function x(t,e){try{return typeof t=="string"?(e!=null?e:document).querySelector(t):t}catch{return null}}function y(t,e){try{return typeof t=="string"?(e!=null?e:document).querySelectorAll(t):t!=null?t:j}catch{return j}}function g(t,e){var n,r;return(r=(n=t==null?void 0:t.classList)==null?void 0:n.contains(e))!=null?r:!1}function L(t,e){var n;return((n=t==null?void 0:t.tagName)==null?void 0:n.toLowerCase())===e}function C(t,e){var n;return((n=t==null?void 0:t.type)==null?void 0:n.toLowerCase())===e}function h(t,e){var n;(n=t==null?void 0:t.classList)==null||n.add(e)}function f(t,e){var n;(n=t==null?void 0:t.classList)==null||n.remove(e)}function p(t,e,n,r=!0){t==null||t.addEventListener(e,n,r)}function H(t,e,n,r=!0){t==null||t.removeEventListener(e,n,r)}function Y(t,e){var n;(n=e==null?void 0:e.parentNode)==null||n.insertBefore(t,e)}function N(t){return t==null?void 0:t.previousElementSibling}function K(t){return t==null?void 0:t.nextElementSibling}function k(t){return t==null?void 0:t.parentElement}function J(t){const e=document.createElement("div");for(let n=0,r=Object.keys(t),a=r.length;n{A()},180)}function $(t,e){if(e&&e.key==="Enter"){const a=N(t);return C(a,"file")?a.click():void 0}const n=t,r=K(t);!C(r,"text")||(r.value=n.files?Array.from(n.files).map(a=>a.name).join(", "):"",r.readOnly=!0,p(r,"keydown",ct,!1),w(r))}function _(t,e){if(e&&e.key==="Enter"){const a=N(t);return C(a,"color")?a.click():void 0}const n=t,r=K(t);!C(r,"text")||(r.readOnly=!0,r.value=n.value,p(r,"keydown",ut,!1),w(r))}function Z(t){const e=k(t),n=k(t);e.removeAttribute("style"),g(e,"min")&&e.style.setProperty("---size",`${Math.max(t.scrollHeight,n.offsetHeight)}px`)}function V(t){const e=k(t),n=x("span",e),r=y("input",e);if(!r.length||!n)return;const a=parseInt(getComputedStyle(document.documentElement).getPropertyValue("--size"))||16,c=g(e,"max")?0:.25*a*100/r[0].offsetWidth,o=[],l=[];for(let T=0,W=r.length;T1&&(i=Math.abs(o[1]-o[0]),u=o[1]>o[0]?o[0]:o[1],s=100-u-i,b>v&&(v=l[1]||0,b=l[0])),e.style.setProperty("---start",`${u}%`),e.style.setProperty("---end",`${s}%`),e.style.setProperty("---value1",`'${v}'`),e.style.setProperty("---value2",`'${b}'`)}function F(t){if(t){const n=t.target;if(n.type==="range")return V(n)}const e=y(".slider > input[type=range]");e.length?p(globalThis,"input",F,!1):H(globalThis,"input",F,!1);for(let n=0,r=e.length;n{var a,c;if(p(document.body,"click",R),E(t),g(e,"active")){if(!n)return f(e,"active");const o=n.target,l=x((a=o.getAttribute("data-ui"))!=null?a:""),i=o.closest("menu"),u=!x("menu",(c=o.closest("[data-ui]"))!=null?c:void 0);return l&&l!==i?I(o,l):!l&&!u&&i?!1:f(e,"active")}const r=y("menu.active");for(let o=0,l=r.length;o{c||(f(t,"active"),f(e,"active"),f(n,"active"),r.close())},l){const u=y("dialog, a, .overlay",o);for(let s=0,v=u.length;s{f(e,"active")},n!=null?n:6e3))}function gt(){if(d.light&&d.dark)return d;const t=document.createElement("body");t.className="light",document.body.appendChild(t);const e=document.createElement("body");e.className="dark",document.body.appendChild(e);const n=getComputedStyle(t),r=getComputedStyle(e),a=["--primary","--on-primary","--primary-container","--on-primary-container","--secondary","--on-secondary","--secondary-container","--on-secondary-container","--tertiary","--on-tertiary","--tertiary-container","--on-tertiary-container","--error","--on-error","--error-container","--on-error-container","--background","--on-background","--surface","--on-surface","--surface-variant","--on-surface-variant","--outline","--outline-variant","--shadow","--scrim","--inverse-surface","--inverse-on-surface","--inverse-primary","--surface-dim","--surface-bright","--surface-container-lowest","--surface-container-low","--surface-container","--surface-container-high","--surface-container-highest"];for(let c=0,o=a.length;c{const r=a=>{let c="";for(let o=0,l=Object.keys(a),i=l.length;o label");for(let i=0,u=r.length;i input:not([type=file], [type=color], [type=range]), .field > select, .field > textarea");for(let i=0,u=a.length;i input[type=file]");for(let i=0,u=c.length;i input[type=color]");for(let i=0,u=o.length;i textarea");for(let i=0,u=l.length;iawait A("setup"));globalThis.beercss=A;globalThis.ui=A; + +export default globalThis.ui; \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..d7d326e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,3 @@ +. /deploy_dir/setpath.sh +ln -s "$DEPLOY_DIR" publish +make \ No newline at end of file