diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..23674ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +.git +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build +/data +/library + +# OS +.DS_Store +Thumbs.db + +# VSCode +/.vscode + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# SQLite +*.db diff --git a/.env.example b/.env.example index d59bf33..c0eef8e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,10 @@ -DATABASE_URL=local.db +# Required environment variables +JWT_SECRET= + +# Optional environment variables +DATABASE_URL= +JWT_ACCESS_TOKEN_EXPIRES= +JWT_REFRESH_TOKEN_EXPIRES= +USER_CLIENT_CHALLENGE_EXPIRES= +TOKEN_UPGRADE_CHALLENGE_EXPIRES= +LIBRARY_PATH= diff --git a/.gitignore b/.gitignore index 171f629..1dbbe60 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,16 @@ node_modules .wrangler /.svelte-kit /build +/data +/library # OS .DS_Store Thumbs.db +# VSCode +/.vscode + # Env .env .env.* diff --git a/.prettierignore b/.prettierignore index ab78a95..0f54b15 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,9 @@ package-lock.json pnpm-lock.yaml yarn.lock + +# Output +/drizzle + +# Documents +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..691038e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Base Image +FROM node:18-alpine AS base +WORKDIR /app + +RUN npm install -g pnpm@8 +COPY pnpm-lock.yaml . + +# Build Stage +FROM base AS build +RUN pnpm fetch + +COPY . . +RUN pnpm install --offline +RUN pnpm build + +# Deploy Stage +FROM base +RUN pnpm fetch --prod + +COPY package.json . +RUN pnpm install --offline --prod + +COPY --from=build /app/build ./build +COPY drizzle ./drizzle + +EXPOSE 3000 +ENV BODY_SIZE_LIMIT=Infinity + +CMD ["node", "./build/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..162676c --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + +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. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +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 AGPL, see +. diff --git a/README.md b/README.md index b5b2950..f2d2028 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,43 @@ -# sv +# ArkVault -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +안전한 미디어 관리 시스템 -## Creating a project +**경고!** 아직 활발히 개발 중인 프로젝트예요. 모든 기능이 구현된 것이 아니며, 불안정할 수 있어요. -If you're seeing this, you've probably already done this step. Congrats! +## Features + +- 🔒 사용자의 미디어는 클라이언트에서 암호화한 상태로 저장돼요. +- 🔑 메타 데이터도 클라이언트에서 암호화돼요. + - ⚠️ 검색의 용이성을 위해, 스키마는 암호화되지 않아요. + - ⚠️ 파일의 MIME 타입과 같은 일부 메타 데이터는 암호화되지 않아요. +- 📱 여러 디바이스에서 동시에 접근할 수 있어요. + +## How to Install + +제공되는 Dockerfile과 docker-compose.yaml 파일의 사용을 권장해요. ```bash -# create a new project in the current directory -npx sv create - -# create a new project in my-app -npx sv create my-app +git clone https://github.com/kmc7468/arkvault -b main +cd arkvault +vim .env # 아래를 참고하여 환경 변수를 설정해 주세요. +docker compose up --build -d ``` -## Developing +모든 데이터는 `./data` 디렉터리에 저장될 거예요. -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +### Environment Variables -```bash -npm run dev +필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요. -# or start the server and open the app in a new browser tab -npm run dev -- --open -``` - -## Building - -To create a production version of your app: - -```bash -npm run build -``` - -You can preview the production build with `npm run preview`. - -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +|이름|필수|기본값|설명| +|-:|:-:|:-:|:-| +|`JWT_SECRET`|Y||JWT의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.| +|`JWT_ACCESS_TOKEN_EXPIRES`||`5m`|Access Token의 유효 시간이에요.| +|`JWT_REFRESH_TOKEN_EXPIRES`||`14d`|Refresh Token의 유효 시간이에요.| +|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.| +|`TOKEN_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.| +|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.| +|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.| +|`PORT`||`80`|ArkVault 서버의 포트예요.| +|`CONTAINER_UID`||`0`|Docker 컨테이너에 매핑할 UID예요. NFS와 함께 사용할 경우 설정이 필요할 수 있어요.| +|`CONTAINER_GID`||`0`|Docker 컨테이너에 매핑할 GID예요. NFS와 함께 사용할 경우 설정이 필요할 수 있어요.| diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ffe08ab --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,22 @@ +services: + server: + build: . + restart: unless-stopped + user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} + volumes: + - ./data:/app/data + environment: + # ArkVault + - DATABASE_URL=/app/data/database.sqlite + - JWT_SECRET=${JWT_SECRET:?} # Required + - JWT_ACCESS_TOKEN_EXPIRES + - JWT_REFRESH_TOKEN_EXPIRES + - USER_CLIENT_CHALLENGE_EXPIRES + - TOKEN_UPGRADE_CHALLENGE_EXPIRES + - LIBRARY_PATH=/app/data/library + # SvelteKit + - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} + - XFF_DEPTH=${TRUST_PROXY:-} + - NODE_ENV=${NODE_ENV:-production} + ports: + - ${PORT:-80}:3000 diff --git a/drizzle.config.ts b/drizzle.config.ts index 68872c7..c0b54d5 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,10 @@ import { defineConfig } from "drizzle-kit"; -if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); export default defineConfig({ - schema: "./src/lib/server/db/schema.ts", + schema: "./src/lib/server/db/schema", dbCredentials: { - url: process.env.DATABASE_URL, + url: process.env.DATABASE_URL || "local.db", }, verbose: true, diff --git a/drizzle/0000_handy_captain_marvel.sql b/drizzle/0000_handy_captain_marvel.sql new file mode 100644 index 0000000..05d5e02 --- /dev/null +++ b/drizzle/0000_handy_captain_marvel.sql @@ -0,0 +1,119 @@ +CREATE TABLE `client` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `encryption_public_key` text NOT NULL, + `signature_public_key` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `user_client` ( + `user_id` integer NOT NULL, + `client_id` integer NOT NULL, + `state` text DEFAULT 'challenging' NOT NULL, + PRIMARY KEY(`client_id`, `user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `user_client_challenge` ( + `id` integer PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `client_id` integer NOT NULL, + `challenge` text NOT NULL, + `allowed_ip` text NOT NULL, + `expires_at` integer NOT NULL, + `is_used` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `directory` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `created_at` integer NOT NULL, + `parent_id` integer, + `user_id` integer NOT NULL, + `master_encryption_key_version` integer NOT NULL, + `encrypted_data_encryption_key` text NOT NULL, + `data_encryption_key_version` integer NOT NULL, + `encrypted_name` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `file` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `path` text NOT NULL, + `parent_id` integer, + `created_at` integer NOT NULL, + `user_id` integer NOT NULL, + `master_encryption_key_version` integer NOT NULL, + `encrypted_data_encryption_key` text NOT NULL, + `data_encryption_key_version` integer NOT NULL, + `content_type` text NOT NULL, + `encrypted_content_iv` text NOT NULL, + `encrypted_name` text NOT NULL, + FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `client_master_encryption_key` ( + `user_id` integer NOT NULL, + `client_id` integer NOT NULL, + `version` integer NOT NULL, + `encrypted_key` text NOT NULL, + `encrypted_key_signature` text NOT NULL, + PRIMARY KEY(`client_id`, `user_id`, `version`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`,`version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `master_encryption_key` ( + `user_id` integer NOT NULL, + `version` integer NOT NULL, + `created_by` integer NOT NULL, + `created_at` integer NOT NULL, + `state` text NOT NULL, + `retired_at` integer, + PRIMARY KEY(`user_id`, `version`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`created_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `refresh_token` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `client_id` integer, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `token_upgrade_challenge` ( + `id` integer PRIMARY KEY NOT NULL, + `refresh_token_id` text NOT NULL, + `client_id` integer NOT NULL, + `challenge` text NOT NULL, + `allowed_ip` text NOT NULL, + `expires_at` integer NOT NULL, + `is_used` integer DEFAULT false NOT NULL, + FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `email` text NOT NULL, + `password` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_client_challenge_challenge_unique` ON `user_client_challenge` (`challenge`);--> statement-breakpoint +CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint +CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `refresh_token_user_id_client_id_unique` ON `refresh_token` (`user_id`,`client_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..d8c1013 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,874 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "929c6bca-d0c0-4899-afc6-a0a498226f28", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "client": { + "name": "client", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_public_key": { + "name": "signature_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "client_encryption_public_key_unique": { + "name": "client_encryption_public_key_unique", + "columns": [ + "encryption_public_key" + ], + "isUnique": true + }, + "client_signature_public_key_unique": { + "name": "client_signature_public_key_unique", + "columns": [ + "signature_public_key" + ], + "isUnique": true + }, + "client_encryption_public_key_signature_public_key_unique": { + "name": "client_encryption_public_key_signature_public_key_unique", + "columns": [ + "encryption_public_key", + "signature_public_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_client": { + "name": "user_client", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'challenging'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_client_user_id_user_id_fk": { + "name": "user_client_user_id_user_id_fk", + "tableFrom": "user_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_client_client_id_client_id_fk": { + "name": "user_client_client_id_client_id_fk", + "tableFrom": "user_client", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_client_user_id_client_id_pk": { + "columns": [ + "client_id", + "user_id" + ], + "name": "user_client_user_id_client_id_pk" + } + }, + "uniqueConstraints": {} + }, + "user_client_challenge": { + "name": "user_client_challenge", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_ip": { + "name": "allowed_ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_client_challenge_challenge_unique": { + "name": "user_client_challenge_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_client_challenge_user_id_user_id_fk": { + "name": "user_client_challenge_user_id_user_id_fk", + "tableFrom": "user_client_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_client_challenge_client_id_client_id_fk": { + "name": "user_client_challenge_client_id_client_id_fk", + "tableFrom": "user_client_challenge", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "directory": { + "name": "directory", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "master_encryption_key_version": { + "name": "master_encryption_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_data_encryption_key": { + "name": "encrypted_data_encryption_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_encryption_key_version": { + "name": "data_encryption_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_name": { + "name": "encrypted_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "directory_encrypted_data_encryption_key_unique": { + "name": "directory_encrypted_data_encryption_key_unique", + "columns": [ + "encrypted_data_encryption_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "directory_user_id_user_id_fk": { + "name": "directory_user_id_user_id_fk", + "tableFrom": "directory", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "directory_parent_id_directory_id_fk": { + "name": "directory_parent_id_directory_id_fk", + "tableFrom": "directory", + "tableTo": "directory", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": { + "name": "directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk", + "tableFrom": "directory", + "tableTo": "master_encryption_key", + "columnsFrom": [ + "user_id", + "master_encryption_key_version" + ], + "columnsTo": [ + "user_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "file": { + "name": "file", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "master_encryption_key_version": { + "name": "master_encryption_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_data_encryption_key": { + "name": "encrypted_data_encryption_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_encryption_key_version": { + "name": "data_encryption_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_content_iv": { + "name": "encrypted_content_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_name": { + "name": "encrypted_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_path_unique": { + "name": "file_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "file_encrypted_data_encryption_key_unique": { + "name": "file_encrypted_data_encryption_key_unique", + "columns": [ + "encrypted_data_encryption_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "file_parent_id_directory_id_fk": { + "name": "file_parent_id_directory_id_fk", + "tableFrom": "file", + "tableTo": "directory", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "file_user_id_user_id_fk": { + "name": "file_user_id_user_id_fk", + "tableFrom": "file", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": { + "name": "file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk", + "tableFrom": "file", + "tableTo": "master_encryption_key", + "columnsFrom": [ + "user_id", + "master_encryption_key_version" + ], + "columnsTo": [ + "user_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "client_master_encryption_key": { + "name": "client_master_encryption_key", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_key_signature": { + "name": "encrypted_key_signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "client_master_encryption_key_user_id_user_id_fk": { + "name": "client_master_encryption_key_user_id_user_id_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_master_encryption_key_client_id_client_id_fk": { + "name": "client_master_encryption_key_client_id_client_id_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk": { + "name": "client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "master_encryption_key", + "columnsFrom": [ + "user_id", + "version" + ], + "columnsTo": [ + "user_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_master_encryption_key_user_id_client_id_version_pk": { + "columns": [ + "client_id", + "user_id", + "version" + ], + "name": "client_master_encryption_key_user_id_client_id_version_pk" + } + }, + "uniqueConstraints": {} + }, + "master_encryption_key": { + "name": "master_encryption_key", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "master_encryption_key_user_id_user_id_fk": { + "name": "master_encryption_key_user_id_user_id_fk", + "tableFrom": "master_encryption_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "master_encryption_key_created_by_client_id_fk": { + "name": "master_encryption_key_created_by_client_id_fk", + "tableFrom": "master_encryption_key", + "tableTo": "client", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "master_encryption_key_user_id_version_pk": { + "columns": [ + "user_id", + "version" + ], + "name": "master_encryption_key_user_id_version_pk" + } + }, + "uniqueConstraints": {} + }, + "refresh_token": { + "name": "refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "refresh_token_user_id_client_id_unique": { + "name": "refresh_token_user_id_client_id_unique", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_token_client_id_client_id_fk": { + "name": "refresh_token_client_id_client_id_fk", + "tableFrom": "refresh_token", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "token_upgrade_challenge": { + "name": "token_upgrade_challenge", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_id": { + "name": "refresh_token_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_ip": { + "name": "allowed_ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "token_upgrade_challenge_challenge_unique": { + "name": "token_upgrade_challenge_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": { + "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": { + "name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk", + "tableFrom": "token_upgrade_challenge", + "tableTo": "refresh_token", + "columnsFrom": [ + "refresh_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_upgrade_challenge_client_id_client_id_fk": { + "name": "token_upgrade_challenge_client_id_client_id_fk", + "tableFrom": "token_upgrade_challenge", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..b2615a0 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1736170919561, + "tag": "0000_handy_captain_marvel", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 612cb6b..4027dc6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import prettier from "eslint-config-prettier"; import js from "@eslint/js"; import { includeIgnoreFile } from "@eslint/compat"; import svelte from "eslint-plugin-svelte"; +import tailwind from "eslint-plugin-tailwindcss"; import globals from "globals"; import { fileURLToPath } from "node:url"; import ts from "typescript-eslint"; @@ -12,6 +13,7 @@ export default ts.config( js.configs.recommended, ...ts.configs.recommended, ...svelte.configs["flat/recommended"], + ...tailwind.configs["flat/recommended"], prettier, ...svelte.configs["flat/prettier"], { diff --git a/package.json b/package.json index 4b09ce6..05b20cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arkvault", "private": true, - "version": "0.0.1", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite dev", @@ -12,21 +12,32 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint .", "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" }, "devDependencies": { "@eslint/compat": "^1.2.3", + "@iconify-json/material-symbols": "^1.2.12", "@sveltejs/adapter-node": "^5.2.9", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@types/better-sqlite3": "^7.6.11", + "@types/file-saver": "^2.0.7", + "@types/jsonwebtoken": "^9.0.7", + "@types/ms": "^0.7.34", + "@types/node-schedule": "^2.1.7", "autoprefixer": "^10.4.20", + "dexie": "^4.0.10", "drizzle-kit": "^0.22.0", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", + "eslint-plugin-tailwindcss": "^3.17.5", + "file-saver": "^2.0.5", "globals": "^15.0.0", + "heic2any": "^0.0.4", + "mime": "^4.0.6", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", @@ -35,10 +46,17 @@ "tailwindcss": "^3.4.9", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", + "unplugin-icons": "^0.22.0", "vite": "^5.4.11" }, "dependencies": { + "argon2": "^0.41.1", "better-sqlite3": "^11.1.2", - "drizzle-orm": "^0.33.0" + "drizzle-orm": "^0.33.0", + "jsonwebtoken": "^9.0.2", + "ms": "^2.1.3", + "node-schedule": "^2.1.1", + "uuid": "^11.0.3", + "zod": "^3.24.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59254a4..a44ef27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,17 +5,38 @@ settings: excludeLinksFromLockfile: false dependencies: + argon2: + specifier: ^0.41.1 + version: 0.41.1 better-sqlite3: specifier: ^11.1.2 version: 11.7.0 drizzle-orm: specifier: ^0.33.0 version: 0.33.0(@types/better-sqlite3@7.6.12)(better-sqlite3@11.7.0) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + ms: + specifier: ^2.1.3 + version: 2.1.3 + node-schedule: + specifier: ^2.1.1 + version: 2.1.1 + uuid: + specifier: ^11.0.3 + version: 11.0.3 + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@eslint/compat': specifier: ^1.2.3 version: 1.2.4(eslint@9.17.0) + '@iconify-json/material-symbols': + specifier: ^1.2.12 + version: 1.2.12 '@sveltejs/adapter-node': specifier: ^5.2.9 version: 5.2.11(@sveltejs/kit@2.15.0) @@ -28,9 +49,24 @@ devDependencies: '@types/better-sqlite3': specifier: ^7.6.11 version: 7.6.12 + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.7 + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 + '@types/node-schedule': + specifier: ^2.1.7 + version: 2.1.7 autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) + dexie: + specifier: ^4.0.10 + version: 4.0.10 drizzle-kit: specifier: ^0.22.0 version: 0.22.8 @@ -43,9 +79,21 @@ devDependencies: eslint-plugin-svelte: specifier: ^2.36.0 version: 2.46.1(eslint@9.17.0)(svelte@5.16.0) + eslint-plugin-tailwindcss: + specifier: ^3.17.5 + version: 3.17.5(tailwindcss@3.4.17) + file-saver: + specifier: ^2.0.5 + version: 2.0.5 globals: specifier: ^15.0.0 version: 15.14.0 + heic2any: + specifier: ^0.0.4 + version: 0.0.4 + mime: + specifier: ^4.0.6 + version: 4.0.6 prettier: specifier: ^3.3.2 version: 3.4.2 @@ -70,6 +118,9 @@ devDependencies: typescript-eslint: specifier: ^8.0.0 version: 8.18.2(eslint@9.17.0)(typescript@5.7.2) + unplugin-icons: + specifier: ^0.22.0 + version: 0.22.0(svelte@5.16.0) vite: specifier: ^5.4.11 version: 5.4.11 @@ -89,6 +140,24 @@ packages: '@jridgewell/trace-mapping': 0.3.25 dev: true + /@antfu/install-pkg@0.4.1: + resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} + dependencies: + package-manager-detector: 0.2.8 + tinyexec: 0.3.1 + dev: true + + /@antfu/install-pkg@0.5.0: + resolution: {integrity: sha512-dKnk2xlAyC7rvTkpkHmu+Qy/2Zc3Vm/l8PtNyIOGDBtXPY3kThfU4ORNEp3V7SXw5XSOb+tOJaUYpfquPzL/Tg==} + dependencies: + package-manager-detector: 0.2.8 + tinyexec: 0.3.1 + dev: true + + /@antfu/utils@0.7.10: + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + dev: true + /@esbuild-kit/core-utils@3.3.2: resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -824,6 +893,31 @@ packages: engines: {node: '>=18.18'} dev: true + /@iconify-json/material-symbols@1.2.12: + resolution: {integrity: sha512-2p2T13Kccy7R2HNbdiVsIcHxjp4s9a+iKlfbtt29hldG1pVNaPIlMALNA9bjdEwPjwsVFe06INCbjCRc68JysQ==} + dependencies: + '@iconify/types': 2.0.0 + dev: true + + /@iconify/types@2.0.0: + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + dev: true + + /@iconify/utils@2.2.1: + resolution: {integrity: sha512-0/7J7hk4PqXmxo5PDBDxmnecw5PxklZJfNjIVG9FM0mEfVrvfudS22rYWsqVk6gR3UJ/mSYS90X4R3znXnqfNA==} + dependencies: + '@antfu/install-pkg': 0.4.1 + '@antfu/utils': 0.7.10 + '@iconify/types': 2.0.0 + debug: 4.4.0 + globals: 15.14.0 + kolorist: 1.8.0 + local-pkg: 0.5.1 + mlly: 1.7.3 + transitivePeerDependencies: + - supports-color + dev: true + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -887,6 +981,11 @@ packages: fastq: 1.18.0 dev: true + /@phc/format@1.0.0: + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1201,10 +1300,30 @@ packages: resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} dev: true + /@types/file-saver@2.0.7: + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true + /@types/jsonwebtoken@9.0.7: + resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + dependencies: + '@types/node': 22.10.2 + dev: true + + /@types/ms@0.7.34: + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + dev: true + + /@types/node-schedule@2.1.7: + resolution: {integrity: sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==} + dependencies: + '@types/node': 22.10.2 + dev: true + /@types/node@22.10.2: resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} dependencies: @@ -1399,6 +1518,16 @@ packages: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true + /argon2@0.41.1: + resolution: {integrity: sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==} + engines: {node: '>=16.17.0'} + requiresBuild: true + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1495,6 +1624,10 @@ packages: update-browserslist-db: 1.1.1(browserslist@4.24.3) dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -1583,11 +1716,22 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: true + /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} dev: true + /cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + dependencies: + luxon: 3.5.0 + dev: false + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1645,6 +1789,10 @@ packages: resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} dev: true + /dexie@4.0.10: + resolution: {integrity: sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==} + dev: true + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true @@ -1761,6 +1909,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /electron-to-chromium@1.5.76: resolution: {integrity: sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==} dev: true @@ -1938,6 +2092,17 @@ packages: - ts-node dev: true + /eslint-plugin-tailwindcss@3.17.5(tailwindcss@3.4.17): + resolution: {integrity: sha512-8Mi7p7dm+mO1dHgRHHFdPu4RDTBk69Cn4P0B40vRQR+MrguUpwmKwhZy1kqYe3Km8/4nb+cyrCF+5SodOEmaow==} + engines: {node: '>=18.12.0'} + peerDependencies: + tailwindcss: ^3.4.0 + dependencies: + fast-glob: 3.3.2 + postcss: 8.4.49 + tailwindcss: 3.4.17 + dev: true + /eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2120,6 +2285,10 @@ packages: flat-cache: 4.0.1 dev: true + /file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + dev: true + /file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} dev: false @@ -2249,6 +2418,10 @@ packages: function-bind: 1.1.2 dev: true + /heic2any@0.0.4: + resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==} + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false @@ -2371,6 +2544,37 @@ packages: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + dev: false + + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -2386,6 +2590,10 @@ packages: resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} dev: true + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: true + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2408,6 +2616,14 @@ packages: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true + /local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + dependencies: + mlly: 1.7.3 + pkg-types: 1.2.1 + dev: true + /locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} dev: true @@ -2419,14 +2635,51 @@ packages: p-locate: 5.0.0 dev: true + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + + /long-timeout@0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + dev: false + /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: true + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} dependencies: @@ -2446,6 +2699,12 @@ packages: picomatch: 2.3.1 dev: true + /mime@4.0.6: + resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==} + engines: {node: '>=16'} + hasBin: true + dev: true + /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -2477,6 +2736,15 @@ packages: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} dev: false + /mlly@1.7.3: + resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} + dependencies: + acorn: 8.14.0 + pathe: 1.1.2 + pkg-types: 1.2.1 + ufo: 1.5.4 + dev: true + /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2489,7 +2757,6 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true /mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2520,10 +2787,29 @@ packages: semver: 7.6.3 dev: false + /node-addon-api@8.3.0: + resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} + engines: {node: ^18 || ^20 || >= 21} + dev: false + + /node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + dev: false + /node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} dev: true + /node-schedule@2.1.1: + resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} + engines: {node: '>=6'} + dependencies: + cron-parser: 4.9.0 + long-timeout: 0.1.1 + sorted-array-functions: 1.3.0 + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2580,6 +2866,10 @@ packages: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} dev: true + /package-manager-detector@0.2.8: + resolution: {integrity: sha512-ts9KSdroZisdvKMWVAVCXiKqnqNfXz4+IbrBG8/BWx/TR5le+jfenvoBuIZ6UWM9nz47W7AbD9qYfAwfWMIwzA==} + dev: true + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2609,6 +2899,10 @@ packages: minipass: 7.1.2 dev: true + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: true @@ -2633,6 +2927,14 @@ packages: engines: {node: '>= 6'} dev: true + /pkg-types@1.2.1: + resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + dependencies: + confbox: 0.1.8 + mlly: 1.7.3 + pathe: 1.1.2 + dev: true + /postcss-import@15.1.0(postcss@8.4.49): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -3007,6 +3309,10 @@ packages: totalist: 3.0.1 dev: true + /sorted-array-functions@1.3.0: + resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} + dev: false + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3225,6 +3531,10 @@ packages: globrex: 0.1.2 dev: true + /tinyexec@0.3.1: + resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} + dev: true + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3285,9 +3595,56 @@ packages: hasBin: true dev: true + /ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + dev: true + /undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + /unplugin-icons@0.22.0(svelte@5.16.0): + resolution: {integrity: sha512-CP+iZq5U7doOifer5bcM0jQ9t3Is7EGybIYt3myVxceI8Zuk8EZEpe1NPtJvh7iqMs1VdbK0L41t9+um9VuuLw==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 || ^2.7.0 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + vue-template-compiler: ^2.6.12 + vue-template-es2015-compiler: ^1.9.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + svelte: + optional: true + vue-template-compiler: + optional: true + vue-template-es2015-compiler: + optional: true + dependencies: + '@antfu/install-pkg': 0.5.0 + '@antfu/utils': 0.7.10 + '@iconify/utils': 2.2.1 + debug: 4.4.0 + kolorist: 1.8.0 + local-pkg: 0.5.1 + svelte: 5.16.0 + unplugin: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: true + + /unplugin@2.1.0: + resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} + engines: {node: '>=18.12.0'} + dependencies: + acorn: 8.14.0 + webpack-virtual-modules: 0.6.2 + dev: true + /update-browserslist-db@1.1.1(browserslist@4.24.3): resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -3308,6 +3665,11 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + dev: false + /vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3357,6 +3719,10 @@ packages: vite: 5.4.11 dev: true + /webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + dev: true + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3411,3 +3777,7 @@ packages: /zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} dev: true + + /zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + dev: false diff --git a/src/app.d.ts b/src/app.d.ts index 520c421..0904582 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,8 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces + +import "unplugin-icons/types/svelte"; + declare global { namespace App { // interface Error {} diff --git a/src/app.html b/src/app.html index 84ffad1..4471298 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,12 @@ + %sveltekit.head% diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..217d7ea --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,26 @@ +import type { ClientInit } from "@sveltejs/kit"; +import { getClientKey, getMasterKeys } from "$lib/indexedDB"; +import { clientKeyStore, masterKeyStore } from "$lib/stores"; + +const prepareClientKeyStore = async () => { + const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([ + getClientKey("encrypt"), + getClientKey("decrypt"), + getClientKey("sign"), + getClientKey("verify"), + ]); + if (encryptKey && decryptKey && signKey && verifyKey) { + clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey }); + } +}; + +const prepareMasterKeyStore = async () => { + const masterKeys = await getMasterKeys(); + if (masterKeys.length > 0) { + masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]))); + } +}; + +export const init: ClientInit = async () => { + await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]); +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..e237845 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,34 @@ +import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; +import schedule from "node-schedule"; +import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; +import { migrateDB } from "$lib/server/db/drizzle"; +import { + cleanupExpiredRefreshTokens, + cleanupExpiredTokenUpgradeChallenges, +} from "$lib/server/db/token"; + +export const init: ServerInit = () => { + migrateDB(); + + schedule.scheduleJob("0 * * * *", () => { + cleanupExpiredUserClientChallenges(); + cleanupExpiredRefreshTokens(); + cleanupExpiredTokenUpgradeChallenges(); + }); +}; + +export const handle: Handle = async ({ event, resolve }) => { + if (["/api", "/auth"].some((path) => event.url.pathname.startsWith(path))) { + return await resolve(event); + } + + const accessToken = event.cookies.get("accessToken"); + if (accessToken) { + return await resolve(event); + } else { + redirect( + 302, + "/auth/login?redirect=" + encodeURIComponent(event.url.pathname + event.url.search), + ); + } +}; diff --git a/src/lib/components/BottomSheet.svelte b/src/lib/components/BottomSheet.svelte new file mode 100644 index 0000000..3d3fbd6 --- /dev/null +++ b/src/lib/components/BottomSheet.svelte @@ -0,0 +1,39 @@ + + +{#if isOpen} + + +
+
+
+ +
e.stopPropagation()} + class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4" + transition:fly={{ y: 100, duration: 200 }} + > + {@render children?.()} +
+
+
+
+{/if} diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte new file mode 100644 index 0000000..5da23b2 --- /dev/null +++ b/src/lib/components/Modal.svelte @@ -0,0 +1,38 @@ + + +{#if isOpen} + + +
+ +
+
e.stopPropagation()} class="rounded-2xl bg-white p-4"> + {@render children?.()} +
+
+
+
+{/if} diff --git a/src/lib/components/TopBar.svelte b/src/lib/components/TopBar.svelte new file mode 100644 index 0000000..6691feb --- /dev/null +++ b/src/lib/components/TopBar.svelte @@ -0,0 +1,31 @@ + + +
+ + {#if title} +

{title}

+ {/if} +
+ {#if children} + {@render children?.()} + {/if} +
+
diff --git a/src/lib/components/buttons/Button.svelte b/src/lib/components/buttons/Button.svelte new file mode 100644 index 0000000..692d4a1 --- /dev/null +++ b/src/lib/components/buttons/Button.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/lib/components/buttons/EntryButton.svelte b/src/lib/components/buttons/EntryButton.svelte new file mode 100644 index 0000000..ed455aa --- /dev/null +++ b/src/lib/components/buttons/EntryButton.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/lib/components/buttons/FloatingButton.svelte b/src/lib/components/buttons/FloatingButton.svelte new file mode 100644 index 0000000..51870c7 --- /dev/null +++ b/src/lib/components/buttons/FloatingButton.svelte @@ -0,0 +1,36 @@ + + +
+
+ +
+
+ +
+
+
+
+
diff --git a/src/lib/components/buttons/TextButton.svelte b/src/lib/components/buttons/TextButton.svelte new file mode 100644 index 0000000..c7688a5 --- /dev/null +++ b/src/lib/components/buttons/TextButton.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/buttons/index.ts b/src/lib/components/buttons/index.ts new file mode 100644 index 0000000..1765400 --- /dev/null +++ b/src/lib/components/buttons/index.ts @@ -0,0 +1,4 @@ +export { default as Button } from "./Button.svelte"; +export { default as EntryButton } from "./EntryButton.svelte"; +export { default as FloatingButton } from "./FloatingButton.svelte"; +export { default as TextButton } from "./TextButton.svelte"; diff --git a/src/lib/components/divs/AdaptiveDiv.svelte b/src/lib/components/divs/AdaptiveDiv.svelte new file mode 100644 index 0000000..ee845cc --- /dev/null +++ b/src/lib/components/divs/AdaptiveDiv.svelte @@ -0,0 +1,7 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/divs/BottomDiv.svelte b/src/lib/components/divs/BottomDiv.svelte new file mode 100644 index 0000000..8a10a99 --- /dev/null +++ b/src/lib/components/divs/BottomDiv.svelte @@ -0,0 +1,7 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/divs/TitleDiv.svelte b/src/lib/components/divs/TitleDiv.svelte new file mode 100644 index 0000000..e622e6e --- /dev/null +++ b/src/lib/components/divs/TitleDiv.svelte @@ -0,0 +1,20 @@ + + +
+
+ {#if Icon} + + {/if} +
+ {@render children?.()} +
diff --git a/src/lib/components/divs/index.ts b/src/lib/components/divs/index.ts new file mode 100644 index 0000000..7bd6a34 --- /dev/null +++ b/src/lib/components/divs/index.ts @@ -0,0 +1,3 @@ +export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte"; +export { default as BottomDiv } from "./BottomDiv.svelte"; +export { default as TitleDiv } from "./TitleDiv.svelte"; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts new file mode 100644 index 0000000..536f01f --- /dev/null +++ b/src/lib/components/index.ts @@ -0,0 +1,3 @@ +export { default as BottomSheet } from "./BottomSheet.svelte"; +export { default as Modal } from "./Modal.svelte"; +export { default as TopBar } from "./TopBar.svelte"; diff --git a/src/lib/components/inputs/TextInput.svelte b/src/lib/components/inputs/TextInput.svelte new file mode 100644 index 0000000..61f42ad --- /dev/null +++ b/src/lib/components/inputs/TextInput.svelte @@ -0,0 +1,35 @@ + + +
+ + + +
+ + diff --git a/src/lib/components/inputs/index.ts b/src/lib/components/inputs/index.ts new file mode 100644 index 0000000..c2c534d --- /dev/null +++ b/src/lib/components/inputs/index.ts @@ -0,0 +1 @@ +export { default as TextInput } from "./TextInput.svelte"; diff --git a/src/lib/hooks/callApi.ts b/src/lib/hooks/callApi.ts new file mode 100644 index 0000000..c08b97d --- /dev/null +++ b/src/lib/hooks/callApi.ts @@ -0,0 +1,35 @@ +export const refreshToken = async (fetchInternal = fetch) => { + return await fetchInternal("/api/auth/refreshToken", { method: "POST" }); +}; + +const callApi = async (input: RequestInfo, init?: RequestInit, fetchInternal = fetch) => { + let res = await fetchInternal(input, init); + if (res.status === 401) { + res = await refreshToken(); + if (!res.ok) { + return res; + } + res = await fetchInternal(input, init); + } + return res; +}; + +export const callGetApi = async (input: RequestInfo, fetchInternal?: typeof fetch) => { + return await callApi(input, undefined, fetchInternal); +}; + +export const callPostApi = async ( + input: RequestInfo, + payload?: T, + fetchInternal?: typeof fetch, +) => { + return await callApi( + input, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload ? JSON.stringify(payload) : undefined, + }, + fetchInternal, + ); +}; diff --git a/src/lib/hooks/gotoStateful.ts b/src/lib/hooks/gotoStateful.ts new file mode 100644 index 0000000..fffc95f --- /dev/null +++ b/src/lib/hooks/gotoStateful.ts @@ -0,0 +1,39 @@ +import { goto } from "$app/navigation"; + +type Path = "/key/export"; + +interface KeyExportState { + redirectPath: string; + + encryptKeyBase64: string; + decryptKeyBase64: string; + signKeyBase64: string; + verifyKeyBase64: string; + + masterKeyWrapped: string; +} + +const useAutoNull = (value: T | null) => { + return { + get: () => { + const result = value; + value = null; + return result; + }, + set: (newValue: T) => { + value = newValue; + }, + }; +}; + +export const keyExportState = useAutoNull(null); + +export function gotoStateful(path: "/key/export", state: KeyExportState): Promise; + +export function gotoStateful(path: Path, state: unknown) { + switch (path) { + case "/key/export": + keyExportState.set(state as KeyExportState); + return goto(path); + } +} diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts new file mode 100644 index 0000000..e3b8dde --- /dev/null +++ b/src/lib/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./callApi"; +export * from "./gotoStateful"; diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/indexedDB.ts b/src/lib/indexedDB.ts new file mode 100644 index 0000000..b2fbd22 --- /dev/null +++ b/src/lib/indexedDB.ts @@ -0,0 +1,64 @@ +import { Dexie, type EntityTable } from "dexie"; + +type ClientKeyUsage = "encrypt" | "decrypt" | "sign" | "verify"; + +interface ClientKey { + usage: ClientKeyUsage; + key: CryptoKey; +} + +type MasterKeyState = "active" | "retired"; + +interface MasterKey { + version: number; + state: MasterKeyState; + key: CryptoKey; +} + +const keyStore = new Dexie("keyStore") as Dexie & { + clientKey: EntityTable; + masterKey: EntityTable; +}; + +keyStore.version(1).stores({ + clientKey: "usage", + masterKey: "version", +}); + +export const getClientKey = async (usage: ClientKeyUsage) => { + const key = await keyStore.clientKey.get(usage); + return key?.key ?? null; +}; + +export const storeClientKey = async (key: CryptoKey, usage: ClientKeyUsage) => { + switch (usage) { + case "encrypt": + case "verify": + if (key.type !== "public") { + throw new Error("Public key required"); + } else if (!key.extractable) { + throw new Error("Public key must be extractable"); + } + break; + case "decrypt": + case "sign": + if (key.type !== "private") { + throw new Error("Private key required"); + } else if (key.extractable) { + throw new Error("Private key must be nonextractable"); + } + break; + } + await keyStore.clientKey.put({ usage, key }); +}; + +export const getMasterKeys = async () => { + return await keyStore.masterKey.toArray(); +}; + +export const storeMasterKeys = async (keys: MasterKey[]) => { + if (keys.some(({ key }) => key.extractable)) { + throw new Error("Master keys must be nonextractable"); + } + await keyStore.masterKey.bulkPut(keys); +}; diff --git a/src/lib/modules/crypto/aes.ts b/src/lib/modules/crypto/aes.ts new file mode 100644 index 0000000..df04851 --- /dev/null +++ b/src/lib/modules/crypto/aes.ts @@ -0,0 +1,89 @@ +import { encodeString, decodeString, encodeToBase64, decodeFromBase64 } from "./util"; + +export const generateMasterKey = async () => { + return { + masterKey: await window.crypto.subtle.generateKey( + { + name: "AES-KW", + length: 256, + } satisfies AesKeyGenParams, + true, + ["wrapKey", "unwrapKey"], + ), + }; +}; + +export const generateDataKey = async () => { + return { + dataKey: await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + } satisfies AesKeyGenParams, + true, + ["encrypt", "decrypt"], + ), + dataKeyVersion: new Date(), + }; +}; + +export const makeAESKeyNonextractable = async (key: CryptoKey) => { + return await window.crypto.subtle.importKey( + "raw", + await window.crypto.subtle.exportKey("raw", key), + key.algorithm, + false, + key.usages, + ); +}; + +export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => { + return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW")); +}; + +export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => { + return { + dataKey: await window.crypto.subtle.unwrapKey( + "raw", + decodeFromBase64(dataKeyWrapped), + masterKey, + "AES-KW", + "AES-GCM", + false, // Nonextractable + ["encrypt", "decrypt"], + ), + }; +}; + +export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + } satisfies AesGcmParams, + dataKey, + data, + ); + return { ciphertext, iv: encodeToBase64(iv.buffer) }; +}; + +export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => { + return await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: decodeFromBase64(iv), + } satisfies AesGcmParams, + dataKey, + ciphertext, + ); +}; + +export const encryptString = async (plaintext: string, dataKey: CryptoKey) => { + const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey); + return { ciphertext: encodeToBase64(ciphertext), iv }; +}; + +export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { + return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey)); +}; diff --git a/src/lib/modules/crypto/index.ts b/src/lib/modules/crypto/index.ts new file mode 100644 index 0000000..e6972ba --- /dev/null +++ b/src/lib/modules/crypto/index.ts @@ -0,0 +1,4 @@ +export * from "./aes"; +export * from "./rsa"; +export * from "./sha"; +export * from "./util"; diff --git a/src/lib/modules/crypto/rsa.ts b/src/lib/modules/crypto/rsa.ts new file mode 100644 index 0000000..9eb81c0 --- /dev/null +++ b/src/lib/modules/crypto/rsa.ts @@ -0,0 +1,152 @@ +import { encodeString, encodeToBase64, decodeFromBase64 } from "./util"; + +export const generateEncryptionKeyPair = async () => { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + } satisfies RsaHashedKeyGenParams, + true, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); + return { + encryptKey: keyPair.publicKey, + decryptKey: keyPair.privateKey, + }; +}; + +export const generateSigningKeyPair = async () => { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-PSS", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + } satisfies RsaHashedKeyGenParams, + true, + ["sign", "verify"], + ); + return { + signKey: keyPair.privateKey, + verifyKey: keyPair.publicKey, + }; +}; + +export const exportRSAKey = async (key: CryptoKey) => { + const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); + return { + key: await window.crypto.subtle.exportKey(format, key), + format, + }; +}; + +export const exportRSAKeyToBase64 = async (key: CryptoKey) => { + return encodeToBase64((await exportRSAKey(key)).key); +}; + +export const makeRSAKeyNonextractable = async (key: CryptoKey) => { + const { key: exportedKey, format } = await exportRSAKey(key); + return await window.crypto.subtle.importKey( + format, + exportedKey, + key.algorithm, + false, + key.usages, + ); +}; + +export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => { + return await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + } satisfies RsaOaepParams, + decryptKey, + decodeFromBase64(challenge), + ); +}; + +export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => { + return encodeToBase64( + await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, { + name: "RSA-OAEP", + } satisfies RsaOaepParams), + ); +}; + +export const unwrapMasterKey = async ( + masterKeyWrapped: string, + decryptKey: CryptoKey, + extractable = false, +) => { + return { + masterKey: await window.crypto.subtle.unwrapKey( + "raw", + decodeFromBase64(masterKeyWrapped), + decryptKey, + { + name: "RSA-OAEP", + } satisfies RsaOaepParams, + "AES-KW", + extractable, + ["wrapKey", "unwrapKey"], + ), + }; +}; + +export const signMessage = async (message: BufferSource, signKey: CryptoKey) => { + return await window.crypto.subtle.sign( + { + name: "RSA-PSS", + saltLength: 32, // SHA-256 + } satisfies RsaPssParams, + signKey, + message, + ); +}; + +export const verifySignature = async ( + message: BufferSource, + signature: BufferSource, + verifyKey: CryptoKey, +) => { + return await window.crypto.subtle.verify( + { + name: "RSA-PSS", + saltLength: 32, // SHA-256 + } satisfies RsaPssParams, + verifyKey, + signature, + message, + ); +}; + +export const signMasterKeyWrapped = async ( + masterKeyWrapped: string, + masterKeyVersion: number, + signKey: CryptoKey, +) => { + const serialized = JSON.stringify({ + version: masterKeyVersion, + key: masterKeyWrapped, + }); + return encodeToBase64(await signMessage(encodeString(serialized), signKey)); +}; + +export const verifyMasterKeyWrapped = async ( + masterKeyWrapped: string, + masterKeyVersion: number, + masterKeyWrappedSig: string, + verifyKey: CryptoKey, +) => { + const serialized = JSON.stringify({ + version: masterKeyVersion, + key: masterKeyWrapped, + }); + return await verifySignature( + encodeString(serialized), + decodeFromBase64(masterKeyWrappedSig), + verifyKey, + ); +}; diff --git a/src/lib/modules/crypto/sha.ts b/src/lib/modules/crypto/sha.ts new file mode 100644 index 0000000..e79f706 --- /dev/null +++ b/src/lib/modules/crypto/sha.ts @@ -0,0 +1,3 @@ +export const digestMessage = async (message: BufferSource) => { + return await window.crypto.subtle.digest("SHA-256", message); +}; diff --git a/src/lib/modules/crypto/util.ts b/src/lib/modules/crypto/util.ts new file mode 100644 index 0000000..a3e3bc0 --- /dev/null +++ b/src/lib/modules/crypto/util.ts @@ -0,0 +1,30 @@ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const encodeString = (data: string) => { + return textEncoder.encode(data); +}; + +export const decodeString = (data: ArrayBuffer) => { + return textDecoder.decode(data); +}; + +export const encodeToBase64 = (data: ArrayBuffer) => { + return btoa(String.fromCharCode(...new Uint8Array(data))); +}; + +export const decodeFromBase64 = (data: string) => { + return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer; +}; + +export const concatenateBuffers = (...buffers: ArrayBuffer[]) => { + const arrays = buffers.map((buffer) => new Uint8Array(buffer)); + const totalLength = arrays.reduce((acc, array) => acc + array.length, 0); + const result = new Uint8Array(totalLength); + + arrays.reduce((offset, array) => { + result.set(array, offset); + return offset + array.length; + }, 0); + return result; +}; diff --git a/src/lib/modules/file.ts b/src/lib/modules/file.ts new file mode 100644 index 0000000..2e25399 --- /dev/null +++ b/src/lib/modules/file.ts @@ -0,0 +1,89 @@ +import { writable, type Writable } from "svelte/store"; +import { callGetApi } from "$lib/hooks"; +import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; +import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas"; +import { + directoryInfoStore, + fileInfoStore, + type DirectoryInfo, + type FileInfo, +} from "$lib/stores/file"; + +const fetchDirectoryInfo = async ( + directoryId: "root" | number, + masterKey: CryptoKey, + infoStore: Writable, +) => { + const res = await callGetApi(`/api/directory/${directoryId}`); + if (!res.ok) throw new Error("Failed to fetch directory information"); + const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json(); + + let newInfo: DirectoryInfo; + if (directoryId === "root") { + newInfo = { + id: "root", + subDirectoryIds: subDirectories, + fileIds: files, + }; + } else { + const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); + newInfo = { + id: directoryId, + dataKey, + dataKeyVersion: new Date(metadata!.dekVersion), + name: await decryptString(metadata!.name, metadata!.nameIv, dataKey), + subDirectoryIds: subDirectories, + fileIds: files, + }; + } + + infoStore.update(() => newInfo); +}; + +export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => { + // TODO: MEK rotation + + let info = directoryInfoStore.get(directoryId); + if (!info) { + info = writable(null); + directoryInfoStore.set(directoryId, info); + } + + fetchDirectoryInfo(directoryId, masterKey, info); + return info; +}; + +const fetchFileInfo = async ( + fileId: number, + masterKey: CryptoKey, + infoStore: Writable, +) => { + const res = await callGetApi(`/api/file/${fileId}`); + if (!res.ok) throw new Error("Failed to fetch file information"); + const metadata: FileInfoResponse = await res.json(); + + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const newInfo: FileInfo = { + id: fileId, + dataKey, + dataKeyVersion: new Date(metadata.dekVersion), + contentType: metadata.contentType, + contentIv: metadata.contentIv, + name: await decryptString(metadata.name, metadata.nameIv, dataKey), + }; + + infoStore.update(() => newInfo); +}; + +export const getFileInfo = (fileId: number, masterKey: CryptoKey) => { + // TODO: MEK rotation + + let info = fileInfoStore.get(fileId); + if (!info) { + info = writable(null); + fileInfoStore.set(fileId, info); + } + + fetchFileInfo(fileId, masterKey, info); + return info; +}; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts new file mode 100644 index 0000000..bdf2404 --- /dev/null +++ b/src/lib/server/db/client.ts @@ -0,0 +1,151 @@ +import { and, or, eq, gt, lte, count } from "drizzle-orm"; +import db from "./drizzle"; +import { client, userClient, userClientChallenge } from "./schema"; + +export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { + return await db.transaction(async (tx) => { + const clients = await tx + .select() + .from(client) + .where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey))); + if (clients.length > 0) { + throw new Error("Already used public key(s)"); + } + + const insertRes = await tx + .insert(client) + .values({ encPubKey, sigPubKey }) + .returning({ id: client.id }); + const { id: clientId } = insertRes[0]!; + await tx.insert(userClient).values({ userId, clientId }); + + return clientId; + }); +}; + +export const getClient = async (clientId: number) => { + const clients = await db.select().from(client).where(eq(client.id, clientId)).execute(); + return clients[0] ?? null; +}; + +export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => { + const clients = await db + .select() + .from(client) + .where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey))) + .execute(); + return clients[0] ?? null; +}; + +export const countClientByPubKey = async (pubKey: string) => { + const clients = await db + .select({ count: count() }) + .from(client) + .where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey))); + return clients[0]?.count ?? 0; +}; + +export const createUserClient = async (userId: number, clientId: number) => { + await db.insert(userClient).values({ userId, clientId }).execute(); +}; + +export const getAllUserClients = async (userId: number) => { + return await db.select().from(userClient).where(eq(userClient.userId, userId)).execute(); +}; + +export const getUserClient = async (userId: number, clientId: number) => { + const userClients = await db + .select() + .from(userClient) + .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) + .execute(); + return userClients[0] ?? null; +}; + +export const getUserClientWithDetails = async (userId: number, clientId: number) => { + const userClients = await db + .select() + .from(userClient) + .innerJoin(client, eq(userClient.clientId, client.id)) + .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) + .execute(); + return userClients[0] ?? null; +}; + +export const setUserClientStateToPending = async (userId: number, clientId: number) => { + await db + .update(userClient) + .set({ state: "pending" }) + .where( + and( + eq(userClient.userId, userId), + eq(userClient.clientId, clientId), + eq(userClient.state, "challenging"), + ), + ) + .execute(); +}; + +export const setUserClientStateToActive = async (userId: number, clientId: number) => { + await db + .update(userClient) + .set({ state: "active" }) + .where( + and( + eq(userClient.userId, userId), + eq(userClient.clientId, clientId), + eq(userClient.state, "pending"), + ), + ) + .execute(); +}; + +export const registerUserClientChallenge = async ( + userId: number, + clientId: number, + answer: string, + allowedIp: string, + expiresAt: Date, +) => { + await db + .insert(userClientChallenge) + .values({ + userId, + clientId, + answer, + allowedIp, + expiresAt, + }) + .execute(); +}; + +export const getUserClientChallenge = async (answer: string, ip: string) => { + const challenges = await db + .select() + .from(userClientChallenge) + .where( + and( + eq(userClientChallenge.answer, answer), + eq(userClientChallenge.allowedIp, ip), + gt(userClientChallenge.expiresAt, new Date()), + eq(userClientChallenge.isUsed, false), + ), + ) + .execute(); + return challenges[0] ?? null; +}; + +export const markUserClientChallengeAsUsed = async (id: number) => { + await db + .update(userClientChallenge) + .set({ isUsed: true }) + .where(eq(userClientChallenge.id, id)) + .execute(); +}; + +export const cleanupExpiredUserClientChallenges = async () => { + await db + .delete(userClientChallenge) + .where(lte(userClientChallenge.expiresAt, new Date())) + .execute(); +}; diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts new file mode 100644 index 0000000..589c91e --- /dev/null +++ b/src/lib/server/db/drizzle.ts @@ -0,0 +1,15 @@ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import env from "$lib/server/loadenv"; + +const client = new Database(env.databaseUrl); +const db = drizzle(client); + +export const migrateDB = () => { + if (process.env.NODE_ENV === "production") { + migrate(db, { migrationsFolder: "./drizzle" }); + } +}; + +export default db; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts new file mode 100644 index 0000000..2fe4b53 --- /dev/null +++ b/src/lib/server/db/file.ts @@ -0,0 +1,194 @@ +import { and, eq, isNull } from "drizzle-orm"; +import db from "./drizzle"; +import { directory, file, mek } from "./schema"; + +type DirectoryId = "root" | number; + +export interface NewDirectoryParams { + userId: number; + parentId: DirectoryId; + mekVersion: number; + encDek: string; + dekVersion: Date; + encName: string; + encNameIv: string; +} + +export interface NewFileParams { + path: string; + parentId: DirectoryId; + userId: number; + mekVersion: number; + encDek: string; + dekVersion: Date; + contentType: string; + encContentIv: string; + encName: string; + encNameIv: string; +} + +export const registerNewDirectory = async (params: NewDirectoryParams) => { + return await db.transaction(async (tx) => { + const meks = await tx + .select() + .from(mek) + .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); + if (meks[0]?.version !== params.mekVersion) { + throw new Error("Invalid MEK version"); + } + + const now = new Date(); + await tx.insert(directory).values({ + createdAt: now, + parentId: params.parentId === "root" ? null : params.parentId, + userId: params.userId, + mekVersion: params.mekVersion, + encDek: params.encDek, + dekVersion: params.dekVersion, + encName: { ciphertext: params.encName, iv: params.encNameIv }, + }); + }); +}; + +export const getAllDirectoriesByParent = async (userId: number, directoryId: DirectoryId) => { + return await db + .select() + .from(directory) + .where( + and( + eq(directory.userId, userId), + directoryId === "root" ? isNull(directory.parentId) : eq(directory.parentId, directoryId), + ), + ) + .execute(); +}; + +export const getDirectory = async (userId: number, directoryId: number) => { + const res = await db + .select() + .from(directory) + .where(and(eq(directory.userId, userId), eq(directory.id, directoryId))) + .execute(); + return res[0] ?? null; +}; + +export const setDirectoryEncName = async ( + userId: number, + directoryId: number, + dekVersion: Date, + encName: string, + encNameIv: string, +) => { + const res = await db + .update(directory) + .set({ encName: { ciphertext: encName, iv: encNameIv } }) + .where( + and( + eq(directory.userId, userId), + eq(directory.id, directoryId), + eq(directory.dekVersion, dekVersion), + ), + ) + .execute(); + return res.changes > 0; +}; + +export const unregisterDirectory = async (userId: number, directoryId: number) => { + return await db.transaction(async (tx) => { + const getFilePaths = async (parentId: number) => { + const files = await tx + .select({ path: file.path }) + .from(file) + .where(and(eq(file.userId, userId), eq(file.parentId, parentId))); + return files.map(({ path }) => path); + }; + const unregisterSubDirectoriesRecursively = async (directoryId: number): Promise => { + const subDirectories = await tx + .select({ id: directory.id }) + .from(directory) + .where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId))); + const subDirectoryFilePaths = await Promise.all( + subDirectories.map(async ({ id }) => await unregisterSubDirectoriesRecursively(id)), + ); + const filePaths = await getFilePaths(directoryId); + + await tx.delete(file).where(eq(file.parentId, directoryId)); + await tx.delete(directory).where(eq(directory.id, directoryId)); + + return filePaths.concat(...subDirectoryFilePaths); + }; + return await unregisterSubDirectoriesRecursively(directoryId); + }); +}; + +export const registerNewFile = async (params: NewFileParams) => { + await db.transaction(async (tx) => { + const meks = await tx + .select() + .from(mek) + .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); + if (meks[0]?.version !== params.mekVersion) { + throw new Error("Invalid MEK version"); + } + + const now = new Date(); + await tx.insert(file).values({ + path: params.path, + parentId: params.parentId === "root" ? null : params.parentId, + createdAt: now, + userId: params.userId, + mekVersion: params.mekVersion, + contentType: params.contentType, + encDek: params.encDek, + dekVersion: params.dekVersion, + encContentIv: params.encContentIv, + encName: { ciphertext: params.encName, iv: params.encNameIv }, + }); + }); +}; + +export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => { + return await db + .select() + .from(file) + .where( + and( + eq(file.userId, userId), + parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId), + ), + ) + .execute(); +}; + +export const getFile = async (userId: number, fileId: number) => { + const res = await db + .select() + .from(file) + .where(and(eq(file.userId, userId), eq(file.id, fileId))) + .execute(); + return res[0] ?? null; +}; + +export const setFileEncName = async ( + userId: number, + fileId: number, + dekVersion: Date, + encName: string, + encNameIv: string, +) => { + const res = await db + .update(file) + .set({ encName: { ciphertext: encName, iv: encNameIv } }) + .where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion))) + .execute(); + return res.changes > 0; +}; + +export const unregisterFile = async (userId: number, fileId: number) => { + const res = await db + .delete(file) + .where(and(eq(file.userId, userId), eq(file.id, fileId))) + .returning({ path: file.path }) + .execute(); + return res[0]?.path ?? null; +}; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts deleted file mode 100644 index 8093e19..0000000 --- a/src/lib/server/db/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { drizzle } from "drizzle-orm/better-sqlite3"; -import Database from "better-sqlite3"; -import { env } from "$env/dynamic/private"; -if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); -const client = new Database(env.DATABASE_URL); -export const db = drizzle(client); diff --git a/src/lib/server/db/mek.ts b/src/lib/server/db/mek.ts new file mode 100644 index 0000000..7215ce0 --- /dev/null +++ b/src/lib/server/db/mek.ts @@ -0,0 +1,60 @@ +import { and, or, eq } from "drizzle-orm"; +import db from "./drizzle"; +import { mek, clientMek } from "./schema"; + +export const registerInitialMek = async ( + userId: number, + createdBy: number, + encMek: string, + encMekSig: string, +) => { + await db.transaction(async (tx) => { + await tx.insert(mek).values({ + userId, + version: 1, + createdBy, + createdAt: new Date(), + state: "active", + }); + await tx.insert(clientMek).values({ + userId, + clientId: createdBy, + mekVersion: 1, + encMek, + encMekSig, + }); + }); +}; + +export const getInitialMek = async (userId: number) => { + const meks = await db + .select() + .from(mek) + .where(and(eq(mek.userId, userId), eq(mek.version, 1))) + .execute(); + return meks[0] ?? null; +}; + +export const getActiveMekVersion = async (userId: number) => { + const meks = await db + .select({ version: mek.version }) + .from(mek) + .where(and(eq(mek.userId, userId), eq(mek.state, "active"))) + .execute(); + return meks[0]?.version ?? null; +}; + +export const getAllValidClientMeks = async (userId: number, clientId: number) => { + return await db + .select() + .from(clientMek) + .innerJoin(mek, and(eq(clientMek.userId, mek.userId), eq(clientMek.mekVersion, mek.version))) + .where( + and( + eq(clientMek.userId, userId), + eq(clientMek.clientId, clientId), + or(eq(mek.state, "active"), eq(mek.state, "retired")), + ), + ) + .execute(); +}; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts deleted file mode 100644 index d6309dd..0000000 --- a/src/lib/server/db/schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; - -export const user = sqliteTable("user", { - id: integer("id").primaryKey(), - age: integer("age"), -}); diff --git a/src/lib/server/db/schema/client.ts b/src/lib/server/db/schema/client.ts new file mode 100644 index 0000000..437695d --- /dev/null +++ b/src/lib/server/db/schema/client.ts @@ -0,0 +1,46 @@ +import { sqliteTable, text, integer, primaryKey, unique } from "drizzle-orm/sqlite-core"; +import { user } from "./user"; + +export const client = sqliteTable( + "client", + { + id: integer("id").primaryKey({ autoIncrement: true }), + encPubKey: text("encryption_public_key").notNull().unique(), // Base64 + sigPubKey: text("signature_public_key").notNull().unique(), // Base64 + }, + (t) => ({ + unq: unique().on(t.encPubKey, t.sigPubKey), + }), +); + +export const userClient = sqliteTable( + "user_client", + { + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + state: text("state", { enum: ["challenging", "pending", "active"] }) + .notNull() + .default("challenging"), + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.clientId] }), + }), +); + +export const userClientChallenge = sqliteTable("user_client_challenge", { + id: integer("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + answer: text("challenge").notNull().unique(), // Base64 + allowedIp: text("allowed_ip").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false), +}); diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts new file mode 100644 index 0000000..dbaf944 --- /dev/null +++ b/src/lib/server/db/schema/file.ts @@ -0,0 +1,60 @@ +import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core"; +import { mek } from "./mek"; +import { user } from "./user"; + +const ciphertext = (name: string) => + text(name, { mode: "json" }).$type<{ + ciphertext: string; // Base64 + iv: string; // Base64 + }>(); + +export const directory = sqliteTable( + "directory", + { + id: integer("id").primaryKey({ autoIncrement: true }), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + parentId: integer("parent_id"), + userId: integer("user_id") + .notNull() + .references(() => user.id), + mekVersion: integer("master_encryption_key_version").notNull(), + encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64 + dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(), + encName: ciphertext("encrypted_name").notNull(), + }, + (t) => ({ + ref1: foreignKey({ + columns: [t.parentId], + foreignColumns: [t.id], + }), + ref2: foreignKey({ + columns: [t.userId, t.mekVersion], + foreignColumns: [mek.userId, mek.version], + }), + }), +); + +export const file = sqliteTable( + "file", + { + id: integer("id").primaryKey({ autoIncrement: true }), + path: text("path").notNull().unique(), + parentId: integer("parent_id").references(() => directory.id), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + userId: integer("user_id") + .notNull() + .references(() => user.id), + mekVersion: integer("master_encryption_key_version").notNull(), + encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64 + dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(), + contentType: text("content_type").notNull(), + encContentIv: text("encrypted_content_iv").notNull(), // Base64 + encName: ciphertext("encrypted_name").notNull(), + }, + (t) => ({ + ref: foreignKey({ + columns: [t.userId, t.mekVersion], + foreignColumns: [mek.userId, mek.version], + }), + }), +); diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts new file mode 100644 index 0000000..4344b00 --- /dev/null +++ b/src/lib/server/db/schema/index.ts @@ -0,0 +1,5 @@ +export * from "./client"; +export * from "./file"; +export * from "./mek"; +export * from "./token"; +export * from "./user"; diff --git a/src/lib/server/db/schema/mek.ts b/src/lib/server/db/schema/mek.ts new file mode 100644 index 0000000..8ab6fb8 --- /dev/null +++ b/src/lib/server/db/schema/mek.ts @@ -0,0 +1,44 @@ +import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core"; +import { client } from "./client"; +import { user } from "./user"; + +export const mek = sqliteTable( + "master_encryption_key", + { + userId: integer("user_id") + .notNull() + .references(() => user.id), + version: integer("version").notNull(), + createdBy: integer("created_by") + .notNull() + .references(() => client.id), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + state: text("state", { enum: ["active", "retired", "dead"] }).notNull(), + retiredAt: integer("retired_at", { mode: "timestamp_ms" }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.version] }), + }), +); + +export const clientMek = sqliteTable( + "client_master_encryption_key", + { + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + mekVersion: integer("version").notNull(), + encMek: text("encrypted_key").notNull(), // Base64 + encMekSig: text("encrypted_key_signature").notNull(), // Base64 + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.clientId, t.mekVersion] }), + ref: foreignKey({ + columns: [t.userId, t.mekVersion], + foreignColumns: [mek.userId, mek.version], + }), + }), +); diff --git a/src/lib/server/db/schema/token.ts b/src/lib/server/db/schema/token.ts new file mode 100644 index 0000000..72106d7 --- /dev/null +++ b/src/lib/server/db/schema/token.ts @@ -0,0 +1,32 @@ +import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; +import { client } from "./client"; +import { user } from "./user"; + +export const refreshToken = sqliteTable( + "refresh_token", + { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id").references(() => client.id), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), // Only used for cleanup + }, + (t) => ({ + unq: unique().on(t.userId, t.clientId), + }), +); + +export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", { + id: integer("id").primaryKey(), + refreshTokenId: text("refresh_token_id") + .notNull() + .references(() => refreshToken.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + answer: text("challenge").notNull().unique(), // Base64 + allowedIp: text("allowed_ip").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false), +}); diff --git a/src/lib/server/db/schema/user.ts b/src/lib/server/db/schema/user.ts new file mode 100644 index 0000000..5d70e00 --- /dev/null +++ b/src/lib/server/db/schema/user.ts @@ -0,0 +1,7 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +export const user = sqliteTable("user", { + id: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull().unique(), + password: text("password").notNull(), +}); diff --git a/src/lib/server/db/token.ts b/src/lib/server/db/token.ts new file mode 100644 index 0000000..e26a8ef --- /dev/null +++ b/src/lib/server/db/token.ts @@ -0,0 +1,133 @@ +import { SqliteError } from "better-sqlite3"; +import { and, eq, gt, lte } from "drizzle-orm"; +import env from "$lib/server/loadenv"; +import db from "./drizzle"; +import { refreshToken, tokenUpgradeChallenge } from "./schema"; + +const expiresAt = () => new Date(Date.now() + env.jwt.refreshExp); + +export const registerRefreshToken = async ( + userId: number, + clientId: number | null, + tokenId: string, +) => { + try { + await db + .insert(refreshToken) + .values({ + id: tokenId, + userId, + clientId, + expiresAt: expiresAt(), + }) + .execute(); + return true; + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return false; + } + throw e; + } +}; + +export const getRefreshToken = async (tokenId: string) => { + const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).execute(); + return tokens[0] ?? null; +}; + +export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => { + return await db.transaction(async (tx) => { + await tx + .delete(tokenUpgradeChallenge) + .where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId)); + const res = await db + .update(refreshToken) + .set({ + id: newTokenId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; + }); +}; + +export const upgradeRefreshToken = async ( + oldTokenId: string, + newTokenId: string, + clientId: number, +) => { + return await db.transaction(async (tx) => { + await tx + .delete(tokenUpgradeChallenge) + .where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId)); + const res = await tx + .update(refreshToken) + .set({ + id: newTokenId, + clientId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; + }); +}; + +export const revokeRefreshToken = async (tokenId: string) => { + await db.delete(refreshToken).where(eq(refreshToken.id, tokenId)).execute(); +}; + +export const cleanupExpiredRefreshTokens = async () => { + await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute(); +}; + +export const registerTokenUpgradeChallenge = async ( + tokenId: string, + clientId: number, + answer: string, + allowedIp: string, + expiresAt: Date, +) => { + await db + .insert(tokenUpgradeChallenge) + .values({ + refreshTokenId: tokenId, + clientId, + answer, + allowedIp, + expiresAt, + }) + .execute(); +}; + +export const getTokenUpgradeChallenge = async (answer: string, ip: string) => { + const challenges = await db + .select() + .from(tokenUpgradeChallenge) + .where( + and( + eq(tokenUpgradeChallenge.answer, answer), + eq(tokenUpgradeChallenge.allowedIp, ip), + gt(tokenUpgradeChallenge.expiresAt, new Date()), + eq(tokenUpgradeChallenge.isUsed, false), + ), + ) + .execute(); + return challenges[0] ?? null; +}; + +export const markTokenUpgradeChallengeAsUsed = async (id: number) => { + await db + .update(tokenUpgradeChallenge) + .set({ isUsed: true }) + .where(eq(tokenUpgradeChallenge.id, id)) + .execute(); +}; + +export const cleanupExpiredTokenUpgradeChallenges = async () => { + await db + .delete(tokenUpgradeChallenge) + .where(lte(tokenUpgradeChallenge.expiresAt, new Date())) + .execute(); +}; diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/user.ts new file mode 100644 index 0000000..38a53f0 --- /dev/null +++ b/src/lib/server/db/user.ts @@ -0,0 +1,8 @@ +import { eq } from "drizzle-orm"; +import db from "./drizzle"; +import { user } from "./schema"; + +export const getUserByEmail = async (email: string) => { + const users = await db.select().from(user).where(eq(user.email, email)).execute(); + return users[0] ?? null; +}; diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts new file mode 100644 index 0000000..a57eff8 --- /dev/null +++ b/src/lib/server/loadenv.ts @@ -0,0 +1,21 @@ +import ms from "ms"; +import { building } from "$app/environment"; +import { env } from "$env/dynamic/private"; + +if (!building) { + if (!env.JWT_SECRET) throw new Error("JWT_SECRET is not set"); +} + +export default { + databaseUrl: env.DATABASE_URL || "local.db", + jwt: { + secret: env.JWT_SECRET, + accessExp: ms(env.JWT_ACCESS_TOKEN_EXPIRES || "5m"), + refreshExp: ms(env.JWT_REFRESH_TOKEN_EXPIRES || "14d"), + }, + challenge: { + userClientExp: ms(env.USER_CLIENT_CHALLENGE_EXPIRES || "5m"), + tokenUpgradeExp: ms(env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m"), + }, + libraryPath: env.LIBRARY_PATH || "library", +}; diff --git a/src/lib/server/modules/auth.ts b/src/lib/server/modules/auth.ts new file mode 100644 index 0000000..37248ed --- /dev/null +++ b/src/lib/server/modules/auth.ts @@ -0,0 +1,90 @@ +import { error, type Cookies } from "@sveltejs/kit"; +import jwt from "jsonwebtoken"; +import { getUserClient } from "$lib/server/db/client"; +import env from "$lib/server/loadenv"; + +type TokenPayload = + | { + type: "access"; + userId: number; + clientId?: number; + } + | { + type: "refresh"; + jti: string; + }; + +export enum TokenError { + EXPIRED, + INVALID, +} + +type Permission = "pendingClient" | "activeClient"; + +export const issueToken = (payload: TokenPayload) => { + return jwt.sign(payload, env.jwt.secret, { + expiresIn: (payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp) / 1000, + }); +}; + +export const verifyToken = (token: string) => { + try { + return jwt.verify(token, env.jwt.secret) as TokenPayload; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return TokenError.EXPIRED; + } + return TokenError.INVALID; + } +}; + +export const authenticate = (cookies: Cookies) => { + const accessToken = cookies.get("accessToken"); + if (!accessToken) { + error(401, "Access token not found"); + } + + const tokenPayload = verifyToken(accessToken); + if (tokenPayload === TokenError.EXPIRED) { + error(401, "Access token expired"); + } else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "access") { + error(401, "Invalid access token"); + } + + return { + userId: tokenPayload.userId, + clientId: tokenPayload.clientId, + }; +}; + +export async function authorize( + cookies: Cookies, + requiredPermission: "pendingClient", +): Promise<{ userId: number; clientId: number }>; + +export async function authorize( + cookies: Cookies, + requiredPermission: "activeClient", +): Promise<{ userId: number; clientId: number }>; + +export async function authorize( + cookies: Cookies, + requiredPermission: Permission, +): Promise<{ userId: number; clientId?: number }> { + const tokenPayload = authenticate(cookies); + const { userId, clientId } = tokenPayload; + const userClient = clientId ? await getUserClient(userId, clientId) : undefined; + + switch (requiredPermission) { + case "pendingClient": + if (!userClient || userClient.state !== "pending") { + error(403, "Forbidden"); + } + return tokenPayload; + case "activeClient": + if (!userClient || userClient.state !== "active") { + error(403, "Forbidden"); + } + return tokenPayload; + } +} diff --git a/src/lib/server/modules/crypto.ts b/src/lib/server/modules/crypto.ts new file mode 100644 index 0000000..de3dbf4 --- /dev/null +++ b/src/lib/server/modules/crypto.ts @@ -0,0 +1,36 @@ +import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto"; +import { promisify } from "util"; + +const makePubKeyToPem = (pubKey: string) => + `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; + +export const verifyPubKey = (pubKey: string) => { + const pubKeyPem = makePubKeyToPem(pubKey); + const pubKeyObject = createPublicKey(pubKeyPem); + return ( + pubKeyObject.asymmetricKeyType === "rsa" && + pubKeyObject.asymmetricKeyDetails?.modulusLength === 4096 + ); +}; + +export const encryptAsymmetric = (data: Buffer, encPubKey: string) => { + return publicEncrypt({ key: makePubKeyToPem(encPubKey), oaepHash: "sha256" }, data); +}; + +export const verifySignature = (data: Buffer, signature: string, sigPubKey: string) => { + return verify( + "rsa-sha256", + data, + { + key: makePubKeyToPem(sigPubKey), + padding: constants.RSA_PKCS1_PSS_PADDING, + }, + Buffer.from(signature, "base64"), + ); +}; + +export const generateChallenge = async (length: number, encPubKey: string) => { + const answer = await promisify(randomBytes)(length); + const challenge = encryptAsymmetric(answer, encPubKey); + return { answer, challenge }; +}; diff --git a/src/lib/server/modules/mek.ts b/src/lib/server/modules/mek.ts new file mode 100644 index 0000000..0019ce0 --- /dev/null +++ b/src/lib/server/modules/mek.ts @@ -0,0 +1,25 @@ +import { error } from "@sveltejs/kit"; +import { getUserClientWithDetails } from "$lib/server/db/client"; +import { getInitialMek } from "$lib/server/db/mek"; +import { verifySignature } from "$lib/server/modules/crypto"; + +export const isInitialMekNeeded = async (userId: number) => { + const initialMek = await getInitialMek(userId); + return !initialMek; +}; + +export const verifyClientEncMekSig = async ( + userId: number, + clientId: number, + version: number, + encMek: string, + encMekSig: string, +) => { + const userClient = await getUserClientWithDetails(userId, clientId); + if (!userClient) { + error(500, "Invalid access token"); + } + + const data = JSON.stringify({ version, key: encMek }); + return verifySignature(Buffer.from(data), encMekSig, userClient.client.sigPubKey); +}; diff --git a/src/lib/server/schemas/auth.ts b/src/lib/server/schemas/auth.ts new file mode 100644 index 0000000..10c8fcc --- /dev/null +++ b/src/lib/server/schemas/auth.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const loginRequest = z.object({ + email: z.string().email().nonempty(), + password: z.string().trim().nonempty(), +}); +export type LoginRequest = z.infer; + +export const tokenUpgradeRequest = z.object({ + encPubKey: z.string().base64().nonempty(), + sigPubKey: z.string().base64().nonempty(), +}); +export type TokenUpgradeRequest = z.infer; + +export const tokenUpgradeResponse = z.object({ + challenge: z.string().base64().nonempty(), +}); +export type TokenUpgradeResponse = z.infer; + +export const tokenUpgradeVerifyRequest = z.object({ + answer: z.string().base64().nonempty(), + answerSig: z.string().base64().nonempty(), +}); +export type TokenUpgradeVerifyRequest = z.infer; diff --git a/src/lib/server/schemas/client.ts b/src/lib/server/schemas/client.ts new file mode 100644 index 0000000..53cbb88 --- /dev/null +++ b/src/lib/server/schemas/client.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const clientListResponse = z.object({ + clients: z.array( + z.object({ + id: z.number().int().positive(), + state: z.enum(["pending", "active"]), + }), + ), +}); +export type ClientListResponse = z.infer; + +export const clientRegisterRequest = z.object({ + encPubKey: z.string().base64().nonempty(), + sigPubKey: z.string().base64().nonempty(), +}); +export type ClientRegisterRequest = z.infer; + +export const clientRegisterResponse = z.object({ + challenge: z.string().base64().nonempty(), +}); +export type ClientRegisterResponse = z.infer; + +export const clientRegisterVerifyRequest = z.object({ + answer: z.string().base64().nonempty(), + answerSig: z.string().base64().nonempty(), +}); +export type ClientRegisterVerifyRequest = z.infer; + +export const clientStatusResponse = z.object({ + id: z.number().int().positive(), + state: z.enum(["pending", "active"]), + isInitialMekNeeded: z.boolean(), +}); +export type ClientStatusResponse = z.infer; diff --git a/src/lib/server/schemas/directory.ts b/src/lib/server/schemas/directory.ts new file mode 100644 index 0000000..eda0ac3 --- /dev/null +++ b/src/lib/server/schemas/directory.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const directoryInfoResponse = z.object({ + metadata: z + .object({ + createdAt: z.string().datetime(), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + dekVersion: z.string().datetime(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), + }) + .optional(), + subDirectories: z.number().int().positive().array(), + files: z.number().int().positive().array(), +}); +export type DirectoryInfoResponse = z.infer; + +export const directoryRenameRequest = z.object({ + dekVersion: z.string().datetime(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type DirectoryRenameRequest = z.infer; + +export const directoryCreateRequest = z.object({ + parentId: z.union([z.enum(["root"]), z.number().int().positive()]), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + dekVersion: z.string().datetime(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type DirectoryCreateRequest = z.infer; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts new file mode 100644 index 0000000..3d3d6f5 --- /dev/null +++ b/src/lib/server/schemas/file.ts @@ -0,0 +1,39 @@ +import mime from "mime"; +import { z } from "zod"; + +export const fileInfoResponse = z.object({ + createdAt: z.string().datetime(), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + dekVersion: z.string().datetime(), + contentType: z + .string() + .nonempty() + .refine((value) => mime.getExtension(value) !== null), // MIME type + contentIv: z.string().base64().nonempty(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type FileInfoResponse = z.infer; + +export const fileRenameRequest = z.object({ + dekVersion: z.string().datetime(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type FileRenameRequest = z.infer; + +export const fileUploadRequest = z.object({ + parentId: z.union([z.enum(["root"]), z.number().int().positive()]), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + dekVersion: z.string().datetime(), + contentType: z + .string() + .nonempty() + .refine((value) => mime.getExtension(value) !== null), // MIME type + contentIv: z.string().base64().nonempty(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type FileUploadRequest = z.infer; diff --git a/src/lib/server/schemas/index.ts b/src/lib/server/schemas/index.ts new file mode 100644 index 0000000..cd2a366 --- /dev/null +++ b/src/lib/server/schemas/index.ts @@ -0,0 +1,5 @@ +export * from "./auth"; +export * from "./client"; +export * from "./directory"; +export * from "./file"; +export * from "./mek"; diff --git a/src/lib/server/schemas/mek.ts b/src/lib/server/schemas/mek.ts new file mode 100644 index 0000000..e79f810 --- /dev/null +++ b/src/lib/server/schemas/mek.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const masterKeyListResponse = z.object({ + meks: z.array( + z.object({ + version: z.number().int().positive(), + state: z.enum(["active", "retired"]), + mek: z.string().base64().nonempty(), + mekSig: z.string().base64().nonempty(), + }), + ), +}); +export type MasterKeyListResponse = z.infer; + +export const initialMasterKeyRegisterRequest = z.object({ + mek: z.string().base64().nonempty(), + mekSig: z.string().base64().nonempty(), +}); +export type InitialMasterKeyRegisterRequest = z.infer; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts new file mode 100644 index 0000000..53c2e51 --- /dev/null +++ b/src/lib/server/services/auth.ts @@ -0,0 +1,164 @@ +import { error } from "@sveltejs/kit"; +import argon2 from "argon2"; +import { v4 as uuidv4 } from "uuid"; +import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client"; +import { getUserByEmail } from "$lib/server/db/user"; +import env from "$lib/server/loadenv"; +import { + getRefreshToken, + registerRefreshToken, + rotateRefreshToken, + upgradeRefreshToken, + revokeRefreshToken, + registerTokenUpgradeChallenge, + getTokenUpgradeChallenge, + markTokenUpgradeChallengeAsUsed, +} from "$lib/server/db/token"; +import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; +import { verifySignature, generateChallenge } from "$lib/server/modules/crypto"; + +const verifyPassword = async (hash: string, password: string) => { + return await argon2.verify(hash, password); +}; + +const issueAccessToken = (userId: number, clientId?: number) => { + return issueToken({ type: "access", userId, clientId }); +}; + +const issueRefreshToken = async (userId: number, clientId?: number) => { + const jti = uuidv4(); + const token = issueToken({ type: "refresh", jti }); + + if (!(await registerRefreshToken(userId, clientId ?? null, jti))) { + error(403, "Already logged in"); + } + return token; +}; + +export const login = async (email: string, password: string) => { + const user = await getUserByEmail(email); + if (!user || !(await verifyPassword(user.password, password))) { + error(401, "Invalid email or password"); + } + + return { + accessToken: issueAccessToken(user.id), + refreshToken: await issueRefreshToken(user.id), + }; +}; + +const verifyRefreshToken = async (refreshToken: string) => { + const tokenPayload = verifyToken(refreshToken); + if (tokenPayload === TokenError.EXPIRED) { + error(401, "Refresh token expired"); + } else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "refresh") { + error(401, "Invalid refresh token"); + } + + const tokenData = await getRefreshToken(tokenPayload.jti); + if (!tokenData) { + error(500, "Refresh token not found"); + } + + return { + jti: tokenPayload.jti, + userId: tokenData.userId, + clientId: tokenData.clientId ?? undefined, + }; +}; + +export const logout = async (refreshToken: string) => { + const { jti } = await verifyRefreshToken(refreshToken); + await revokeRefreshToken(jti); +}; + +export const refreshToken = async (refreshToken: string) => { + const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); + const newJti = uuidv4(); + + if (!(await rotateRefreshToken(oldJti, newJti))) { + error(500, "Refresh token not found"); + } + return { + accessToken: issueAccessToken(userId, clientId), + refreshToken: issueToken({ type: "refresh", jti: newJti }), + }; +}; + +const expiresAt = () => new Date(Date.now() + env.challenge.tokenUpgradeExp); + +const createChallenge = async ( + ip: string, + tokenId: string, + clientId: number, + encPubKey: string, +) => { + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerTokenUpgradeChallenge( + tokenId, + clientId, + answer.toString("base64"), + ip, + expiresAt(), + ); + return challenge.toString("base64"); +}; + +export const createTokenUpgradeChallenge = async ( + refreshToken: string, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + const { jti, userId, clientId } = await verifyRefreshToken(refreshToken); + if (clientId) { + error(403, "Forbidden"); + } + + const client = await getClientByPubKeys(encPubKey, sigPubKey); + const userClient = client ? await getUserClient(userId, client.id) : undefined; + if (!client) { + error(401, "Invalid public key(s)"); + } else if (!userClient || userClient.state === "challenging") { + error(401, "Unregistered client"); + } + + return { challenge: await createChallenge(ip, jti, client.id, encPubKey) }; +}; + +export const upgradeToken = async ( + refreshToken: string, + ip: string, + answer: string, + answerSig: string, +) => { + const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); + if (clientId) { + error(403, "Forbidden"); + } + + const challenge = await getTokenUpgradeChallenge(answer, ip); + if (!challenge) { + error(401, "Invalid challenge answer"); + } else if (challenge.refreshTokenId !== oldJti) { + error(403, "Forbidden"); + } + + const client = await getClient(challenge.clientId); + if (!client) { + error(500, "Invalid challenge answer"); + } else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { + error(401, "Invalid challenge answer signature"); + } + + await markTokenUpgradeChallengeAsUsed(challenge.id); + + const newJti = uuidv4(); + if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) { + error(500, "Refresh token not found"); + } + return { + accessToken: issueAccessToken(userId, client.id), + refreshToken: issueToken({ type: "refresh", jti: newJti }), + }; +}; diff --git a/src/lib/server/services/client.ts b/src/lib/server/services/client.ts new file mode 100644 index 0000000..1f99d3a --- /dev/null +++ b/src/lib/server/services/client.ts @@ -0,0 +1,111 @@ +import { error } from "@sveltejs/kit"; +import { + createClient, + getClient, + getClientByPubKeys, + countClientByPubKey, + createUserClient, + getAllUserClients, + getUserClient, + setUserClientStateToPending, + registerUserClientChallenge, + getUserClientChallenge, + markUserClientChallengeAsUsed, +} from "$lib/server/db/client"; +import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto"; +import { isInitialMekNeeded } from "$lib/server/modules/mek"; +import env from "$lib/server/loadenv"; + +export const getUserClientList = async (userId: number) => { + const userClients = await getAllUserClients(userId); + return { + userClients: userClients.map(({ clientId, state }) => ({ + id: clientId, + state: state as "pending" | "active", + })), + }; +}; + +const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp); + +const createUserClientChallenge = async ( + userId: number, + ip: string, + clientId: number, + encPubKey: string, +) => { + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt()); + return challenge.toString("base64"); +}; + +export const registerUserClient = async ( + userId: number, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + let clientId; + + const client = await getClientByPubKeys(encPubKey, sigPubKey); + if (client) { + const userClient = await getUserClient(userId, client.id); + if (userClient) { + error(409, "Client already registered"); + } + + await createUserClient(userId, client.id); + clientId = client.id; + } else { + if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { + error(400, "Invalid public key(s)"); + } else if (encPubKey === sigPubKey) { + error(400, "Public keys must be different"); + } else if ( + (await countClientByPubKey(encPubKey)) > 0 || + (await countClientByPubKey(sigPubKey)) > 0 + ) { + error(409, "Public key(s) already registered"); + } + + clientId = await createClient(encPubKey, sigPubKey, userId); + } + + return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) }; +}; + +export const getUserClientStatus = async (userId: number, clientId: number) => { + const userClient = await getUserClient(userId, clientId); + if (!userClient) { + error(500, "Invalid access token"); + } + + return { + state: userClient.state as "pending" | "active", + isInitialMekNeeded: await isInitialMekNeeded(userId), + }; +}; + +export const verifyUserClient = async ( + userId: number, + ip: string, + answer: string, + answerSig: string, +) => { + const challenge = await getUserClientChallenge(answer, ip); + if (!challenge) { + error(401, "Invalid challenge answer"); + } else if (challenge.userId !== userId) { + error(403, "Forbidden"); + } + + const client = await getClient(challenge.clientId); + if (!client) { + error(500, "Invalid challenge answer"); + } else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { + error(401, "Invalid challenge answer signature"); + } + + await markUserClientChallengeAsUsed(challenge.id); + await setUserClientStateToPending(userId, challenge.clientId); +}; diff --git a/src/lib/server/services/directory.ts b/src/lib/server/services/directory.ts new file mode 100644 index 0000000..01d39d5 --- /dev/null +++ b/src/lib/server/services/directory.ts @@ -0,0 +1,80 @@ +import { error } from "@sveltejs/kit"; +import { unlink } from "fs/promises"; +import { + getAllDirectoriesByParent, + registerNewDirectory, + getDirectory, + setDirectoryEncName, + unregisterDirectory, + getAllFilesByParent, + type NewDirectoryParams, +} from "$lib/server/db/file"; +import { getActiveMekVersion } from "$lib/server/db/mek"; + +export const deleteDirectory = async (userId: number, directoryId: number) => { + const directory = await getDirectory(userId, directoryId); + if (!directory) { + error(404, "Invalid directory id"); + } + + const filePaths = await unregisterDirectory(userId, directoryId); + filePaths.map((path) => unlink(path)); // Intended +}; + +export const renameDirectory = async ( + userId: number, + directoryId: number, + dekVersion: Date, + newEncName: string, + newEncNameIv: string, +) => { + const directory = await getDirectory(userId, directoryId); + if (!directory) { + error(404, "Invalid directory id"); + } else if (directory.dekVersion.getTime() !== dekVersion.getTime()) { + error(400, "Invalid DEK version"); + } + + if (!(await setDirectoryEncName(userId, directoryId, dekVersion, newEncName, newEncNameIv))) { + error(500, "Invalid directory id or DEK version"); + } +}; + +export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => { + const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined; + if (directory === null) { + error(404, "Invalid directory id"); + } + + const directories = await getAllDirectoriesByParent(userId, directoryId); + const files = await getAllFilesByParent(userId, directoryId); + + return { + metadata: directory && { + createdAt: directory.createdAt, + mekVersion: directory.mekVersion, + encDek: directory.encDek, + dekVersion: directory.dekVersion, + encName: directory.encName, + }, + directories: directories.map(({ id }) => id), + files: files.map(({ id }) => id), + }; +}; + +export const createDirectory = async (params: NewDirectoryParams) => { + const activeMekVersion = await getActiveMekVersion(params.userId); + if (activeMekVersion === null) { + error(500, "Invalid MEK version"); + } else if (activeMekVersion !== params.mekVersion) { + error(400, "Invalid MEK version"); + } + + const oneMinuteAgo = new Date(Date.now() - 60 * 1000); + const oneMinuteLater = new Date(Date.now() + 60 * 1000); + if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) { + error(400, "Invalid DEK version"); + } + + await registerNewDirectory(params); +}; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts new file mode 100644 index 0000000..7bf9b72 --- /dev/null +++ b/src/lib/server/services/file.ts @@ -0,0 +1,144 @@ +import { error } from "@sveltejs/kit"; +import { createReadStream, createWriteStream, ReadStream, WriteStream } from "fs"; +import { mkdir, stat, unlink } from "fs/promises"; +import { dirname } from "path"; +import { v4 as uuidv4 } from "uuid"; +import { + registerNewFile, + getFile, + setFileEncName, + unregisterFile, + type NewFileParams, +} from "$lib/server/db/file"; +import { getActiveMekVersion } from "$lib/server/db/mek"; +import env from "$lib/server/loadenv"; + +export const deleteFile = async (userId: number, fileId: number) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } + + const path = await unregisterFile(userId, fileId); + if (!path) { + error(500, "Invalid file id"); + } + + unlink(path); // Intended +}; + +const convertToReadableStream = (readStream: ReadStream) => { + return new ReadableStream({ + start: (controller) => { + readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer))); + readStream.on("end", () => controller.close()); + readStream.on("error", (e) => controller.error(e)); + }, + cancel: () => { + readStream.destroy(); + }, + }); +}; + +export const getFileStream = async (userId: number, fileId: number) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } + + const { size } = await stat(file.path); + return { + encContentStream: convertToReadableStream(createReadStream(file.path)), + encContentSize: size, + }; +}; + +export const renameFile = async ( + userId: number, + fileId: number, + dekVersion: Date, + newEncName: string, + newEncNameIv: string, +) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } else if (file.dekVersion.getTime() !== dekVersion.getTime()) { + error(400, "Invalid DEK version"); + } + + if (!(await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv))) { + error(500, "Invalid file id or DEK version"); + } +}; + +export const getFileInformation = async (userId: number, fileId: number) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } + + return { + createdAt: file.createdAt, + mekVersion: file.mekVersion, + encDek: file.encDek, + dekVersion: file.dekVersion, + contentType: file.contentType, + encContentIv: file.encContentIv, + encName: file.encName, + }; +}; + +const convertToWritableStream = (writeStream: WriteStream) => { + return new WritableStream({ + write: (chunk) => + new Promise((resolve, reject) => { + writeStream.write(chunk, (e) => { + if (e) { + reject(e); + } else { + resolve(); + } + }); + }), + close: () => new Promise((resolve) => writeStream.end(resolve)), + }); +}; + +const safeUnlink = async (path: string) => { + await unlink(path).catch(console.error); +}; + +export const uploadFile = async ( + params: Omit, + encContentStream: ReadableStream, +) => { + const activeMekVersion = await getActiveMekVersion(params.userId); + if (activeMekVersion === null) { + error(500, "Invalid MEK version"); + } else if (activeMekVersion !== params.mekVersion) { + error(400, "Invalid MEK version"); + } + + const oneMinuteAgo = new Date(Date.now() - 60 * 1000); + const oneMinuteLater = new Date(Date.now() + 60 * 1000); + if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) { + error(400, "Invalid DEK version"); + } + + const path = `${env.libraryPath}/${params.userId}/${uuidv4()}`; + await mkdir(dirname(path), { recursive: true }); + + try { + await encContentStream.pipeTo( + convertToWritableStream(createWriteStream(path, { flags: "wx", mode: 0o600 })), + ); + await registerNewFile({ + ...params, + path, + }); + } catch (e) { + await safeUnlink(path); + throw e; + } +}; diff --git a/src/lib/server/services/mek.ts b/src/lib/server/services/mek.ts new file mode 100644 index 0000000..95caef9 --- /dev/null +++ b/src/lib/server/services/mek.ts @@ -0,0 +1,32 @@ +import { error } from "@sveltejs/kit"; +import { setUserClientStateToActive } from "$lib/server/db/client"; +import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek"; +import { isInitialMekNeeded, verifyClientEncMekSig } from "$lib/server/modules/mek"; + +export const getClientMekList = async (userId: number, clientId: number) => { + const clientMeks = await getAllValidClientMeks(userId, clientId); + return { + encMeks: clientMeks.map((clientMek) => ({ + version: clientMek.master_encryption_key.version, + state: clientMek.master_encryption_key.state as "active" | "retired", + encMek: clientMek.client_master_encryption_key.encMek, + encMekSig: clientMek.client_master_encryption_key.encMekSig, + })), + }; +}; + +export const registerInitialActiveMek = async ( + userId: number, + createdBy: number, + encMek: string, + encMekSig: string, +) => { + if (!(await isInitialMekNeeded(userId))) { + error(409, "Initial MEK already registered"); + } else if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) { + error(400, "Invalid signature"); + } + + await registerInitialMek(userId, createdBy, encMek, encMekSig); + await setUserClientStateToActive(userId, createdBy); +}; diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts new file mode 100644 index 0000000..f784b03 --- /dev/null +++ b/src/lib/services/auth.ts @@ -0,0 +1,37 @@ +import { encodeToBase64, decryptChallenge, signMessage } from "$lib/modules/crypto"; +import type { + TokenUpgradeRequest, + TokenUpgradeResponse, + TokenUpgradeVerifyRequest, +} from "$lib/server/schemas"; + +export const requestTokenUpgrade = async ( + encryptKeyBase64: string, + decryptKey: CryptoKey, + verifyKeyBase64: string, + signKey: CryptoKey, +) => { + let res = await fetch("/api/auth/upgradeToken", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + encPubKey: encryptKeyBase64, + sigPubKey: verifyKeyBase64, + } satisfies TokenUpgradeRequest), + }); + if (!res.ok) return false; + + const { challenge }: TokenUpgradeResponse = await res.json(); + const answer = await decryptChallenge(challenge, decryptKey); + const answerSig = await signMessage(answer, signKey); + + res = await fetch("/api/auth/upgradeToken/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + answer: encodeToBase64(answer), + answerSig: encodeToBase64(answerSig), + } satisfies TokenUpgradeVerifyRequest), + }); + return res.ok; +}; diff --git a/src/lib/services/key.ts b/src/lib/services/key.ts new file mode 100644 index 0000000..79a4390 --- /dev/null +++ b/src/lib/services/key.ts @@ -0,0 +1,70 @@ +import { callGetApi, callPostApi } from "$lib/hooks"; +import { storeMasterKeys } from "$lib/indexedDB"; +import { + encodeToBase64, + decryptChallenge, + signMessage, + unwrapMasterKey, + verifyMasterKeyWrapped, +} from "$lib/modules/crypto"; +import type { + ClientRegisterRequest, + ClientRegisterResponse, + ClientRegisterVerifyRequest, + MasterKeyListResponse, +} from "$lib/server/schemas"; +import { masterKeyStore } from "$lib/stores"; + +export const requestClientRegistration = async ( + encryptKeyBase64: string, + decryptKey: CryptoKey, + verifyKeyBase64: string, + signKey: CryptoKey, +) => { + let res = await callPostApi("/api/client/register", { + encPubKey: encryptKeyBase64, + sigPubKey: verifyKeyBase64, + }); + if (!res.ok) return false; + + const { challenge }: ClientRegisterResponse = await res.json(); + const answer = await decryptChallenge(challenge, decryptKey); + const answerSig = await signMessage(answer, signKey); + + res = await callPostApi("/api/client/register/verify", { + answer: encodeToBase64(answer), + answerSig: encodeToBase64(answerSig), + }); + return res.ok; +}; + +export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { + const res = await callGetApi("/api/mek/list"); + if (!res.ok) return false; + + const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json(); + const masterKeys = await Promise.all( + masterKeysWrapped.map( + async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => { + const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey); + return { + version, + state, + key: masterKey, + isValid: await verifyMasterKeyWrapped( + masterKeyWrapped, + version, + masterKeyWrappedSig, + verifyKey, + ), + }; + }, + ), + ); + if (!masterKeys.every(({ isValid }) => isValid)) return false; + + await storeMasterKeys(masterKeys); + masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]))); + + return true; +}; diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts new file mode 100644 index 0000000..24997da --- /dev/null +++ b/src/lib/stores/file.ts @@ -0,0 +1,31 @@ +import type { Writable } from "svelte/store"; + +export type DirectoryInfo = + | { + id: "root"; + dataKey?: undefined; + dataKeyVersion?: undefined; + name?: undefined; + subDirectoryIds: number[]; + fileIds: number[]; + } + | { + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + name: string; + subDirectoryIds: number[]; + fileIds: number[]; + }; + +export interface FileInfo { + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + contentType: string; + contentIv: string; + name: string; +} + +export const directoryInfoStore = new Map<"root" | number, Writable>(); +export const fileInfoStore = new Map>(); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts new file mode 100644 index 0000000..537209a --- /dev/null +++ b/src/lib/stores/index.ts @@ -0,0 +1,2 @@ +export * from "./file"; +export * from "./key"; diff --git a/src/lib/stores/key.ts b/src/lib/stores/key.ts new file mode 100644 index 0000000..d742634 --- /dev/null +++ b/src/lib/stores/key.ts @@ -0,0 +1,18 @@ +import { writable } from "svelte/store"; + +export interface ClientKeys { + encryptKey: CryptoKey; + decryptKey: CryptoKey; + signKey: CryptoKey; + verifyKey: CryptoKey; +} + +export interface MasterKey { + version: number; + state: "active" | "retired" | "dead"; + key: CryptoKey; +} + +export const clientKeyStore = writable(null); + +export const masterKeyStore = writable | null>(null); diff --git a/src/routes/(fullscreen)/+layout.svelte b/src/routes/(fullscreen)/+layout.svelte new file mode 100644 index 0000000..017f507 --- /dev/null +++ b/src/routes/(fullscreen)/+layout.svelte @@ -0,0 +1,11 @@ + + + +
+ {@render children()} +
+
diff --git a/src/routes/(fullscreen)/auth/login/+page.server.ts b/src/routes/(fullscreen)/auth/login/+page.server.ts new file mode 100644 index 0000000..da7da9c --- /dev/null +++ b/src/routes/(fullscreen)/auth/login/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const redirectPath = url.searchParams.get("redirect") || "/home"; + + const accessToken = cookies.get("accessToken"); + if (accessToken) { + redirect(302, redirectPath); + } + + return { redirectPath }; +}; diff --git a/src/routes/(fullscreen)/auth/login/+page.svelte b/src/routes/(fullscreen)/auth/login/+page.svelte new file mode 100644 index 0000000..1ebb66b --- /dev/null +++ b/src/routes/(fullscreen)/auth/login/+page.svelte @@ -0,0 +1,71 @@ + + + + 로그인 + + + +
+

환영합니다!

+

서비스를 이용하려면 로그인을 해야해요.

+
+
+ + +
+
+ + + 계정이 없어요 + diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts new file mode 100644 index 0000000..4e6145d --- /dev/null +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -0,0 +1,40 @@ +import { exportRSAKeyToBase64 } from "$lib/modules/crypto"; +import type { LoginRequest } from "$lib/server/schemas"; +import { requestTokenUpgrade as requestTokenUpgradeInternal } from "$lib/services/auth"; +import { requestClientRegistration } from "$lib/services/key"; +import type { ClientKeys } from "$lib/stores"; + +export { requestMasterKeyDownload } from "$lib/services/key"; + +export const requestLogin = async (email: string, password: string) => { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password } satisfies LoginRequest), + }); + return res.ok; +}; + +export const requestTokenUpgrade = async ({ + encryptKey, + decryptKey, + signKey, + verifyKey, +}: ClientKeys) => { + const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey); + const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey); + if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { + return true; + } + + if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { + return await requestTokenUpgradeInternal( + encryptKeyBase64, + decryptKey, + verifyKeyBase64, + signKey, + ); + } else { + return false; + } +}; diff --git a/src/routes/(fullscreen)/client/pending/+page.svelte b/src/routes/(fullscreen)/client/pending/+page.svelte new file mode 100644 index 0000000..ebee560 --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/+page.svelte @@ -0,0 +1,61 @@ + + + + 승인을 기다리고 있어요. + + + +
+

승인을 기다리고 있어요.

+

+ 회원님의 다른 디바이스에서 이 디바이스의 데이터 접근을 승인해야 서비스를 이용할 수 있어요. +

+
+
+
+ +

암호 키 지문

+
+

+ {#if !fingerprint} + 지문 생성하는 중... + {:else} + {#await fingerprint} + 지문 생성하는 중... + {:then fingerprint} + {fingerprint} + {/await} + {/if} +

+

+ 암호 키 지문은 디바이스마다 다르게 생성돼요.
+ 지문이 일치하는지 확인 후 승인해 주세요. +

+
+
diff --git a/src/routes/(fullscreen)/client/pending/+page.ts b/src/routes/(fullscreen)/client/pending/+page.ts new file mode 100644 index 0000000..455f322 --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ url }) => { + const redirectPath = url.searchParams.get("redirect") || "/home"; + return { redirectPath }; +}; diff --git a/src/routes/(fullscreen)/client/pending/service.ts b/src/routes/(fullscreen)/client/pending/service.ts new file mode 100644 index 0000000..39a27bc --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/service.ts @@ -0,0 +1,15 @@ +import { concatenateBuffers, exportRSAKey, digestMessage } from "$lib/modules/crypto"; + +export { requestMasterKeyDownload } from "$lib/services/key"; + +export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => { + const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey); + const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey); + const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer)); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toUpperCase() + .match(/.{1,4}/g)! + .join(" "); +}; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte new file mode 100644 index 0000000..642017a --- /dev/null +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -0,0 +1,94 @@ + + + + 파일 + + +
+ +
+ {#snippet viewerLoading(message: string)} +
+

{message}

+
+ {/snippet} + + {#if $info && contentType === "image"} + {#if contentUrl} + {$info.name} + {:else} + {@render viewerLoading("이미지를 불러오고 있어요.")} + {/if} + {:else if contentType === "video"} + {#if contentUrl} + + + {:else} + {@render viewerLoading("비디오를 불러오고 있어요.")} + {/if} + {/if} +
+
diff --git a/src/routes/(fullscreen)/file/[id]/+page.ts b/src/routes/(fullscreen)/file/[id]/+page.ts new file mode 100644 index 0000000..45c696e --- /dev/null +++ b/src/routes/(fullscreen)/file/[id]/+page.ts @@ -0,0 +1,17 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import type { PageLoad } from "./$types"; + +export const ssr = false; // Because of heic2any + +export const load: PageLoad = async ({ params }) => { + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(404, "Not found"); + const { id } = zodRes.data; + + return { id }; +}; diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts new file mode 100644 index 0000000..fc97c3e --- /dev/null +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -0,0 +1,31 @@ +import { decryptData } from "$lib/modules/crypto"; + +export const requestFileDownload = ( + fileId: number, + fileEncryptedIv: string, + dataKey: CryptoKey, +) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "arraybuffer"; + + xhr.addEventListener("load", async () => { + if (xhr.status !== 200) { + reject(new Error("Failed to download file")); + return; + } + + const fileDecrypted = await decryptData( + xhr.response as ArrayBuffer, + fileEncryptedIv, + dataKey, + ); + resolve(fileDecrypted); + }); + + // TODO: Progress, ... + + xhr.open("GET", `/api/file/${fileId}/download`); + xhr.send(); + }); +}; diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte new file mode 100644 index 0000000..a2bca6d --- /dev/null +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -0,0 +1,115 @@ + + + + 암호 키 생성하기 + + + +
+

암호 키를 파일로 내보낼까요?

+
+

+ 모든 디바이스의 암호 키가 유실되면, 서버에 저장된 데이터를 영원히 복호화할 수 없게 돼요. +

+

만약의 상황을 위해 암호 키를 파일로 내보낼 수 있어요.

+
+
+
+ + + { + isBeforeContinueModalOpen = true; + }} + > + 내보내지 않을래요 + + + + + diff --git a/src/routes/(fullscreen)/key/export/+page.ts b/src/routes/(fullscreen)/key/export/+page.ts new file mode 100644 index 0000000..a64ea53 --- /dev/null +++ b/src/routes/(fullscreen)/key/export/+page.ts @@ -0,0 +1,11 @@ +import { error } from "@sveltejs/kit"; +import { keyExportState } from "$lib/hooks/gotoStateful"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async () => { + const state = keyExportState.get(); + if (!state) { + error(403, "Forbidden"); + } + return state; +}; diff --git a/src/routes/(fullscreen)/key/export/BeforeContinueBottomSheet.svelte b/src/routes/(fullscreen)/key/export/BeforeContinueBottomSheet.svelte new file mode 100644 index 0000000..47b6baa --- /dev/null +++ b/src/routes/(fullscreen)/key/export/BeforeContinueBottomSheet.svelte @@ -0,0 +1,31 @@ + + + +
+
+

암호 키 파일을 저장하셨나요?

+

+ 암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요. 파일이 잘 저장되었는지 다시 + 한 번 확인해 주세요. +

+
+ +
+ + +
+
+
+
diff --git a/src/routes/(fullscreen)/key/export/BeforeContinueModal.svelte b/src/routes/(fullscreen)/key/export/BeforeContinueModal.svelte new file mode 100644 index 0000000..7b9d98d --- /dev/null +++ b/src/routes/(fullscreen)/key/export/BeforeContinueModal.svelte @@ -0,0 +1,31 @@ + + + +
+
+

내보내지 않고 계속할까요?

+

암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요.

+
+
+ + +
+
+
diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts new file mode 100644 index 0000000..45de8d9 --- /dev/null +++ b/src/routes/(fullscreen)/key/export/service.ts @@ -0,0 +1,56 @@ +import { callPostApi } from "$lib/hooks"; +import { storeClientKey } from "$lib/indexedDB"; +import { signMasterKeyWrapped } from "$lib/modules/crypto"; +import type { InitialMasterKeyRegisterRequest } from "$lib/server/schemas"; +import type { ClientKeys } from "$lib/stores"; + +export { requestTokenUpgrade } from "$lib/services/auth"; +export { requestClientRegistration } from "$lib/services/key"; + +type SerializedKeyPairs = { + generator: "ArkVault"; + exportedAt: Date; +} & { + version: 1; + encryptKey: string; + decryptKey: string; + signKey: string; + verifyKey: string; +}; + +export const serializeClientKeys = ( + encryptKeyBase64: string, + decryptKeyBase64: string, + signKeyBase64: string, + verifyKeyBase64: string, +) => { + return { + version: 1, + generator: "ArkVault", + exportedAt: new Date(), + encryptKey: encryptKeyBase64, + decryptKey: decryptKeyBase64, + signKey: signKeyBase64, + verifyKey: verifyKeyBase64, + } satisfies SerializedKeyPairs; +}; + +export const storeClientKeys = async (clientKeys: ClientKeys) => { + await Promise.all([ + storeClientKey(clientKeys.encryptKey, "encrypt"), + storeClientKey(clientKeys.decryptKey, "decrypt"), + storeClientKey(clientKeys.signKey, "sign"), + storeClientKey(clientKeys.verifyKey, "verify"), + ]); +}; + +export const requestInitialMasterKeyRegistration = async ( + masterKeyWrapped: string, + signKey: CryptoKey, +) => { + const res = await callPostApi("/api/mek/register/initial", { + mek: masterKeyWrapped, + mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), + }); + return res.ok || res.status === 409; +}; diff --git a/src/routes/(fullscreen)/key/generate/+page.svelte b/src/routes/(fullscreen)/key/generate/+page.svelte new file mode 100644 index 0000000..81c6180 --- /dev/null +++ b/src/routes/(fullscreen)/key/generate/+page.svelte @@ -0,0 +1,79 @@ + + + + 암호 키 생성하기 + + + +
+

암호 키 생성하기

+

회원님의 디바이스 간의 안전한 데이터 동기화를 위해 암호 키를 생성해야 해요.

+
+
+
+ +

왜 암호 키가 필요한가요?

+
+
+ {#each orders as { title, description }, i} + + {/each} +
+
+
+ + + 키를 갖고 있어요 + diff --git a/src/routes/(fullscreen)/key/generate/+page.ts b/src/routes/(fullscreen)/key/generate/+page.ts new file mode 100644 index 0000000..455f322 --- /dev/null +++ b/src/routes/(fullscreen)/key/generate/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ url }) => { + const redirectPath = url.searchParams.get("redirect") || "/home"; + return { redirectPath }; +}; diff --git a/src/routes/(fullscreen)/key/generate/Order.svelte b/src/routes/(fullscreen)/key/generate/Order.svelte new file mode 100644 index 0000000..2cf22d6 --- /dev/null +++ b/src/routes/(fullscreen)/key/generate/Order.svelte @@ -0,0 +1,33 @@ + + +
+
+

+ {order} +

+ {#if !isLast} +
+ {/if} +
+
+

+ {title} +

+

+ {#if description} + {description} + {/if} +

+
+
diff --git a/src/routes/(fullscreen)/key/generate/service.ts b/src/routes/(fullscreen)/key/generate/service.ts new file mode 100644 index 0000000..b63da21 --- /dev/null +++ b/src/routes/(fullscreen)/key/generate/service.ts @@ -0,0 +1,36 @@ +import { + generateEncryptionKeyPair, + generateSigningKeyPair, + exportRSAKeyToBase64, + makeRSAKeyNonextractable, + generateMasterKey, + wrapMasterKey, +} from "$lib/modules/crypto"; +import { clientKeyStore } from "$lib/stores"; + +export const generateClientKeys = async () => { + const { encryptKey, decryptKey } = await generateEncryptionKeyPair(); + const { signKey, verifyKey } = await generateSigningKeyPair(); + + clientKeyStore.set({ + encryptKey, + decryptKey: await makeRSAKeyNonextractable(decryptKey), + signKey: await makeRSAKeyNonextractable(signKey), + verifyKey, + }); + + return { + encryptKey, + encryptKeyBase64: await exportRSAKeyToBase64(encryptKey), + decryptKeyBase64: await exportRSAKeyToBase64(decryptKey), + signKeyBase64: await exportRSAKeyToBase64(signKey), + verifyKeyBase64: await exportRSAKeyToBase64(verifyKey), + }; +}; + +export const generateInitialMasterKey = async (encryptKey: CryptoKey) => { + const { masterKey } = await generateMasterKey(); + return { + masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey), + }; +}; diff --git a/src/routes/(main)/+layout.svelte b/src/routes/(main)/+layout.svelte new file mode 100644 index 0000000..09cbb3c --- /dev/null +++ b/src/routes/(main)/+layout.svelte @@ -0,0 +1,15 @@ + + +
+
+ + {@render children()} + +
+ +
diff --git a/src/routes/(main)/BottomBar.svelte b/src/routes/(main)/BottomBar.svelte new file mode 100644 index 0000000..db42bd2 --- /dev/null +++ b/src/routes/(main)/BottomBar.svelte @@ -0,0 +1,38 @@ + + +
+ +
+ {#each pages as { path, label, icon: Icon }} + {@const textColor = !page.url.pathname.startsWith(path) ? "text-gray-600" : ""} + + {/each} +
+
+
diff --git a/src/routes/(main)/category/+page.svelte b/src/routes/(main)/category/+page.svelte new file mode 100644 index 0000000..73d68b7 --- /dev/null +++ b/src/routes/(main)/category/+page.svelte @@ -0,0 +1,3 @@ +
+

아직 개발 중이에요.

+
diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte new file mode 100644 index 0000000..f7fc3d4 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -0,0 +1,132 @@ + + + + 파일 + + + + +
+ {#if data.id !== "root"} + + {/if} + {#if $info} + {@const topMargin = data.id === "root" ? "mt-4" : ""} +
+ {#key $info} + goto(`/${type}/${id}`)} + onEntryMenuClick={(entry) => { + selectedEntry = entry; + isDirectoryEntryMenuBottomSheetOpen = true; + }} + /> + {/key} +
+ {/if} +
+ + { + isCreateBottomSheetOpen = true; + }} +/> + { + isCreateBottomSheetOpen = false; + isCreateDirectoryModalOpen = true; + }} + onFileUploadClick={() => { + isCreateBottomSheetOpen = false; + fileInput?.click(); + }} +/> + + + { + isDirectoryEntryMenuBottomSheetOpen = false; + isRenameDirectoryEntryModalOpen = true; + }} + onDeleteClick={() => { + isDirectoryEntryMenuBottomSheetOpen = false; + isDeleteDirectoryEntryModalOpen = true; + }} +/> + { + await requestDirectoryEntryRename(selectedEntry!, newName); + info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + }} +/> + { + await requestDirectoryEntryDeletion(selectedEntry!); + info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + }} +/> diff --git a/src/routes/(main)/directory/[[id]]/+page.ts b/src/routes/(main)/directory/[[id]]/+page.ts new file mode 100644 index 0000000..cfa37f8 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/+page.ts @@ -0,0 +1,17 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ params }) => { + const zodRes = z + .object({ + id: z.coerce.number().int().positive().optional(), + }) + .safeParse(params); + if (!zodRes.success) error(404, "Not found"); + const { id } = zodRes.data; + + return { + id: id ? id : ("root" as const), + }; +}; diff --git a/src/routes/(main)/directory/[[id]]/CreateBottomSheet.svelte b/src/routes/(main)/directory/[[id]]/CreateBottomSheet.svelte new file mode 100644 index 0000000..c306c8f --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/CreateBottomSheet.svelte @@ -0,0 +1,32 @@ + + + +
+ +
+ +

폴더 만들기

+
+
+ +
+ +

파일 업로드

+
+
+
+
diff --git a/src/routes/(main)/directory/[[id]]/CreateDirectoryModal.svelte b/src/routes/(main)/directory/[[id]]/CreateDirectoryModal.svelte new file mode 100644 index 0000000..52265fe --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/CreateDirectoryModal.svelte @@ -0,0 +1,30 @@ + + + +

새 폴더

+
+ +
+
+ + +
+
diff --git a/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte b/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte new file mode 100644 index 0000000..07fb6dd --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte @@ -0,0 +1,63 @@ + + + + {#if selectedEntry} + {@const { type, name } = selectedEntry} + {@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name} +
+
+

+ {#if type === "directory"} + '{nameShort}' 폴더를 삭제할까요? + {:else} + '{nameShort}' 파일을 삭제할까요? + {/if} +

+

+ {#if type === "directory"} + 삭제한 폴더는 복구할 수 없어요.
+ 폴더 안의 모든 파일과 폴더도 함께 삭제돼요. + {:else} + 삭제한 파일은 복구할 수 없어요. + {/if} +

+
+
+ + +
+
+ {/if} +
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte new file mode 100644 index 0000000..ea8a984 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -0,0 +1,57 @@ + + +{#if info.subDirectoryIds.length + info.fileIds.length > 0} +
+ {#each subDirectoryInfos as subDirectory} + + {/each} + {#each fileInfos as file} + + {/each} +
+{:else} +
+

폴더가 비어 있어요.

+
+{/if} diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte new file mode 100644 index 0000000..c9ef1d8 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -0,0 +1,63 @@ + + +{#if $info} + + +
+
+
+ +
+

+ {$info.name} +

+ +
+
+{/if} + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte new file mode 100644 index 0000000..3ff6d9a --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte @@ -0,0 +1,65 @@ + + +{#if $info} + + +
+
+
+ +
+

+ {$info.name} +

+ +
+
+{/if} + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts new file mode 100644 index 0000000..72ab278 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts @@ -0,0 +1,2 @@ +export { default } from "./DirectoryEntries.svelte"; +export * from "./service"; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts new file mode 100644 index 0000000..2ad5941 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -0,0 +1,30 @@ +import { get, type Writable } from "svelte/store"; +import type { DirectoryInfo, FileInfo } from "$lib/stores"; + +export enum SortBy { + NAME_ASC, + NAME_DESC, +} + +type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number; + +const sortByNameAsc: SortFunc = (a, b) => { + if (a && b) return a.name!.localeCompare(b.name!); + return 0; +}; + +const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b); + +export const sortEntries = ( + entries: Writable[], + sortBy: SortBy = SortBy.NAME_ASC, +) => { + let sortFunc: SortFunc; + if (sortBy === SortBy.NAME_ASC) { + sortFunc = sortByNameAsc; + } else { + sortFunc = sortByNameDesc; + } + + entries.sort((a, b) => sortFunc(get(a), get(b))); +}; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntryMenuBottomSheet.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntryMenuBottomSheet.svelte new file mode 100644 index 0000000..231acc5 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntryMenuBottomSheet.svelte @@ -0,0 +1,62 @@ + + + +
+ {#if selectedEntry} + {@const { type, name } = selectedEntry} +
+
+ {#if type === "directory"} + + {:else} + + {/if} +
+

+ {name} +

+
+
+ {/if} + +
+ +

이름 바꾸기

+
+
+ +
+ +

삭제하기

+
+
+
+
diff --git a/src/routes/(main)/directory/[[id]]/RenameDirectoryEntryModal.svelte b/src/routes/(main)/directory/[[id]]/RenameDirectoryEntryModal.svelte new file mode 100644 index 0000000..015d157 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/RenameDirectoryEntryModal.svelte @@ -0,0 +1,47 @@ + + + +

이름 바꾸기

+
+ +
+
+ + +
+
diff --git a/src/routes/(main)/directory/[[id]]/service.ts b/src/routes/(main)/directory/[[id]]/service.ts new file mode 100644 index 0000000..3000080 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/service.ts @@ -0,0 +1,101 @@ +import { callPostApi } from "$lib/hooks"; +import { generateDataKey, wrapDataKey, encryptData, encryptString } from "$lib/modules/crypto"; +import type { + DirectoryRenameRequest, + DirectoryCreateRequest, + FileRenameRequest, + FileUploadRequest, +} from "$lib/server/schemas"; +import type { MasterKey } from "$lib/stores"; + +export interface SelectedDirectoryEntry { + type: "directory" | "file"; + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + name: string; +} + +export const requestDirectoryCreation = async ( + name: string, + parentId: "root" | number, + masterKey: MasterKey, +) => { + const { dataKey, dataKeyVersion } = await generateDataKey(); + const nameEncrypted = await encryptString(name, dataKey); + await callPostApi("/api/directory/create", { + parentId, + mekVersion: masterKey.version, + dek: await wrapDataKey(dataKey, masterKey.key), + dekVersion: dataKeyVersion.toISOString(), + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + }); +}; + +export const requestFileUpload = async ( + file: File, + parentId: "root" | number, + masterKey: MasterKey, +) => { + const { dataKey, dataKeyVersion } = await generateDataKey(); + const fileEncrypted = await encryptData(await file.arrayBuffer(), dataKey); + const nameEncrypted = await encryptString(file.name, dataKey); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + parentId, + mekVersion: masterKey.version, + dek: await wrapDataKey(dataKey, masterKey.key), + dekVersion: dataKeyVersion.toISOString(), + contentType: file.type, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + } satisfies FileUploadRequest), + ); + form.set("content", new Blob([fileEncrypted.ciphertext])); + + return new Promise((resolve, reject) => { + // TODO: Progress, Scheduling, ... + + const xhr = new XMLHttpRequest(); + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + resolve(); + } else { + reject(new Error(xhr.responseText)); + } + }); + + xhr.open("POST", "/api/file/upload"); + xhr.send(form); + }); +}; + +export const requestDirectoryEntryRename = async ( + entry: SelectedDirectoryEntry, + newName: string, +) => { + const newNameEncrypted = await encryptString(newName, entry.dataKey); + + if (entry.type === "directory") { + await callPostApi(`/api/directory/${entry.id}/rename`, { + dekVersion: entry.dataKeyVersion.toISOString(), + name: newNameEncrypted.ciphertext, + nameIv: newNameEncrypted.iv, + }); + } else { + await callPostApi(`/api/file/${entry.id}/rename`, { + dekVersion: entry.dataKeyVersion.toISOString(), + name: newNameEncrypted.ciphertext, + nameIv: newNameEncrypted.iv, + }); + } +}; + +export const requestDirectoryEntryDeletion = async (entry: SelectedDirectoryEntry) => { + await callPostApi(`/api/${entry.type}/${entry.id}/delete`); +}; diff --git a/src/routes/(main)/favorite/+page.svelte b/src/routes/(main)/favorite/+page.svelte new file mode 100644 index 0000000..73d68b7 --- /dev/null +++ b/src/routes/(main)/favorite/+page.svelte @@ -0,0 +1,3 @@ +
+

아직 개발 중이에요.

+
diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte new file mode 100644 index 0000000..73d68b7 --- /dev/null +++ b/src/routes/(main)/home/+page.svelte @@ -0,0 +1,3 @@ +
+

아직 개발 중이에요.

+
diff --git a/src/routes/(main)/menu/+page.svelte b/src/routes/(main)/menu/+page.svelte new file mode 100644 index 0000000..73d68b7 --- /dev/null +++ b/src/routes/(main)/menu/+page.svelte @@ -0,0 +1,3 @@ +
+

아직 개발 중이에요.

+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3fa208a..d682821 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,27 @@ {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index cc88df0..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,2 +0,0 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

diff --git a/src/routes/+server.ts b/src/routes/+server.ts new file mode 100644 index 0000000..e02bb98 --- /dev/null +++ b/src/routes/+server.ts @@ -0,0 +1,6 @@ +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = () => { + redirect(302, "/home"); +}; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..479f561 --- /dev/null +++ b/src/routes/api/auth/login/+server.ts @@ -0,0 +1,25 @@ +import { error, text } from "@sveltejs/kit"; +import env from "$lib/server/loadenv"; +import { loginRequest } from "$lib/server/schemas"; +import { login } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const zodRes = loginRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { email, password } = zodRes.data; + + const { accessToken, refreshToken } = await login(email, password); + cookies.set("accessToken", accessToken, { + path: "/", + maxAge: env.jwt.accessExp / 1000, + sameSite: "strict", + }); + cookies.set("refreshToken", refreshToken, { + path: "/api/auth", + maxAge: env.jwt.refreshExp / 1000, + sameSite: "strict", + }); + + return text("Logged in", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..f9f0ea6 --- /dev/null +++ b/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,14 @@ +import { error, text } from "@sveltejs/kit"; +import { logout } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + await logout(token); + cookies.delete("accessToken", { path: "/" }); + cookies.delete("refreshToken", { path: "/api/auth" }); + + return text("Logged out", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/auth/refreshToken/+server.ts b/src/routes/api/auth/refreshToken/+server.ts new file mode 100644 index 0000000..374fd8c --- /dev/null +++ b/src/routes/api/auth/refreshToken/+server.ts @@ -0,0 +1,23 @@ +import { error, text } from "@sveltejs/kit"; +import env from "$lib/server/loadenv"; +import { refreshToken as doRefreshToken } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + const { accessToken, refreshToken } = await doRefreshToken(token); + cookies.set("accessToken", accessToken, { + path: "/", + maxAge: env.jwt.accessExp / 1000, + sameSite: "strict", + }); + cookies.set("refreshToken", refreshToken, { + path: "/api/auth", + maxAge: env.jwt.refreshExp / 1000, + sameSite: "strict", + }); + + return text("Token refreshed", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/auth/upgradeToken/+server.ts b/src/routes/api/auth/upgradeToken/+server.ts new file mode 100644 index 0000000..cb09582 --- /dev/null +++ b/src/routes/api/auth/upgradeToken/+server.ts @@ -0,0 +1,25 @@ +import { error, json } from "@sveltejs/kit"; +import { + tokenUpgradeRequest, + tokenUpgradeResponse, + type TokenUpgradeResponse, +} from "$lib/server/schemas"; +import { createTokenUpgradeChallenge } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + const zodRes = tokenUpgradeRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { encPubKey, sigPubKey } = zodRes.data; + + const { challenge } = await createTokenUpgradeChallenge( + token, + getClientAddress(), + encPubKey, + sigPubKey, + ); + return json(tokenUpgradeResponse.parse({ challenge } satisfies TokenUpgradeResponse)); +}; diff --git a/src/routes/api/auth/upgradeToken/verify/+server.ts b/src/routes/api/auth/upgradeToken/verify/+server.ts new file mode 100644 index 0000000..eb78286 --- /dev/null +++ b/src/routes/api/auth/upgradeToken/verify/+server.ts @@ -0,0 +1,33 @@ +import { error, text } from "@sveltejs/kit"; +import env from "$lib/server/loadenv"; +import { tokenUpgradeVerifyRequest } from "$lib/server/schemas"; +import { upgradeToken } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + const zodRes = tokenUpgradeVerifyRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { answer, answerSig } = zodRes.data; + + const { accessToken, refreshToken } = await upgradeToken( + token, + getClientAddress(), + answer, + answerSig, + ); + cookies.set("accessToken", accessToken, { + path: "/", + maxAge: env.jwt.accessExp / 1000, + sameSite: "strict", + }); + cookies.set("refreshToken", refreshToken, { + path: "/api/auth", + maxAge: env.jwt.refreshExp / 1000, + sameSite: "strict", + }); + + return text("Token upgraded", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/client/list/+server.ts b/src/routes/api/client/list/+server.ts new file mode 100644 index 0000000..5354ece --- /dev/null +++ b/src/routes/api/client/list/+server.ts @@ -0,0 +1,15 @@ +import { error, json } from "@sveltejs/kit"; +import { authenticate } from "$lib/server/modules/auth"; +import { clientListResponse, type ClientListResponse } from "$lib/server/schemas"; +import { getUserClientList } from "$lib/server/services/client"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies }) => { + const { userId, clientId } = authenticate(cookies); + if (!clientId) { + error(403, "Forbidden"); + } + + const { userClients } = await getUserClientList(userId); + return json(clientListResponse.parse({ clients: userClients } satisfies ClientListResponse)); +}; diff --git a/src/routes/api/client/register/+server.ts b/src/routes/api/client/register/+server.ts new file mode 100644 index 0000000..0a6e5a0 --- /dev/null +++ b/src/routes/api/client/register/+server.ts @@ -0,0 +1,23 @@ +import { error, json } from "@sveltejs/kit"; +import { authenticate } from "$lib/server/modules/auth"; +import { + clientRegisterRequest, + clientRegisterResponse, + type ClientRegisterResponse, +} from "$lib/server/schemas"; +import { registerUserClient } from "$lib/server/services/client"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const { userId, clientId } = authenticate(cookies); + if (clientId) { + error(403, "Forbidden"); + } + + const zodRes = clientRegisterRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { encPubKey, sigPubKey } = zodRes.data; + + const { challenge } = await registerUserClient(userId, getClientAddress(), encPubKey, sigPubKey); + return json(clientRegisterResponse.parse({ challenge } satisfies ClientRegisterResponse)); +}; diff --git a/src/routes/api/client/register/verify/+server.ts b/src/routes/api/client/register/verify/+server.ts new file mode 100644 index 0000000..e48b454 --- /dev/null +++ b/src/routes/api/client/register/verify/+server.ts @@ -0,0 +1,19 @@ +import { error, text } from "@sveltejs/kit"; +import { authenticate } from "$lib/server/modules/auth"; +import { clientRegisterVerifyRequest } from "$lib/server/schemas"; +import { verifyUserClient } from "$lib/server/services/client"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const { userId, clientId } = authenticate(cookies); + if (clientId) { + error(403, "Forbidden"); + } + + const zodRes = clientRegisterVerifyRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { answer, answerSig } = zodRes.data; + + await verifyUserClient(userId, getClientAddress(), answer, answerSig); + return text("Client verified", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/client/status/+server.ts b/src/routes/api/client/status/+server.ts new file mode 100644 index 0000000..a1e9cd8 --- /dev/null +++ b/src/routes/api/client/status/+server.ts @@ -0,0 +1,21 @@ +import { error, json } from "@sveltejs/kit"; +import { authenticate } from "$lib/server/modules/auth"; +import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas"; +import { getUserClientStatus } from "$lib/server/services/client"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies }) => { + const { userId, clientId } = authenticate(cookies); + if (!clientId) { + error(403, "Forbidden"); + } + + const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId); + return json( + clientStatusResponse.parse({ + id: clientId, + state, + isInitialMekNeeded, + } satisfies ClientStatusResponse), + ); +}; diff --git a/src/routes/api/directory/[id]/+server.ts b/src/routes/api/directory/[id]/+server.ts new file mode 100644 index 0000000..7cd1d09 --- /dev/null +++ b/src/routes/api/directory/[id]/+server.ts @@ -0,0 +1,34 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { directoryInfoResponse, type DirectoryInfoResponse } from "$lib/server/schemas"; +import { getDirectoryInformation } from "$lib/server/services/directory"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.union([z.enum(["root"]), z.coerce.number().int().positive()]), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { metadata, directories, files } = await getDirectoryInformation(userId, id); + return json( + directoryInfoResponse.parse({ + metadata: metadata && { + createdAt: metadata.createdAt.toISOString(), + mekVersion: metadata.mekVersion, + dek: metadata.encDek, + dekVersion: metadata.dekVersion.toISOString(), + name: metadata.encName.ciphertext, + nameIv: metadata.encName.iv, + }, + subDirectories: directories, + files, + } satisfies DirectoryInfoResponse), + ); +}; diff --git a/src/routes/api/directory/[id]/delete/+server.ts b/src/routes/api/directory/[id]/delete/+server.ts new file mode 100644 index 0000000..c7777df --- /dev/null +++ b/src/routes/api/directory/[id]/delete/+server.ts @@ -0,0 +1,20 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { deleteDirectory } from "$lib/server/services/directory"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + await deleteDirectory(userId, id); + return text("Directory deleted", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/directory/[id]/rename/+server.ts b/src/routes/api/directory/[id]/rename/+server.ts new file mode 100644 index 0000000..c951a9c --- /dev/null +++ b/src/routes/api/directory/[id]/rename/+server.ts @@ -0,0 +1,25 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { directoryRenameRequest } from "$lib/server/schemas"; +import { renameDirectory } from "$lib/server/services/directory"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const paramsZodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!paramsZodRes.success) error(400, "Invalid path parameters"); + const { id } = paramsZodRes.data; + + const bodyZodRes = directoryRenameRequest.safeParse(await request.json()); + if (!bodyZodRes.success) error(400, "Invalid request body"); + const { dekVersion, name, nameIv } = bodyZodRes.data; + + await renameDirectory(userId, id, new Date(dekVersion), name, nameIv); + return text("Directory renamed", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/directory/create/+server.ts b/src/routes/api/directory/create/+server.ts new file mode 100644 index 0000000..b31d15f --- /dev/null +++ b/src/routes/api/directory/create/+server.ts @@ -0,0 +1,24 @@ +import { error, text } from "@sveltejs/kit"; +import { authorize } from "$lib/server/modules/auth"; +import { directoryCreateRequest } from "$lib/server/schemas"; +import { createDirectory } from "$lib/server/services/directory"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = directoryCreateRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { parentId, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data; + + await createDirectory({ + userId, + parentId, + mekVersion, + encDek: dek, + dekVersion: new Date(dekVersion), + encName: name, + encNameIv: nameIv, + }); + return text("Directory created", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/file/[id]/+server.ts b/src/routes/api/file/[id]/+server.ts new file mode 100644 index 0000000..ceb8a0f --- /dev/null +++ b/src/routes/api/file/[id]/+server.ts @@ -0,0 +1,33 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileInfoResponse, type FileInfoResponse } from "$lib/server/schemas"; +import { getFileInformation } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { createdAt, mekVersion, encDek, dekVersion, contentType, encContentIv, encName } = + await getFileInformation(userId, id); + return json( + fileInfoResponse.parse({ + createdAt: createdAt.toISOString(), + mekVersion, + dek: encDek, + dekVersion: dekVersion.toISOString(), + contentType: contentType, + contentIv: encContentIv, + name: encName.ciphertext, + nameIv: encName.iv, + } satisfies FileInfoResponse), + ); +}; diff --git a/src/routes/api/file/[id]/delete/+server.ts b/src/routes/api/file/[id]/delete/+server.ts new file mode 100644 index 0000000..4cbf733 --- /dev/null +++ b/src/routes/api/file/[id]/delete/+server.ts @@ -0,0 +1,20 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { deleteFile } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + await deleteFile(userId, id); + return text("File deleted", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/file/[id]/download/+server.ts b/src/routes/api/file/[id]/download/+server.ts new file mode 100644 index 0000000..42b832f --- /dev/null +++ b/src/routes/api/file/[id]/download/+server.ts @@ -0,0 +1,25 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { getFileStream } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { encContentStream, encContentSize } = await getFileStream(userId, id); + return new Response(encContentStream, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": encContentSize.toString(), + }, + }); +}; diff --git a/src/routes/api/file/[id]/rename/+server.ts b/src/routes/api/file/[id]/rename/+server.ts new file mode 100644 index 0000000..46fd4b3 --- /dev/null +++ b/src/routes/api/file/[id]/rename/+server.ts @@ -0,0 +1,25 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileRenameRequest } from "$lib/server/schemas"; +import { renameFile } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const paramsZodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!paramsZodRes.success) error(400, "Invalid path parameters"); + const { id } = paramsZodRes.data; + + const bodyZodRes = fileRenameRequest.safeParse(await request.json()); + if (!bodyZodRes.success) error(400, "Invalid request body"); + const { dekVersion, name, nameIv } = bodyZodRes.data; + + await renameFile(userId, id, new Date(dekVersion), name, nameIv); + return text("File renamed", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/file/upload/+server.ts b/src/routes/api/file/upload/+server.ts new file mode 100644 index 0000000..2de4c9a --- /dev/null +++ b/src/routes/api/file/upload/+server.ts @@ -0,0 +1,37 @@ +import { error, text } from "@sveltejs/kit"; +import { authorize } from "$lib/server/modules/auth"; +import { fileUploadRequest } from "$lib/server/schemas"; +import { uploadFile } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const form = await request.formData(); + const metadata = form.get("metadata"); + const content = form.get("content"); + if (typeof metadata !== "string" || !(content instanceof File)) { + error(400, "Invalid request body"); + } + + const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata)); + if (!zodRes.success) error(400, "Invalid request body"); + const { parentId, mekVersion, dek, dekVersion, contentType, contentIv, name, nameIv } = + zodRes.data; + + await uploadFile( + { + userId, + parentId, + mekVersion, + encDek: dek, + dekVersion: new Date(dekVersion), + contentType, + encContentIv: contentIv, + encName: name, + encNameIv: nameIv, + }, + content.stream(), + ); + return text("File uploaded", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/mek/list/+server.ts b/src/routes/api/mek/list/+server.ts new file mode 100644 index 0000000..ccb9fa3 --- /dev/null +++ b/src/routes/api/mek/list/+server.ts @@ -0,0 +1,20 @@ +import { json } from "@sveltejs/kit"; +import { authorize } from "$lib/server/modules/auth"; +import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/schemas"; +import { getClientMekList } from "$lib/server/services/mek"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies }) => { + const { userId, clientId } = await authorize(cookies, "activeClient"); + const { encMeks } = await getClientMekList(userId, clientId); + return json( + masterKeyListResponse.parse({ + meks: encMeks.map(({ version, state, encMek, encMekSig }) => ({ + version, + state, + mek: encMek, + mekSig: encMekSig, + })), + } satisfies MasterKeyListResponse), + ); +}; diff --git a/src/routes/api/mek/register/initial/+server.ts b/src/routes/api/mek/register/initial/+server.ts new file mode 100644 index 0000000..ba959fb --- /dev/null +++ b/src/routes/api/mek/register/initial/+server.ts @@ -0,0 +1,19 @@ +import { error, text } from "@sveltejs/kit"; +import { authenticate } from "$lib/server/modules/auth"; +import { initialMasterKeyRegisterRequest } from "$lib/server/schemas"; +import { registerInitialActiveMek } from "$lib/server/services/mek"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const { userId, clientId } = authenticate(cookies); + if (!clientId) { + error(403, "Forbidden"); + } + + const zodRes = initialMasterKeyRegisterRequest.safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { mek, mekSig } = zodRes.data; + + await registerInitialActiveMek(userId, clientId, mek, mekSig); + return text("MEK registered", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index 9d75afa..e8c5803 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -4,7 +4,53 @@ export default { content: ["./src/**/*.{html,js,svelte,ts}"], theme: { - extend: {}, + extend: { + colors: { + primary: { + "000": "#FFF5F5", + 100: "#FFE3E3", + 200: "#FFC9C9", + 300: "#FFA8A8", + 400: "#FF8787", + 500: "#FF6B6B", + 600: "#FA5252", + 700: "#F03E3E", + 800: "#E03131", + 900: "#C92A2A", + }, + gray: { + "000": "#F8F9FA", + 100: "#F1F3F5", + 200: "#E9ECEF", + 300: "#DEE2E6", + 400: "#CED4DA", + 500: "#ADB5BD", + 600: "#868E96", + 700: "#495057", + 800: "#343A40", + 900: "#212529", + }, + }, + fontFamily: { + sans: [ + '"Pretendard Variable"', + "Pretendard", + "-apple-system", + "BlinkMacSystemFont", + "system-ui", + "Roboto", + '"Helvetica Neue"', + '"Segoe UI"', + '"Apple SD Gothic Neo"', + '"Noto Sans KR"', + '"Malgun Gothic"', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + "sans-serif", + ], + }, + }, }, plugins: [], diff --git a/tsconfig.json b/tsconfig.json index f4d0a0e..33a6652 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, + "noUncheckedIndexedAccess": true, "moduleResolution": "bundler" } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias diff --git a/vite.config.ts b/vite.config.ts index 80864b9..1e576b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,12 @@ import { sveltekit } from "@sveltejs/kit/vite"; +import Icons from "unplugin-icons/vite"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [sveltekit()], + plugins: [ + sveltekit(), + Icons({ + compiler: "svelte", + }), + ], });