diff --git a/.gitignore b/.gitignore
index 20ff2b2d..4a818859 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
-dev.conf
+_site/
target/
-
-# ensime
-.ensime_cache/
-.ensime
+local/
+elm-stuff/
+result
\ No newline at end of file
diff --git a/.projectile b/.projectile
new file mode 100644
index 00000000..ae3a0324
--- /dev/null
+++ b/.projectile
@@ -0,0 +1,3 @@
+!/local
+!/local/dev.conf
+!/local/testing.org
diff --git a/.scalafmt.conf b/.scalafmt.conf
new file mode 100644
index 00000000..96ae4980
--- /dev/null
+++ b/.scalafmt.conf
@@ -0,0 +1,15 @@
+version = "2.2.0"
+
+align = most
+#align.arrowEnumeratorGenerator = true
+
+maxColumn = 100
+
+rewrite.rules = [
+ AvoidInfix
+ RedundantBraces
+ RedundantParens
+ AsciiSortImports
+ PreferCurlyFors
+ SortModifiers
+]
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 496fc3c3..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# must use non-containerized build, because elm-compiler is too slow
-# otherwise and using the trick with sysconfcpu breaks sbt
-sudo: true
-language: scala
-scala:
- - 2.12.3
-
-jdk:
- - openjdk8
-
-cache:
- directories:
- - $HOME/.ivy2/cache
- - $HOME/.sbt/boot
- - sysconfcpus
-
-install:
- - nvm install node
- - nvm use node
- - node --version
- - npm --version
- - npm install -g elm@0.18.0
-
-before_script:
- - export TZ=Europe/Berlin
-
-script:
- - sbt ++$TRAVIS_SCALA_VERSION ";run-all-tests ;make"
diff --git a/LICENSE.txt b/LICENSE.txt
deleted file mode 100644
index 94a9ed02..00000000
--- a/LICENSE.txt
+++ /dev/null
@@ -1,674 +0,0 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/README.md b/README.md
index dfb12725..15e65d89 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,14 @@
-sharry
-======
+# Sharry
Sharry allows to share files with others in a simple way. It is a
self-hosted web application. The basic concept is: upload files and get
a url back that can then be shared.
-
-How it works
-------------
+## How it works
-### Authenticated users -> others
+### Authenticated users → others
Authenticated users can upload their files on a web site together with
an optional password and a time period. The time period defines how long
@@ -20,7 +17,7 @@ can be shared, e.g. via email.
The download page is hard to guess, but open to everyone.
-### Others -> Authenticated users
+### Others → Authenticated users
Anonymous can send files to registered ones. Each registered user can
maintain alias pages. An alias page is behind a “hard-to-guess” URL
@@ -29,207 +26,12 @@ corresponding user. The form does not allow to specify a password or
validation period, but a description can be given. The user belonging to
the alias can be notified via email.
-### Others -> Others
+## Documentation
-If authentication is enabled, it is not possible to share files between
-non-registered users. One party must be registered. But authentication
-can be completely disabled. Then any user can upload files. This may be
-useful within a closed network.
+Please see the [documentation site](https://eikek.github.io/sharry).
-Upload and Download
--------------------
-Sharry aims to provide a good support for large files. That means
-downloads and uploads are resumable. Large files can be downloaded via
-[byte serving](https://en.wikipedia.org/wiki/Byte_serving), which allows
-for example to watch video files directly in the browser. Uploads are
-resumable, too, by using
-[resumable.js](https://github.com/23/resumable.js) on the client.
-Uploads can be retried where only chunks not already at the server are
-transferred.
+## License
-Each published upload has a validity period, after which the public
-download page doesn't work anymore. A cleanup job running periodically
-can delete those files to save space.
-
-Features
---------
-
-- resumable and recoverable upload of multiple files; thanks to
- [resumable.js](https://github.com/23/resumable.js)
-- validation period for uploads
-- resumable downloads using [byte
- serving](https://en.wikipedia.org/wiki/Byte_serving)
-- download single files or all in a zip
-- protect downloads with a password
-- automatic removal of invalid uploads
-- external authentication (via system command or http requests)
-- managing accounts, uploads and alias pages
-- a command line client for uploading files
-
-Try it
-------
-
-~~There is a demo installation at . You can
-use the account `sharry` and no password to log in. The mail feature is
-not enabled and uploads are restricted to 1.5M.~~ Sorry, I had to shut down this service.
-
-Or, clone this project and use sbt (see below for prerequisites) to
-compile and run:
-
-``` shell
-sbt run-sharry
-```
-
-This will build the project and start the server. Point your browser to
- and login with user `admin` and password
-`admin`.
-
-Or, download a binary from the [release
-page](https://github.com/eikek/sharry/releases).
-
-Documentation
--------------
-
-These pages are shown in each sharry instance, for example
-[here](https://sharrydemo.eknet.org/#manual/index.md). The documentation
-to the command line client is included.
-
-Building
---------
-
-For the server, you need Java8, [sbt](http://scala-sbt.org) and
-[Elm](http://elm-lang.org/) installed first. Then clone the project and
-run:
-
-``` shell
-sbt make
-```
-
-This creates a file in `modules/server/target/scala-2.12` named
-`sharry-server-*.jar.sh`. This is an executable jar file and can be used
-to run sharry:
-
-The `--console` argument allows to terminate the server from the
-terminal (otherwise it's `Ctrl-C`). By default a
-[H2](http://h2database.com) database is configured in the current
-working directory.
-
-``` shell
-$ ./modules/server/target/scala-2.12/sharry-server-0.0.1-SNAPSHOT.jar.sh --console
-2017-05-08T14:53:07.345+0200 INFO [main] sharry.server.main$ [main.scala:36]
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- Sharry 0.0.1-SNAPSHOT (build 2017-05-08 12:49:58UTC) is starting up …
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
-2017-05-08T14:53:08.563+0200 INFO [main] sharry.server.main$ [main.scala:42]
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- • Running initialize tasks …
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
-2017-05-08T14:53:08.622+0200 INFO [main] com.zaxxer.hikari.HikariDataSource [HikariDataSource.java:93] HikariPool-1 - Started.
-2017-05-08T14:53:09.272+0200 INFO [main] sharry.server.main$ [main.scala:62]
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- • Starting http server at 0.0.0.0:9090
-––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
-Hit RETURN to stop the server
-```
-
-The command also builds the command line client. It can be found at
-`modules/cli/target/scala-2.12` named `sharry-cli-*-.jar.sh`.
-
-Building only the command line client doesn't require Elm and can be
-built separately with `sbt make-cli`.
-
-Dependencies
-------------
-
-The server and cli part is written in [Scala](http://scala-lang.or) and
-uses the following great libraries:
-
-- [fs2](https://github.com/functional-streams-for-scala/fs2) all the
- way
-- [fs2-http](https://github.com/Spinoco/fs2-http) for the http stack
-- [doobie](https://github.com/tpolecat/doobie) for db access (which
- uses fs2, too)
-- [circe](https://github.com/circe/circe) great library for json
-- [pureconfig](https://github.com/pureconfig/pureconfig) is reading
- the configuration file using
- [config](https://github.com/typesafehub/config) library
-- …
-
-See all of them in the [libs](./project/libs.scala) file.
-
-The frontend is written in [Elm](http://elm-lang.org/). Two libraries
-aside from `elm-lang/*` are used:
-
-- [evancz/elm-markdown](https://github.com/evancz/elm-markdown)
- rendering markdown
-- [NoRedInk/elm-decode-pipeline](https://github.com/NoRedInk/elm-decode-pipeline)
- decoding json
-
-Non-elm components:
-
-- [semantic-ui](https://semantic-ui.com) for a well looking web
- interface
-- [resumable.js](https://github.com/23/resumable.js) for handling
- uploads at the client
-
-Configuring (server)
---------------------
-
-Sharry reads a configuration file that can be given as an argument to
-the executable. Please see the
-[default](./modules/server/src/main/resources/reference.conf)
-configuration for all available options and their default values. It
-also contains hopefully helpful comments.
-
-For more detailed information on its syntax, please refer to the
-[specification](https://github.com/typesafehub/config/blob/master/HOCON.md)
-and documentation of [config
-library](https://github.com/typesafehub/config).
-
-The important settings are
-
-- `sharry.web.bindHost` and `sharry.web.bindPort` the host and port
- for binding the http server
-- `sharry.web.baseurl` this must be set to the external base url. So
- if the app is at , then it should be set to
- this value. It is used to restrict the authentication cookie and to
- create links in the web application.
-- `sharry.db.driver|user|url|password` the JDBC settings; currently it
- should work with postgres and h2
-- `sharry.upload.max-file-size` maximum file size to upload
-- `sharry.authc.enable=true|false` whether to enable authentication
- (default is `true`)
-- `sharry.authc.extern.admin.enable=true|false` enables an admin
- account for initial login (password is `admin`), default is `false`
-
-Every setting can also be given as a Java system property by adding it
-to the environment variable `SHARRY_JAVA_OPTS` (`-D` prefix is required
-here):
-
-``` shell
-SHARRY_JAVA_OPTS="-Dsharry.authc.enable=false" ./sharry-server-0.0.1-SNAPSHOT.jar.sh
-```
-
-This overrides same settings in the configuration file.
-
-### Reverse Proxy
-
-When running behind a reverse proxy, it is importand to use HTTP 1.1.
-For example, a minimal nginx config would look like this:
-
-``` conf
-server {
- listen 0.0.0.0:80;
-
- proxy_request_buffering off;
- proxy_buffering off;
-
- location / {
- proxy_pass http://127.0.0.1:9090;
- # this is important, because fs2-http can only do 1.1
- # and it effectively disables request_buffering
- proxy_http_version 1.1;
- }
-}
-```
+This project is distributed under the
+[GPLv3+](https://spdx.org/licenses/GPL-3.0-or-later.html)
diff --git a/_config.yml b/_config.yml
deleted file mode 100644
index 277f1f2c..00000000
--- a/_config.yml
+++ /dev/null
@@ -1 +0,0 @@
-theme: jekyll-theme-cayman
diff --git a/modules/webapp/src/main/html/icon.svg b/artwork/icon.svg
similarity index 100%
rename from modules/webapp/src/main/html/icon.svg
rename to artwork/icon.svg
diff --git a/artwork/icon_small.svg b/artwork/icon_small.svg
new file mode 100644
index 00000000..491090b0
--- /dev/null
+++ b/artwork/icon_small.svg
@@ -0,0 +1,143 @@
+
+
+
+
diff --git a/modules/webapp/src/main/html/logo.png b/artwork/logo.png
similarity index 100%
rename from modules/webapp/src/main/html/logo.png
rename to artwork/logo.png
diff --git a/modules/webapp/src/main/html/logo.svg b/artwork/logo.svg
similarity index 100%
rename from modules/webapp/src/main/html/logo.svg
rename to artwork/logo.svg
diff --git a/build.nix b/build.nix
deleted file mode 100644
index 80fa4011..00000000
--- a/build.nix
+++ /dev/null
@@ -1,23 +0,0 @@
-with import { };
-let
- nixpkgs1803dist = builtins.fetchTarball {
- url = "https://github.com/NixOS/nixpkgs/archive/18.03.tar.gz";
- sha256 = "0hk4y2vkgm1qadpsm4b0q1vxq889jhxzjx3ragybrlwwg54mzp4f";
- };
- pkgs1803 = import nixpkgs1803dist {};
- initScript = writeScript "sharry-build-init" ''
- export LD_LIBRARY_PATH=
- ${bash}/bin/bash -c sbt
- '';
-in
-buildFHSUserEnv {
- name = "sharry-sbt";
- targetPkgs = pkgs: with pkgs; [
- netcat jdk8 wget which zsh dpkg sbt git pkgs1803.elmPackages.elm ncurses fakeroot mc jekyll
- # haskells http client needs this (to download elm packages)
- iana-etc
- ];
- runScript = ''
- ${initScript}
- '';
-}
diff --git a/build.sbt b/build.sbt
index d8f457a9..82f865be 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,237 +1,326 @@
-import libs._
-import Path.relativeTo
-import java.nio.file.{Files, StandardCopyOption}
-import org.apache.tika.Tika
+import com.github.eikek.sbt.openapi._
+import scala.sys.process._
import com.typesafe.sbt.SbtGit.GitKeys._
-lazy val sharedSettings = Seq(
- name := "sharry",
- scalaVersion := `scala-version`,
+val sharedSettings = Seq(
+ organization := "com.github.eikek",
+ scalaVersion := "2.13.1",
scalacOptions ++= Seq(
- "-encoding", "UTF-8",
- "-Xfatal-warnings", // fail when there are warnings
"-deprecation",
+ "-encoding", "UTF-8",
+ "-language:higherKinds",
+ "-language:postfixOps",
"-feature",
+ "-Xfatal-warnings", // fail when there are warnings
"-unchecked",
- "-language:higherKinds",
"-Xlint",
- "-Yno-adapted-args",
"-Ywarn-dead-code",
"-Ywarn-numeric-widen",
- "-Ywarn-unused-import"
+ "-Ywarn-value-discard"
),
- scalacOptions in (Compile, console) ~= (_ filterNot (Set("-Xfatal-warnings", "-Ywarn-unused-import").contains)),
- scalacOptions in (Test) := (scalacOptions in (Compile, console)).value
+ scalacOptions in (Compile, console) := Seq()
+)
+
+val testSettings = Seq(
+ testFrameworks += new TestFramework("minitest.runner.Framework"),
+ libraryDependencies ++= Dependencies.miniTest
+)
+
+val elmSettings = Seq(
+ Compile/resourceGenerators += (Def.task {
+ compileElm(streams.value.log
+ , (Compile/baseDirectory).value
+ , (Compile/resourceManaged).value
+ , name.value
+ , version.value)
+ }).taskValue,
+ watchSources += Watched.WatchSource(
+ (Compile/sourceDirectory).value/"elm"
+ , FileFilter.globFilter("*.elm")
+ , HiddenFileFilter
+ )
+)
+
+val webjarSettings = Seq(
+ Compile/resourceGenerators += (Def.task {
+ copyWebjarResources(Seq((sourceDirectory in Compile).value/"webjar")
+ , (Compile/resourceManaged).value
+ , name.value
+ , version.value
+ , streams.value.log
+ )
+ }).taskValue,
+ watchSources += Watched.WatchSource(
+ (Compile / sourceDirectory).value/"webjar"
+ , FileFilter.globFilter("*.js") || FileFilter.globFilter("*.css")
+ , HiddenFileFilter
+ )
+)
+
+val debianSettings = Seq(
+ maintainer := "Eike Kettner ",
+ packageSummary := description.value,
+ packageDescription := description.value,
+ mappings in Universal += {
+ val conf = (Compile / resourceDirectory).value / "reference.conf"
+ if (!conf.exists) {
+ sys.error(s"File $conf not found")
+ }
+ conf -> "conf/sharry.conf"
+ },
+ bashScriptExtraDefines += """addJava "-Dconfig.file=${app_home}/../conf/sharry.conf""""
+)
+
+val buildInfoSettings = Seq(
+ buildInfoKeys := Seq[BuildInfoKey](name
+ , version
+ , scalaVersion
+ , sbtVersion
+ , gitHeadCommit
+ , gitHeadCommitDate
+ , gitUncommittedChanges
+ , gitDescribedVersion),
+ buildInfoOptions += BuildInfoOption.ToJson,
+ buildInfoOptions += BuildInfoOption.BuildTime
)
-lazy val coreDeps = Seq(`cats-core`, `fs2-core`, `fs2-io`, log4s, `scodec-bits`)
-lazy val testDeps = Seq(scalatest, `logback-classic`).map(_ % "test")
-lazy val common = project.in(file("modules/common")).
- enablePlugins(BuildInfoPlugin).
- disablePlugins(AssemblyPlugin).
+
+val common = project.in(file("modules/common")).
settings(sharedSettings).
- settings(Seq(
+ settings(testSettings).
+ settings(
name := "sharry-common",
- description := "Some common utility code",
- libraryDependencies ++= coreDeps ++ testDeps,
- libraryDependencies ++= Seq(`circe-core`, `circe-generic`, `circe-parser`, `scala-bcrypt`),
- buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, gitHeadCommit, gitHeadCommitDate, gitUncommittedChanges, gitDescribedVersion),
- buildInfoPackage := "sharry.common",
- buildInfoOptions += BuildInfoOption.ToJson,
- buildInfoOptions += BuildInfoOption.BuildTime
- ))
-
-lazy val mdutil = project.in(file("modules/mdutil")).
+ libraryDependencies ++=
+ Dependencies.loggingApi ++
+ Dependencies.fs2 ++
+ Dependencies.fs2io ++
+ Dependencies.circe ++
+ Dependencies.pureconfig
+ )
+
+val store = project.in(file("modules/store")).
settings(sharedSettings).
+ settings(testSettings).
settings(
- name := "sharry-mdutil",
- description := "Markdown utility for sharry based on flexmark-java",
- libraryDependencies ++= testDeps ++ coreDeps ++ Seq(
- `flexmark-core`, `flexmark-gfm-tables`, `flexmark-gfm-strikethrough`,
- `flexmark-formatter`, jsoup
- ))
-
-lazy val store = project.in(file("modules/store")).
- disablePlugins(AssemblyPlugin).
- settings(sharedSettings).
- settings(Seq(
name := "sharry-store",
- description := "Storage for files and account data",
- libraryDependencies ++= testDeps ++ coreDeps ++ Seq(
- `doobie-core`, `bitpeace-core`, h2, postgres, tika, `scodec-bits`
- ))).
- dependsOn(common % "compile->compile;test->test")
-
-
-// resumable.js is too old as webjar, so download it from github
-lazy val fetchResumableJs = Def.task {
- val dir = (target in Compile).value
- val url = new java.net.URL("https://raw.githubusercontent.com/23/resumable.js/feb33c8f8d5d614d3d476fc2b3e82372c7b6408a/resumable.js")
- val outFile = dir / "resumable.js"
- val logger = streams.value.log
- if (!outFile.exists) {
- logger.info(s"Downloading $url -> ${outFile.getName} …")
- val conn = url.openConnection()
- conn.connect()
- val inStream = conn.getInputStream
- IO.createDirectories(Seq(outFile.getParentFile))
- Files.copy(inStream, outFile.toPath, StandardCopyOption.REPLACE_EXISTING)
- inStream.close
- }
-
- Seq(outFile -> outFile.getName)
-}
+ libraryDependencies ++=
+ Dependencies.doobie ++
+ Dependencies.bitpeace ++
+ Dependencies.tika ++
+ Dependencies.fs2 ++
+ Dependencies.databases ++
+ Dependencies.flyway ++
+ Dependencies.loggingApi
+ ).
+ dependsOn(common)
-lazy val webapp = project.in(file("modules/webapp")).
- enablePlugins(WebjarPlugin, ElmPlugin).
- disablePlugins(AssemblyPlugin).
+val restapi = project.in(file("modules/restapi")).
+ enablePlugins(OpenApiSchema).
settings(sharedSettings).
- settings(Seq(
- name := "sharry-webapp",
- description := "A web frontend for sharry",
- libraryDependencies ++= testDeps ++ coreDeps ++ Seq(
- `semantic-ui`, jquery, highlightjs, `logback-classic`, yamusca,
- `fs2-http`
- ),
- // elm stuff
- elmVersion := "0.18.0 <= v < 0.19.0",
- elmDependencies in Compile ++= Seq(
- "elm-lang/core" -> "5.0.0 <= v < 6.0.0",
- "elm-lang/html" -> "2.0.0 <= v < 3.0.0",
- "elm-lang/http" -> "1.0.0 <= v < 2.0.0",
- "elm-lang/animation-frame" -> "1.0.0 <= v < 2.0.0",
- "elm-lang/navigation" -> "2.0.0 <= v < 3.0.0",
- "evancz/elm-markdown" -> "3.0.0 <= v < 4.0.0",
- "NoRedInk/elm-decode-pipeline" -> "3.0.0 <= v < 4.0.0"
- ),
- elmDependencies in Test ++= Seq(
- "elm-community/elm-test" -> "4.0.0 <= v < 5.0.0"
- ),
- // webjar stuff
- resourceGenerators in Compile += (elmMake in Compile).taskValue,
- webjarPackage in (Compile, webjarSource) := "sharry.webapp.route",
- sourceGenerators in Compile += (webjarSource in Compile).taskValue,
- resourceGenerators in Compile += (webjarContents in Compile).taskValue,
- webjarWebPackages in Compile += Def.task({
- val elmFiles = (elmMake in Compile).value pair relativeTo((elmMakeOutputPath in Compile).value)
- val src = (sourceDirectory in Compile).value
- val htmlFiles = (src/"html" ** "*").get.filter(_.isFile).toSeq pair relativeTo(src/"html")
- val cssFiles = IO.listFiles(src/"css").toSeq pair relativeTo(src/"css")
- val jsFiles = IO.listFiles(src/"js").toSeq pair relativeTo(src/"js")
- val resumable = fetchResumableJs.value
- WebPackage("org.webjars", name.value, version.value, elmFiles ++ htmlFiles ++ cssFiles ++ jsFiles ++ resumable)
- }).taskValue,
- resourceGenerators in Compile += (webjarWebPackageResources in Compile).taskValue)).
+ settings(testSettings).
+ settings(
+ name := "sharry-restapi",
+ libraryDependencies ++=
+ Dependencies.circe,
+ openapiTargetLanguage := Language.Scala,
+ openapiPackage := Pkg("sharry.restapi.model"),
+ openapiSpec := (Compile/resourceDirectory).value/"sharry-openapi.yml",
+ openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto).
+ addMapping(CustomMapping.forType({
+ case TypeDef("LocalDateTime", _) =>
+ TypeDef("Timestamp", Imports("sharry.common.Timestamp"))
+ })).
+ addMapping(CustomMapping.forFormatType({
+ case "ident" => field =>
+ field.copy(typeDef = TypeDef("Ident", Imports("sharry.common.Ident")))
+ case "accountstate" => field =>
+ field.copy(typeDef = TypeDef("AccountState", Imports("sharry.common.AccountState")))
+ case "accountsource" => field =>
+ field.copy(typeDef = TypeDef("AccountSource", Imports("sharry.common.AccountSource")))
+ case "password" => field =>
+ field.copy(typeDef = TypeDef("Password", Imports("sharry.common.Password")))
+ case "signupmode" => field =>
+ field.copy(typeDef = TypeDef("SignupMode", Imports("sharry.common.SignupMode")))
+ case "uri" => field =>
+ field.copy(typeDef = TypeDef("LenientUri", Imports("sharry.common.LenientUri")))
+ case "duration" => field =>
+ field.copy(typeDef = TypeDef("Duration", Imports("sharry.common.Duration")))
+ case "size" => field =>
+ field.copy(typeDef = TypeDef("ByteSize", Imports("sharry.common.ByteSize")))
+ }))).
dependsOn(common)
-lazy val docs = project.in(file("modules/docs")).
+val backend = project.in(file("modules/backend")).
settings(sharedSettings).
+ settings(testSettings).
settings(
- name := "sharry-docs",
- libraryDependencies ++= coreDeps ++ Seq(yamusca, `fs2-http`),
- sourceGenerators in Compile += (Def.task {
- val docdir = (baseDirectory in LocalRootProject).value/"docs"
- val tika = new Tika()
- val list = sbt.Path.allSubpaths(docdir).toList.map {
- case (file, path) =>
- val checksum = Hash.toHex(Hash(file))
- (path, checksum, tika.detect(file), file.length)
- }
+ name := "sharry-backend",
+ libraryDependencies ++=
+ Dependencies.loggingApi ++
+ Dependencies.fs2 ++
+ Dependencies.bcrypt ++
+ Dependencies.yamusca ++
+ Dependencies.emil
+ ).dependsOn(common, store)
- val code = s"""package sharry.docs.md
- |object toc extends TocAccess {
- | val contents: List[(String, String, String, Long)] = ${list.map(t => "(\""+t._1+"\",\""+ t._2+"\", \""+t._3+"\", "+t._4+")")}
- |}""".stripMargin
+val webapp = project.in(file("modules/webapp")).
+ enablePlugins(OpenApiSchema).
+ settings(sharedSettings).
+ settings(elmSettings).
+ settings(webjarSettings).
+ settings(
+ name := "sharry-webapp",
+ openapiTargetLanguage := Language.Elm,
+ openapiPackage := Pkg("Api.Model"),
+ openapiSpec := (restapi/Compile/resourceDirectory).value/"sharry-openapi.yml",
+ openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline)
+ )
- val tocFile = (sourceManaged in Compile).value/"toc.scala"
- IO.write(tocFile, code)
- Seq(tocFile)
+val restserver = project.in(file("modules/restserver")).
+ enablePlugins(BuildInfoPlugin
+ , JavaServerAppPackaging
+ , DebianPlugin
+ , SystemdPlugin).
+ settings(sharedSettings).
+ settings(testSettings).
+ settings(debianSettings).
+ settings(buildInfoSettings).
+ settings(
+ name := "sharry-restserver",
+ libraryDependencies ++=
+ Dependencies.http4s ++
+ Dependencies.http4sclient ++
+ Dependencies.circe ++
+ Dependencies.pureconfig ++
+ Dependencies.yamusca ++
+ Dependencies.webjars ++
+ Dependencies.loggingApi ++
+ Dependencies.logging,
+ addCompilerPlugin(Dependencies.kindProjectorPlugin),
+ addCompilerPlugin(Dependencies.betterMonadicFor),
+ buildInfoPackage := "sharry.restserver",
+ javaOptions in reStart ++=
+ Seq(s"-Dconfig.file=${(LocalRootProject/baseDirectory).value/"local"/"dev.conf"}",
+ "-Dsharry.migrate-old-dbschema=false",
+ "-Xmx512M"),
+ Compile/resourceGenerators += Def.task {
+ copyWebjarResources(Seq((restapi/Compile/resourceDirectory).value/"sharry-openapi.yml")
+ , (Compile/resourceManaged).value
+ , name.value
+ , version.value
+ , streams.value.log)
+ }.taskValue,
+ Compile/sourceGenerators += (Def.task {
+ createWebjarSource(Dependencies.webjars, (Compile/sourceManaged).value)
}).taskValue,
- resourceGenerators in Compile += (Def.task {
- val docdir = (baseDirectory in LocalRootProject).value/"docs"
- val target = (resourceManaged in Compile).value/"sharry"/"docs"/"md"
- sbt.Path.allSubpaths(docdir).toSeq.map {
- case (file, path) =>
- val targetFile = target/path
- IO.copy(Seq((file, targetFile)))
- targetFile
- }
- }).taskValue
- ).
- dependsOn(mdutil)
+ Compile/unmanagedResourceDirectories ++= Seq((Compile/resourceDirectory).value.getParentFile/"templates")
+ ).dependsOn(restapi, backend, webapp)
-lazy val server = project.in(file("modules/server")).
+lazy val microsite = project.in(file("modules/microsite")).
+ enablePlugins(MicrositesPlugin).
+ disablePlugins(ReleasePlugin).
settings(sharedSettings).
settings(
- name := "sharry-server",
- description := "The sharry application as a rest server",
- libraryDependencies ++= testDeps ++ coreDeps ++ Seq(
- `logback-classic`, pureconfig, `scala-bcrypt`, `fs2-http`,
- `doobie-hikari`, `javax-mail`, `javax-mail-api`, dnsjava, yamusca
- ),
- assemblyJarName in assembly := s"sharry-server-${version.value}.jar.sh",
- assemblyOption in assembly := (assemblyOption in assembly).value.copy(
- prependShellScript = Some(
- Seq("#!/usr/bin/env sh", """exec java -jar -XX:+UseG1GC $SHARRY_JAVA_OPTS "$0" "$@"""" + "\n")
- )
+ name := "sharry-microsite",
+ publishArtifact := false,
+ skip in publish := true,
+ micrositeFooterText := Some(
+ """
+ |
\n")
- }
-
- "mapLinks" should "rewrite markdown links" in {
- val test = """|# title1
- |this is an inline [link](./local.txt).""".stripMargin
-
- val doc = Document.parse(test).mapLinks(_ => Link("http://google.com"))
- doc.renderMd should be (
- """|# title1
- |
- |this is an inline [link](http://google.com).
- |""".stripMargin
- )
- }
-
- it should "not change on identity" in {
- val test = """|# title 1
- |
- |- [git](http://git-scm.org)
- |
- |text
- |""".stripMargin
-
- val doc = Document.parse(test).mapLinks(identity _)
- doc.renderMd should be (test)
- }
-
- it should "create a new document" in {
- val test = """|# title1
- |
- |this is an inline [link](./local.txt).
- |""".stripMargin
-
- val doc1 = Document.parse(test)
- val doc2 = doc1.mapLinks(_ => Link("bla"))
- doc1.renderMd should be (test)
- doc2.renderMd should be (test.replace("./local.txt", "bla"))
- }
-
- it should "rewrite html links" in {
- val testMd = """# title1
- |
- |and some text with bla
aha
- |
- |and images &
- |
- |
- |
- |end.""".stripMargin
- val doc = Document.parse(testMd).mapLinks(l => Link("http://google.com"))
- // indentation is not preserved, which is ok
- doc.renderMd should be ( """# title1
- |
- |and some text with bla
aha
- |
- |and images &
- |
- |
- |
- |end.
- |""".stripMargin)
- }
-}
diff --git a/modules/microsite/docs/doc/configure.md b/modules/microsite/docs/doc/configure.md
new file mode 100644
index 00000000..c7c2a664
--- /dev/null
+++ b/modules/microsite/docs/doc/configure.md
@@ -0,0 +1,407 @@
+---
+layout: docs
+title: Configuring
+permalink: doc/configure
+---
+
+# {{ page.title }}
+
+Sharry's executable can take one argument – a configuration file. If
+that is not given, the defaults are used. The config file overrides
+default values, so only values that differ from the defaults are
+necessary to specify.
+
+
+## File Format
+
+The format of the configuration files can be
+[HOCON](https://github.com/lightbend/config/blob/master/HOCON.md#hocon-human-optimized-config-object-notation),
+JSON or whatever the used [config
+library](https://github.com/lightbend/config) understands. The default
+values below are in HOCON format, which is recommended, since it
+allows comments and has some [advanced
+features](https://github.com/lightbend/config/blob/master/README.md#features-of-hocon). Please
+refer to their documentation for more on this.
+
+
+## Important Config Options
+
+The configuration for the REST server is below `sharry.restserver`.
+
+### JDBC
+
+This configures the connection to the database. By default, a H2
+database in the current `/tmp` directory is configured. This will
+create the database on demand in this directory.
+
+The config looks like this:
+
+```
+sharry.restserver.backend.jdbc {
+ url = ...
+ user = ...
+ password = ...
+}
+```
+
+The `url` is the connection to the database. It must start with
+`jdbc`, followed by name of the database. The rest is specific to the
+database used: it is either a path to a file for H2 or a host/database
+url for MariaDB and PostgreSQL.
+
+When using H2, the user is `sa`, the password can be empty and the url
+must include these options:
+
+```
+;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
+```
+
+#### Examples
+
+PostgreSQL:
+```
+url = "jdbc:postgresql://localhost:5432/sharrydb"
+```
+
+MariaDB:
+```
+url = "jdbc:mariadb://localhost:3306/sharrydb"
+```
+
+H2
+```
+url = "jdbc:h2:///path/to/a/file.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"
+```
+
+### Bind
+
+The host and port the http server binds to.
+
+```
+sharry.restsserver.bind {
+ address = localhost
+ port = 9090
+}
+```
+
+By default, it binds to `localhost` and some predefined port.
+
+### baseurl
+
+The base url is an important setting that defines the http URL where
+sharry can be reached. The REST server uses this url to create
+absolute urls and to configure the authenication cookie. These URLs
+are sent to the client, so they must resolve back to the sharry
+server. If you see "network error" error messages in the browser, then
+this setting is probably not correct.
+
+By default it is build using the information from the `bind` setting.
+
+
+```
+sharry.restserver.baseurl = ...
+```
+
+#### Examples
+
+```
+sharry.restserver.baseurl = "https://sharry.example.com"
+```
+
+
+### registration options
+
+This defines if and how new users can create accounts. There are 3
+options:
+
+- *closed* no new user can sign up
+- *open* new users can sign up
+- *invite* new users can sign up but require an invitation key
+
+
+```
+sharry.restserver.signup {
+ mode = "open"
+
+ # If mode == 'invite', a password must be provided to generate
+ # invitation keys. It must not be empty.
+ new-invite-password = ""
+
+ # If mode == 'invite', this is the period an invitation token is
+ # considered valid.
+ invite-time = "3 days"
+}
+```
+
+The mode `invite` is intended to open the application only to some
+users. An admin user can create invitation keys and distribute them to
+the desired people. While the user must be admin, it is also necessary
+to provide the `new-invite-password`. The idea is that only the person
+who installs sharry knows this. If it is not set (must be non-empty),
+then invitation won't work. New invitation keys can be generated from
+within the web application or via REST calls (using `curl`, for
+example).
+
+```
+curl -X POST -H 'Sharry-Auth: {{apikey}}' -d '{"password":"blabla"}' "http://localhost:7880/api/v1/open/signup/newinvite"
+```
+
+## Authentication
+
+The initial authentication will generate an authentication token which
+is valid for a some time. Subsequent calls to secured routes can use
+this token. The token can be given as a normal http header or via a
+cookie header.
+
+The following options configure this token:
+
+```
+sharry.restserver.auth {
+ server-secret = "hex:caffee" # or "b64:Y2FmZmVlCg=="
+ session-valid = "5 minutes"
+}
+```
+
+The `server-secret` is used to sign the token. If multiple REST
+servers are deployed, all must share the same server secret. Otherwise
+tokens from one instance are not valid on another instance. The secret
+can be given as Base64 encoded string or in hex form. Use the prefix
+`hex:` and `b64:`, respectively.
+
+The `session-valid` deterimens how long a token is valid. This can be
+just some minutes, the web application obtains new ones
+periodically. So a short time is recommended.
+
+
+### Login Modules
+
+Login modules are used to initially authenticate a user given some
+credentials. There are some modules that take a username/password pair
+and hand it to an external service or program for verification. If
+valid, sharry creates an account transparently. Then there is the
+`oauth` setting which supports authentication via OAuth using “OAuth
+Code Flow”.
+
+All login modules can be enabled/disabled and have an `order` property
+that defines the order the login modules are tried. The modules are
+tried in the specified order until one gives a response.
+
+#### Fixed
+
+This is a simple login module for bootstrapping. It defines an admin
+account using the supplied username and password (plain text) from the
+config file.
+
+```
+fixed {
+ enabled = false
+ user = "admin"
+ password = "admin"
+ order = 10
+}
+```
+
+It is disabled by default. If the given username doesn't match the
+configured username this login module is skipped and the next is
+tried.
+
+#### Http
+
+The http login module issues a http request with the username/password
+pair as payload. The response status code determines valid
+authentication.
+
+```
+http {
+ enabled = false
+ url = "{% raw %}http://localhost:1234/auth?user={{user}}&password={{pass}}{% endraw %}"
+ method = "POST"
+ body = ""
+ content-type = ""
+ order = 20
+}
+```
+
+If the method is `POST`, the body is sent as specified using the given
+content type. The body and url are processed before as mustache
+templates, where `{% raw %}{{user}}{% endraw %}` and `{% raw
+%}{{pass}}{% endraw %}` are replaced by their actual values. For other
+requests than `POST`, the body is ignored.
+
+
+#### Http Basic
+
+The http-basic login module issues a http request with an
+`Authorization` header against some configured url. The header uses
+the [Basic](https://en.wikipedia.org/wiki/Basic_access_authentication)
+scheme to transport the username/password pair.
+
+```
+http-basic {
+ enabled = false
+ url = "http://somehost:2345/path"
+ method = "GET"
+ order = 30
+}
+```
+
+If the response is successful (in `2xx`), the user is authenticated.
+
+
+#### Command
+
+Allows to validate a username/password pair using some external system
+command. This is the most flexible approach.
+
+```
+command {
+ enabled = false
+ program = [
+ "/path/to/someprogram"
+ "{% raw %}{{login}}{% endraw %}"
+ "{% raw %}{{pass}}{% endraw %}"
+ ]
+ # the return code to consider successful verification
+ success = 0
+ order = 30
+}
+```
+
+The return code of the command is used to determine valid
+authentication. The `program` value is an array where the first item
+is the path to the program and subsequent elements define its
+arguments.
+
+All arguments are processed as a mustache template and variables `{%
+raw %}{{user}}{% endraw %}` and `{% raw %}{{pass}}{% endraw %}` are
+replaced by their actual values.
+
+
+#### Internal
+
+The internal login module simply authenticates against the sharry
+database. If it is disabled, you should disable signup, too, because those
+user won't be authenticated.
+
+
+#### OAuth
+
+There is now an option to authenticate using a external provider
+supporting the OAuth “code flow”. There are two examples in the config
+file for Github and Google. I tried to generalise it as much as
+possible, but (it seems to me) OAuth is not really a protocol, every
+provider may choose to do it little differently.
+
+The `oauth` login module can be configured with multiple such
+providers. Here is an example:
+
+```
+oauth = [
+ {
+ enabled = false
+ id = "github"
+ name = "Github"
+ icon = "github"
+ authorize-url = "https://github.com/login/oauth/authorize"
+ token-url = "https://github.com/login/oauth/access_token"
+ user-url = "https://api.github.com/user"
+ user-id-key = "login"
+ client-id = ""
+ client-secret = ""
+ }
+]
+```
+
+Each such entry in the array results in a button on the login screen.
+
+
+
+Here is how it roughly works: If a user clicks this button, it reaches
+a specific url in sharry. Sharry will read the corresponding config
+entry and redirect to the provider adding all the necessary details.
+The user then authenticates at the provider, which redirects back to
+sharry – so this method only works if sharry is publicly available,
+obviously. Then sharry does one more request to turn the code from the
+redirect into a different code. And then it tries to get the account
+name.
+
+Let's go through the config values of one entry:
+
+- `enabled`: allows to disable this entry without removing it from the
+ file.
+- `id`: the id that is used in the url behind the button on the login
+ screen. It is also used to amend the account name.
+- `name`: The name rendered as button text.
+- `icon`: a [semantic-ui icon
+ name](https://semantic-ui.com/elements/icon.html) for the button
+- `authorize-url` this is the URL of the provider where sharry
+ redirects to at first, attaching `client_id` and the redirect uri
+ back to sharry.
+- `token-url`: The url to the provdier where the response from the
+ `authorize-url` can be turned into a token.
+- `user-url`: The url to the provider that retrieves the user
+ information given a token as obtained from `token-url`.
+- `user-id-key`: Now it get's a bit hairy…. The protocol doesn't
+ define (afaik) a common way how to exchange user data. So google
+ does it different from github. Sharry supports JSON responses only
+ and uses the value of `user-id-key` to lookup a value in that
+ response structure. For example, the github response is a simple
+ JSON object, where the login name is at field `login`. The path must
+ evaluate to a string. This value is used for the new account inside
+ sharry.
+- `client-id` and `client-secret` These are provider specific values
+ that you need to obtain there. With github, for example, you
+ register a new "app" which generates these values.
+
+Once sharry gets the account name, it creates a new account (if it not
+exists already) using the account name from the provider amended with
+`@`.
+
+I only tested this with github and google, I would appreciate any
+information on how it works with other providers.
+
+
+## Default Config
+
+
+```
+{% include server.conf %}
+```
+
+## Logging
+
+By default, sharry logs to stdout. This works well, when managed by
+systemd or other inits. Logging is done by
+[logback](https://logback.qos.ch/). Please refer to its documentation
+for how to configure logging.
+
+If you created your logback config file, it can be added as argument
+to the executable using this syntax:
+
+```
+/path/to/sharry -Dlogback.configurationFile=/path/to/your/logging-config-file
+```
+
+To get started, the default config looks like this:
+
+``` xml
+
+
+ true
+
+
+ [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n
+
+
+
+
+
+
+
+
+```
+
+The `` means, that only log statements with level
+"INFO" will be printed. But the `` above says, that for loggers with name "sharry"
+statements from level "DEBUG" on will be printed, too.
diff --git a/modules/microsite/docs/doc/dev.md b/modules/microsite/docs/doc/dev.md
new file mode 100644
index 00000000..6d5aeb5d
--- /dev/null
+++ b/modules/microsite/docs/doc/dev.md
@@ -0,0 +1,66 @@
+---
+layout: docs
+title: Development
+permalink: doc/dev
+---
+
+# {{ page.title }}
+
+## Building
+
+[Sbt](https://scala-sbt.org) is used to build the application. Clone
+the sources and run:
+
+- `make` to compile all sources (Elm + Scala)
+- `restserver/universal:packageBin` to create zip packages
+- `restserver/debian:packageBin` to create debian packages
+
+The zip files can be found afterwards in:
+
+```
+modules/restserver/target/universal
+```
+
+
+## Starting Servers with `reStart`
+
+When developing, it's very convenient to use the [revolver sbt
+plugin](https://github.com/spray/sbt-revolver). Start the sbt console
+and then run:
+
+```
+sbt:sharry-root> restserver/reStart
+```
+
+This starts a REST server. Prefixing the commads with `~`, results in
+recompile+restart once a source file is modified.
+
+Note that with current sbt the revolver plugin will not restart the
+server if elm files are changed. But this is not really necessary:
+just run a second sbt shell with `~ compile` and sbt will *compile*
+all elm files on change and the final js file is immediately
+available. Only a browser refresh is necessary to load the new web
+app.
+
+## Custom config file
+
+The sbt build is setup such that a file `local/dev.conf` (from the
+root of the source tree) is picked up as config file, if it exists. So
+you can create a custom config file for development. For example, a
+custom database for development may be setup this way:
+
+```
+#jdbcurl = "jdbc:h2:///home/dev/workspace/projects/sharry/local/sharry-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"
+#jdbcurl = "jdbc:mariadb://localhost:3306/sharrydev"
+jdbcurl = "jdbc:postgresql://localhost:5432/sharrydev"
+
+sharry.restserver {
+ backend {
+ jdbc {
+ url = ${jdbcurl}
+ user = "dev"
+ password = "dev"
+ }
+ }
+}
+```
diff --git a/modules/microsite/docs/doc/index.md b/modules/microsite/docs/doc/index.md
new file mode 100644
index 00000000..4da20349
--- /dev/null
+++ b/modules/microsite/docs/doc/index.md
@@ -0,0 +1,68 @@
+---
+layout: docs
+title: Documentation
+---
+
+# Sharry
+
+Sharry allows to share files with others in a simple way. It is a
+self-hosted web application. The basic concept is: upload files and get
+a url back that can then be shared.
+
+
+
+## How it works
+
+### Authenticated users → others
+
+Authenticated users can upload their files on a web site together with
+an optional password and a time period. The time period defines how long
+the file is available for download. Then a public URL is generated that
+can be shared, e.g. via email, with everyone.
+
+While the download page is hard to guess, everyone who knows it can
+access the files.
+
+### Others → Authenticated users
+
+Anonymous can send files to registered users. Each registered user can
+maintain *alias pages*. An alias page is also behind a “hard-to-guess”
+URL (just like the download page) and allows everyone to upload files
+to the corresponding user. The user belonging to the alias can be
+notified via email.
+
+## Features
+
+- *Both ways:* Receive and send files to/from anonymous users.
+- *Integration:* Sharry aims to be easy to integrate in other
+ environments.
+ - Authentication: There are many ways to authenticate users from
+ different sources and/or use internal user management.
+ - [REST Api](rest) exposing all the features, making it available for
+ scripts.
+- *Reliable up- and downloads*
+ - Uploads: While the server accepts standard multipart requests, it
+ also supports the [tus protocol](https://tus.io) allowing for
+ resumable uploads. In case network goes down in the middle of
+ uploading a large file, simply upload the same file again and it
+ will start where it left off.
+ - Downloads: Using ETag and [range
+ requests](https://en.wikipedia.org/wiki/Byte_serving) allows the
+ clients (the browser, mostly) to cache files and to download only
+ portions of files. This makes it possible to efficiently view
+ videos in the browser (being able to click into the timeline).
+- *Web client* for managing and accessing shares.
+- *Signup* Let all users create new accounts, only invited ones or none.
+- *Restrict public download pages* using three properties: a lifetime, a
+ password (acting as a second secret) and download-limit.
+- *Periodic cleanup* will remove expired shares
+- *Send E-Mails* from within Sharry (if configured)
+- *DBMS* Data is stored in a relational database, supporting
+ [PostgreSQL](https://postgresql.org), [MariaDB](https://mariadb.org)
+ and [H2](https://h2database.com) (not using a separate database
+ server).
+
+## License
+
+This project is distributed under the
+[GPLv3+](https://spdx.org/licenses/GPL-3.0-or-later.html)
diff --git a/modules/microsite/docs/doc/install.md b/modules/microsite/docs/doc/install.md
new file mode 100644
index 00000000..93bca3d6
--- /dev/null
+++ b/modules/microsite/docs/doc/install.md
@@ -0,0 +1,150 @@
+---
+layout: docs
+title: Installation
+permalink: doc/install
+---
+
+# {{ page.title }}
+
+This page contains detailed installation instructions. For a quick
+start, refer to [this page](quickstart).
+
+Sharry is a *REST Server* that also provides the web application. The
+web application runs in the browser and talks to the server using the
+[REST Api](rest).
+
+The [download page](https://github.com/eikek/sharry/releases)
+provides pre-compiled packages and the [development page](dev.html)
+contains build instructions.
+
+
+## Prerequisites
+
+### Java
+
+Very often, Java is already installed. You can check this by opening a
+terminal and typing `java -version`. Otherwise install Java using your
+package manager or see [this site](https://adoptopenjdk.net/) for
+other options.
+
+It is enough to install the JRE. The JDK is required, if you want to
+build sharry from source.
+
+Sharry has been tested with Java version 1.8 (or sometimes referred
+to as JRE 8 and JDK 8, respectively). The pre-build packages are also
+build using JDK 8. But a later version of Java should work as well.
+
+
+## Database
+
+Sharry stores all its information (files, accounts etc) in a database.
+The following products are supported:
+
+- PostreSQL
+- MariaDB
+- H2
+
+The H2 database is an interesting option for personal and mid-size
+setups, as it requires no additional work (i.e. no separate db
+server). It is integrated into sharry and works really well. It is
+also configured as the default database.
+
+For large installations, PostgreSQL or MariaDB is recommended. Create
+a database and a user with enough privileges (read, write, create
+table) to that database.
+
+When using H2, make sure to add the options
+`;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE` at the end of the url. See
+the [default config](configure.html) for an example.
+
+
+## Installing from ZIP files
+
+After extracting the zip files, you'll find a start script in the
+`bin/` folder.
+
+
+## Installing from DEB packages
+
+The DEB packages can be installed on Debian, or Debian based Distros:
+
+``` bash
+$ sudo dpkg -i sharry*.deb
+```
+
+Then the start scripts are in your `$PATH`. Run `sharry-restserver`
+from a terminal window.
+
+The packages come with a systemd unit file that will be installed to
+autostart the services.
+
+
+## Running
+
+Run the start script (in the corresponding `bin/` directory when using
+the zip files):
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver
+```
+
+This will startup using the default configuration. The configuration
+should be adopted to your needs. For example, the database connection
+is configured to use a H2 database that is created in the `/tmp`
+directory. Please refer to the [configuration page](configure)
+for how to create a custom config file. Once you have your config
+file, simply pass it as argument to the command:
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver /path/to/server-config.conf
+```
+
+After starting the rest server, you can reach the web application at
+path `/app`, so using default values it would be
+`http://localhost:9090/app`.
+
+You should be able to create a new account and sign in.
+
+
+### Options
+
+The start scripts support some options to configure the JVM. One often
+used setting is the maximum heap size of the JVM. By default, java
+determines it based on properties of the current machine. You can
+specify it by given java startup options to the command:
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver -J-Xmx1G -- /path/to/server-config.conf
+```
+
+This would limit the maximum heap to 1GB. The double slash separates
+internal options and the arguments to the program. Another frequently
+used option is to change the default temp directory. Usually it is
+`/tmp`, but it may be desired to have a dedicated temp directory,
+which can be configured:
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver -J-Xmx1G -Djava.io.tmpdir=/path/to/othertemp -- /path/to/server-config.conf
+```
+
+The command:
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver -h
+```
+
+gives an overview of supported options.
+
+
+### System properties
+
+All options that are given with `-D` are called system properties.
+These can be used to overwrite certain configuration values. System
+properties always take precedence over values defined in config files.
+
+This can be handy to temporarily change some configuration, for
+example, enable the fixed admin account like this:
+
+```
+$ ./sharry-restserver*/bin/sharry-restserver -Dsharry.restserver.backend.auth.fixed.enabled=true -- /path/to/server-config.conf
+```
diff --git a/modules/microsite/docs/doc/migration.md b/modules/microsite/docs/doc/migration.md
new file mode 100644
index 00000000..93b24b67
--- /dev/null
+++ b/modules/microsite/docs/doc/migration.md
@@ -0,0 +1,97 @@
+---
+layout: docs
+title: Migration
+permalink: doc/migration
+---
+
+# {{ page.title }}
+
+For users of Sharry version 0.6.x, the database schema must be
+migrated (kind of) manually. The application doesn't do it
+automatically. However, there is a built-in script that converts the
+old schema into the new one.
+
+But: At first, please backup the data. If you don't care, then its
+probably easier to just start with a new database :).
+
+When migrating from Sharry version < 0.6.x, you'll need first to run a
+0.6 version against the database. This will evolve the db schema to
+the point where the migration-script from 1.0 can take it further.
+Then follow this guide.
+
+## Postgres and MariaDB
+
+For these databases, you can start the restserver binary with a
+special option `-Dsharry.migrate-old-dbschema=true`.
+
+```
+./sharry-restserver-@VERSION@/bin/sharry-restserver -Dsharry.migrate-old-dbschema=true ./sharry-new.conf
+```
+
+This will not start the restserver but rather run the migration
+against the database configured in given config file.
+
+If that completes successfully, you can startup sharry as normal
+(without that option).
+
+
+## H2
+
+H2 is a little more involved. This is because the database
+initialization changed and the parameters given with the URL cannot be
+changed afterwards.
+
+The steps are roughly this:
+
+- create a dump
+- change the dump to make it postgres compatible
+- import it into a new database (using the new connection settings)
+- run the migration from above
+
+### Dump
+
+The dump can be created using a tool provided by h2: `ScriptTool`
+([doc](https://h2database.com/javadoc/org/h2/tools/Script.html)). It
+is in the jar file that is on your disk if you have sharry installed.
+So the dump can be created like this:
+
+```
+java -cp sharry-restserver-@VERSION@/lib/com.h2database.h2-1.4.200.jar org.h2.tools.ScriptTool -url "jdbc:h2:///var/data/sharry/sharry-old-db" -user sa -password ""
+```
+
+This will create a `backup.sql` file in the current directory.
+
+
+### Change the Dump
+
+This dump uses some incompatible things: all identifiers are upper
+case and the datatype for a blob is called bytea in postgres. This can
+be changed with GNU Sed:
+
+```
+sed -i 's,"CHUNKDATA" BLOB NOT NULL,"CHUNKDATA" BYTEA NOT NULL,g' backup.sql
+sed -i 's,"[_A-Z]*",\L&,g' backup.sql
+```
+
+The first command fixes the datatype thing and the second converts all
+words in quotes into lowercase.
+
+Note: for the second command, the GNU version of Sed is required.
+
+### Import the Dump
+
+Now the changed dump must be imported into a new database. Since h2
+creates one on demand, just run the command and specify now the new
+connection – to an unexisting file and with the required settings
+`MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE`. Again, a tool from h2 can be
+used (`RunScript`,
+[doc](https://h2database.com/javadoc/org/h2/tools/RunScript.html)):
+
+```
+java -cp sharry-restserver-@VERSION@/lib/com.h2database.h2-1.4.200.jar org.h2.tools.RunScript -url "jdbc:h2:///var/data/sharry/sharry-newdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE" -user sa -password ""
+```
+
+### Migrate
+
+Now run sharry with the migration setting as described above for
+Postgres and MariaDB.
diff --git a/modules/microsite/docs/doc/quickstart.md b/modules/microsite/docs/doc/quickstart.md
new file mode 100644
index 00000000..3b339876
--- /dev/null
+++ b/modules/microsite/docs/doc/quickstart.md
@@ -0,0 +1,25 @@
+---
+layout: docs
+title: Quickstart
+permalink: doc/quickstart
+---
+
+# Quickstart
+
+To quickly try out sharry, follow these steps:
+
+1. Download a zip (version @VERSION@) from the [release
+ page](https://github.com/eikek/sharry/releases).
+ - e.g. [sharry-restserver-@VERSION@.zip](https://github.com/eikek/sharry/releases/download/release%2F@VERSION@/sharry-restserver-@VERSION@.zip)
+2. Unpack it to some place.
+ ```bash
+ $ unzip sharry-restserver-@VERSION@.zip
+ ```
+3. Run the executable:
+ ```bash
+ $ /path/to/extracted-zip/bin/sharry-restserver
+ ```
+4. Goto , signup and login
+
+If you want to know more, for example what can be
+[configured](configure), checkout these pages.
diff --git a/modules/microsite/docs/doc/rest.md b/modules/microsite/docs/doc/rest.md
new file mode 100644
index 00000000..10c9d1f7
--- /dev/null
+++ b/modules/microsite/docs/doc/rest.md
@@ -0,0 +1,64 @@
+---
+layout: docs
+title: Rest Api
+permalink: doc/rest
+---
+
+# REST Api
+
+Sharry is provided as a REST server and a web application client. The
+REST Api is specified using openapi 3.0 and it's static documentation
+can be seen [here](../openapi/sharry-openapi.html).
+
+The "raw" `openapi.yml` specification file can be found
+[here](../openapi/sharry-openapi.yml).
+
+The calls are divided into 4 categories:
+
+- `/open/*`: no authentication is required to access
+- `/sec/*`: an authenticated user is required
+- `/alias/*`: these routes are allowed with a valid *alias id* given
+ as header `Sharry-Alias`
+- `/admin/*`: an authenticated user that is admin is required
+
+Authentication works by logging in with username/password (or an
+oauth2 flow) that generates a token that has to be sent with every
+request to a secured and admin route. It is possible to sent it via a
+`Cookie` header or the special `Sharry-Auth` header.
+
+Files can be uploaded using different methods. There is an endpoint
+that can take all files and meta data from one single request. For
+more reliable uploads, the server implements the [tus
+protocol](https://tus.io/protocols/resumable-upload.html) that allows
+to resume failed or paused uploads.
+
+## Authentication
+
+The unprotected route `/open/auth/login` can be used to login with
+account name and password. The response contains a token that can be
+used for accessing protected routes. The token is only valid for a
+restricted time which can be configured (default is 5 minutes).
+
+New tokens can be generated using an existing valid token and the
+protected route `/sec/auth/session`. This will return the same
+response as above, giving a new token.
+
+This token can be added to requests in two ways: as a cookie header or
+a "normal" http header. If a cookie header is used, the cookie name
+must be `sharry_auth` and a custom header must be named
+`Sharry-Auth`.
+
+## Live Api
+
+Besides the statically generated documentation at this site, the rest
+server provides a openapi generated documenation, that allows playing
+around with the api. It requires a running sharry rest server. If it
+is deployed at `http://localhost:9090`, then check this url:
+
+```
+http://localhost:909/api/doc
+```
+
+## Examples
+
+TODO
diff --git a/modules/microsite/docs/doc/screenshots.md b/modules/microsite/docs/doc/screenshots.md
new file mode 100644
index 00000000..3ed4c5b1
--- /dev/null
+++ b/modules/microsite/docs/doc/screenshots.md
@@ -0,0 +1,38 @@
+---
+layout: docs
+title: Screenhots
+permalink: doc/screenshots
+---
+
+# Screenshots
+
+These are some screenshots to get a little impression of the web
+client. It might be outdated, though.
+
+## Home Screen
+
+
+
+## Uploading a file
+
+
+
+## View Share Details
+
+
+
+## View Link to public page
+
+
+
+## View Files
+
+
+## View Files with preview
+
+
+
+
+
+## Preview a single file
+
diff --git a/modules/microsite/docs/doc/webapp.md b/modules/microsite/docs/doc/webapp.md
new file mode 100644
index 00000000..9c339eb4
--- /dev/null
+++ b/modules/microsite/docs/doc/webapp.md
@@ -0,0 +1,197 @@
+---
+layout: docs
+title: Webapplication
+permalink: doc/webapp
+---
+
+# Webapplication
+
+The web client is written in [Elm](https://elm-lang.org), an awesome
+programming language for the web :-). The [tus javascript client
+library](https://github.com/tus/tus-js-client) is used to realize the
+resumable uploads. All the css is provided by
+[Semantic-UI](https://semantic-ui.com/).
+
+
+## Creating a new share
+
+After logging in, you can create new shares:
+
+
+
+The details are all optional and can also be changed afterwards. It is
+required to specfiy a description, some files or both. Otherwise
+submitting won't work.
+
+The detail options are explained below.
+
+### Name
+
+A share may have a name. This name is only for the owner and helps to
+find shares easier in the list view. The name will also be used as the
+head line, if the description doesn't contain a markdown headline (a
+line starting with `#`).
+
+
+### Description
+
+You can add some text to a share which will be displayed at the
+download page. The description can be
+[markdown](http://daring-fireball.net) and is converted to HTML when
+being displayed.
+
+Furthermore, the description text is processed as a
+[mustache](http://mustache.github.io/mustache.5.html) template and
+allows to refer to the attached files. You can access the following
+properties of any uploaded file:
+
+- `id`
+- `filename`
+- `url`
+- `mimetype`
+- `size`
+- `length`
+- `checksum`
+
+The `size` is the file size as a human readable string, while `length`
+is the number in bytes. You can refer to files using their name or
+index in the list. When using the file name, all dots in there must be
+removed.
+
+{% raw %}
+```
+{{#file.0}}{{url}}{{/file.0}}
+```
+{% endraw %}
+
+or
+
+{% raw %}
+```
+{{filename.dsc0100JPG.url}}
+```
+{% endraw %}
+
+This makes it possible to embed files in the description, for example
+to display an image file, you could write the following description:
+
+{% raw %}
+```
+![an image]({{filename.DSCF0343JPG.url}})
+```
+{% endraw %}
+
+There is also a `files` property that can be used to iterate through
+all uploaded files. So this would render the id and url of all files:
+
+{% raw %}
+```
+{{#files}}
+- {{id}}: {{url}}
+{{/files}}
+```
+{% endraw %}
+
+### Validity Time
+
+Every upload has a validity time after which the uploaded files are
+“expired”. Then the public download page is not visible anymore and
+the files can't be downloaded from non-protected urls.
+
+The files are there and the user that owns them still has access. They
+are eventually removed by a cleanup job.
+
+
+### Password
+
+The files can be further protected by a password. The download page
+requires this password in order to download the files.
+
+The idea is that this password is a second secret, next to the url.
+You can share the URL using one channel (maybe e-mail) and the
+password using another channel. A person must have both things in
+order to see the files.
+
+
+### Maximum Views
+
+This setting restricts the number of accesses to the download page. If
+the download page is accessed more than this number, it will not work
+anymore.
+
+
+## Publish / Unpublish / Republish
+
+A share that has not been published can only be accessed by its owner.
+In order to create a link for everyone else, click the `Publish`
+button in the top right of the detail view of a share.
+
+
+
+Once a share is published that circle is green.
+
+
+
+The expiry time is calculated from the validity time added to the
+point in time the share is publshed. You'll see it in the details
+pane.
+
+If the share is published you can get the link clicking on the *Share
+Link* pane. You can copy&paste it, scan the QR code or send it via
+e-Mail (if sharry is configured for that, it can be sent directly in
+the webapp).
+
+You can unpublish a share at any time. The public link will
+immediately stop working and the circle will be empty. Then there are
+two options for publishing it again: one will generate a new random
+link, the other option reuses the current public link. If you hit the
+*Publish* button in the top right corner again, the share will be
+published anew – meaning the current validity time is added to the
+current point in time, but the public link will *not* change. All
+people that you have shared it with earlier can immediately open the
+site again.
+
+If you rather like to publish it to a new URL, click the black publish
+button at the bottom of the *Detail* pane (see the screenshot below).
+
+
+## Edit Details
+
+The share properties can be changed in the detail view of a share. The
+detail view consists of a top menu, then follows the description, then
+the file list menu and finally the list of files.
+
+Open the *Detail* tab in the top menu to see all properties.
+
+
+
+Properties that have the little blue edit icon in front of their names
+can be changed by clicking that icon.
+
+The description can be edited by clicking the edit icon next to the
+`Publish` button in the top right corner.
+
+It is also possible to add or remove files of that share. Click the
+right menu item of the file list menu and the upload form appears.
+
+
+## Alias Pages
+
+The alias page is a way to let other users upload files for you. The
+idea is the same as with shared downloads: there is a cryptic URL you
+can share with others. This url allows to upload files that will be
+associated to the owner of that alias page.
+
+Click in the top right menu that opens a drop down menu and choose
+*Aliases*. There you can create, edit and remove alias pages.
+
+Alias pages are also convenient for quickly uploading files for
+yourself, as they don't require any authentication. For example, using
+curl you could do:
+
+```
+$ curl -H'Sharry-Alias: E5EohHtJHxN' -F file=@test.jpg -F file=@logo.jpg http://localhost:9090/api/v2/alias/upload
+{"success":true,"message":"Share created.","id":"FDQvHK2LVGe-SjkDjQxMiSo-8fPyBqKX3AY-nmWWnDsrRX3"}
+```
+
+See the [REST page](rest) for more details on the various routes.
diff --git a/modules/microsite/docs/index.md b/modules/microsite/docs/index.md
new file mode 100644
index 00000000..454f06c9
--- /dev/null
+++ b/modules/microsite/docs/index.md
@@ -0,0 +1,7 @@
+---
+layout: homeFeatures
+features:
+ - first: ["Send files…", "Send your files while receivers are not required to have an account."]
+ - second: ["Receive files…", "Receive files from peers without requiring them to sign up either."]
+ - third: ["Reliable Up- and Downloads", "Up- and downloads are resumable so working under unreliable network conditions is possible."]
+---
diff --git a/modules/microsite/docs/screenshots/20191216-222321.jpg b/modules/microsite/docs/screenshots/20191216-222321.jpg
new file mode 100644
index 00000000..c4761a04
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-222321.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-222359.jpg b/modules/microsite/docs/screenshots/20191216-222359.jpg
new file mode 100644
index 00000000..f9737faa
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-222359.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223105.jpg b/modules/microsite/docs/screenshots/20191216-223105.jpg
new file mode 100644
index 00000000..4291436b
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223105.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223117.jpg b/modules/microsite/docs/screenshots/20191216-223117.jpg
new file mode 100644
index 00000000..b8211572
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223117.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223125.jpg b/modules/microsite/docs/screenshots/20191216-223125.jpg
new file mode 100644
index 00000000..5bcff57a
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223125.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223128.jpg b/modules/microsite/docs/screenshots/20191216-223128.jpg
new file mode 100644
index 00000000..3ab224fd
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223128.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223153.jpg b/modules/microsite/docs/screenshots/20191216-223153.jpg
new file mode 100644
index 00000000..0b438cda
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223153.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191216-223216.jpg b/modules/microsite/docs/screenshots/20191216-223216.jpg
new file mode 100644
index 00000000..eed33835
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191216-223216.jpg differ
diff --git a/modules/microsite/docs/screenshots/20191217-210812.jpg b/modules/microsite/docs/screenshots/20191217-210812.jpg
new file mode 100644
index 00000000..cd9a17c9
Binary files /dev/null and b/modules/microsite/docs/screenshots/20191217-210812.jpg differ
diff --git a/modules/microsite/docs/screenshots/edit.png b/modules/microsite/docs/screenshots/edit.png
new file mode 100644
index 00000000..c487ce46
Binary files /dev/null and b/modules/microsite/docs/screenshots/edit.png differ
diff --git a/modules/microsite/docs/screenshots/login.jpg b/modules/microsite/docs/screenshots/login.jpg
new file mode 100644
index 00000000..ff7d78c0
Binary files /dev/null and b/modules/microsite/docs/screenshots/login.jpg differ
diff --git a/modules/microsite/docs/screenshots/publish_done.jpg b/modules/microsite/docs/screenshots/publish_done.jpg
new file mode 100644
index 00000000..e81f3baf
Binary files /dev/null and b/modules/microsite/docs/screenshots/publish_done.jpg differ
diff --git a/modules/microsite/docs/screenshots/publish_empty.jpg b/modules/microsite/docs/screenshots/publish_empty.jpg
new file mode 100644
index 00000000..6ba553fe
Binary files /dev/null and b/modules/microsite/docs/screenshots/publish_empty.jpg differ
diff --git a/modules/microsite/src/main/resources/microsite/css/styles.css b/modules/microsite/src/main/resources/microsite/css/styles.css
new file mode 100644
index 00000000..8766374a
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/css/styles.css
@@ -0,0 +1,3 @@
+img.screenshot {
+ width: 100%;
+}
diff --git a/modules/microsite/src/main/resources/microsite/data/menu.yml b/modules/microsite/src/main/resources/microsite/data/menu.yml
new file mode 100644
index 00000000..62a8f296
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/data/menu.yml
@@ -0,0 +1,30 @@
+options:
+ - title: Introduction
+ url: doc/
+ section: index
+
+ - title: Quickstart
+ url: doc/quickstart
+
+ - title: Installation
+ url: doc/install
+
+ nested_options:
+ - title: Migration
+ url: doc/migration
+
+ - title: Configuring
+ url: doc/configure
+
+ - title: Webapp
+ url: doc/webapp
+
+ nested_options:
+ - title: Screenshots
+ url: doc/screenshots
+
+ - title: REST Api
+ url: doc/rest
+
+ - title: Development
+ url: doc/dev
diff --git a/modules/microsite/src/main/resources/microsite/img/favicon-32x32.png b/modules/microsite/src/main/resources/microsite/img/favicon-32x32.png
new file mode 120000
index 00000000..a8532ea0
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/favicon-32x32.png
@@ -0,0 +1 @@
+../../../../../../webapp/src/main/webjar/favicon/favicon-32x32.png
\ No newline at end of file
diff --git a/modules/microsite/src/main/resources/microsite/img/features-header.svg b/modules/microsite/src/main/resources/microsite/img/features-header.svg
new file mode 120000
index 00000000..e37bbcae
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/features-header.svg
@@ -0,0 +1 @@
+../../../../../../../artwork/logo.svg
\ No newline at end of file
diff --git a/modules/microsite/src/main/resources/microsite/img/first-feature-icon.svg b/modules/microsite/src/main/resources/microsite/img/first-feature-icon.svg
new file mode 100644
index 00000000..9220a8e2
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/first-feature-icon.svg
@@ -0,0 +1,117 @@
+
+
diff --git a/modules/microsite/src/main/resources/microsite/img/light-navbar-brand.svg b/modules/microsite/src/main/resources/microsite/img/light-navbar-brand.svg
new file mode 120000
index 00000000..44539840
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/light-navbar-brand.svg
@@ -0,0 +1 @@
+../../../../../../../artwork/icon_small.svg
\ No newline at end of file
diff --git a/modules/microsite/src/main/resources/microsite/img/light-sidebar-brand.svg b/modules/microsite/src/main/resources/microsite/img/light-sidebar-brand.svg
new file mode 120000
index 00000000..44539840
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/light-sidebar-brand.svg
@@ -0,0 +1 @@
+../../../../../../../artwork/icon_small.svg
\ No newline at end of file
diff --git a/modules/microsite/src/main/resources/microsite/img/second-feature-icon.svg b/modules/microsite/src/main/resources/microsite/img/second-feature-icon.svg
new file mode 100644
index 00000000..48125095
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/second-feature-icon.svg
@@ -0,0 +1,113 @@
+
+
diff --git a/modules/microsite/src/main/resources/microsite/img/third-feature-icon.svg b/modules/microsite/src/main/resources/microsite/img/third-feature-icon.svg
new file mode 100644
index 00000000..7c6c7d1f
--- /dev/null
+++ b/modules/microsite/src/main/resources/microsite/img/third-feature-icon.svg
@@ -0,0 +1,100 @@
+
+
diff --git a/modules/restapi/src/main/resources/sharry-openapi.yml b/modules/restapi/src/main/resources/sharry-openapi.yml
new file mode 100644
index 00000000..3d802d4d
--- /dev/null
+++ b/modules/restapi/src/main/resources/sharry-openapi.yml
@@ -0,0 +1,1836 @@
+openapi: 3.0.0
+
+info:
+ title: Sharry
+ version: 1.0.0-SNAPSHOT
+ description: |
+ Sharry provides a way to share files with others in a convenient
+ way. The core functionality is provided by a server that can be
+ controlled via REST calls.
+
+ The calls are divided into 4 categories:
+
+ - `/open/*`: no authentication is required to access
+ - `/sec/*`: an authenticated user is required
+ - `/alias/*`: these routes are allowed with a valid *alias id*
+ given as header `Sharry-Alias`
+ - `/admin/*`: an authenticated user that is admin is required
+
+ Authentication works by logging in with username/password (or an
+ oauth2 flow) that generates a token that has to be sent with every
+ request to a secured and admin route. It is possible to sent it
+ via a `Cookie` header or the special `Sharry-Auth` header.
+
+ Files can be uploaded using different methods. There is an
+ endpoint that can take all files and meta data from one single
+ request. For more reliable uploads, the server implements the [tus
+ protocol](https://tus.io/protocols/resumable-upload.html) that
+ allows to resume failed or paused uploads.
+
+tags:
+ - name: Information
+ description: Get information about this API.
+ - name: Authentication
+ description: Various methods to authenticate.
+ - name: Registration
+ description: Register a new account.
+ - name: Account Management
+ description: Admins can create/update/delete accounts.
+ - name: Alias
+ description: Edit your alias pages.
+ - name: Shares
+ description: Edit shares.
+ - name: Shares Upload
+ description: Create or Add Files
+servers:
+ - url: /api/v2
+ description: Current host
+
+paths:
+ /open/info/version:
+ get:
+ tags: [ Information ]
+ summary: Version information.
+ description: |
+ Returns version information about server application.
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/VersionInfo"
+ /open/info/appconfig:
+ get:
+ tags: [ Information ]
+ summary: Basic configuration.
+ description: |
+ Return basic information for setting up a web client.
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AppConfig"
+
+ /open/auth/login:
+ post:
+ tags: [ Authentication ]
+ summary: Authenticate with account name and password.
+ description: |
+ Authenticate with account name and password.
+
+ If successful, an authentication token is returned that can be
+ used for subsequent calls to protected routes.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UserPass"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AuthResult"
+ /open/auth/oauth/{id}:
+ get:
+ tags: [ Authentication ]
+ summary: Authenticate via OAuth2
+ description: |
+ The `id` must be a configured OAuth provider. This requests
+ will redirect the client to the configured provider. After
+ authentication there, the provider will redirect back to
+ sharry.
+
+ This only works, if sharry uses TLS (https), and the correct
+ callback-url is configured at the provider.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ '303':
+ description: See other
+ /open/auth/oauth/{id}/resume:
+ post:
+ tags: [ Authentication ]
+ summary: Callback url from OAuth2 providers.
+ description: |
+ This endpoint is for OAuth2 providers when delegating control
+ back to sharry. At this stage, sharry will do basic validation
+ and then finishes logging into the application.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ code:
+ type: string
+ responses:
+ '200':
+ description: OK
+ '403':
+ description: Forbidden
+ /sec/auth/session:
+ post:
+ tags: [ Authentication ]
+ summary: Authentication with a token
+ description: |
+ Authenticate with a token. This can be used to get a new
+ authentication token based on another valid one.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AuthResult"
+ /sec/auth/logout:
+ post:
+ tags: [ Authentication ]
+ summary: Logout.
+ description: |
+ This route informs the server about a logout. This is not
+ strictly necessary.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ /open/signup/register:
+ post:
+ tags: [ Registration ]
+ summary: Register a new account.
+ description: |
+ Create a new account.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Registration"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ /admin/signup/newinvite:
+ post:
+ tags: [ Registration ]
+ summary: Generate a new invite.
+ description: |
+ When signup mode is set to "invite", sharry requires an
+ invitation key when signing up. These keys can be created
+ here. Creating such keys requires an admin user. It also asks
+ for a password that must be set in the configuration file.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenInvite"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InviteResult"
+
+ /admin/account:
+ get:
+ tags: [ Account Management ]
+ summary: List all accounts.
+ description: |
+ Lists all available (internal and external) accounts. An
+ optional query parameter can be used to narrow the list down
+ by username. It is a simple substring search in the username
+ property.
+ parameters:
+ - $ref: "#/components/parameters/q"
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountList"
+ post:
+ tags: [ Account Management ]
+ summary: Create a new account.
+ description: |
+ Creates a new account. The account is marked as internal and
+ the provided password is used when authenticating. Sharry
+ supports external authentication, these accounts however,
+ cannot be directly created. They are created on demand.
+
+ The username and password properties are mandatory. The others
+ are optional or have a sensible default.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountCreate"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /admin/account/{id}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ get:
+ tags: [ Account Management ]
+ summary: Details about one account.
+ description: |
+ Returns details about the account with the given id. Note that
+ the id is *not* the username, but the account-id.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountDetail"
+ post:
+ tags: [ Account Management ]
+ summary: Modify an account.
+ description: |
+ Modifies an existing account. It is only possible to modify
+ `state`, `email` and the `admin` property.
+
+ If the `email` property is not supplied, an existing email is
+ removed from the account.
+
+ The password can be changed for an account. If it is `null` or
+ empty, it is left unchanged. Also, if the account is not
+ internal, a given password is ignored.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountModify"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ /sec/settings/email:
+ get:
+ tags: [ Account Management ]
+ summary: Get your E-Mail address.
+ description: |
+ Allows the current user to get their e-mail address.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailInfo"
+ post:
+ tags: [ Account Management ]
+ summary: Edit your E-Mail.
+ description: |
+ Allows the current user to change their e-mail address.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailChange"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ delete:
+ tags: [ Account Management ]
+ summary: Removed your E-Mail.
+ description: |
+ Allows the current user to remove their e-mail address.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/settings/password:
+ post:
+ tags: [ Account Management ]
+ summary: Change your password.
+ description: |
+ Allows users to change their password. This is only valid for
+ internal accounts.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PasswordChange"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/alias:
+ get:
+ tags: [ Alias ]
+ summary: List all aliases.
+ description: |
+ Lists all aliases of the current user.
+ parameters:
+ - $ref: "#/components/parameters/q"
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AliasList"
+ post:
+ tags: [ Alias ]
+ summary: Create new alias
+ description: |
+ Create a new alias. The id is generated to some random string
+ if not specified, such that the URLs resulting from this alias
+ are not guessable.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AliasChange"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IdResult"
+ /sec/alias/{id}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ get:
+ tags: [ Alias ]
+ summary: Details about one alias.
+ description: |
+ Returns details about an alias for the given id.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AliasDetail"
+ post:
+ tags: [ Alias ]
+ summary: Change an alias
+ description: |
+ Change some properties of an existing alias.
+
+ The id is optional; if it is not specified a new random one
+ will be generated.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AliasChange"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IdResult"
+ delete:
+ tags: [ Alias ]
+ summary: Delete an alias.
+ description: |
+ Deletes an alias.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /alias/upload:
+ $ref: "#/paths/~1sec~1upload"
+
+ /sec/upload:
+ post:
+ tags:
+ - Shares Upload
+ summary: Upload files to create a share.
+ description: |
+ Allows to create a new share by uploading data using
+ `multipart/form-data` requests. All requests must have content
+ type `multipart/form-data`.
+
+ All parts of a `multipart/form-data` request are treated as
+ files except if one with name *"meta"* is found. This is
+ expected to contain a JSON structure with the metadata
+ (validity, password etc). If this is missing, default values
+ will be used. All other parts are added as files to the new
+ share. It is allowed to send only a "meta" part or even an
+ empty body. In these cases the new share will be created
+ without files..
+
+ If this route is at `/alias/` a `Sharry-Alias` header is
+ required.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ meta:
+ $ref: "#/components/schemas/ShareProperties"
+ file:
+ type: array
+ items:
+ type: string
+ format: binary
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IdResult"
+
+ /sec/share/search:
+ get:
+ tags:
+ - Shares
+ summary: Search your shares.
+ security:
+ - authTokenHeader: []
+ description: |
+ Returns a list of all shares of the current user.
+ parameters:
+ - $ref: "#/components/parameters/q"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareList"
+
+ /alias/upload/new:
+ $ref: "#/paths/~1sec~1upload~1new"
+
+ /sec/upload/new:
+ post:
+ tags:
+ - Shares Upload
+ summary: Create a new empty share.
+ description: |
+ This endpoint allows to only upload json data to create a new
+ empty share.
+
+ The same thing can be achieved by using `multipart/form-data`
+ requests to the `/sec/upload` endpoint containing only one part
+ named "meta". But this endpoint may be more convenient to use.
+
+ If this route is at `/alias/` a `Sharry-Alias` header is
+ required.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareProperties"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IdResult"
+
+ /open/share/{pid}:
+ get:
+ tags:
+ - Shares (Public)
+ summary: Get details about a share.
+ description: |
+ Returns all details about a share.
+
+ If the share is password protected, the password must be
+ supplied using the header `Sharry-Password`. If it is not
+ supplied, a 401 response is sent. If it is wrong, a 403
+ response will be returned.
+ parameters:
+ - $ref: "#/components/parameters/pid"
+ - $ref: "#/components/parameters/SharryPassword"
+ responses:
+ 401:
+ description: Unauthorized
+ 403:
+ description: Forbidden
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareDetail"
+
+ /sec/share/{id}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ get:
+ tags:
+ - Shares
+ summary: Get details about a share.
+ security:
+ - authTokenHeader: []
+ description: |
+ Returns all details about a share.
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareDetail"
+
+ delete:
+ tags:
+ - Shares
+ summary: Delete a share.
+ description: |
+ Allows to delete a share and all associated files.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /alias/upload/{id}/files/add:
+ $ref: "#/paths/~1sec~1upload~1%7Bid%7D~1files~1add"
+
+ /sec/upload/{id}/files/add:
+ post:
+ tags:
+ - Shares Upload
+ summary: Add more files to a share.
+ description: |
+ This endpoint can be used to add more files to an existing
+ share. It must be a `multipart/form-data` request, where each
+ part results in a new file added to the share.
+
+ If this route is at `/alias/` a `Sharry-Alias` header is
+ required.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ file:
+ type: array
+ items:
+ type: string
+ format: binary
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/share/{id}/name:
+ post:
+ tags:
+ - Shares
+ summary: Set a new name.
+ description: |
+ Sets the name of the share.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SingleString"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ delete:
+ tags:
+ - Shares
+ summary: Deletes the name of a share.
+ description: |
+ A name is optional and can be removed via this route.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+
+ /sec/share/{id}/validity:
+ post:
+ tags:
+ - Shares
+ summary: Set a new validity time.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ security:
+ - authTokenHeader: []
+ description: |
+ Sets the validity property of the share to a new value.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SingleNumber"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+
+ /sec/share/{id}/description:
+ post:
+ tags:
+ - Shares
+ summary: Set a new description.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ security:
+ - authTokenHeader: []
+ description: |
+ Sets the description of share.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SingleString"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/share/{id}/maxviews:
+ post:
+ tags:
+ - Shares
+ summary: Set new maximum downloads.
+ parameters:
+ - $ref: "#/components/parameters/id"
+ security:
+ - authTokenHeader: []
+ description: |
+ Sets the maximum downloads property.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SingleNumber"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/share/{id}/password:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ post:
+ tags:
+ - Shares
+ summary: Sets a password to this share.
+ description: |
+ Sets or changes the password of the share. If the share
+ already has a password defined, it must be given with the
+ request. Otherwise it may be empty.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SingleString"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ delete:
+ tags:
+ - Shares
+ summary: Removes the password from the share.
+ security:
+ - authTokenHeader: []
+ description: |
+ Removes the password that has been set for this share. If this
+ share has no password set, a successful response is sent.
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/share/{id}/publish:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ post:
+ tags:
+ - Shares
+ summary: Publishes a share.
+ security:
+ - authTokenHeader: []
+ description: |
+ A share can be published. That means it is accessible by
+ everyone (no access protection!) using a different url and id.
+ This link can then be shared. Once the validity time is
+ expired, the public link won't work anymore.
+
+ If the share is already published, this is a no-op (resulting
+ in a successful response).
+
+ If the share was previously published the request can control,
+ wether the old id should be reused (resulting in the same
+ links as before), or a new random one should be generated.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PublishData"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ delete:
+ tags:
+ - Shares
+ summary: Unpublish a share.
+ security:
+ - authTokenHeader: []
+ description: |
+ If a share is currently published it can be un-published using
+ this endpoint.
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /open/share/{pid}/file/{fid}:
+ get:
+ tags:
+ - Shares (Public)
+ summary: Retrieve a file from the share.
+ description: |
+ Returns a file from a share.
+
+ The response supports byte-serving and ETag.
+ parameters:
+ - $ref: "#/components/parameters/pid"
+ - $ref: "#/components/parameters/fid"
+ responses:
+ 200:
+ description: Ok
+ content:
+ "*/*":
+ schema:
+ type: string
+ format: binary
+
+ /sec/share/{id}/file/{fid}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ - $ref: "#/components/parameters/fid"
+ get:
+ tags:
+ - Shares
+ summary: Retrieve a file from the share.
+ description: |
+ Returns a file from a share.
+
+ The response supports byte-serving and ETag.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ "*/*":
+ schema:
+ type: string
+ format: binary
+ delete:
+ tags:
+ - Shares
+ summary: Remove a file from a share.
+ description: |
+ Deletes a file from a share.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /alias/upload/{id}/files/tus:
+ $ref: "#/paths/~1sec~1upload~1%7Bid%7D~1files~1tus"
+
+ /sec/upload/{id}/files/tus:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ options:
+ tags:
+ - Shares Upload
+ summary: "[Tus] Protocol information."
+ description: |
+ Implements the TUS protocol OPTIONS request to return common
+ information about what parts of the tus protocol are supported
+ by this server.
+
+ Please see the
+ [protocol](https://tus.io/protocols/resumable-upload.html)
+ specification for more details.
+
+ If this is the `/alias` route, a `Sharry-Alias` header is
+ required.
+ security:
+ - authTokenHeader: []
+ responses:
+ 204:
+ description: NoContent
+ headers:
+ Tus-Resumable:
+ schema:
+ type: string
+ Tus-Extension:
+ schema:
+ type: string
+ Tus-Version:
+ schema:
+ type: string
+ post:
+ tags:
+ - Shares Upload
+ summary: "[Tus] Create new (empty) files using tus protocol"
+ description: |
+ Create a new (empty) file via tus' *creation* extension.
+
+ This follows the tus protocol, but uses different headers for
+ transporting the filename and filetype. While the tus protocol
+ defines a `Upload-Metadata` header, what it can contain is not
+ specified. It requires custom negotiation between server and
+ client, so we can as well use different headers that are
+ easier to read and write:
+
+ - `Sharry-File-Name` specifies the filename (percent-encoded)
+ - `Sharry-File-Type` specifies the content type
+ - `Sharry-File-Length` can be used to specifiy the total
+ length in bytes. If not found `Upload-Length` is used.
+
+ The total length must be specified, name and content type are
+ optional.
+
+ Please see the
+ [protocol](https://tus.io/protocols/resumable-upload.html)
+ specification for more details.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/SharryFileName"
+ - $ref: "#/components/parameters/SharryFileType"
+ - $ref: "#/components/parameters/SharryFileLength"
+ - $ref: "#/components/parameters/UploadLength"
+ responses:
+ 201:
+ description: Created
+ headers:
+ Tus-Resumable:
+ schema:
+ type: string
+ Location:
+ schema:
+ type: string
+
+ /alias/upload/{id}/files/tus/{fid}:
+ $ref: "#/paths/~1sec~1upload~1%7Bid%7D~1files~1tus~1%7Bfid%7D"
+
+ /sec/upload/{id}/files/tus/{fid}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ - $ref: "#/components/parameters/fid"
+ patch:
+ tags:
+ - Shares Upload
+ summary: "[Tus] Upload binary data"
+ description: |
+ Endpoint for receiving the binary data belonging to a file.
+ The file must have been created before using a POST request to
+ the parent path url.
+
+ The `Upload-Offset` header must be specified, it may be set to
+ `0`.
+
+ You may also use the `POST` method instead.
+
+ Please see the
+ [protocol](https://tus.io/protocols/resumable-upload.html)
+ specification for more details.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/UploadOffset"
+ requestBody:
+ content:
+ application/offset+octet-stream:
+ schema:
+ type: string
+ format: binary
+ responses:
+ 204:
+ description: NoContent
+ headers:
+ Tus-Resumable:
+ schema:
+ type: string
+ Upload-Offset:
+ schema:
+ type: integer
+ format: int64
+ 404:
+ description: Not Found
+ head:
+ tags:
+ - Shares Upload
+ summary: "[Tus] Information about a file."
+ description: |
+ Returns the upload status of the file. Returns the total
+ expected length and the number of bytes that have really been
+ saved. This is used by clients to determine the next
+ `Upload-Offset` to use.
+
+ Please see the
+ [protocol](https://tus.io/protocols/resumable-upload.html)
+ specification for more details.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ headers:
+ Tus-Resumable:
+ schema:
+ type: string
+ Upload-Offset:
+ schema:
+ type: integer
+ format: int64
+ Upload-Length:
+ schema:
+ type: integer
+ format: int64
+
+ /alias/mail/notify/{id}:
+ post:
+ tags:
+ - Mail
+ summary: Notify the owner.
+ description: |
+ After uploading some files via an alias page, the client can
+ request to notify the owner via e-mail that an upload just
+ finished.
+
+ The corresponding user must have an e-mail address in their
+ account and the mail feautre must be enabled in the config
+ file.
+ security:
+ - aliasTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/mail/template/share/{id}:
+ get:
+ tags:
+ - Mail
+ summary: Get the mail template for a published share.
+ description: |
+ To send a link to a published share via e-mail, templates can
+ be specified in the configuration file. The server can then
+ insert the required data (like the cryptic url), so the user
+ is freed from copy-pasting things.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MailTemplate"
+ /sec/mail/template/alias/{aid}:
+ get:
+ tags:
+ - Mail
+ summary: Get the mail template for a published share.
+ description: |
+ To send a link to an alias page via e-mail, templates can be
+ specified in the configuration file. The server can then
+ insert the required data (like the cryptic url), so the user
+ is freed from copy-pasting things.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/aliasId"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MailTemplate"
+ /sec/mail/send:
+ post:
+ tags:
+ - Mail
+ summary: Send an e-mail.
+ description: |
+ This will send the given e-mail as is to the specified
+ recipients. This will only work, if the server enabled the
+ mail feature.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SimpleMail"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+components:
+ schemas:
+ SimpleMail:
+ description: |
+ A simple e-mail.
+ required:
+ - recipients
+ - subject
+ - body
+ properties:
+ recipients:
+ type: array
+ items:
+ type: string
+ subject:
+ type: string
+ body:
+ type: string
+ MailTemplate:
+ description: |
+ Contents of a mail template.
+ required:
+ - subject
+ - body
+ properties:
+ subject:
+ type: string
+ body:
+ type: string
+ PublishData:
+ description: |
+ Input when publishing a share.
+ required:
+ - reuseId
+ properties:
+ reuseId:
+ type: boolean
+ SingleString:
+ description: |
+ Sending a single string value.
+ required:
+ - value
+ properties:
+ value:
+ type: string
+ SingleNumber:
+ description: |
+ For sending a single number.
+ required:
+ - value
+ properties:
+ value:
+ type: integer
+ format: int64
+ ShareDetail:
+ description: |
+ Details about a single share.
+ required:
+ - id
+ - validity
+ - maxViews
+ - password
+ - created
+ - files
+ properties:
+ id:
+ type: string
+ format: ident
+ name:
+ type: string
+ aliasId:
+ type: string
+ format: ident
+ aliasName:
+ type: string
+ validity:
+ type: integer
+ format: duration
+ maxViews:
+ type: integer
+ password:
+ type: boolean
+ descriptionRaw:
+ type: string
+ description:
+ type: string
+ created:
+ type: integer
+ format: date-time
+ publishInfo:
+ $ref: "#/components/schemas/SharePublish"
+ files:
+ type: array
+ items:
+ $ref: "#/components/schemas/ShareFile"
+ SharePublish:
+ description: |
+ Information about a published share.
+ required:
+ - id
+ - enabled
+ - views
+ - publishDate
+ - publishUntil
+ - expired
+ properties:
+ id:
+ type: string
+ format: ident
+ enabled:
+ type: boolean
+ views:
+ type: integer
+ format: int32
+ publishDate:
+ type: integer
+ format: date-time
+ publishUntil:
+ type: integer
+ format: date-time
+ expired:
+ type: boolean
+ lastAccess:
+ type: integer
+ format: date-time
+ ShareFile:
+ description: |
+ Details about a file belonging to a share.
+ required:
+ - id
+ - filename
+ - size
+ - mimetype
+ - checksum
+ - storedSize
+ properties:
+ id:
+ type: string
+ format: ident
+ filename:
+ type: string
+ size:
+ type: integer
+ format: size
+ mimetype:
+ type: string
+ checksum:
+ type: string
+ storedSize:
+ type: integer
+ format: size
+ ShareList:
+ description: |
+ A list of shares
+ required:
+ - items
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/ShareListItem"
+ ShareListItem:
+ description: |
+ Some details about a share used when searching for shares.
+ required:
+ - id
+ - validity
+ - maxViews
+ - password
+ - created
+ - files
+ - size
+ properties:
+ id:
+ type: string
+ format: ident
+ name:
+ type: string
+ aliasName:
+ type: string
+ validity:
+ type: integer
+ format: duration
+ maxViews:
+ type: integer
+ format: int32
+ password:
+ type: boolean
+ created:
+ type: integer
+ format: date-time
+ files:
+ type: integer
+ size:
+ type: integer
+ format: size
+ published:
+ type: boolean
+ ShareProperties:
+ description: |
+ Describes a share.
+ required:
+ - validity
+ - maxViews
+ properties:
+ name:
+ type: string
+ validity:
+ type: integer
+ format: duration
+ description:
+ type: string
+ maxViews:
+ type: integer
+ format: int32
+ password:
+ type: string
+ format: password
+ IdResult:
+ description: |
+ Some basic result of an operation and an identifier. The
+ identifier is valid on success only.
+ required:
+ - success
+ - message
+ - id
+ properties:
+ success:
+ type: boolean
+ message:
+ type: string
+ id:
+ type: string
+ format: ident
+ AliasList:
+ description: |
+ A list of aliases.
+ required:
+ - items
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/AliasDetail"
+ AliasDetail:
+ description: |
+ Details about one alias.
+ required:
+ - id
+ - name
+ - validity
+ - enabled
+ - created
+ properties:
+ id:
+ type: string
+ format: ident
+ name:
+ type: string
+ validity:
+ type: integer
+ format: duration
+ enabled:
+ type: boolean
+ created:
+ type: integer
+ format: date-time
+ AliasChange:
+ description: |
+ Data for changing alias properties.
+ required:
+ - name
+ - validity
+ - enabled
+ properties:
+ id:
+ type: string
+ format: ident
+ name:
+ type: string
+ validity:
+ type: integer
+ format: duration
+ enabled:
+ type: boolean
+ EmailInfo:
+ description: |
+ Accounts may optionally have an e-mail address registered.
+ properties:
+ email:
+ type: string
+ EmailChange:
+ description: |
+ Change your email.
+ required:
+ - email
+ properties:
+ email:
+ type: string
+ PasswordChange:
+ description: |
+ Change your password.
+ required:
+ - oldPassword
+ - newPassword
+ properties:
+ oldPassword:
+ type: string
+ format: password
+ newPassword:
+ type: string
+ format: password
+ AccountList:
+ description: |
+ A list of accounts.
+ required:
+ - items
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/AccountDetail"
+ AccountDetail:
+ description: |
+ Information about an account.
+ required:
+ - id
+ - login
+ - source
+ - state
+ - admin
+ - loginCount
+ - shares
+ - created
+ properties:
+ id:
+ type: string
+ format: ident
+ login:
+ type: string
+ format: ident
+ source:
+ type: string
+ format: accountsource
+ state:
+ type: string
+ format: accountstate
+ admin:
+ type: boolean
+ email:
+ type: string
+ loginCount:
+ type: integer
+ format: int32
+ shares:
+ type: integer
+ format: int32
+ lastLogin:
+ type: integer
+ format: date-time
+ created:
+ type: integer
+ format: date-time
+ AccountCreate:
+ description: |
+ Create an account.
+ required:
+ - login
+ - admin
+ - state
+ - password
+ properties:
+ login:
+ type: string
+ format: ident
+ state:
+ type: string
+ format: accountstate
+ admin:
+ type: boolean
+ password:
+ type: string
+ format: password
+ email:
+ type: string
+ AccountModify:
+ description: |
+ Modify an existing account.
+ required:
+ - admin
+ - state
+ properties:
+ state:
+ type: string
+ format: accountstate
+ admin:
+ type: boolean
+ email:
+ type: string
+ password:
+ type: string
+ format: password
+ AppConfig:
+ description: |
+ Initial configuration.
+ required:
+ - appName
+ - baseUrl
+ - assetsPath
+ - signupMode
+ - oauthConfig
+ - chunkSize
+ - retryDelays
+ - maxValidity
+ - maxSize
+ - mailEnabled
+ properties:
+ appName:
+ type: string
+ baseUrl:
+ type: string
+ format: uri
+ assetsPath:
+ type: string
+ signupMode:
+ type: string
+ format: signupmode
+ oauthConfig:
+ type: array
+ items:
+ $ref: "#/components/schemas/OAuthItem"
+ chunkSize:
+ type: integer
+ format: int64
+ retryDelays:
+ type: array
+ items:
+ type: integer
+ format: int64
+ maxValidity:
+ type: integer
+ format: duration
+ maxSize:
+ type: integer
+ format: size
+ mailEnabled:
+ type: boolean
+ OAuthItem:
+ description: |
+ Information about a configured OAuth provider.
+ required:
+ - id
+ - name
+ properties:
+ id:
+ type: string
+ format: ident
+ name:
+ type: string
+ icon:
+ type: string
+ GenInvite:
+ description: |
+ A request to generate a new invitation key.
+ required:
+ - password
+ properties:
+ password:
+ type: string
+ format: password
+ InviteResult:
+ description: |
+ The result when requesting new invitation keys.
+ required:
+ - success
+ - message
+ properties:
+ success:
+ type: boolean
+ message:
+ type: string
+ key:
+ type: string
+ format: ident
+ Registration:
+ description: |
+ Data for registering a new account.
+ required:
+ - login
+ - password
+ properties:
+ login:
+ type: string
+ format: ident
+ password:
+ type: string
+ format: password
+ invite:
+ type: string
+ format: ident
+ BasicResult:
+ description: |
+ Some basic result of an operation.
+ required:
+ - success
+ - message
+ properties:
+ success:
+ type: boolean
+ message:
+ type: string
+ UserPass:
+ description: |
+ Account name and password.
+ required:
+ - account
+ - password
+ properties:
+ account:
+ type: string
+ password:
+ type: string
+ AuthResult:
+ description: |
+ The response to a authentication request.
+ required:
+ - id
+ - user
+ - admin
+ - success
+ - message
+ - validMs
+ properties:
+ id:
+ type: string
+ format: ident
+ user:
+ type: string
+ format: ident
+ admin:
+ type: boolean
+ success:
+ type: boolean
+ message:
+ type: string
+ token:
+ description: |
+ The authentication token that should be used for
+ subsequent requests to secured endpoints.
+ type: string
+ validMs:
+ description: |
+ How long the token is valid in ms.
+ type: integer
+ format: int64
+ VersionInfo:
+ description: |
+ Information about the software.
+ required:
+ - version
+ - builtAtMillis
+ - builtAtString
+ - gitCommit
+ - gitVersion
+ properties:
+ version:
+ type: string
+ builtAtMillis:
+ type: integer
+ format: int64
+ builtAtString:
+ type: string
+ gitCommit:
+ type: string
+ gitVersion:
+ type: string
+ securitySchemes:
+ authTokenHeader:
+ type: apiKey
+ in: header
+ name: Sharry-Auth
+ aliasTokenHeader:
+ type: apiKey
+ in: header
+ name: Sharry-Alias
+ parameters:
+ aliasId:
+ name: aid
+ in: path
+ description: The alias identifier.
+ required: true
+ schema:
+ type: string
+ fid:
+ name: fid
+ in: path
+ description: A file identifier
+ required: true
+ schema:
+ type: string
+ id:
+ name: id
+ in: path
+ description: A share identifier
+ required: true
+ schema:
+ type: string
+ pid:
+ name: pid
+ in: path
+ description: A public share identifier
+ required: true
+ schema:
+ type: string
+ q:
+ name: q
+ in: query
+ description: A query string
+ required: false
+ schema:
+ type: string
+ SharryFileName:
+ name: Sharry-File-Name
+ in: header
+ required: false
+ schema:
+ type: string
+ SharryFileLength:
+ name: Sharry-File-Length
+ in: header
+ required: false
+ schema:
+ type: integer
+ format: int64
+ SharryFileType:
+ name: Sharry-File-Type
+ in: header
+ required: false
+ schema:
+ type: string
+ UploadLength:
+ name: Upload-Length
+ in: header
+ required: true
+ schema:
+ type: integer
+ format: int64
+ UploadOffset:
+ name: Upload-Offset
+ in: header
+ required: true
+ schema:
+ type: integer
+ format: int64
+ SharryPassword:
+ name: Sharry-Password
+ in: header
+ required: false
+ schema:
+ type: string
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
new file mode 100644
index 00000000..00bd9ea4
--- /dev/null
+++ b/modules/restserver/src/main/resources/logback.xml
@@ -0,0 +1,16 @@
+
+
+ true
+
+
+ [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf
new file mode 100644
index 00000000..c53c4a1a
--- /dev/null
+++ b/modules/restserver/src/main/resources/reference.conf
@@ -0,0 +1,327 @@
+sharry.restserver {
+
+ # This is the base URL this application is deployed to. This is used
+ # to create absolute URLs and to configure the cookie.
+ #
+ # Note: Currently deploying behind a path is not supported. The URL
+ # should not end in a slash.
+ base-url = "http://localhost:9090"
+
+
+ # Where the server binds to.
+ bind {
+ address = "localhost"
+ port = 9090
+ }
+
+ webapp {
+ # This is shown in the top right corner of the web application
+ app-name = "Sharry"
+
+ # Chunk size used for one request. The server will re-chunk the
+ # stream into smaller chunks. But the client can transfer more in
+ # one requests, resulting in faster uploads.
+ #
+ # You might need to adjust this value depending on your setup. A
+ # higher value usually means faster uploads.
+ chunk-size = "100M"
+
+ # Number of milliseconds the client should wait before doing a new
+ # upload attempt after something failed. The length of the array
+ # denotes the number of retries.
+ retry-delays = [0, 3000, 6000, 12000, 24000, 48000]
+ }
+
+ backend {
+
+ # Authentication is flexible to let Sharry be integrated in other
+ # environments.
+ auth {
+
+ # The secret for this server that is used to sign the authenicator
+ # tokens. You can use base64 or hex strings (prefix with b64: and
+ # hex:, respectively)
+ server-secret = "hex:caffee"
+
+ # How long an authentication token is valid. The web application
+ # will get a new one periodically.
+ session-valid = "5 minutes"
+
+ #### Login Modules
+ ##
+ ## The following settings configure how users are authenticated.
+ ## There are several ways possible. The simplest is to
+ ## authenticate agains the internal database. But often there is
+ ## already a user management component and sharry can be
+ ## configured to authenticated against other services.
+
+ # A fixed login module simply checks the username and password
+ # agains the information provided here. This only applies if the
+ # user matches, otherwise the next login module is tried.
+ fixed {
+ enabled = false
+ user = "admin"
+ password = "admin"
+ order = 10
+ }
+
+ # The http authentication module sends the username and password
+ # via a HTTP request and uses the response to indicate success or
+ # failure.
+ #
+ # If the method is POST, the `body' is sent with the request and
+ # the `content-type' is used.
+ http {
+ enabled = false
+ url = "http://localhost:1234/auth?user={{user}}&password={{pass}}"
+ method = "POST"
+ body = ""
+ content-type = ""
+ order = 20
+ }
+
+ # Use HTTP Basic authentication. Sharry first sends a request
+ # without credentials to obtain a 401 response from the server,
+ # which may include a charset to use. Then it constructs a
+ # Authorization header using the Basic scheme and tries again.
+ # The response body will be ignored, only the status is
+ # inspected.
+ http-basic {
+ enabled = false
+ url = "http://somehost:2345/path"
+ method = "GET"
+ order = 30
+ }
+
+ # The command authentication module runs an external command
+ # giving it the username and password. The return code indicates
+ # success or failure.
+ command {
+ enabled = false
+ program = [
+ "/path/to/someprogram"
+ "{{user}}"
+ "{{pass}}"
+ ]
+ # the return code to consider successful verification
+ success = 0
+ order = 40
+ }
+
+ # The internal authentication module checks against the internal
+ # database.
+ internal {
+ enabled = true
+ order = 50
+ }
+
+ # Uses OAuth2 "Code-Flow" for authentication against a
+ # configured provider.
+ #
+ # A provider (like Github or Google for example) must be
+ # configured correctly for this to work. Each element in the array
+ # results into a button on the login page.
+ #
+ # Examples for Github and Google are provided below. You need to
+ # setup an “application” to obtain a client_secret and clien_id.
+ #
+ # Details:
+ # - enabled: allows to toggle it on or off
+ # - id: a unique id that is part of the url
+ # - name: a name that is displayed inside the button on the
+ # login screen
+ # - icon: a semantic-ui icon name for the button
+ # - authorize-url: the url of the provider where the user can
+ # login and grant the permission to retrieve the user name
+ # - token-url: the url used to obtain a bearer token using the
+ # response from the authentication above. The response from
+ # the provider must be json or url-form-encdode.
+ # - user-url: the url to finalyy retrieve user information –
+ # only JSON responses are supported.
+ # - user-id-key: the name of the field in the json response
+ # denoting the user name
+ oauth = [
+ {
+ enabled = false
+ id = "github"
+ name = "Github"
+ icon = "github"
+ authorize-url = "https://github.com/login/oauth/authorize"
+ token-url = "https://github.com/login/oauth/access_token"
+ user-url = "https://api.github.com/user"
+ user-id-key = "login"
+ client-id = ""
+ client-secret = ""
+ },
+ {
+ enabled = false
+ id = "google"
+ name = "Google"
+ icon = "google"
+ authorize-url = "https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.profile"
+ token-url = "https://oauth2.googleapis.com/token"
+ user-url = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
+ user-id-key = "name"
+ client-id = ""
+ client-secret = ""
+ }
+ ]
+ }
+
+ # The database connection.
+ #
+ # By default a H2 file-based database is configured. You can
+ # provide a postgresql or mariadb connection here. When using H2
+ # use the PostgreSQL compatibility mode.
+ jdbc {
+ url = "jdbc:h2://"${java.io.tmpdir}"/sharry-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"
+ user = "sa"
+ password = ""
+ }
+
+ # Configuration for registering new users at the local database.
+ # Accounts registered here are checked via the `internal'
+ # authentication plugin as described above.
+ signup {
+
+ # The mode defines if new users can signup or not. It can have
+ # three values:
+ #
+ # - open: every new user can sign up
+ # - invite: new users can sign up only if they provide a correct
+ # invitation key. Invitation keys can be generated by an admin.
+ # - closed: signing up is disabled.
+ mode = "open"
+
+ # If mode == 'invite', this is the period an invitation token is
+ # considered valid.
+ invite-time = "14 days"
+
+ # A password that is required when generating invitation keys.
+ # This is more to protect against accidentally creating
+ # invitation keys. Generating such keys is only permitted to
+ # admin users.
+ invite-password = "generate-invite"
+ }
+
+
+ share {
+ # When storing binary data use chunks of this size.
+ chunk-size = "512K"
+
+ # Maximum size of a share.
+ max-size = "1.5G"
+
+ # Maximum validity for uploads
+ max-validity = 365 days
+
+ # If true, user that received files from their alias pages are
+ # notified via email (if they have an email address in ther
+ # profile)
+ enable-upload-notification = true
+ }
+
+ cleanup {
+ # Whether to enable the upload cleanup job that periodically
+ # removes invalid uploads
+ enabled = true
+
+ # The interval for the cleanup job
+ interval = 14 days
+
+ # Age of invalid uploads to get collected by cleanup job
+ invalid-age = 7 days
+ }
+
+ mail {
+
+ # Enable/Disable the mail feature.
+ #
+ # If it is disabled, the webapp will not show any related
+ # controls. Notifications are disabled, too.
+ #
+ # If enabled, explicit SMTP settings must be provided.
+ enabled = false
+
+ # The SMTP settings that are used to sent mails with.
+ smtp {
+ # Host and port of the SMTP server
+ host = "localhost"
+ port = 25
+
+ # User credentials to authenticate at the server. If the user
+ # is empty, mails are sent without authentication.
+ user = ""
+ password = ""
+
+ # One of: none, starttls, ssl
+ ssl-type = "starttls"
+
+ # In case of self-signed certificates or other problems like
+ # that, checking certificates can be disabled.
+ check-certificates = true
+
+ # Timeout for mail commands.
+ timeout = "10 seconds"
+
+ # The default mail address used for the `From' field.
+ #
+ # If left empty, the e-mail address of the current user is used.
+ default-from = ""
+
+ # When creating mails, the List-Id header is set to this value.
+ #
+ # This helps identifying these mails in muas. If it is empty,
+ # the header is not set.
+ list-id = "Sharry"
+ }
+
+ templates = {
+ download = {
+ subject = "Download ready."
+ body = """Hello,
+
+there are some files for you to download. Visit this link:
+
+{{{url}}}
+
+{{#password}}
+The required password will be sent by other means.
+{{/password}}
+
+
+Greetings,
+{{user}} via Sharry
+"""
+ }
+
+ alias = {
+ subject = "Link for Upload"
+ body = """Hello,
+
+please use the following link to sent files to me:
+
+{{{url}}}
+
+Greetings,
+{{user}} via Sharry
+"""
+ }
+
+ upload-notify = {
+ subject = "[Sharry] Files arrived"
+ body = """Hello {{user}},
+
+there have been files uploaded for you via the alias '{{aliasName}}'.
+View it here:
+
+{{{url}}}
+
+Greetings,
+Sharry
+"""
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/restserver/src/main/scala/sharry/restserver/Config.scala b/modules/restserver/src/main/scala/sharry/restserver/Config.scala
new file mode 100644
index 00000000..e7113848
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/Config.scala
@@ -0,0 +1,19 @@
+package sharry.restserver
+
+import sharry.backend.{Config => BackendConfig}
+import sharry.common._
+
+case class Config(
+ baseUrl: LenientUri,
+ bind: Config.Bind,
+ webapp: Config.Webapp,
+ backend: BackendConfig
+)
+
+object Config {
+
+ case class Bind(address: String, port: Int)
+
+ case class Webapp(appName: String, chunkSize: ByteSize, retryDelays: Seq[Duration])
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/sharry/restserver/ConfigFile.scala
new file mode 100644
index 00000000..189052c3
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/ConfigFile.scala
@@ -0,0 +1,46 @@
+package sharry.restserver
+
+import cats.implicits._
+import sharry.common.pureconfig.Implicits._
+import sharry.common.SignupMode
+import _root_.pureconfig._
+import _root_.pureconfig.generic.auto._
+import emil.MailAddress
+import emil.javamail.syntax._
+import emil.SSLType
+import yamusca.imports._
+
+object ConfigFile {
+ import Implicits._
+
+ def loadConfig: Config =
+ ConfigSource.default.at("sharry.restserver").loadOrThrow[Config]
+
+ object Implicits {
+ implicit val signupModeReader: ConfigReader[SignupMode] =
+ ConfigReader[String].emap(reason(SignupMode.fromString))
+
+ implicit val mailAddressReader: ConfigReader[Option[MailAddress]] =
+ ConfigReader[String].emap(
+ reason(s => if (s.trim.isEmpty) Right(None) else MailAddress.parse(s).map(m => Some(m)))
+ )
+
+ implicit val mailSslTypeReader: ConfigReader[SSLType] =
+ ConfigReader[String].emap(
+ reason(
+ s =>
+ s.toLowerCase match {
+ case "none" => Right(SSLType.NoEncryption)
+ case "starttls" => Right(SSLType.StartTLS)
+ case "ssl" => Right(SSLType.SSL)
+ case _ => Left(s"Invalid ssl type '$s'. Use one of none, ssl or starttls.")
+ }
+ )
+ )
+
+ implicit val templateReader: ConfigReader[Template] =
+ ConfigReader[String].emap(
+ reason(s => mustache.parse(s).leftMap(err => s"Error parsing template at ${err._1.pos}"))
+ )
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/CookieData.scala b/modules/restserver/src/main/scala/sharry/restserver/CookieData.scala
new file mode 100644
index 00000000..bc99309d
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/CookieData.scala
@@ -0,0 +1,57 @@
+package sharry.restserver
+
+import org.http4s._
+import org.http4s.util._
+import sharry.backend.auth._
+import sharry.common.AccountId
+
+case class CookieData(auth: AuthToken) {
+ def accountId: AccountId = auth.account
+ def asString: String = auth.asString
+
+ def asCookie(cfg: Config): ResponseCookie = {
+ val domain = cfg.baseUrl.host
+ val sec = cfg.baseUrl.scheme.exists(_.endsWith("s"))
+ val path = cfg.baseUrl.path / "api" / "v2"
+ ResponseCookie(
+ CookieData.cookieName,
+ asString,
+ domain = domain,
+ path = Some(path.asString),
+ httpOnly = true,
+ secure = sec
+ )
+ }
+}
+object CookieData {
+ val cookieName = "sharry_auth"
+ val headerName = "Sharry-Auth"
+
+ def authenticator[F[_]](r: Request[F]): Either[String, String] =
+ fromCookie(r).orElse(fromHeader(r))
+
+ def fromCookie[F[_]](req: Request[F]): Either[String, String] =
+ for {
+ header <- headers.Cookie.from(req.headers).toRight("Cookie parsing error")
+ cookie <- header.values.toList
+ .find(_.name == cookieName)
+ .toRight("Couldn't find the authcookie")
+ } yield cookie.content
+
+ def fromHeader[F[_]](req: Request[F]): Either[String, String] =
+ req.headers
+ .get(CaseInsensitiveString(headerName))
+ .map(_.value)
+ .toRight("Couldn't find an authenticator")
+
+ def deleteCookie(cfg: Config): ResponseCookie =
+ ResponseCookie(
+ cookieName,
+ "",
+ domain = cfg.baseUrl.host,
+ path = Some(cfg.baseUrl.path / "api" / "v2").map(_.asString),
+ httpOnly = true,
+ secure = cfg.baseUrl.scheme.exists(_.endsWith("s")),
+ maxAge = Some(-1)
+ )
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/Main.scala b/modules/restserver/src/main/scala/sharry/restserver/Main.scala
new file mode 100644
index 00000000..e76c0c6f
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/Main.scala
@@ -0,0 +1,66 @@
+package sharry.restserver
+
+import cats.effect._
+import cats.implicits._
+
+import scala.concurrent.ExecutionContext
+import java.util.concurrent.Executors
+import java.nio.file.{Files, Paths}
+
+import org.log4s._
+import sharry.common._
+import sharry.store.migrate.MigrateFrom06
+
+object Main extends IOApp {
+ private[this] val logger = getLogger
+
+ val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(
+ Executors.newCachedThreadPool(ThreadFactories.ofName("sharry-restserver-blocking"))
+ )
+ val blocker = Blocker.liftExecutionContext(blockingEc)
+ val connectEC: ExecutionContext = ExecutionContext.fromExecutorService(
+ Executors.newFixedThreadPool(5, ThreadFactories.ofName("sharry-dbconnect"))
+ )
+
+ def run(args: List[String]) = {
+ args match {
+ case file :: Nil =>
+ val path = Paths.get(file).toAbsolutePath.normalize
+ logger.info(s"Using given config file: $path")
+ System.setProperty("config.file", file)
+ case _ =>
+ Option(System.getProperty("config.file")) match {
+ case Some(f) if f.nonEmpty =>
+ val path = Paths.get(f).toAbsolutePath.normalize
+ if (!Files.exists(path)) {
+ logger.info(s"Not using config file '$f' because it doesn't exist")
+ System.clearProperty("config.file")
+ } else {
+ logger.info(s"Using config file from system properties: $f")
+ }
+ case _ =>
+ }
+ }
+
+ val cfg = ConfigFile.loadConfig
+ val banner = Banner(
+ BuildInfo.version,
+ BuildInfo.gitHeadCommit,
+ cfg.backend.jdbc.url,
+ Option(System.getProperty("config.file")),
+ cfg.baseUrl
+ )
+ logger.info(s"\n${banner.render("***>")}")
+ if ("true" == System.getProperty("sharry.migrate-old-dbschema")) {
+ MigrateFrom06[IO](cfg.backend.jdbc, connectEC, blocker).
+ use(mig => mig.migrate).
+ as(ExitCode.Success)
+ } else {
+ RestServer
+ .stream[IO](cfg, ExecutionContext.global, connectEC, blocker)
+ .compile
+ .drain
+ .as(ExitCode.Success)
+ }
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/RestApp.scala b/modules/restserver/src/main/scala/sharry/restserver/RestApp.scala
new file mode 100644
index 00000000..38e179b0
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/RestApp.scala
@@ -0,0 +1,12 @@
+package sharry.restserver
+
+import sharry.backend.BackendApp
+
+trait RestApp[F[_]] {
+
+ def config: Config
+
+ def init: F[Unit]
+
+ def backend: BackendApp[F]
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/sharry/restserver/RestAppImpl.scala
new file mode 100644
index 00000000..cdf7bb45
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/RestAppImpl.scala
@@ -0,0 +1,33 @@
+package sharry.restserver
+
+import cats.implicits._
+import cats.effect._
+import sharry.backend.BackendApp
+
+import scala.concurrent.ExecutionContext
+
+final class RestAppImpl[F[_]: Sync](val config: Config, val backend: BackendApp[F])
+ extends RestApp[F] {
+
+ def init: F[Unit] =
+ Sync[F].pure(())
+
+ def shutdown: F[Unit] =
+ ().pure[F]
+
+}
+
+object RestAppImpl {
+
+ def create[F[_]: ConcurrentEffect: ContextShift: Timer](
+ cfg: Config,
+ connectEC: ExecutionContext,
+ blocker: Blocker
+ ): Resource[F, RestApp[F]] =
+ for {
+ backend <- BackendApp(cfg.backend, connectEC, blocker)
+ app = new RestAppImpl[F](cfg, backend)
+ appR <- Resource.make(app.init.map(_ => app))(_.shutdown)
+ } yield appR
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/RestServer.scala b/modules/restserver/src/main/scala/sharry/restserver/RestServer.scala
new file mode 100644
index 00000000..8cc87307
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/RestServer.scala
@@ -0,0 +1,136 @@
+package sharry.restserver
+
+import cats.data.Kleisli
+import cats.data.OptionT
+import cats.effect._
+import cats.implicits._
+import fs2.Stream
+import org.http4s.HttpRoutes
+import org.http4s.Response
+import org.http4s.client.Client
+import org.http4s.client.blaze.BlazeClientBuilder
+import org.http4s.implicits._
+import org.http4s.server.Router
+import org.http4s.server.blaze.BlazeServerBuilder
+import org.http4s.server.middleware.Logger
+import org.log4s.getLogger
+import scala.concurrent.ExecutionContext
+
+import sharry.common.syntax.all._
+import sharry.backend.auth.AuthToken
+import sharry.restserver.routes._
+import sharry.restserver.webapp._
+
+object RestServer {
+ private[this] val logger = getLogger
+
+ def stream[F[_]: ConcurrentEffect](
+ cfg: Config,
+ ec: ExecutionContext,
+ connectEC: ExecutionContext,
+ blocker: Blocker
+ )(
+ implicit T: Timer[F],
+ CS: ContextShift[F]
+ ): Stream[F, Nothing] = {
+
+ val templates = TemplateRoutes[F](blocker, cfg)
+ val app = for {
+ restApp <- RestAppImpl.create[F](cfg, connectEC, blocker)
+ _ <- Resource.liftF(restApp.init)
+ client <- BlazeClientBuilder[F](ec).resource
+
+ httpApp = Router(
+ "/api/v2/open/" -> openRoutes(cfg, client, restApp),
+ "/api/v2/sec/" -> Authenticate(restApp.backend.login, cfg.backend.auth) { token =>
+ securedRoutes(cfg, restApp, token)
+ },
+ "/api/v2/alias/" -> Authenticate.alias(restApp.backend.login, cfg.backend.auth) { token =>
+ aliasRoutes[F](cfg, restApp, token)
+ },
+ "/api/v2/admin/" -> Authenticate(restApp.backend.login, cfg.backend.auth) { token =>
+ if (token.account.admin) adminRoutes(cfg, restApp, token)
+ else notFound[F](token)
+ },
+ "/api/doc" -> templates.doc,
+ "/app/assets" -> WebjarRoutes.appRoutes[F](blocker, cfg),
+ "/app" -> templates.app
+ ).orNotFound
+
+ // With Middlewares in place
+ finalHttpApp = Logger.httpApp(false, false)(httpApp)
+
+ } yield finalHttpApp
+
+ Stream
+ .resource(app)
+ .flatMap(
+ httpApp =>
+ BlazeServerBuilder[F]
+ .bindHttp(cfg.bind.port, cfg.bind.address)
+ .withHttpApp(httpApp)
+ .withoutBanner
+ .serve
+ )
+
+ }.drain
+
+ def aliasRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F], token: AuthToken): HttpRoutes[F] =
+ Router(
+ "upload" -> ShareUploadRoutes(
+ restApp.backend,
+ token,
+ cfg,
+ cfg.baseUrl / "api" / "v2" / "alias" / "upload"
+ ),
+ "mail" -> NotifyRoutes(restApp.backend, token, cfg)
+ )
+
+ def securedRoutes[F[_]: Effect](
+ cfg: Config,
+ restApp: RestApp[F],
+ token: AuthToken
+ ): HttpRoutes[F] =
+ Router(
+ "auth" -> LoginRoutes.session(restApp.backend.login, cfg),
+ "settings" -> SettingRoutes(restApp.backend, token, cfg),
+ "alias" -> AliasRoutes(restApp.backend, token, cfg),
+ "share" -> ShareRoutes(restApp.backend, token, cfg),
+ "upload" -> ShareUploadRoutes(
+ restApp.backend,
+ token,
+ cfg,
+ cfg.baseUrl / "api" / "v2" / "sec" / "upload"
+ ),
+ "mail" -> MailRoutes(restApp.backend, token, cfg)
+ )
+
+ def adminRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F], token: AuthToken): HttpRoutes[F] =
+ Router(
+ "signup" -> RegisterRoutes(restApp.backend, cfg).genInvite,
+ "account" -> AccountRoutes(restApp.backend, cfg)
+ )
+
+ def openRoutes[F[_]: ConcurrentEffect](
+ cfg: Config,
+ client: Client[F],
+ restApp: RestApp[F]
+ ): HttpRoutes[F] =
+ Router(
+ "info" -> InfoRoutes(cfg),
+ "auth" -> LoginRoutes.login(restApp.backend, client, cfg),
+ "signup" -> RegisterRoutes(restApp.backend, cfg).signup,
+ "share" -> OpenShareRoutes(restApp.backend, cfg)
+ )
+
+ def notFound[F[_]: Effect](token: AuthToken): HttpRoutes[F] =
+ Kleisli(
+ req =>
+ OptionT.liftF(
+ logger
+ .finfo[F](s"Non-admin '${token.account}' calling admin routes")
+ .map(_ => Response.notFound[F])
+ )
+ )
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/oauth/CodeFlow.scala b/modules/restserver/src/main/scala/sharry/restserver/oauth/CodeFlow.scala
new file mode 100644
index 00000000..54de241b
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/oauth/CodeFlow.scala
@@ -0,0 +1,124 @@
+package sharry.restserver.oauth
+
+import cats.data.OptionT
+import cats.effect.ConcurrentEffect
+import cats.implicits._
+import org.http4s._
+import org.http4s.Method._
+import org.http4s.client.Client
+import org.http4s.client.dsl.Http4sClientDsl
+import org.http4s.client.middleware.RequestLogger
+import org.http4s.headers.Accept
+import org.http4s.headers.Authorization
+import org.http4s.circe.CirceEntityCodec._
+import org.log4s.getLogger
+
+import sharry.common.syntax.all._
+import sharry.backend.auth.AuthConfig
+import io.circe.Json
+import org.http4s.client.middleware.ResponseLogger
+import sharry.common.Ident
+
+object CodeFlow {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: ConcurrentEffect](
+ client: Client[F]
+ )(cfg: AuthConfig.OAuth, redirectUri: String, code: String): OptionT[F, Ident] = {
+
+ val dsl = new Http4sClientDsl[F] {}
+ val c = logRequests[F](logResponses[F](client))
+
+ for {
+ _ <- OptionT.liftF(
+ logger.fdebug[F](s"Obtaining access_token for provider ${cfg.id.id} and code $code")
+ )
+ token <- codeToToken[F](c, dsl, cfg, redirectUri, code)
+ _ <- OptionT.liftF(
+ logger.fdebug[F](s"Obtaining user-info for provider ${cfg.id.id} and token $token")
+ )
+ user <- tokenToUser[F](c, dsl, cfg, token)
+ } yield user
+ }
+
+ private def codeToToken[F[_]: ConcurrentEffect](
+ c: Client[F],
+ dsl: Http4sClientDsl[F],
+ cfg: AuthConfig.OAuth,
+ redirectUri: String,
+ code: String
+ ): OptionT[F, String] = {
+ import dsl._
+
+ val req = POST(
+ UrlForm(
+ "client_id" -> cfg.clientId,
+ "client_secret" -> cfg.clientSecret,
+ "code" -> code,
+ "grant_type" -> "authorization_code",
+ "redirect_uri" -> redirectUri
+ ),
+ Uri.unsafeFromString(cfg.tokenUrl.asString)
+ )
+
+ OptionT(c.fetch(req) {
+ case Status.Successful(r) =>
+ val u1 = r.as[UrlForm].map(_.getFirst("access_token"))
+ val u2 = r.as[Json].map(_.asObject.flatMap(_.apply("access_token")).flatMap(_.asString))
+ u1.recoverWith(_ => u2).flatTap(at => logger.finfo(s"Got token: $at"))
+ case r =>
+ logger
+ .ferror[F](s"Error obtaining access token '${r.status.code}' / ${r.as[String]}")
+ .map(_ => None)
+ })
+ }
+
+ private def tokenToUser[F[_]: ConcurrentEffect](
+ c: Client[F],
+ dsl: Http4sClientDsl[F],
+ cfg: AuthConfig.OAuth,
+ token: String
+ ): OptionT[F, Ident] = {
+ import dsl._
+
+ val req = GET(
+ Uri.unsafeFromString(cfg.userUrl.asString),
+ Authorization(Credentials.Token(AuthScheme.Bearer, token)),
+ Accept(MediaType.application.json)
+ )
+
+ val resp: F[Option[Ident]] = c.fetch(req) {
+ case Status.Successful(r) =>
+ r.as[Json]
+ .flatTap(j => logger.ftrace(s"user structure: ${j.noSpaces}"))
+ .map(j => j.findAllByKey(cfg.userIdKey).find(_.isString).flatMap(_.asString))
+ .map(_.map(normalizeUid))
+ .flatTap(uid => logger.finfo(s"Got user id: $uid"))
+ case r =>
+ r.as[String]
+ .flatMap(err => logger.ferror(s"Cannot obtain user info: ${r.status.code} / ${err}"))
+ .map(_ => None)
+
+ }
+
+ OptionT(resp)
+ }
+
+ private def normalizeUid(uid: String): Ident =
+ Ident.unsafe(uid.filter(Ident.chars.contains))
+
+ private def logRequests[F[_]: ConcurrentEffect](c: Client[F]): Client[F] =
+ RequestLogger(
+ logHeaders = true,
+ logBody = true,
+ logAction = Some((msg: String) => logger.ftrace[F](msg))
+ )(c)
+
+ private def logResponses[F[_]: ConcurrentEffect](c: Client[F]): Client[F] =
+ ResponseLogger(
+ logHeaders = true,
+ logBody = true,
+ logAction = Some((msg: String) => logger.ftrace[F](msg))
+ )(c)
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/AccountRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/AccountRoutes.scala
new file mode 100644
index 00000000..067638cf
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/AccountRoutes.scala
@@ -0,0 +1,84 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s._
+
+import sharry.backend.BackendApp
+import sharry.restapi.model._
+import sharry.restserver.Config
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.store.records.ModAccount
+import cats.data.OptionT
+import sharry.backend.account.{NewAccount, AccountItem}
+
+object AccountRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ val r1 = HttpRoutes[F]({
+ case GET -> Root / Ident(id) =>
+ for {
+ _ <- OptionT.liftF(logger.fdebug(s"Loading accout $id"))
+ acc <- OptionT(backend.account.findDetailById(id))
+ resp <- OptionT.liftF(Ok(accountDetail(acc)))
+ } yield resp
+ })
+ val r2 = HttpRoutes.of[F] {
+ case req @ POST -> Root / Ident(id) =>
+ for {
+ in <- req.as[AccountModify]
+ res <- backend.account.modify(id, ModAccount(in.state, in.admin, in.email, in.password))
+ resp <- Ok(Conv.basicResult(res, "Account successfully modified."))
+ } yield resp
+
+ case req @ GET -> Root =>
+ val q = req.params.getOrElse("q", "")
+ for {
+ _ <- logger.ftrace(s"Listing accounts: $q")
+ all <- backend.account.findAccounts(q).take(100).compile.toVector
+ list = AccountList(all.map(accountDetail).toList)
+ resp <- Ok(list)
+ } yield resp
+
+ case req @ POST -> Root =>
+ for {
+ in <- req.as[AccountCreate]
+ acc <- NewAccount.create(
+ in.login,
+ AccountSource.Intern,
+ in.state,
+ in.password,
+ in.email,
+ in.admin
+ )
+ res <- backend.account.create(acc)
+ resp <- Ok(Conv.basicResult(res, "Account successfully created."))
+ } yield resp
+ }
+ r2 <+> r1
+ }
+
+ def accountDetail(a: AccountItem): AccountDetail =
+ AccountDetail(
+ a.acc.id,
+ a.acc.login,
+ a.acc.source,
+ a.acc.state,
+ a.acc.admin,
+ a.acc.email,
+ a.acc.loginCount,
+ a.shares,
+ a.acc.lastLogin,
+ a.acc.created
+ )
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/AliasRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/AliasRoutes.scala
new file mode 100644
index 00000000..7518af9f
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/AliasRoutes.scala
@@ -0,0 +1,79 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s.getLogger
+
+import sharry.backend.BackendApp
+import sharry.backend.auth.AuthToken
+import sharry.restapi.model._
+import sharry.restserver.Config
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.store.records.RAlias
+import cats.data.OptionT
+
+object AliasRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], token: AuthToken, cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ POST -> Root =>
+ for {
+ in <- req.as[AliasChange]
+ _ <- logger.fdebug(s"Create new alias for ${token.account}")
+ na <- RAlias.createNew[F](token.account.id, in.name, in.validity, in.enabled)
+ res <- backend.alias.create(na)
+ resp <- Ok(convert(Conv.basicResult(res, "Alias successfully created."), na.id))
+ } yield resp
+
+ case req @ GET -> Root =>
+ val q = req.params.getOrElse("q", "")
+ for {
+ _ <- logger.ftrace(s"Listing aliases for ${token.account}")
+ list <- backend.alias.findAll(token.account.id, q).take(100).compile.toVector
+ resp <- Ok(AliasList(list.map(convert).toList))
+ } yield resp
+
+ case req @ POST -> Root / Ident(id) =>
+ for {
+ in <- req.as[AliasChange]
+ _ <- logger.fdebug(s"Change alias $id to $in")
+ na <- RAlias.createNew[F](token.account.id, in.name, in.validity, in.enabled)
+ res <- backend.alias.modify(id, token.account.id, na.copy(id = in.id.getOrElse(na.id)))
+ resp <- Ok(
+ convert(
+ Conv.basicResult(res, "Alias successfully modified."),
+ in.id.getOrElse(na.id)
+ )
+ )
+ } yield resp
+
+ case GET -> Root / Ident(id) =>
+ val opt = for {
+ adb <- OptionT(backend.alias.findById(id, token.account.id))
+ resp <- OptionT.liftF(Ok(convert(adb)))
+ } yield resp
+ opt.getOrElseF(NotFound())
+
+ case DELETE -> Root / Ident(id) =>
+ for {
+ res <- backend.alias.delete(id, token.account.id)
+ resp <- Ok(BasicResult(res, if (res) "Alias deleted." else "Alias not found"))
+ } yield resp
+ }
+ }
+
+ def convert(r: RAlias): AliasDetail =
+ AliasDetail(r.id, r.name, r.validity, r.enabled, r.created)
+
+ def convert(br: BasicResult, aliasId: Ident): IdResult =
+ IdResult(br.success, br.message, aliasId)
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/Authenticate.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/Authenticate.scala
new file mode 100644
index 00000000..049af8df
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/Authenticate.scala
@@ -0,0 +1,82 @@
+package sharry.restserver.routes
+
+import cats.data._
+import cats.effect._
+import cats.implicits._
+import sharry.backend.auth._
+import sharry.restserver._
+import org.http4s._
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.server._
+import org.http4s.syntax.string._
+
+object Authenticate {
+
+ def authenticateRequest[F[_]: Effect](
+ auth: String => F[LoginResult]
+ )(req: Request[F]): F[LoginResult] =
+ CookieData.authenticator(req) match {
+ case Right(str) => auth(str)
+ case Left(_) => LoginResult.invalidAuth.pure[F]
+ }
+
+ def of[F[_]: Effect](S: Login[F], cfg: AuthConfig)(
+ pf: PartialFunction[AuthedRequest[F, AuthToken], F[Response[F]]]
+ ): HttpRoutes[F] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ val middleware = createAuthMiddleware(dsl, S, cfg)
+
+ middleware(AuthedRoutes.of(pf))
+ }
+
+ def apply[F[_]: Effect](S: Login[F], cfg: AuthConfig)(
+ f: AuthToken => HttpRoutes[F]
+ ): HttpRoutes[F] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ val middleware = createAuthMiddleware(dsl, S, cfg)
+
+ middleware(AuthedRoutes(authReq => f(authReq.context).run(authReq.req)))
+ }
+
+ def alias[F[_]: Effect](S: Login[F], cfg: AuthConfig)(
+ f: AuthToken => HttpRoutes[F]
+ ): HttpRoutes[F] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ import dsl._
+
+ def aliasId(req: Request[F]): String =
+ req.headers.get("sharry-alias".ci).map(_.value).getOrElse("")
+
+ val authUser: Kleisli[F, Request[F], Either[String, AuthToken]] =
+ Kleisli(r => S.loginAlias(cfg)(aliasId(r)).map(_.toEither))
+
+ val onFailure: AuthedRoutes[String, F] =
+ Kleisli(req => OptionT.liftF(Forbidden(req.context)))
+
+ val middleware = AuthMiddleware(authUser, onFailure)
+
+ middleware(AuthedRoutes(authReq => f(authReq.context).run(authReq.req)))
+ }
+
+ private def getUser[F[_]: Effect](
+ auth: String => F[LoginResult]
+ ): Kleisli[F, Request[F], Either[String, AuthToken]] =
+ Kleisli(r => authenticateRequest(auth)(r).map(_.toEither))
+
+ private def createAuthMiddleware[F[_]: Effect](
+ dsl: Http4sDsl[F],
+ S: Login[F],
+ cfg: AuthConfig
+ ): AuthMiddleware[F, AuthToken] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ import dsl._
+
+ val authUser = getUser[F](S.loginSession(cfg))
+
+ val onFailure: AuthedRoutes[String, F] =
+ Kleisli(req => OptionT.liftF(Forbidden(req.context)))
+
+ AuthMiddleware(authUser, onFailure)
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/ByteResponse.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/ByteResponse.scala
new file mode 100644
index 00000000..ec776b36
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/ByteResponse.scala
@@ -0,0 +1,140 @@
+package sharry.restserver.routes
+
+import cats.data.OptionT
+import cats.implicits._
+import org.http4s._
+import org.http4s.headers._
+import org.http4s.dsl.Http4sDsl
+import bitpeace.FileMeta
+import sharry.common._
+import sharry.backend.share._
+import sharry.backend.BackendApp
+import bitpeace.RangeDef
+import cats.data.Ior
+import cats.effect.Sync
+
+object ByteResponse {
+
+ def apply[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ req: Request[F],
+ backend: BackendApp[F],
+ shareId: ShareId,
+ pass: Option[Password],
+ fid: Ident
+ ) =
+ req.headers
+ .get(Range)
+ .map(_.ranges.head)
+ .map(sr => range(dsl, sr, req, backend, shareId, pass, fid))
+ .getOrElse(all(dsl, req, backend, shareId, pass, fid))
+
+ def range[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ sr: Range.SubRange,
+ req: Request[F],
+ backend: BackendApp[F],
+ shareId: ShareId,
+ pass: Option[Password],
+ fid: Ident
+ ): F[Response[F]] = {
+ import dsl._
+
+ val rangeDef = sr.second
+ .map(until => RangeDef.byteRange(Ior.both(sr.first.toInt, until.toInt)))
+ .getOrElse {
+ if (sr.first == 0) RangeDef.all
+ else RangeDef.byteRange(Ior.left(sr.first.toInt))
+ }
+
+ (for {
+ file <- backend.share.loadFile(shareId, fid, pass, rangeDef)
+ resp <- OptionT.liftF {
+ if (rangeInvalid(file.fileMeta, sr)) RangeNotSatisfiable()
+ else partialResponse(dsl, file, sr)
+ }
+ } yield resp).getOrElseF(NotFound())
+ }
+
+ def all[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ req: Request[F],
+ backend: BackendApp[F],
+ shareId: ShareId,
+ pass: Option[Password],
+ fid: Ident
+ ): F[Response[F]] = {
+ import dsl._
+
+ (for {
+ file <- backend.share.loadFile(shareId, fid, pass, RangeDef.all)
+ resp <- OptionT.liftF(
+ etag(dsl, req, file).getOrElseF(
+ Ok(file.data).map(
+ _.withHeaders(
+ `Content-Type`(mediaType(file)),
+ `Accept-Ranges`.bytes,
+ `Last-Modified`(timestamp(file)),
+ `Content-Disposition`("inline", fileNameMap(file)),
+ ETag(file.fileMeta.checksum),
+ `Content-Length`.unsafeFromLong(file.fileMeta.length)
+ )
+ )
+ )
+ )
+ } yield resp).getOrElseF(NotFound())
+ }
+
+ private def etag[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ req: Request[F],
+ file: FileRange[F]
+ ): OptionT[F, Response[F]] = {
+ import dsl._
+
+ val noneMatch = req.headers.get(`If-None-Match`).flatMap(_.tags).map(_.head.tag)
+
+ if (Some(file.fileMeta.checksum) == noneMatch) OptionT.liftF(NotModified())
+ else OptionT.none
+ }
+
+ private def partialResponse[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ file: FileRange[F],
+ range: Range.SubRange
+ ): F[Response[F]] = {
+ import dsl._
+ val len = file.fileMeta.length
+ PartialContent(file.data).map(
+ _.withHeaders(
+ `Accept-Ranges`.bytes,
+ `Content-Type`(mediaType(file)),
+ `Last-Modified`(timestamp(file)),
+ `Content-Disposition`("inline", fileNameMap(file)),
+ `Content-Length`
+ .unsafeFromLong(range.second.getOrElse(len) - range.first),
+ `Content-Range`(RangeUnit.Bytes, subRangeResp(range, len), Some(len))
+ )
+ )
+ }
+
+ private def subRangeResp(in: Range.SubRange, length: Long): Range.SubRange =
+ in match {
+ case Range.SubRange(n, None) =>
+ Range.SubRange(n.toLong, Some(length - 1))
+ case Range.SubRange(n, Some(t)) =>
+ Range.SubRange(n, Some(t))
+ }
+
+ private def rangeInvalid(file: FileMeta, range: Range.SubRange): Boolean =
+ range.first < 0 || range.second.exists(t => t < range.first || t > file.length)
+
+ private def mediaType[F[_]](file: FileRange[F]) =
+ MediaType.unsafeParse(file.fileMeta.mimetype.asString)
+
+ private def timestamp[F[_]](file: FileRange[F]) =
+ HttpDate.unsafeFromInstant(file.fileMeta.timestamp)
+
+ private def fileNameMap[F[_]](file: FileRange[F]) =
+ file.shareFile.filename.map(n => Map("filename" -> n)).getOrElse(Map.empty)
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/Conv.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/Conv.scala
new file mode 100644
index 00000000..6e89d010
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/Conv.scala
@@ -0,0 +1,46 @@
+package sharry.restserver.routes
+
+import sharry.common.Ident
+import sharry.store.AddResult
+import sharry.restapi.model.BasicResult
+import sharry.restapi.model.IdResult
+import sharry.backend.share.UploadResult
+
+object Conv {
+
+ def basicResult(ar: AddResult, successMsg: String): BasicResult =
+ ar match {
+ case AddResult.Success =>
+ BasicResult(true, successMsg)
+ case AddResult.EntityExists(msg) =>
+ BasicResult(false, msg)
+ case AddResult.Failure(ex) =>
+ BasicResult(false, ex.getMessage)
+ }
+
+ def idResult(successMsg: String)(ar: Either[Throwable, Ident]): IdResult =
+ ar match {
+ case Right(id) => IdResult(true, successMsg, id)
+ case Left(ex) => IdResult(false, s"${ex.getClass}: ${ex.getMessage}", Ident.empty)
+ }
+
+ def uploadResult(successMsg: String)(ur: UploadResult[Ident]): IdResult =
+ ur match {
+ case UploadResult.Success(id) =>
+ IdResult(true, successMsg, id)
+ case UploadResult.ValidityExceeded(max) =>
+ IdResult(false, s"Maximum validity ($max) exceeded", Ident.empty)
+ case UploadResult.SizeExceeded(max) =>
+ IdResult(false, s"Maximum size ($max) exceeded", Ident.empty)
+ }
+
+ def uploadBasicResult[A](successMsg: String)(ur: UploadResult[A]): BasicResult =
+ ur match {
+ case UploadResult.Success(_) =>
+ BasicResult(true, successMsg)
+ case UploadResult.ValidityExceeded(max) =>
+ BasicResult(false, s"Maximum validity ($max) exceeded")
+ case UploadResult.SizeExceeded(max) =>
+ BasicResult(false, s"Maximum size ($max) exceeded")
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala
new file mode 100644
index 00000000..e7f98a9e
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala
@@ -0,0 +1,47 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import org.http4s._
+import org.http4s.HttpRoutes
+import org.http4s.dsl.Http4sDsl
+import org.http4s.circe.CirceEntityEncoder._
+
+import sharry.restapi.model._
+import sharry.restserver.{BuildInfo, Config}
+
+object InfoRoutes {
+
+ def apply[F[_]: Sync](cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+ HttpRoutes.of[F] {
+ case GET -> Root / "version" =>
+ Ok(
+ VersionInfo(
+ BuildInfo.version,
+ BuildInfo.builtAtMillis,
+ BuildInfo.builtAtString,
+ BuildInfo.gitHeadCommit.getOrElse(""),
+ BuildInfo.gitDescribedVersion.getOrElse("")
+ )
+ )
+ case GET -> Root / "appconfig" =>
+ Ok(appConfig(cfg))
+ }
+ }
+
+ def appConfig(cfg: Config): AppConfig =
+ AppConfig(
+ cfg.webapp.appName,
+ cfg.baseUrl,
+ s"/app/assets/sharry-webapp/${BuildInfo.version}",
+ cfg.backend.signup.mode,
+ cfg.backend.auth.oauth.filter(_.enabled).map(oa => OAuthItem(oa.id, oa.name, oa.icon)).toList,
+ cfg.webapp.chunkSize.bytes,
+ cfg.webapp.retryDelays.map(_.millis).toList,
+ cfg.backend.share.maxValidity,
+ cfg.backend.share.maxSize,
+ cfg.backend.mail.enabled
+ )
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala
new file mode 100644
index 00000000..73e9160e
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala
@@ -0,0 +1,138 @@
+package sharry.restserver.routes
+
+import cats.data.OptionT
+import cats.effect._
+import cats.implicits._
+import org.http4s._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Location
+import org.log4s._
+
+import sharry.backend.BackendApp
+import sharry.backend.account.NewAccount
+import sharry.backend.auth._
+import sharry.common._
+import sharry.restapi.model._
+import sharry.restserver._
+import sharry.restserver.oauth.CodeFlow
+import org.http4s.client.Client
+
+object LoginRoutes {
+ private[this] val logger = getLogger
+
+ def login[F[_]: ConcurrentEffect](
+ S: BackendApp[F],
+ client: Client[F],
+ cfg: Config
+ ): HttpRoutes[F] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of[F] {
+ case req @ POST -> Root / "login" =>
+ for {
+ up <- req.as[UserPass]
+ res <- S.login.loginUserPass(cfg.backend.auth)(
+ UserPassData(up.account, Password(up.password))
+ )
+ resp <- makeResponse(dsl, cfg, res)
+ } yield resp
+
+ case req @ GET -> Root / "oauth" / id =>
+ findOAuthProvider(cfg, id) match {
+ case Some(p) =>
+ val uri = p.authorizeUrl
+ .withQuery("client_id", p.clientId)
+ .withQuery(
+ "redirect_uri",
+ redirectUri(cfg, p).asString
+ )
+ .withQuery("response_type", "code")
+ logger.debug(s"Redirecting to OAuth provider ${p.id.id}: ${uri.asString}")
+ SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString))))
+ case None =>
+ logger.debug(s"No oauth provider found with id '$id'")
+ BadRequest()
+ }
+
+ case req @ GET -> Root / "oauth" / id / "resume" =>
+ val prov = OptionT.fromOption[F](findOAuthProvider(cfg, id))
+ val code = OptionT.fromOption[F](req.params.get("code"))
+
+ val userId = for {
+ p <- prov
+ c <- code
+ u <- CodeFlow(client)(p, redirectUri(cfg, p).asString, c)
+ acc <- OptionT.liftF(
+ NewAccount.create(u ++ Ident.atSign ++ p.id, AccountSource.OAuth(p.id.id))
+ )
+ id <- OptionT.liftF(S.account.createIfMissing(acc))
+ accId = AccountId(id, acc.login, false, None)
+ _ <- OptionT.liftF(S.account.updateLoginStats(accId))
+ token <- OptionT.liftF(
+ AuthToken.user[F](accId, cfg.backend.auth.serverSecret)
+ )
+ } yield token
+
+ val uri = cfg.baseUrl.withQuery("oauth", "1") / "app" / "login"
+ val location = Location(Uri.unsafeFromString(uri.asString))
+ userId.value.flatMap {
+ case Some(t) =>
+ TemporaryRedirect(location)
+ .map(_.addCookie(CookieData(t).asCookie(cfg)))
+ case None => TemporaryRedirect(location)
+ }
+ }
+ }
+
+ private def redirectUri(cfg: Config, prov: AuthConfig.OAuth): LenientUri =
+ cfg.baseUrl / "api" / "v2" / "open" / "auth" / "oauth" / prov.id.id / "resume"
+
+ private def findOAuthProvider(cfg: Config, id: String): Option[AuthConfig.OAuth] =
+ cfg.backend.auth.oauth.filter(_.enabled).find(_.id.id == id)
+
+ def session[F[_]: Effect](S: Login[F], cfg: Config): HttpRoutes[F] = {
+ val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of[F] {
+ case req @ POST -> Root / "session" =>
+ Authenticate
+ .authenticateRequest(S.loginSession(cfg.backend.auth))(req)
+ .flatMap(res => makeResponse(dsl, cfg, res))
+
+ case POST -> Root / "logout" =>
+ Ok().map(_.addCookie(CookieData.deleteCookie(cfg)))
+ }
+ }
+
+ def makeResponse[F[_]: Effect](
+ dsl: Http4sDsl[F],
+ cfg: Config,
+ res: LoginResult
+ ): F[Response[F]] = {
+ import dsl._
+ res match {
+ case LoginResult.Ok(token) =>
+ for {
+ cd <- AuthToken.user(token.account, cfg.backend.auth.serverSecret).map(CookieData.apply)
+ resp <- Ok(
+ AuthResult(
+ token.account.id,
+ token.account.userLogin,
+ token.account.admin,
+ true,
+ "Login successful",
+ Some(cd.asString),
+ cfg.backend.auth.sessionValid.millis
+ )
+ ).map(_.addCookie(cd.asCookie(cfg)))
+ } yield resp
+ case _ =>
+ Ok(AuthResult(Ident.empty, Ident.empty, false, false, "Login failed.", None, 0L))
+ }
+ }
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/MailRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/MailRoutes.scala
new file mode 100644
index 00000000..ee883d0f
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/MailRoutes.scala
@@ -0,0 +1,84 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import cats.data.EitherT
+import cats.data.OptionT
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s.getLogger
+
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.backend.auth.AuthToken
+import sharry.backend.BackendApp
+import sharry.backend.mail.{MailData, MailSendResult}
+import sharry.restserver.Config
+import sharry.restapi.model.BasicResult
+import sharry.restapi.model.MailTemplate
+import sharry.restapi.model.SimpleMail
+import emil.MailAddress
+import emil.javamail.syntax._
+
+object MailRoutes {
+
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], token: AuthToken, cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ val baseurl = cfg.baseUrl / "app"
+ HttpRoutes.of {
+ case GET -> Root / "template" / "alias" / Ident(id) =>
+ for {
+ md <- backend.mail.getAliasTemplate(token.account, id, baseurl / "share")
+ resp <- Ok(MailTemplate(md.subject, md.body))
+ } yield resp
+
+ case GET -> Root / "template" / "share" / Ident(id) =>
+ (for {
+ md <- backend.mail.getShareTemplate(token.account, id, baseurl / "open")
+ resp <- OptionT.liftF(Ok(MailTemplate(md.subject, md.body)))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root / "send" =>
+ def parseAddress(m: SimpleMail): Either[String, List[MailAddress]] =
+ m.recipients.traverse(MailAddress.parse)
+
+ def send(rec: List[MailAddress], sm: SimpleMail): F[MailSendResult] =
+ backend.mail
+ .sendMail(token.account, rec, MailData(sm.subject, sm.body))
+
+ val res = for {
+ mail <- EitherT.liftF(req.as[SimpleMail])
+ rec <- EitherT.fromEither[F](parseAddress(mail))
+ res <- EitherT.liftF[F, String, MailSendResult](send(rec, mail))
+ _ <- EitherT.liftF[F, String, Unit](logger.fdebug(s"Sending mail: $res"))
+ } yield res
+
+ res.foldF(
+ err => Ok(BasicResult(false, s"Some recipient addresses are invalid: $err")),
+ r => Ok(mailSendResult(r))
+ )
+ }
+ }
+
+ private def mailSendResult(mr: MailSendResult): BasicResult =
+ mr match {
+ case MailSendResult.Success => BasicResult(true, "Mail successfully sent.")
+ case MailSendResult.SendFailure(ex) =>
+ BasicResult(false, s"Mail sending failed: ${ex.getMessage}")
+ case MailSendResult.NoRecipients => BasicResult(false, "There are no recipients")
+ case MailSendResult.NoSender =>
+ BasicResult(
+ false,
+ "There are no sender addresses specified. You " +
+ "may need to add an e-mail address to your account."
+ )
+ case MailSendResult.FeatureDisabled =>
+ BasicResult(false, "The mail feature is disabled")
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/NotifyRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/NotifyRoutes.scala
new file mode 100644
index 00000000..56327681
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/NotifyRoutes.scala
@@ -0,0 +1,60 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s.getLogger
+
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.backend.auth.AuthToken
+import sharry.backend.mail.NotifyResult
+import sharry.backend.BackendApp
+import sharry.restserver.Config
+import sharry.restapi.model.BasicResult
+
+object NotifyRoutes {
+
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], token: AuthToken, cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ POST -> Root / "notify" / Ident(id) =>
+ token.account.alias match {
+ case Some(alias) =>
+ val baseurl = cfg.baseUrl / "app" / "upload"
+ for {
+ _ <- logger.fdebug("Notify about alias upload")
+ res <- backend.mail.notifyAliasUpload(alias, id, baseurl)
+ resp <- Ok(basicResult(res))
+ } yield resp
+
+ case None =>
+ NotFound()
+ }
+ }
+ }
+
+ private def basicResult(n: NotifyResult): BasicResult = n match {
+ case NotifyResult.InvalidAlias =>
+ BasicResult(false, "Invalid alias")
+
+ case NotifyResult.FeatureDisabled =>
+ BasicResult(false, "Mail feature is disabled.")
+
+ case NotifyResult.MissingEmail =>
+ BasicResult(false, "There is no e-mail address.")
+
+ case NotifyResult.SendFailed(err) =>
+ BasicResult(false, s"Sending failed: $err.")
+
+ case NotifyResult.SendSuccessful =>
+ BasicResult(true, s"Mail sent.")
+ }
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/OpenShareRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/OpenShareRoutes.scala
new file mode 100644
index 00000000..00e02eeb
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/OpenShareRoutes.scala
@@ -0,0 +1,31 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+
+import sharry.common._
+import sharry.backend.BackendApp
+import sharry.restserver.Config
+import sharry.backend.share._
+import sharry.restserver.routes.headers.SharryPassword
+
+object OpenShareRoutes {
+
+ def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of[F] {
+ case req @ GET -> Root / Ident(id) =>
+ val pw = SharryPassword(req)
+ ShareDetailResponse(dsl, backend, cfg, ShareId.publish(id), pw)
+
+ case req @ GET -> Root / Ident(id) / file / Ident(fid) =>
+ val pw = SharryPassword(req)
+ ByteResponse(dsl, req, backend, ShareId.publish(id), pw, fid)
+
+ }
+ }
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/RegisterRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/RegisterRoutes.scala
new file mode 100644
index 00000000..a295865a
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/RegisterRoutes.scala
@@ -0,0 +1,76 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s._
+
+import sharry.backend.BackendApp
+import sharry.backend.signup.{NewInviteResult, SignupResult}
+import sharry.backend.signup.OSignup.RegisterData
+import sharry.restapi.model._
+import sharry.restserver.Config
+
+object RegisterRoutes {
+ private[this] val logger = getLogger
+
+ trait InternRoutes[F[_]] {
+ def signup: HttpRoutes[F]
+ def genInvite: HttpRoutes[F]
+ }
+
+ def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config): InternRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ new InternRoutes[F] {
+ def signup =
+ HttpRoutes.of {
+ case req @ POST -> Root / "register" =>
+ for {
+ data <- req.as[Registration]
+ res <- backend.signup.register(cfg.backend.signup)(convert(data))
+ resp <- Ok(convert(res))
+ } yield resp
+ }
+ def genInvite =
+ HttpRoutes.of {
+ case req @ POST -> Root / "newinvite" =>
+ for {
+ data <- req.as[GenInvite]
+ res <- backend.signup.newInvite(cfg.backend.signup)(data.password)
+ resp <- Ok(convert(res))
+ } yield resp
+ }
+ }
+ }
+
+ def convert(r: NewInviteResult): InviteResult = r match {
+ case NewInviteResult.Success(id) =>
+ InviteResult(true, "New invitation created.", Some(id))
+ case NewInviteResult.InvitationDisabled =>
+ InviteResult(false, "Signing up is not enabled for invitations.", None)
+ case NewInviteResult.PasswordMismatch =>
+ InviteResult(false, "Password is invalid.", None)
+ }
+
+ def convert(r: SignupResult): BasicResult = r match {
+ case SignupResult.AccountExists =>
+ BasicResult(false, "An account with this name already exists.")
+ case SignupResult.InvalidInvitationKey =>
+ BasicResult(false, "Invalid invitation key.")
+ case SignupResult.SignupClosed =>
+ BasicResult(false, "Sorry, registration is closed.")
+ case SignupResult.Failure(ex) =>
+ logger.error(ex)("Error signing up")
+ BasicResult(false, s"Internal error: ${ex.getMessage}")
+ case SignupResult.Success =>
+ BasicResult(true, "Signup successful")
+ }
+
+ def convert(r: Registration): RegisterData =
+ RegisterData(r.login, r.password, r.invite)
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/SettingRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/SettingRoutes.scala
new file mode 100644
index 00000000..026cbb5b
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/SettingRoutes.scala
@@ -0,0 +1,56 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s.getLogger
+
+import sharry.backend.BackendApp
+import sharry.backend.auth.AuthToken
+import sharry.restapi.model._
+import sharry.restserver.Config
+import sharry.common.syntax.all._
+
+object SettingRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], token: AuthToken, cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ POST -> Root / "email" =>
+ for {
+ in <- req.as[EmailChange]
+ _ <- logger.fdebug(s"Changing email for ${token.account} to $in")
+ res <- backend.account.setEmail(token.account.id, in.email.some)
+ resp <- Ok(Conv.basicResult(res, "E-Mail successfully changed."))
+ } yield resp
+
+ case DELETE -> Root / "email" =>
+ for {
+ _ <- logger.fdebug(s"Delete email for ${token.account}")
+ res <- backend.account.setEmail(token.account.id, None)
+ resp <- Ok(Conv.basicResult(res, "E-Mail successfully deleted."))
+ } yield resp
+
+ case GET -> Root / "email" =>
+ for {
+ acc <- backend.account.findById(token.account.id)
+ email = acc.flatMap(_.email)
+ resp <- Ok(EmailInfo(email))
+ } yield resp
+
+ case req @ POST -> Root / "password" =>
+ for {
+ in <- req.as[PasswordChange]
+ _ <- logger.fdebug(s"Changing password for ${token.account}")
+ res <- backend.account.changePassword(token.account.id, in.oldPassword, in.newPassword)
+ resp <- Ok(Conv.basicResult(res, "Password successfully changed."))
+ } yield resp
+ }
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/ShareDetailResponse.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareDetailResponse.scala
new file mode 100644
index 00000000..4238aa2b
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareDetailResponse.scala
@@ -0,0 +1,74 @@
+package sharry.restserver.routes
+
+import cats.data.OptionT
+import org.http4s._
+import org.http4s.headers._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.circe.CirceEntityEncoder._
+
+import cats.effect.Sync
+import sharry.common._
+import sharry.backend.share._
+import sharry.backend.BackendApp
+import sharry.restapi.model.{ShareDetail => ShareDetailDto, _}
+import sharry.restserver.Config
+
+object ShareDetailResponse {
+
+ def apply[F[_]: Sync](
+ dsl: Http4sDsl[F],
+ backend: BackendApp[F],
+ cfg: Config,
+ shareId: ShareId,
+ pass: Option[Password]
+ ): F[Response[F]] = {
+ import dsl._
+
+ val baseUri = shareId.fold(
+ pub => cfg.baseUrl / "api" / "v2" / "open" / "share" / pub.id.id / "file",
+ priv => cfg.baseUrl / "api" / "v2" / "sec" / "share" / priv.id.id / "file"
+ )
+
+ val authChallenge = `WWW-Authenticate`(Challenge("sharry", "sharry"))
+
+ (for {
+ now <- OptionT.liftF(Timestamp.current[F])
+ detail <- backend.share.shareDetails(shareId, pass)
+ resp <- OptionT.liftF(
+ detail.fold(d => Ok(shareDetail(now, baseUri)(d)),
+ _ => Forbidden(), _ => Unauthorized(authChallenge)))
+ } yield resp).getOrElseF(NotFound())
+ }
+
+ def shareDetail(now: Timestamp, baseUri: LenientUri)(item: ShareDetail): ShareDetailDto = {
+ val files = item.files.map(
+ f => ShareFile(f.id, f.name.getOrElse(""), f.length, f.mimetype.asString, f.checksum, f.saved)
+ )
+
+ ShareDetailDto(
+ item.share.id,
+ item.share.name,
+ item.share.aliasId,
+ item.alias.map(_.name),
+ item.share.validity,
+ item.share.maxViews,
+ item.share.password.nonEmpty,
+ item.share.description,
+ item.descProcessed(baseUri),
+ item.share.created,
+ item.published.map(
+ p =>
+ SharePublish(
+ p.id,
+ p.enabled,
+ p.views,
+ p.publishDate,
+ p.publishUntil,
+ p.publishUntil.isBefore(now),
+ p.lastAccess
+ )
+ ),
+ files.toList
+ )
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/ShareRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareRoutes.scala
new file mode 100644
index 00000000..7da293af
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareRoutes.scala
@@ -0,0 +1,155 @@
+package sharry.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import cats.data.OptionT
+import org.http4s._
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s._
+
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.backend.BackendApp
+import sharry.restapi.model._
+import sharry.restserver.Config
+import sharry.backend.share._
+import sharry.backend.auth.AuthToken
+import sharry.store.AddResult
+import sharry.restserver.routes.headers.SharryPassword
+
+object ShareRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](backend: BackendApp[F], token: AuthToken, cfg: Config): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of[F] {
+ case req @ GET -> Root / "search" =>
+ val q = req.params.getOrElse("q", "")
+ for {
+ _ <- logger.ftrace(s"Listing shares: $q")
+ now <- Timestamp.current[F]
+ all <- backend.share.findShares(q, token.account).take(100).compile.toVector
+ list = ShareList(all.map(shareListItem(now)).toList)
+ resp <- Ok(list)
+ } yield resp
+
+ case req @ GET -> Root / Ident(id) =>
+ val pw = SharryPassword(req)
+ ShareDetailResponse(dsl, backend, cfg, ShareId.secured(id, token.account), pw)
+
+ case req @ POST -> Root / Ident(id) / "publish" =>
+ (for {
+ in <- OptionT.liftF(req.as[PublishData])
+ res <- backend.share
+ .publish(id, token.account, in.reuseId)
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Share published.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case DELETE -> Root / Ident(id) / "publish" =>
+ (for {
+ res <- backend.share.unpublish(id, token.account).attempt.map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Share unpublished.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ GET -> Root / Ident(id) / file / Ident(fid) =>
+ val pw = SharryPassword(req)
+ ByteResponse(dsl, req, backend, ShareId.secured(id, token.account), pw, fid)
+
+ case req @ DELETE -> Root / Ident(id) / file / Ident(fid) =>
+ (for {
+ e <- backend.share.deleteFile(token.account, fid).attempt.map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(e, "File deleted.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ DELETE -> Root / Ident(id) =>
+ (for {
+ e <- backend.share.deleteShare(token.account, id).attempt.map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(e, "Share deleted.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root / Ident(id) / "description" =>
+ (for {
+ in <- OptionT.liftF(req.as[SingleString])
+ res <- backend.share
+ .setDescription(token.account, id, in.value)
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Description updated.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root / Ident(id) / "name" =>
+ (for {
+ in <- OptionT.liftF(req.as[SingleString])
+ res <- backend.share
+ .setName(token.account, id, Some(in.value))
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Name updated.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ DELETE -> Root / Ident(id) / "name" =>
+ (for {
+ res <- backend.share.setName(token.account, id, None).attempt.map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Name deleted.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root / Ident(id) / "validity" =>
+ (for {
+ in <- OptionT.liftF(req.as[SingleNumber])
+ res <- backend.share
+ .setValidity(token.account, id, Duration.millis(in.value))
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Validity updated.")))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root / Ident(id) / "maxviews" =>
+ (for {
+ in <- OptionT.liftF(req.as[SingleNumber])
+ res <- backend.share
+ .setMaxViews(token.account, id, in.value.toInt)
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Max. views updated.")))
+ } yield resp).getOrElseF(NotFound())
+
+
+ case req @ POST -> Root / Ident(id) / "password" =>
+ (for {
+ in <- OptionT.liftF(req.as[SingleString])
+ res <- backend.share
+ .setPassword(token.account, id, Some(Password(in.value)))
+ .attempt
+ .map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Password updated.")))
+ } yield resp).getOrElseF(NotFound())
+
+
+ case req @ DELETE -> Root / Ident(id) / "password" =>
+ (for {
+ res <- backend.share.setPassword(token.account, id, None).attempt.map(AddResult.fromEither)
+ resp <- OptionT.liftF(Ok(Conv.basicResult(res, "Password deleted.")))
+ } yield resp).getOrElseF(NotFound())
+ }
+ }
+
+ def shareListItem(now: Timestamp)(item: ShareItem): ShareListItem =
+ ShareListItem(
+ item.share.id,
+ item.share.name,
+ item.aliasName,
+ item.share.validity,
+ item.share.maxViews,
+ item.share.password != None,
+ item.share.created,
+ item.files.count,
+ item.files.size,
+ item.published.filter(_.enabled).map(ps => ps.publishUntil.isAfter(now))
+ )
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/ShareUploadRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareUploadRoutes.scala
new file mode 100644
index 00000000..a9fa9648
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/ShareUploadRoutes.scala
@@ -0,0 +1,123 @@
+package sharry.restserver.routes
+
+import fs2.Stream
+import cats.effect._
+import cats.implicits._
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.dsl.Http4sDsl
+import org.log4s.getLogger
+
+import sharry.backend.BackendApp
+import sharry.backend.auth.AuthToken
+import sharry.backend.share.{File, ShareData}
+import sharry.restapi.model._
+import sharry.restserver.Config
+import sharry.restserver.routes.tus.TusRoutes
+import sharry.common._
+import sharry.common.syntax.all._
+import org.http4s.multipart.Multipart
+import org.http4s.headers.{`Content-Length`, `Content-Type`}
+import bitpeace.Mimetype
+import cats.data.OptionT
+
+object ShareUploadRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](
+ backend: BackendApp[F],
+ token: AuthToken,
+ cfg: Config,
+ rootUrl: LenientUri
+ ): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ POST -> Root =>
+ for {
+ _ <- logger.fdebug("Uploading files to create a new share.")
+ multipart <- req.as[Multipart[F]]
+ updata <- readMultipart(multipart)
+ upid <- backend.share.create(updata, token.account)
+ res <- Ok(Conv.uploadResult("Share created.")(upid))
+ } yield res
+
+ case req @ POST -> Root / "new" =>
+ for {
+ _ <- logger.fdebug("Create empty share")
+ in <- req.as[ShareProperties]
+ updata = ShareData[F](
+ in.validity,
+ in.maxViews,
+ in.description,
+ in.password,
+ in.name,
+ Stream.empty
+ )
+ upid <- backend.share.create(updata, token.account)
+ res <- Ok(Conv.uploadResult("Share created.")(upid))
+ } yield res
+
+ case req @ POST -> Root / Ident(id) / "files" / "add" =>
+ (for {
+ _ <- OptionT.liftF(logger.fdebug("Uploading a file to an existing share"))
+ multipart <- OptionT.liftF(req.as[Multipart[F]])
+ updata <- OptionT.liftF(readMultipart(multipart))
+ ur <- backend.share.addFile(id, token.account, updata.files)
+ resp <- OptionT.liftF(Ok(Conv.uploadBasicResult("File(s) added")(ur)))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ (PATCH | POST | GET | OPTIONS | HEAD) -> Ident(id) /: "files" /: "tus" /: rest =>
+ val pi = req.pathInfo.substring(id.id.length() + 10)
+ val rootUri = rootUrl / id.id / "files" / "tus"
+ TusRoutes(id, backend, token, cfg, rootUri).run(req.withPathInfo(pi)).getOrElseF(NotFound())
+ }
+ }
+
+ def readMultipart[F[_]: Effect](mp: Multipart[F]): F[ShareData[F]] = {
+ def parseMeta(body: Stream[F, Byte]): F[ShareProperties] =
+ body
+ .through(fs2.text.utf8Decode)
+ .parseJsonAs[ShareProperties]
+ .map(_.fold(ex => {
+ logger.error(ex)("Reading upload metadata failed.")
+ throw ex
+ }, identity))
+
+ def fromContentType(header: `Content-Type`): Mimetype =
+ Mimetype(header.mediaType.mainType, header.mediaType.subType)
+
+ val meta: F[ShareProperties] = mp.parts
+ .find(_.name.exists(_.equalsIgnoreCase("meta")))
+ .map(p => parseMeta(p.body))
+ .getOrElse(ShareProperties(None, Duration.days(2), None, 30, None).pure[F])
+
+ val files = mp.parts
+ .filter(p => p.name.forall(s => !s.equalsIgnoreCase("meta")))
+ .map(
+ p =>
+ File(
+ p.filename,
+ p.headers.get(`Content-Type`).map(fromContentType),
+ p.headers.get(`Content-Length`).map(_.length),
+ p.body
+ )
+ )
+
+ for {
+ metaData <- meta
+ _ <- logger.fdebug(s"Parsed upload meta data: $metaData")
+ shd = ShareData[F](
+ metaData.validity,
+ metaData.maxViews,
+ metaData.description,
+ metaData.password,
+ metaData.name,
+ Stream.emits(files)
+ )
+ } yield shd
+ }
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/headers/SharryPassword.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/headers/SharryPassword.scala
new file mode 100644
index 00000000..b14e90f0
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/headers/SharryPassword.scala
@@ -0,0 +1,17 @@
+package sharry.restserver.routes.headers
+
+import org.http4s.Request
+import sharry.common.LenientUri
+import org.http4s.syntax.string._
+import sharry.common.Password
+
+object SharryPassword {
+
+ def apply[F[_]](req: Request[F]): Option[Password] =
+ req.headers
+ .get("sharry-password".ci)
+ .map(_.value)
+ .map(LenientUri.percentDecode)
+ .map(Password.apply)
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileLength.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileLength.scala
new file mode 100644
index 00000000..03047f04
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileLength.scala
@@ -0,0 +1,14 @@
+package sharry.restserver.routes.tus
+
+import org.http4s._
+import org.http4s.syntax.string._
+import sharry.common.ByteSize
+
+object SharryFileLength {
+
+ def apply[F[_]](req: Request[F]): Option[ByteSize] =
+ sizeHeader(req, "sharry-file-length").orElse(sizeHeader(req, "upload-length"))
+
+ private[tus] def sizeHeader[F[_]](req: Request[F], name: String): Option[ByteSize] =
+ req.headers.get(name.ci).flatMap(_.value.toLongOption).map(ByteSize.apply)
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileName.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileName.scala
new file mode 100644
index 00000000..19428a17
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileName.scala
@@ -0,0 +1,12 @@
+package sharry.restserver.routes.tus
+
+import org.http4s.Request
+import sharry.common.LenientUri
+import org.http4s.syntax.string._
+
+object SharryFileName {
+
+ def apply[F[_]](req: Request[F]): Option[String] =
+ req.headers.get("sharry-file-name".ci).map(_.value).map(LenientUri.percentDecode)
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileType.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileType.scala
new file mode 100644
index 00000000..7ed13d45
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/SharryFileType.scala
@@ -0,0 +1,12 @@
+package sharry.restserver.routes.tus
+
+import org.http4s.Request
+import org.http4s.syntax.string._
+import bitpeace.Mimetype
+
+object SharryFileType {
+
+ def apply[F[_]](req: Request[F]): Option[Mimetype] =
+ req.headers.get("sharry-file-type".ci).map(_.value).flatMap(s => Mimetype.parse(s).toOption)
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusMaxSize.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusMaxSize.scala
new file mode 100644
index 00000000..533dcc7a
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusMaxSize.scala
@@ -0,0 +1,15 @@
+package sharry.restserver.routes.tus
+
+import org.http4s.Request
+import sharry.common.ByteSize
+import org.http4s.Header
+
+object TusMaxSize {
+
+ def get[F[_]](req: Request[F]): Option[ByteSize] =
+ SharryFileLength.sizeHeader(req, "upload-length")
+
+ def apply(size: ByteSize): Header =
+ Header("Tus-Max-Size", size.bytes.toString())
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusRoutes.scala
new file mode 100644
index 00000000..0946998e
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/TusRoutes.scala
@@ -0,0 +1,112 @@
+package sharry.restserver.routes.tus
+
+import cats.effect._
+import cats.implicits._
+import cats.data.OptionT
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers._
+import org.log4s.getLogger
+
+import bitpeace.Mimetype
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.backend.BackendApp
+import sharry.backend.auth.AuthToken
+import sharry.backend.share.{FileInfo, UploadResult}
+import sharry.restserver.Config
+
+object TusRoutes {
+ private[this] val logger = getLogger
+
+ def apply[F[_]: Effect](
+ shareId: Ident,
+ backend: BackendApp[F],
+ token: AuthToken,
+ cfg: Config,
+ rootUrl: LenientUri
+ ): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ OPTIONS -> Root =>
+ NoContent.apply(TusHeader.resumable, TusHeader.extension, TusHeader.version)
+
+ case req @ POST -> Root =>
+ // creation extension
+ TusHeader.fileInfo(req) match {
+ case Some(info) =>
+ backend.share
+ .createEmptyFile(shareId, token.account, info)
+ .semiflatMap({
+ case UploadResult.Success(fid) =>
+ val url = rootUrl / fid.id
+ Created(TusHeader.resumable, Location(Uri.unsafeFromString(url.asString)))
+ case UploadResult.ValidityExceeded(_) =>
+ BadRequest()
+ case UploadResult.SizeExceeded(_) =>
+ PayloadTooLarge("max size exceeded").
+ map(_.withHeaders(TusMaxSize(cfg.backend.share.maxSize)))
+ })
+ .getOrElseF(NotFound())
+
+ case None =>
+ BadRequest("No length header")
+ }
+
+ case req @ (POST | PATCH) -> Root / Ident(fileId) =>
+ val offset = UploadOffset.get(req).getOrElse(ByteSize.zero)
+ val length = req.headers.get(`Content-Length`).map(_.length).map(ByteSize.apply)
+ backend.share
+ .addFileData(shareId, fileId, token.account, length, offset, req.body)
+ .flatMap({
+ case UploadResult.Success(saved) =>
+ OptionT.liftF(NoContent(TusHeader.resumable, UploadOffset(saved)))
+ case UploadResult.ValidityExceeded(_) =>
+ OptionT.liftF(BadRequest())
+ case UploadResult.SizeExceeded(_) =>
+ OptionT.liftF(PayloadTooLarge("max size exceeded"))
+ })
+ .getOrElseF(NotFound())
+
+ case req @ HEAD -> Root / Ident(fileId) =>
+ (for {
+ _ <- OptionT.liftF(logger.fdebug(s"Return info for file ${fileId.id}"))
+ data <- backend.share.getFileData(fileId, token.account)
+ resp <- OptionT.liftF(
+ Ok(
+ TusHeader.resumable,
+ UploadOffset(data.saved),
+ TusHeader.cacheControl,
+ TusMaxSize(cfg.backend.share.maxSize),
+ UploadLength(data.length)
+ )
+ )
+ } yield resp).getOrElseF(NotFound())
+
+ }
+ }
+
+ object TusHeader {
+
+ def fileInfo[F[_]](req: Request[F]): Option[FileInfo] = {
+ val name = SharryFileName(req)
+ val len = SharryFileLength(req)
+ val mime = SharryFileType(req).getOrElse(Mimetype.`application/octet-stream`)
+
+ len.map(l => FileInfo(l.bytes, name, mime))
+ }
+
+ def resumable: Header =
+ Header("Tus-Resumable", "1.0.0")
+ def extension: Header =
+ Header("Tus-Extension", "creation")
+ def version: Header =
+ Header("Tus-Version", "1.0.0")
+
+ def cacheControl: Header =
+ `Cache-Control`(CacheDirective.`no-store`)
+ }
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadLength.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadLength.scala
new file mode 100644
index 00000000..61c294ab
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadLength.scala
@@ -0,0 +1,15 @@
+package sharry.restserver.routes.tus
+
+import org.http4s.Request
+import sharry.common.ByteSize
+import org.http4s.Header
+
+object UploadLength {
+
+ def get[F[_]](req: Request[F]): Option[ByteSize] =
+ SharryFileLength.sizeHeader(req, "upload-length")
+
+ def apply(size: ByteSize): Header =
+ Header("Upload-Length", size.bytes.toString())
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadOffset.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadOffset.scala
new file mode 100644
index 00000000..d767623b
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/routes/tus/UploadOffset.scala
@@ -0,0 +1,15 @@
+package sharry.restserver.routes.tus
+
+import org.http4s.Request
+import sharry.common.ByteSize
+import org.http4s.Header
+
+object UploadOffset {
+
+ def get[F[_]](req: Request[F]): Option[ByteSize] =
+ SharryFileLength.sizeHeader(req, "upload-offset")
+
+ def apply(size: ByteSize): Header =
+ Header("Upload-Offset", size.bytes.toString())
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/webapp/TemplateRoutes.scala
new file mode 100644
index 00000000..496dd06e
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/webapp/TemplateRoutes.scala
@@ -0,0 +1,153 @@
+package sharry.restserver.webapp
+
+import fs2._
+import cats.effect._
+import cats.implicits._
+import org.http4s._
+import org.http4s.headers._
+import org.http4s.HttpRoutes
+import org.http4s.dsl.Http4sDsl
+import org.slf4j._
+import _root_.io.circe.syntax._
+import yamusca.imports._
+import yamusca.implicits._
+import java.net.URL
+import java.util.concurrent.atomic.AtomicReference
+
+import sharry.restapi.model.AppConfig
+import sharry.restserver.{BuildInfo, Config}
+import sharry.restserver.webapp.YamuscaConverter._
+import sharry.restserver.routes.InfoRoutes
+
+object TemplateRoutes {
+ private[this] val logger = LoggerFactory.getLogger(getClass)
+
+ val `text/html` = new MediaType("text", "html")
+
+ trait InnerRoutes[F[_]] {
+ def doc: HttpRoutes[F]
+ def app: HttpRoutes[F]
+ }
+
+ def apply[F[_]: Effect](blocker: Blocker, cfg: Config)(
+ implicit C: ContextShift[F]
+ ): InnerRoutes[F] = {
+ val indexTemplate = memo(loadResource("/index.html").flatMap(loadTemplate(_, blocker)))
+ val docTemplate = memo(loadResource("/doc.html").flatMap(loadTemplate(_, blocker)))
+
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+ new InnerRoutes[F] {
+ def doc =
+ HttpRoutes.of[F] {
+ case GET -> Root =>
+ for {
+ templ <- docTemplate
+ resp <- Ok(DocData(cfg).render(templ), `Content-Type`(`text/html`))
+ } yield resp
+ }
+ def app =
+ HttpRoutes.of[F] {
+ case GET -> rest =>
+ for {
+ templ <- indexTemplate
+ resp <- Ok(IndexData(cfg).render(templ), `Content-Type`(`text/html`))
+ } yield resp
+ }
+ }
+ }
+
+ def loadResource[F[_]: Sync](name: String): F[URL] =
+ Option(getClass.getResource(name)) match {
+ case None =>
+ Sync[F].raiseError(new Exception("Unknown resource: " + name))
+ case Some(r) =>
+ r.pure[F]
+ }
+
+ def loadUrl[F[_]: Sync](url: URL, blocker: Blocker)(implicit C: ContextShift[F]): F[String] =
+ Stream
+ .bracket(Sync[F].delay(url.openStream))(in => Sync[F].delay(in.close))
+ .flatMap(in => io.readInputStream(in.pure[F], 64 * 1024, blocker, false))
+ .through(text.utf8Decode)
+ .compile
+ .fold("")(_ + _)
+
+ def parseTemplate[F[_]: Sync](str: String): F[Template] =
+ Sync[F].delay {
+ mustache.parse(str) match {
+ case Right(t) => t
+ case Left((_, err)) => sys.error(err)
+ }
+ }
+
+ def loadTemplate[F[_]: Sync](url: URL, blocker: Blocker)(
+ implicit C: ContextShift[F]
+ ): F[Template] =
+ loadUrl[F](url, blocker)
+ .flatMap(s => parseTemplate(s))
+ .map(t => {
+ logger.info(s"Compiled template $url")
+ t
+ })
+
+ case class DocData(swaggerRoot: String, openapiSpec: String)
+ object DocData {
+
+ def apply(cfg: Config): DocData =
+ DocData(
+ "/app/assets" + Webjars.swaggerui,
+ s"/app/assets/${BuildInfo.name}/${BuildInfo.version}/sharry-openapi.yml"
+ )
+
+ implicit def yamuscaValueConverter: ValueConverter[DocData] =
+ ValueConverter.deriveConverter[DocData]
+ }
+
+ case class IndexData(
+ flags: AppConfig,
+ faviconBase: String,
+ cssUrls: Seq[String],
+ jsUrls: Seq[String],
+ appExtraJs: String,
+ flagsJson: String
+ )
+
+ object IndexData {
+
+ def apply(cfg: Config): IndexData =
+ IndexData(
+ InfoRoutes.appConfig(cfg),
+ s"/app/assets/sharry-webapp/${BuildInfo.version}/favicon",
+ Seq(
+ "/app/assets" + Webjars.semanticui + "/semantic.min.css",
+ s"/app/assets/sharry-webapp/${BuildInfo.version}/sharry.css"
+ ),
+ Seq(
+ "/app/assets" + Webjars.jquery + "/jquery.min.js",
+ "/app/assets" + Webjars.semanticui + "/semantic.min.js",
+ "/app/assets" + Webjars.tusjsclient + "/dist/tus.min.js",
+ s"/app/assets/sharry-webapp/${BuildInfo.version}/sharry-app.js"
+ ),
+ s"/app/assets/sharry-webapp/${BuildInfo.version}/sharry.js",
+ InfoRoutes.appConfig(cfg).asJson.spaces2
+ )
+
+ implicit def yamuscaValueConverter: ValueConverter[IndexData] =
+ ValueConverter.deriveConverter[IndexData]
+ }
+
+ private def memo[F[_]: Sync, A](fa: => F[A]): F[A] = {
+ val ref = new AtomicReference[A]()
+ Sync[F].suspend {
+ Option(ref.get) match {
+ case Some(a) => a.pure[F]
+ case None =>
+ fa.map(a => {
+ ref.set(a)
+ a
+ })
+ }
+ }
+ }
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/webapp/WebjarRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/webapp/WebjarRoutes.scala
new file mode 100644
index 00000000..af998c21
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/webapp/WebjarRoutes.scala
@@ -0,0 +1,43 @@
+package sharry.restserver.webapp
+
+import cats.effect._
+import org.http4s._
+import org.http4s.HttpRoutes
+import org.http4s.server.staticcontent.webjarService
+import org.http4s.server.staticcontent.NoopCacheStrategy
+import org.http4s.server.staticcontent.WebjarService.{WebjarAsset, Config => WebjarConfig}
+
+import sharry.restserver.Config
+
+object WebjarRoutes {
+
+ def appRoutes[F[_]: Effect](blocker: Blocker, cfg: Config)(
+ implicit C: ContextShift[F]
+ ): HttpRoutes[F] =
+ webjarService(
+ WebjarConfig(
+ filter = assetFilter,
+ blocker = blocker,
+ cacheStrategy = NoopCacheStrategy[F]
+ )
+ )
+
+ def assetFilter(asset: WebjarAsset): Boolean =
+ List(
+ ".js",
+ ".css",
+ ".html",
+ ".jpg",
+ ".png",
+ ".eot",
+ ".json",
+ ".woff",
+ ".woff2",
+ ".svg",
+ ".map",
+ ".otf",
+ ".ttf",
+ ".yml"
+ ).exists(e => asset.asset.endsWith(e))
+
+}
diff --git a/modules/restserver/src/main/scala/sharry/restserver/webapp/YamuscaConverter.scala b/modules/restserver/src/main/scala/sharry/restserver/webapp/YamuscaConverter.scala
new file mode 100644
index 00000000..530ca5fb
--- /dev/null
+++ b/modules/restserver/src/main/scala/sharry/restserver/webapp/YamuscaConverter.scala
@@ -0,0 +1,17 @@
+package sharry.restserver.webapp
+
+import yamusca.imports._
+import yamusca.implicits._
+import sharry.restapi.model.AppConfig
+import sharry.restapi.model.OAuthItem
+import sharry.backend.mustache.YamuscaCommon
+
+object YamuscaConverter extends YamuscaCommon {
+
+ implicit def yamuscaOAuthItemConverter: ValueConverter[OAuthItem] =
+ ValueConverter.deriveConverter[OAuthItem]
+
+ implicit def yamuscaAppConfigValueConverter: ValueConverter[AppConfig] =
+ ValueConverter.deriveConverter[AppConfig]
+
+}
diff --git a/modules/restserver/src/main/templates/doc.html b/modules/restserver/src/main/templates/doc.html
new file mode 100644
index 00000000..72820155
--- /dev/null
+++ b/modules/restserver/src/main/templates/doc.html
@@ -0,0 +1,60 @@
+
+
+
+
+ Swagger UI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/restserver/src/main/templates/index.html b/modules/restserver/src/main/templates/index.html
new file mode 100644
index 00000000..c964a3ab
--- /dev/null
+++ b/modules/restserver/src/main/templates/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {{ flags.appName }}
+ {{# cssUrls }}
+
+ {{/ cssUrls }}
+ {{# jsUrls }}
+
+ {{/ jsUrls}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/server/src/main/resources/logback.xml b/modules/server/src/main/resources/logback.xml
deleted file mode 100644
index 796f3d58..00000000
--- a/modules/server/src/main/resources/logback.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
- %d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} %level [%thread] %logger [%file:%line] %msg%n
-
-
-
-
-
-
-
-
-
diff --git a/modules/server/src/main/resources/reference.conf b/modules/server/src/main/resources/reference.conf
deleted file mode 100644
index 56e7758c..00000000
--- a/modules/server/src/main/resources/reference.conf
+++ /dev/null
@@ -1,265 +0,0 @@
-sharry {
-
- # the database connection. sharry is distributed with the H2
- # database.
- db {
- driver = "org.h2.Driver"
- url = "jdbc:h2:./sharry-db.h2"
- user = "sa"
- password = ""
- }
-
- log {
- # A logback configuration file to use instead of the default config
- config = ""
- }
-
- upload {
- # When storing and transfering binary data use chunks of this size.
- chunk-size = "256K"
-
- # Allow simultaneous uploads
- simultaneous-uploads = 3
-
- # Maximum number of files that can be uploaded to one share
- max-files = 50
-
- # Maximum size of one uploaded file
- max-file-size = "1.5G"
-
- # Maximum validity for uploads
- max-validity = 365 days
-
- # Whether to enable the upload cleanup job that periodically
- # removes invalid uploads
- cleanup-enable = true
-
- # The interval for the cleanup job
- cleanup-interval = 30 days
-
- # Age of invalid uploads to get collected by cleanup job
- cleanup-invalid-age = 7 days
-
- # Duration an anonymous user can hit delete after uploading from
- # an alias page
- alias-delete-time = 2 minutes
-
- # If true, user that received files from their alias pages are
- # notified via email (if they have an email address in ther
- # profile)
- enable-upload-notification = true
- }
-
- web {
- # the host for binding the http server
- bind-host = "0.0.0.0"
-
- # the port for binding the http server
- bind-port = 9090
-
- # The name of the application rendered in the pages
- app-name = "Sharry"
-
- # The base url to use when constructing urls.
- baseurl = "http://localhost:9090/"
-
- # A short text that is displayed below the login form. Visible to
- # everyone. It can be markdown formatted text.
- welcome-message = ""
-
- # the .css used with highlightjs to syntax highlight code
- # blocks.
- highlightjs-theme = "github"
-
- # Settings regarding sending mails from sharry
- #
- # Valid smtp settings are required for sending mails. They are
- # defined below. If you are running sharry on a fixed IP, it is
- # usually not necessary to specify extra smtp settings. Sharry
- # will find out the MX host for each email address using its
- # domain. Since many public smtp servers usually don't accept mail
- # from a dial-up network; or it will be added to the spam
- # (e.g. gmail), this only works reasonable on fixed ips.
- mail {
- # Enable mail notification in the web interface. Allows on
- # download and alias pages to send a mail with information about
- # the page.
- enable = false
-
- # The default language used to retrieve templates
- default-language = "en"
-
- # Email templates (mustache) per language code used from the
- # download page. The first line is the subject. The terms {{url}}
- # and {{username}} are replaced with the url and username,
- # respectively.
- download-templates = {
- en = """Ready to download
-Hi,
-
-I have pushed some files for you to download. Visit this page:
-
-{{{url}}}
-{{#password}}
-
-You'll need a password for downloading!
-{{/password}}
-
-Cheers,
-{{{username}}}"""
-
- de = """Dateien fertig
-Hallo,
-
-Du kannst die Dateien hier runterladen:
-
-{{{url}}}
-{{#password}}
-
-Du brauchst das Passwort für den Zugriff!
-{{/password}}
-
-Viele Grüße
-{{{username}}}"""
- }
-
- # Email tepmlates (mustache) as above but for the alias
- # page. Again, first line is the subject.
- alias-templates = {
- en = """Link for uploading files
-Hi,
-
-please use the following link to send the files to me:
-
-{{{url}}}
-
-Cheers,
-{{{username}}}"""
-
- de = """Dateien senden
-Hallo,
-
-Du kannst den folgenden Link verwenden, um mir Dateien zu schicken:
-
-{{{url}}}
-
-Danke und viele Grüße
-{{{username}}}"""
- }
-
- # Email templates (mustache) used when generating mails to
- # notify about new uploads.
- notify-templates = {
- en = """Upload arrived
-Hi {{{username}}}
-
-someone uploaded files for you using alias site '{{{alias}}}'.
-
-Checkout {{{uploadUrl}}} to view it.
-
-Cheers,
-Sharry"""
-
- de = """Dateien erhalten
-Hallo {{{username}}},
-
-jemand hat Dir Dateien zukommen lassen über die Alias-Seite
-'{{{alias}}}'. Du kannst sie hier finden:
-
-{{{uploadUrl}}}
-
-
-Viele Grüße
-Sharry"""
- }
- }
- }
-
-
- # authentication settings
- authc {
- # A secret used for signing the cookie. If it is empty a randomly
- # generated byte sequence is used. So it can be safely left
- # empty. It's only required if you want the cookies to survive
- # application restarts.
- #
- # It can be hex or base64 encoded, for example:
- # hex:ffa2ff
- # b64:ZNNJGE1xfehaLVsJXigp87v4y1JzNj0EMyUER5nmtTw=
- #
- # On Linux, random bits can be generated using /dev/urandom or
- # /dev/random:
- # cat /dev/urandom | head -c 64 | base64 -w0
- app-key = ""
-
- # The lifetime of the authentication cookie that is used at the
- # client. The client can refresh the cookie by logging in with a
- # valid cookie.
- max-cookie-lifetime = 15 minutes
-
- # Authentication can be disabled here. Then every request is going
- # to be associated to the `default-user'.
- enable = true
-
- # the account name used when authentication is disabled
- default-user = "sharry"
-
- # it is possible to use external tools to validate login/password
- # pairs. A new account is created automatically for those
- # accounts.
- #
- # If multiple external strategies are enabled, they are tried in
- # some order and the first success wins.
- extern {
- # a preconifgured admin account to get started
- admin {
- enable = false
- login = admin
- password = admin
- }
-
- # use a system command and pass the login and password via
- # placeholder {login} and {password}.
- command {
- enable = false
- program = [
- "/path/to/someprogram"
- "{login}"
- "{password}"
- ]
- # the return code to consider successful verification
- success = 0
- }
-
- # use a http request to do password verification. It only checks
- # the response status code for a 200.
- http {
- enable = false
- # the url to use, it may contain placeholders {login} and {password}
- url = "https://somehost/auth?login={login}&pass={password}"
- # the http method to use
- method = "POST"
- # the body of the request. it may be empty (for GET requests),
- # placeholders {login} and {password} can be used here
- body = """{ "login": "{login}", "pass": "{password}" }"""
- # if `body' is non-empty, use this contentType
- content-type = "application/json"
- }
- }
- }
-
- # settings for smtp client.
- #
- # The from field is used for all outgoing mails as From: header. If
- # you don't add a smtp host, it is tried to look it up via DNS using
- # the domain of the recipient email.
- smtp {
- host = ""
- port = 0
- user = ""
- password = ""
- from = "noreply@localhost"
- start-tls = false
- ssl = false
- }
-}
\ No newline at end of file
diff --git a/modules/server/src/main/scala/sharry/server/App.scala b/modules/server/src/main/scala/sharry/server/App.scala
deleted file mode 100644
index d4c7cfdc..00000000
--- a/modules/server/src/main/scala/sharry/server/App.scala
+++ /dev/null
@@ -1,117 +0,0 @@
-package sharry.server
-
-import java.net.URL
-import java.nio.file.Path
-import java.nio.channels.AsynchronousChannelGroup
-
-import scala.collection.JavaConverters._
-import cats.effect.IO
-import bitpeace._
-
-import scala.concurrent.ExecutionContext
-import sharry.common.version
-import sharry.docs.route
-import sharry.docs.md.ManualContext
-import sharry.store.account._
-import sharry.store.upload._
-import sharry.server.authc._
-import sharry.webapp.route.webjar
-import sharry.common.data._
-import sharry.server.routes.{account, alias, download, login, mail, settings, upload}
-
-import scala.io.Codec
-
-/** Instantiate the app from a given configuration */
-final class App(val cfg: config.Config)(implicit ACG: AsynchronousChannelGroup, SCH: fs2.Scheduler, EC: ExecutionContext) {
- if (cfg.logConfig.exists) {
- setupLogging(cfg.logConfig.config)
- }
-
- val jdbc = cfg.jdbc.transactor.unsafeRunSync
-
- val bitpeaceConfig: BitpeaceConfig[IO] = BitpeaceConfig.defaultTika[IO]
- val accountStore: AccountStore = new SqlAccountStore(jdbc)
- val uploadStore: UploadStore = new SqlUploadStore(jdbc, bitpeaceConfig)
-
- val auth = new Authenticate(accountStore, cfg.authConfig, ExternAuthc(cfg))
-
- val uploadConfig = cfg.uploadConfig
-
- val remoteConfig = RemoteConfig(
- paths.mounts.mapValues(_.path) + ("baseUrl" -> cfg.webConfig.baseurl)
- , cfg.webConfig.appName
- , cfg.authConfig.enable
- , cfg.authConfig.maxCookieLifetime.millis
- , uploadConfig.chunkSize.toBytes
- , uploadConfig.simultaneousUploads
- , uploadConfig.maxFiles
- , uploadConfig.maxFileSize.toBytes
- , uploadConfig.maxValidity.formatExact
- , version.projectString
- , routes.authz.aliasHeaderName
- , cfg.webmailConfig.enable
- , cfg.webConfig.highlightjsTheme
- , cfg.webConfig.welcomeMessage
- , version.shortVersion
- )
-
- val notifier: notification.Notifier = notification.scheduleNotify(
- cfg.smtpSetting, cfg.webConfig, cfg.webmailConfig, uploadStore, accountStore)
-
-
- def endpoints = {
- routes.syntax.choice2(
- webjar.endpoint(remoteConfig)
- , route.manual(paths.manual.matcher, ManualContext(version.longVersion, version.shortVersion, defaultConfig, defaultCliConfig, cliHelp))
- , login.endpoint(auth, cfg.webConfig, cfg.authConfig)
- , account.endpoint(auth, cfg.authConfig, accountStore, cfg.webConfig)
- , upload.endpoint(cfg.authConfig, uploadConfig, uploadStore, notifier)
- , download.endpoint(cfg.authConfig, cfg.webConfig, uploadStore)
- , alias.endpoint(cfg.authConfig, uploadConfig, uploadStore)
- , mail.endpoint(cfg.authConfig, cfg.smtpSetting, cfg.webmailConfig, accountStore)
- , settings.endpoint(remoteConfig)
- )
- }
-
- private lazy val defaultConfig = {
- getClass.getClassLoader.getResources("reference.conf").
- asScala.toList.
- filter(_.toString contains "sharry-server").
- map(urlToLines).
- headOption.getOrElse("")
- }
-
- private lazy val defaultCliConfig = {
- Option(getClass.getResource("/reference-cli.conf")).
- map(urlToLines).getOrElse("")
- }
-
- private lazy val cliHelp = {
- Option(getClass.getResource("/cli-help.txt")).
- map(urlToLines).getOrElse("")
- }
-
- def setupLogging(logFile: Path): Unit = {
- import org.slf4j.LoggerFactory
- import ch.qos.logback.classic.LoggerContext
- import ch.qos.logback.classic.joran.JoranConfigurator
- import ch.qos.logback.core.util.StatusPrinter
- val context = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext]
- scala.util.Try {
- val config = new JoranConfigurator()
- config.setContext(context)
- context.reset()
- config.doConfigure(logFile.toString)
- }
- StatusPrinter.printInCaseOfErrorsOrWarnings(context)
- }
-
- private def urlToLines(url: URL): String = {
- val source = scala.io.Source.fromURL(url)(Codec.UTF8)
- try {
- source.getLines.mkString("\n")
- } finally {
- source.close()
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/authc/Authenticate.scala b/modules/server/src/main/scala/sharry/server/authc/Authenticate.scala
deleted file mode 100644
index 2c122f28..00000000
--- a/modules/server/src/main/scala/sharry/server/authc/Authenticate.scala
+++ /dev/null
@@ -1,86 +0,0 @@
-package sharry.server.authc
-
-import java.time.Instant
-import org.log4s._
-import fs2.{Stream, Pipe}
-import cats.effect.IO
-import com.github.t3hnar.bcrypt._
-import sharry.common.streams
-import sharry.common.data.Account
-import sharry.store.account.AccountStore
-import sharry.server.config.AuthConfig
-
-final class Authenticate(store: AccountStore, authConfig: AuthConfig, ext: ExternAuthc) {
- implicit private[this] val logger = getLogger
-
- def authc(login: String, pass: String): Stream[IO,AuthResult] = {
- if (authConfig.enable) {
- store.getAccount(login).
- through(streams.logEmpty(_.debug(s"No account found for login: $login"))).
- through(streams.logEach((a, l) => l.debug(s"Authenticating account ${a.noPass}"))).
- through(checkEnabled).
- through(verifyPresent(login, pass, ext)).
- through(verifyNewAccount(login, pass, ext)).
- through(logResult(login))
- } else {
- logger.warn(s"Authentication is disabled. Using default user ${authConfig.defaultUser}")
- store.getAccount(authConfig.defaultUser).
- through(checkEnabled).
- through(verifyPresent(login, pass, ExternAuthc.disabledAuth(authConfig))).
- through(verifyNewAccount(login, pass, ExternAuthc.disabledAuth(authConfig)))
- }
- }
-
- def logResult(login: String): Pipe[IO, AuthResult, AuthResult] =
- streams.logEach { (ar, logger) =>
- ar match {
- case Left(err) => logger.warn(s"Authentication failed for $login: $err")
- case Right(a) => logger.debug(s"Authentication successfull for ${a.noPass}")
- }
- }
-
- /** Authenticates a {{Token}} that is generated from an account. Thus
- * it fails if the account doesn't exist. */
- def authc(token: Token, now: Instant): Stream[IO, AuthResult] = {
- val fail = Stream.emit(AuthResult.failed).covary[IO].through(logResult(token.login))
- if (!token.verify(now, authConfig.appKey)) fail
- else store.getAccount(token.login).
- through(checkEnabled).
- through(streams.ifEmpty(fail)).
- through(logResult(token.login))
- }
-
- // if present check enabled
- def checkEnabled[F[_]]: Pipe[F, Account, AuthResult] =
- _.map { acc =>
- if (acc.enabled) AuthResult(acc) else AuthResult.fail("User account locked")
- }
-
- // if present, verify password internally or externally
- def verifyPresent(login: String, givenPass: String, ext: ExternAuthc): Pipe[IO, AuthResult, AuthResult] =
- _.flatMap {
- case Right(a @ Account.Internal(_, pass)) =>
- if (pass.exists(p => givenPass.isBcrypted(p))) Stream.emit(Right(a))
- else Stream.emit(AuthResult.failed)
- case Right(a @ Account.External(_)) =>
- streams.slogT(_.debug(s"Verify ${a.noPass} externally")) ++ ext.verify(login, givenPass).map {
- case Some(_) => AuthResult(a)
- case None => AuthResult.failed
- }
- case ar => Stream.emit(ar)
- }
-
- // if absent, verify via ext. if ok, create account; fail otherwise
- def verifyNewAccount(login: String, pass: String, ext: ExternAuthc): Pipe[IO, AuthResult, AuthResult] = {
- val create: Stream[IO,AuthResult] = streams.slogT(_.debug(s"Verify $login externally")) ++
- ext.verify(login, pass).flatMap {
- case Some(acc) =>
- streams.slogT(_.debug(s"Create new external account $acc")) ++
- store.createAccount(acc).map(_ => AuthResult(acc))
- case None =>
- Stream.emit(AuthResult.failed)
- }
-
- _.through(streams.ifEmpty(create))
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/authc/ExternAuthc.scala b/modules/server/src/main/scala/sharry/server/authc/ExternAuthc.scala
deleted file mode 100644
index 4fe2ae68..00000000
--- a/modules/server/src/main/scala/sharry/server/authc/ExternAuthc.scala
+++ /dev/null
@@ -1,113 +0,0 @@
-package sharry.server.authc
-
-import java.nio.channels.AsynchronousChannelGroup
-import org.log4s._
-import scala.sys.process._
-import fs2.Stream
-import cats.effect.IO
-import cats.syntax.either._
-import spinoco.fs2.http
-import spinoco.fs2.http.HttpRequest
-import spinoco.protocol.http.{HttpMethod, Uri, HttpStatusCode}
-import spinoco.protocol.mime.ContentType
-import scala.concurrent.ExecutionContext
-
-import sharry.common.data._
-import sharry.server.config._
-
-trait ExternAuthc {
- def verify(login: String, pass: String): Stream[IO,Option[Account]]
-}
-
-object ExternAuthc {
- implicit private[this] val logger = getLogger
-
- def apply(f: (String, String) => Stream[IO,Option[Account]]): ExternAuthc =
- new ExternAuthc {
- def verify(login: String, pass: String) = f(login, pass)
- }
-
- def apply(cfg: Config)(implicit ACG: AsynchronousChannelGroup, EC: ExecutionContext): ExternAuthc = apply {
- List(
- new Command(cfg.authcCommand),
- new Http(cfg.authcHttp),
- configAdmin(cfg.adminAccount)
- )
- }
-
- def apply(ext: Seq[ExternAuthc]): ExternAuthc = ExternAuthc { (login, pass) =>
- Stream.emits(ext).
- flatMap(_.verify(login, pass)).
- find(_.isDefined).
- lastOr(None)
- }
-
- final class Command(cfg: AuthcCommand) extends ExternAuthc {
- def verify(login: String, pass: String) =
- if (!cfg.enable) Stream.emit(None)
- else Stream.eval(IO {
- val cmd = cfg.program.map(_.replace("{login}", login).replace("{password}", pass))
- val r = Either.catchOnly[Exception] {
- logger.debug(s"Running external auth command: ${cfg.program.map(_.replace("{login}", login))}")
- Process(cmd).!
- }
- logger.debug(s"Result of command authc: $r")
- if (r == Right(cfg.success)) Some(Account.newExtern(login))
- else None
- })
- }
-
- final class Http(cfg: AuthcHttp)(implicit ACG: AsynchronousChannelGroup, EC: ExecutionContext) extends ExternAuthc {
- def verify(login: String, pass: String) =
- if (!cfg.enable) Stream.emit(None)
- else {
- logger.debug(s"Start with http authentication for $login")
- val makeRequest: IO[HttpRequest[IO]] = IO {
-
- val replace: String => String =
- _.replace("{login}", login).replace("{password}", pass)
-
- val req = for {
- url <- Uri.parse(replace(cfg.url)).toEither
- method <- parse(cfg.method, HttpMethod.codec)
- mime <- parse(cfg.contentType, ContentType.codec)
- } yield HttpRequest.get[IO](url).
- withMethod(method).
- withUtf8Body(replace(cfg.body)).
- withContentType(mime)
-
- req.valueOr { err =>
- logger.error(s"Error making http request for $login: $err")
- throw new Exception(err.toString)
- }
- }
-
- def execute(req: HttpRequest[IO]): Stream[IO,Option[Account]] = {
- Stream.eval(http.client[IO]()).flatMap { client =>
- client.request(req).map { resp =>
- logger.debug(s"External HTTP auth against ${cfg.url} for $login responds with ${resp.header.status}")
- if (resp.header.status != HttpStatusCode.Ok) None
- else Some(Account.newExtern(login))
- }
- }
- }
-
- Stream.eval(makeRequest).flatMap(execute)
- }
- }
-
- def configAdmin(cfg: AdminAccount): ExternAuthc = ExternAuthc { (login, pass) =>
- Stream.emit {
- if (cfg.enable && cfg.login == login && cfg.password == pass)
- Some(Account.newExtern(login).copy(admin = true))
- else
- None
- }
- }
-
- def disabledAuth(cfg: AuthConfig): ExternAuthc = ExternAuthc { (login, pass) =>
- Stream.emit {
- Some(Account.newExtern(cfg.defaultUser))
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/authc/Token.scala b/modules/server/src/main/scala/sharry/server/authc/Token.scala
deleted file mode 100644
index f382751e..00000000
--- a/modules/server/src/main/scala/sharry/server/authc/Token.scala
+++ /dev/null
@@ -1,54 +0,0 @@
-package sharry.server.authc
-
-import java.time.Instant
-import java.time.temporal.TemporalAmount
-
-import scala.util.Try
-import scodec.bits.ByteVector
-import com.github.t3hnar.bcrypt
-import io.circe._
-import sharry.common.data.Account
-import sharry.common.sign
-
-case class Token(salt: String, login: String, ends: Instant, signature: String) {
- def asString = s"${salt}%${login}%${ends.toString}%${signature}"
-
- def verify(now: Instant, appKey: ByteVector): Boolean = {
- val sigv = sign.sign(appKey, s"${salt}%${login}%${ends.toString}").toHex
- now.isBefore(ends) && sigv.zip(signature).forall({ case (a, b) => a == b })
- }
-
- def extend(duration: TemporalAmount, appKey: ByteVector) =
- Token(login, ends.plus(duration), appKey)
-}
-
-object Token {
- val invalid = Token("invalid", Instant.ofEpochMilli(0), ByteVector.view("invalid".getBytes))
-
- def apply(login: String, ends: Instant, appKey: ByteVector): Token = {
- val salt = bcrypt.generateSalt
- val data = s"${salt}%${login}%${ends.toString}"
- val sig = sign.sign(appKey, data)
- Token(salt, login, ends, sig.toHex)
- }
-
- def parse(s: String): Token = {
- val parts = s.split("%", 4).toList
- parts match {
- case salt :: login :: ends :: sig :: Nil
- if (Account.validateLogin(login).isValid) =>
- Try(Token(salt, login, Instant.parse(ends), sig)).
- toOption.
- getOrElse(Token(salt, login, Instant.ofEpochMilli(0), sig))
- case _ =>
- invalid
- }
- }
-
- implicit val _jsonEnc: Encoder[Token] =
- Encoder.forProduct1("token")(t => t.asString)
-
- implicit val _jsonDec: Decoder[Token] =
- Decoder.forProduct1("token")(Token.parse)
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/authc/package.scala b/modules/server/src/main/scala/sharry/server/authc/package.scala
deleted file mode 100644
index 168d3a53..00000000
--- a/modules/server/src/main/scala/sharry/server/authc/package.scala
+++ /dev/null
@@ -1,24 +0,0 @@
-package sharry.server
-
-import cats.syntax.either._
-import scodec.{Err, Codec}
-import scodec.bits.ByteVector
-
-import sharry.common.data._
-
-package object authc {
- type AuthResult = Either[String, Account]
-
- object AuthResult {
- val failed = fail("Login failed")
- def fail(msg: String): AuthResult = Left(msg)
- def ok(a: Account): AuthResult = Right(a)
- def apply(a: Account): AuthResult = ok(a)
- }
-
- def parse[A](str: String, codec: Codec[A]): Either[Err, A] =
- for {
- bv <- ByteVector.encodeUtf8(str).leftMap(ex => Err(ex.getMessage))
- a <- codec.decodeValue(bv.bits).toEither
- } yield a
-}
diff --git a/modules/server/src/main/scala/sharry/server/codec/HttpHeaderCodec.scala b/modules/server/src/main/scala/sharry/server/codec/HttpHeaderCodec.scala
deleted file mode 100644
index f1729e3c..00000000
--- a/modules/server/src/main/scala/sharry/server/codec/HttpHeaderCodec.scala
+++ /dev/null
@@ -1,39 +0,0 @@
-package sharry.server.codec
-
-import scodec.{Err, Attempt, Codec}
-import scodec.codecs._
-import spinoco.protocol.http.header._
-import spinoco.protocol.http.codec.{HttpHeaderCodec => SpinocoCodec}
-
-object HttpHeaderCodec {
-
- /**
- * Wraps all header codecs in
- * [[spinoco.protocol.http.codec.HttpHeaderCodec]] to allow empty
- * values.
- *
- */
- def codec(maxHeaderLength: Int, otherHeaders: (String, Codec[HttpHeader]) *):Codec[HttpHeader] = {
- val all = allCodecs ++
- otherHeaders.map { case (hdr,codec) => hdr.toLowerCase -> choice(codec, emptyHeader(hdr.toLowerCase)) }.toMap
- SpinocoCodec.codec(maxHeaderLength, all.toSeq: _*)
- }
-
-
- val emptyString: Codec[Unit] = {
- ascii.exmap(
- s => if (s.trim.isEmpty) Attempt.successful(()) else Attempt.failure(Err("Expected end of input")),
- _ => Attempt.successful("")
- )
- }
-
-
- def emptyHeader(name: String): Codec[HttpHeader] =
- recover(emptyString).exmap(
- _ => Attempt.successful(GenericHeader(name, "")),
- _ => Attempt.successful(true))
-
- val allCodecs = SpinocoCodec.allHeaderCodecs.
- map { case (name, codec) => name -> choice(codec, emptyHeader(name)) }
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/config.scala b/modules/server/src/main/scala/sharry/server/config.scala
deleted file mode 100644
index b6435d63..00000000
--- a/modules/server/src/main/scala/sharry/server/config.scala
+++ /dev/null
@@ -1,144 +0,0 @@
-package sharry.server
-
-import java.nio.file.Path
-import java.util.UUID
-import scodec.bits.ByteVector
-import cats.effect.IO
-import doobie.hikari._
-import pureconfig._
-import pureconfig.error._
-import pureconfig.ConvertHelpers._
-import spinoco.protocol.http.Uri
-import yamusca.imports._
-import sharry.common.sizes._
-import sharry.common.file._
-import sharry.common.duration._
-import sharry.server.email._
-
-object config {
-
- case class Jdbc(driver: String, url: String, user: String, password: String) {
- def transactor: IO[HikariTransactor[IO]] =
- HikariTransactor.newHikariTransactor[IO](driver, url, user, password)
- }
-
- case class AuthConfig(enable: Boolean, defaultUser: String, maxCookieLifetime: Duration, appKey: ByteVector)
-
- case class AuthcCommand(enable: Boolean, program: Seq[String], success: Int)
-
- case class AuthcHttp(enable: Boolean, url: String, method: String, body: String, contentType: String)
-
- case class AdminAccount(enable: Boolean, login: String, password: String)
-
- case class WebConfig(bindHost: String
- , bindPort: Int
- , appName: String
- , baseurl: String
- , highlightjsTheme: String
- , welcomeMessage: String) {
- lazy val domain = Uri.parse(baseurl).require.host.host
- }
-
- case class WebmailConfig(enable: Boolean
- , defaultLanguage: String
- , downloadTemplates: Map[String, Template]
- , aliasTemplates: Map[String, Template]
- , notifyTemplates: Map[String, Template]) {
-
- def findDownloadTemplate(lang: String): Option[(String, Template)] =
- downloadTemplates.find(_._1 == lang)
-
- def findAliasTemplate(lang: String): Option[(String, Template)] =
- aliasTemplates.find(_._1 == lang)
- }
-
- case class LogConfig(config: Path) {
- def exists = config.exists && !config.isDirectory
- }
-
- case class UploadConfig(
- chunkSize: Size
- , simultaneousUploads: Int
- , maxFiles: Int
- , maxFileSize: Size
- , maxValidity: Duration
- , aliasDeleteTime: Duration
- , enableUploadNotification: Boolean
- , cleanupEnable: Boolean
- , cleanupInterval: Duration
- , cleanupInvalidAge: Duration
- )
-
-
- trait Config {
- def jdbc: Jdbc
- def authConfig: AuthConfig
- def authcCommand: AuthcCommand
- def authcHttp: AuthcHttp
- def adminAccount: AdminAccount
- def webConfig: WebConfig
- def uploadConfig: UploadConfig
- def logConfig: LogConfig
- def smtpConfig: SmtpSetting
- def smtpSetting: GetSetting =
- if (smtpConfig.host.isEmpty) (GetSetting.fromDomain andThen (_.map(_.copy(from = smtpConfig.from))))
- else GetSetting.of(smtpConfig)
- def webmailConfig: WebmailConfig
- }
-
- object Config {
- object default extends Config {
- val jdbc: Jdbc = loadConfig[Jdbc]("sharry.db").get
- val authConfig: AuthConfig = loadConfig[AuthConfig]("sharry.authc").get
- val authcCommand: AuthcCommand = loadConfig[AuthcCommand]("sharry.authc.extern.command").get
- val authcHttp: AuthcHttp = loadConfig[AuthcHttp]("sharry.authc.extern.http").get
- val adminAccount = loadConfig[AdminAccount]("sharry.authc.extern.admin").get
- val webConfig = loadConfig[WebConfig]("sharry.web").get
- val uploadConfig = loadConfig[UploadConfig]("sharry.upload").get
- val logConfig = loadConfig[LogConfig]("sharry.log").get
- val smtpConfig: SmtpSetting = loadConfig[SmtpSetting]("sharry.smtp").get
- val webmailConfig: WebmailConfig = loadConfig[WebmailConfig]("sharry.web.mail").get
- }
- implicit final class ConfigEitherOps[A](r: Either[ConfigReaderFailures, A]) {
- def get: A = r match {
- case Right(a) => a
- case Left(errs) => sys.error(errs.toString)
- }
- }
- }
-
- implicit def hint[T] = ProductHint[T](ConfigFieldMapping(CamelCase, KebabCase))
-
- implicit def templateConvert: ConfigReader[Template] = ConfigReader.fromString[Template](catchReadError(s =>
- mustache.parse(s) match {
- case Right(t) => t
- case Left(err) => throw new IllegalArgumentException(s"Template parsing failed: $err")
- }
- ))
-
- implicit def durationConvert: ConfigReader[Duration] = ConfigReader.fromString[Duration](catchReadError(s =>
- Duration.unsafeParse(s)
- ))
-
-
- implicit def bytevectorConvert: ConfigReader[ByteVector] =
- ConfigReader.fromString[ByteVector](catchReadError(s =>
- s.span(_ != ':') match {
- case ("", "") => ByteVector(UUID.randomUUID.toString.getBytes)
- case ("b64", value) => ByteVector.fromValidBase64(value.drop(1))
- case ("hex", value) => ByteVector.fromValidHex(value.drop(1))
- case _ => throw new IllegalArgumentException(s"invalid bytes: $s. Make sure to prefix with either 'b64:' or 'hex:'.")
- }))
-
- //we cannot delegate to Config#getBytes; see https://github.com/melrief/pureconfig/issues/86
- implicit def sizeConvert: ConfigReader[Size] =
- ConfigReader.fromString[Size](
- catchReadError(
- sz => sz.toLowerCase.last match {
- case 'k' => KBytes(sz.dropRight(1).toDouble)
- case 'm' => MBytes(sz.dropRight(1).toDouble)
- case 'g' => GBytes(sz.dropRight(1).toDouble)
- case _ => Bytes(sz.toLong)
- }))
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/Address.scala b/modules/server/src/main/scala/sharry/server/email/Address.scala
deleted file mode 100644
index 4bcc75e9..00000000
--- a/modules/server/src/main/scala/sharry/server/email/Address.scala
+++ /dev/null
@@ -1,26 +0,0 @@
-package sharry.server.email
-
-import javax.mail.internet.InternetAddress
-import cats.effect.IO
-import io.circe._
-
-case class Address(mail: InternetAddress) {
- lazy val address = mail.getAddress
- lazy val personal = Option(mail.getPersonal)
- lazy val domain: String = {
- address.lastIndexOf('@') match {
- case -1 => ""
- case n => address.substring(n+1)
- }
- }
-}
-
-object Address {
- def parse(mail: String): IO[Address] = IO {
- val a = new InternetAddress(mail)
- a.validate
- Address(a)
- }
-
- implicit val _jsonEncoder: Encoder[Address] = Encoder.encodeString.contramap[Address](_.mail.toString)
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/Header.scala b/modules/server/src/main/scala/sharry/server/email/Header.scala
deleted file mode 100644
index 9f6e74fc..00000000
--- a/modules/server/src/main/scala/sharry/server/email/Header.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package sharry.server.email
-
-trait Header {
- def name: String
-}
-
-object Header {
- case class GenericHeader(name: String, value: String) extends Header
-
- case class To(mail: Address) extends Header {
- val name = To.name
- }
- object To { val name = "To" }
-
- case class Subject(line: String) extends Header {
- val name = Subject.name
- }
- object Subject { val name = "Subject" }
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/Mail.scala b/modules/server/src/main/scala/sharry/server/email/Mail.scala
deleted file mode 100644
index a81190a1..00000000
--- a/modules/server/src/main/scala/sharry/server/email/Mail.scala
+++ /dev/null
@@ -1,49 +0,0 @@
-package sharry.server.email
-
-import cats.effect.IO
-import cats._
-import cats.implicits._
-
-import Header._
-
-case class Mail(header: List[Header], body: Body) {
- def withTo(m: Address): Mail =
- withHeader(To(m))
- def addTo(m: Address): Mail =
- copy(header = To(m) :: header)
- def withSubject(line: String): Mail =
- withHeader(Subject(line))
-
- def recipients: List[Address] = header.
- collect({ case To(a) => a })
-
- def singleRecipient: String =
- recipients.headOption.map(_.mail.toString) getOrElse ""
-
- /** Replace all same named headers with `h` */
- def withHeader(h: Header): Mail = {
- val newHeader = (h :: header).
- map(e => if (h.name == e.name) h else e).
- groupBy(_.name).
- map(_._2.head).
- toList
- copy(header = newHeader)
- }
-
- def withTextBody(text: String): Mail =
- copy(body = text)
-}
-
-object Mail {
-
- def apply(to: String, subject: String, text: String): IO[Mail] =
- for {
- t <- Address.parse(to)
- } yield Mail(List(To(t), Subject(subject)), text)
-
- def apply(to: List[String], subject: String, text: String): IO[Mail] =
- for {
- t <- Traverse[List].traverse(to)(Address.parse)
- } yield Mail(Subject(subject) :: t.map(To.apply).toList, text)
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/SmtpSetting.scala b/modules/server/src/main/scala/sharry/server/email/SmtpSetting.scala
deleted file mode 100644
index be705d4c..00000000
--- a/modules/server/src/main/scala/sharry/server/email/SmtpSetting.scala
+++ /dev/null
@@ -1,36 +0,0 @@
-package sharry.server.email
-
-import org.xbill.DNS._
-import cats.effect.IO
-import cats.implicits._
-
-case class SmtpSetting(
- host: String,
- port: Int,
- user: String,
- password: String,
- from: String,
- startTls: Boolean,
- ssl: Boolean
-) {
-
- def hidePass = copy(password = if (password.isEmpty) "" else "***")
-}
-
-
-object SmtpSetting {
- def fromAddress(m: Address): IO[Option[SmtpSetting]] =
- findMx(m.domain).handleError(_ => Nil).
- map(_.headOption).
- map(_.map(fromMx))
-
- def fromMx(host: String): SmtpSetting =
- SmtpSetting(host, 0, "", "", "", false, false)
-
- private def findMx(domain: String): IO[List[String]] = IO {
- val records = new Lookup(domain, Type.MX).run()
- .map(_.asInstanceOf[MXRecord]).toList.sortBy(_.getPriority)
-
- records.map(_.getTarget.toString.stripSuffix("."))
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/client.scala b/modules/server/src/main/scala/sharry/server/email/client.scala
deleted file mode 100644
index 7188a442..00000000
--- a/modules/server/src/main/scala/sharry/server/email/client.scala
+++ /dev/null
@@ -1,123 +0,0 @@
-package sharry.server.email
-
-import javax.mail._
-import org.log4s._
-import shapeless.syntax.std.tuple._
-import cats.data.ValidatedNel
-import cats.data.Validated.{Valid,Invalid}
-import cats.implicits._
-import cats.effect.IO
-import fs2.Stream
-
-import Header._
-
-object client {
- private[this] val logger = getLogger
-
- type Attempt[A] = Either[Throwable, A]
-
- def send_(setting: GetSetting)(mail: Mail): Stream[IO, Attempt[Mail]] = {
- splitMail(mail).
- evalMap(send1(setting))
- }
-
- def send(setting: GetSetting)(mail: IO[Mail]): Stream[IO, Attempt[Mail]] =
- Stream.eval(mail).flatMap(send_(setting))
-
- private def send1(setting: GetSetting)(mail: Mail): IO[Attempt[Mail]] = {
- val mimeMsg = extract1(mail).flatMap {
- case (to, subject, body, moreHeaders) =>
- for {
- smtp <- setting(to)
- sess <- makeSession(smtp)
- msg <- IO {
- val msg = new internet.MimeMessage(sess)
- msg.setFrom(smtp.from)
- msg.setRecipient(Message.RecipientType.TO, to.mail)
- msg.setSubject(subject)
- msg.setText(body)
- moreHeaders.foreach { h =>
- msg.addHeader(h.name, h.value)
- }
- lazy val sout ={
- val out = new java.io.ByteArrayOutputStream()
- msg.writeTo(out)
- out
- }
- logger.debug(s"Createt mime message: ${new String(sout.toByteArray)}")
- msg
- }
- } yield msg
- }
-
- mimeMsg.map(Transport.send).
- map(_ => mail).
- handleErrorWith({ case ex =>
- logger.error(ex)(s"Error sending mail: $mail")
- IO.raiseError(new Exception(mail.singleRecipient + ": "+ ex.getMessage))
- }).
- attempt
- }
-
- private def extract1(mail: Mail): IO[(Address, String, String, List[GenericHeader])] = {
- def validate[A](l: List[A], msg: String): ValidatedNel[String, A] = l match {
- case a :: Nil => Valid(a).toValidatedNel
- case Nil => Invalid(s"There is no $msg.").toValidatedNel
- case all => Invalid(s"There are more than one $msg: $all").toValidatedNel
- }
-
- val tos = validate(mail.header.collect({case To(a) => a}), "recipient")
- val subjects = validate(mail.header.collect({case Subject(line) => line}), "subject line")
- val text = Valid(mail.body).toValidatedNel
- val generic = mail.header.collect({case h: GenericHeader => h})
-
- tos.product(subjects).product(text) match {
- case Valid((t1, t)) => IO.pure(t1 :+ t :+ generic)
- case Invalid(msgs) => IO.raiseError(new Exception(msgs.toList.mkString(", ")))
- }
- }
-
- private def makeSession(setting: SmtpSetting): IO[Session] = {
- val props = System.getProperties()
- logger.debug(s"Make mail session from ${setting.hidePass}")
- props.setProperty("mail.transport.protocol", "smtp");
- if (setting.host.nonEmpty) {
- logger.debug(s"Using mail host ${setting.host}")
- props.setProperty(s"mail.smtp.host", setting.host)
- if (setting.port > 0) {
- logger.debug(s"Using mailport ${setting.port}")
- props.setProperty("mail.smtp.port", setting.port.toString)
- }
- if (setting.user.nonEmpty) {
- props.setProperty("mail.user", setting.user)
- props.setProperty("mail.smtp.auth", "true")
- }
- if (setting.startTls) {
- props.setProperty("mail.smtp.starttls.enable", "true")
- }
- if (setting.ssl) {
- props.setProperty("mail.smtp.ssl.enable", "true")
- }
- }
- if (Option(props.getProperty("mail.smtp.host")).exists(_.nonEmpty))
- IO.pure {
- if (setting.user.nonEmpty) {
- Session.getInstance(props, new Authenticator() {
- override def getPasswordAuthentication() = {
- logger.debug(s"Authenticating with ${setting.user}/${setting.hidePass.password}")
- new PasswordAuthentication(setting.user, setting.password)
- }
- })
- } else {
- Session.getInstance(props)
- }
- }
- else
- IO.raiseError(new Exception("no smtp host provided"))
- }
-
- private def splitMail(m: Mail): Stream[IO, Mail] = {
- Stream.emits(m.header.filter(_.name == To.name).
- map(to => m.withHeader(to)))
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/email/package.scala b/modules/server/src/main/scala/sharry/server/email/package.scala
deleted file mode 100644
index 0b7034bd..00000000
--- a/modules/server/src/main/scala/sharry/server/email/package.scala
+++ /dev/null
@@ -1,21 +0,0 @@
-package sharry.server
-
-import cats.effect.IO
-
-/** Utility for sending simple (text) emails. */
-package object email {
- type Body = String
-
- type GetSetting = Address => IO[SmtpSetting]
-
- object GetSetting {
- def of(s: SmtpSetting): GetSetting =
- _ => IO.pure(s)
-
- val fromDomain: GetSetting =
- a => SmtpSetting.fromAddress(a).flatMap {
- case Some(s) => IO.pure(s)
- case None => IO.raiseError(new Exception(s"No smtp host found for address $a"))
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/main.scala b/modules/server/src/main/scala/sharry/server/main.scala
deleted file mode 100644
index 368f63b9..00000000
--- a/modules/server/src/main/scala/sharry/server/main.scala
+++ /dev/null
@@ -1,187 +0,0 @@
-package sharry.server
-
-import java.time.Instant
-import java.net.InetSocketAddress
-import java.util.concurrent.{Executors, ThreadFactory}
-import java.util.concurrent.atomic.AtomicLong
-import java.nio.file.{Path, Paths}
-import java.nio.channels.AsynchronousChannelGroup
-import scala.concurrent.ExecutionContext
-import scala.concurrent.duration._
-
-import fs2._
-import cats.effect.IO
-import cats.implicits._
-import scodec.{Attempt, Codec}
-import spinoco.fs2.http
-import spinoco.fs2.http.HttpResponse
-import spinoco.fs2.http.body.BodyEncoder
-import spinoco.fs2.http.routing._
-import spinoco.protocol.http.HttpRequestHeader
-import spinoco.protocol.http.HttpStatusCode
-import spinoco.protocol.http.codec.HttpRequestHeaderCodec
-
-import org.log4s._
-
-import sharry.common.BuildInfo
-import sharry.common.file._
-import sharry.common.streams
-import sharry.common.version
-import sharry.store.evolution
-import sharry.server.codec.HttpHeaderCodec
-
-object main {
- implicit val logger = getLogger
-
- def main(args: Array[String]): Unit = {
-
- implicit val EC = ExecutionContext.fromExecutorService(Executors.newCachedThreadPool(new ThreadFactory() {
- private val counter = new AtomicLong(0)
- def newThread(r: Runnable) =
- new Thread(r, s"sharry-${counter.getAndIncrement}")
- }))
- implicit val ACG = AsynchronousChannelGroup.withThreadPool(EC) // http.server requires a group
- val EC2 = Executors.newScheduledThreadPool(5)
- implicit val SCH = Scheduler.fromScheduledExecutorService(EC2)
-
- logger.info(s"""
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- | Sharry ${version.longVersion} build at ${BuildInfo.builtAtString.dropRight(4)}UTC is starting up …
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––""".stripMargin)
- val startupCfg = StartConfig.parse(args)
- startupCfg.setup.unsafeRunSync
- val app = new App(config.Config.default)
-
- logger.info("""
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- | • Running initialize tasks …
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––""".stripMargin)
- evolution(app.cfg.jdbc.url).runChanges(app.jdbc).unsafeRunSync
- async.start(startCleanup(app)).unsafeRunSync
-
- val shutdown =
- for {
- _ <- IO(logger.info("Closing database"))
- _ <- IO(app.jdbc.kernel.close())
- _ <- IO(logger.info("Closing threadpools"))
- _ <- IO(EC2.shutdown())
- _ <- IO(EC.shutdown())
- } yield ()
-
- val server = http.server[IO](
- bindTo = new InetSocketAddress(app.cfg.webConfig.bindHost, app.cfg.webConfig.bindPort),
- requestCodec = requestHeaderCodec,
- requestHeaderReceiveTimeout = 10.seconds,
- sendFailure = handleSendFailure _, // (Option[HttpRequestHeader], HttpResponse[F], Throwable) => Stream[F, Nothing],
- requestFailure = logRequestErrors _)(route(app.endpoints)).
- onFinalize(shutdown)
-
- logger.info(s"""
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
- | • Starting http server at ${app.cfg.webConfig.bindHost}:${app.cfg.webConfig.bindPort}
- |––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––""".stripMargin)
-
- if (startupCfg.console) {
- startWithConsole(server).unsafeRunSync
- } else {
- server.compile.drain.unsafeRunSync
- }
- }
-
-
- private def startWithConsole(server: Stream[IO,Unit]): IO[Unit] = {
- implicit val ec = ExecutionContext.Implicits.global
- async.signalOf[IO, Boolean](false).flatMap ({ interrupt =>
- for {
- wait1 <- async.start(server.interruptWhen(interrupt).compile.drain)
- _ <- IO(println("Hit RETURN to stop the server"))
- _ <- IO(scala.io.StdIn.readLine())
- _ <- interrupt.set(true)
- _ <- wait1
- _ <- IO(logger.info("Sharry has stopped"))
- } yield ()
- })
- }
-
- private def startCleanup(app: App)(implicit SCH: Scheduler, EC: ExecutionContext): IO[Unit] = {
- val cfg = app.uploadConfig
- if (cfg.cleanupEnable) {
- logger.info(s"Scheduling cleanup job every ${cfg.cleanupInterval}")
- val stream = SCH.awakeEvery[IO](cfg.cleanupInterval.asScala).
- flatMap({ _ =>
- logger.info("Running cleanup job")
- val since = Instant.now.minus(cfg.cleanupInvalidAge.asJava)
- app.uploadStore.cleanup(since).
- through(streams.ifEmpty(Stream.emit(0))).fold1(_ + _).
- evalMap(n => IO(logger.info(s"Cleanup job removed $n uploads"))) ++
- Stream.eval(IO(logger.info("Cleanup job done."))).drain
- })
-
- stream.compile.drain
- } else {
- logger.info("Not starting cleanup job as requested")
- IO.pure(())
- }
- }
-
- private def logRequestErrors[F[_]](error: Throwable): Stream[F, HttpResponse[F]] = Stream.suspend {
- implicit val enc = BodyEncoder.utf8String
- logger.error(error)("Error in request")
- Stream.emit(HttpResponse[F](HttpStatusCode.InternalServerError).withBody(error.getClass + ":" + error.getMessage)).covary[F]
- }
-
- private def handleSendFailure[F[_]](header: Option[HttpRequestHeader], response: HttpResponse[F], err:Throwable): Stream[F, Nothing] = {
- Stream.suspend {
- err match {
- case _: java.io.IOException if err.getMessage == "Broken pipe" || err.getMessage == "Connection reset by peer" =>
- logger.warn(s"Error sending response: ${err.getMessage}! Request headers: ${header}")
- case _ =>
- logger.error(err)(s"Error sending response! Request headers: ${header}")
- }
- Stream.empty
- }
- }
-
- private def requestHeaderCodec: Codec[HttpRequestHeader] = {
- val codec = HttpRequestHeaderCodec.codec(HttpHeaderCodec.codec(Int.MaxValue))
- Codec (
- h => codec.encode(h),
- v => codec.decode(v) match {
- case a: Attempt.Successful[_] => a
- case f@ Attempt.Failure(cause) =>
- logger.error(s"Error parsing request ${v.decodeUtf8} \n$cause")
- f
- }
- )
- }
-
- case class StartConfig(console: Boolean, configFile: Option[Path]) {
- def setup: IO[Unit] = IO {
- configFile.foreach { f =>
- logger.info(s"Using config file $f")
- System.setProperty("config.file", f.toString)
- }
- }
- }
-
- object StartConfig {
-
- def parse(args: Seq[String]): StartConfig = {
- val console = {
- args.exists(_ == "--console") ||
- Option(System.getProperty("sharry.console")).
- exists(_ equalsIgnoreCase "true")
- }
-
- val file = args.find(_ != "--console").
- map(f => Paths.get(f)).
- orElse {
- Option(System.getProperty("sharry.optionalConfig")).
- map(f => Paths.get(f)).
- filter(_.exists)
- }
-
- StartConfig(console, file)
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/notification.scala b/modules/server/src/main/scala/sharry/server/notification.scala
deleted file mode 100644
index fc5ad18f..00000000
--- a/modules/server/src/main/scala/sharry/server/notification.scala
+++ /dev/null
@@ -1,102 +0,0 @@
-package sharry.server
-
-import java.time.Instant
-import fs2.{async, Scheduler, Stream}
-import cats.effect.IO
-import yamusca.implicits._
-import scala.concurrent.ExecutionContext
-
-import sharry.store.upload.UploadStore
-import sharry.store.account.AccountStore
-import sharry.store.data.Alias
-import sharry.common.streams
-import sharry.common.duration._
-import sharry.common.data._
-import sharry.server.config._
-import sharry.server.email._
-
-object notification {
-
- type Notifier = (String, Alias, Duration) => Stream[IO,Unit]
-
- def scheduleNotify(smtp: GetSetting
- , webCfg: WebConfig
- , mailCfg: WebmailConfig
- , store: UploadStore
- , accounts: AccountStore)
- (implicit SCH: Scheduler, EC: ExecutionContext): Notifier = { (id, alias, time) =>
-
- val send = client.send_(smtp)_
- val workTask = findRecipient(id, alias, store, accounts).
- evalMap(makeNotifyMail(webCfg, mailCfg)).
- flatMap(send).
- compile.drain
-
- checkAliasAccess(id, alias, time, store).flatMap {
- case true =>
- findRecipient(id, alias, store, accounts).
- evalMap(_ => async.start(schedule(workTask, time))).
- map(_ => ())
-
- case false =>
- Stream.emit(())
- }
- }
-
- private def schedule[A](task: IO[A], delay: Duration)
- (implicit SCH: Scheduler, EC: ExecutionContext): IO[Unit] = {
-
- SCH.sleep[IO](delay.asScala).evalMap(_ => task).compile.drain
- }
-
- def checkAliasAccess(id: String
- , alias: Alias
- , time: Duration
- , store: UploadStore) = {
- // a request authorized by an alias id to delete an upload is only
- // valid if issued less than X minutes after uploading and it was
- // initially uploaded by this alias
- val now = Instant.now
- store.getUpload(id, alias.login).
- map({ info =>
- info.upload.alias == Some(alias.id) &&
- info.upload.created.plus(time.asJava).isAfter(now)
- })
- }
-
-
- def makeNotifyMail(webCfg: WebConfig, mailCfg: WebmailConfig)
- (data: (Upload, String)): IO[Mail] = {
- val (upload, recipient) = data
- val templ = mailCfg.notifyTemplates(mailCfg.defaultLanguage)
- val ctx = Map(
- "username" -> Some(upload.login)
- , "uploadId" -> Some(upload.id)
- , "alias" -> upload.aliasName
- , "aliasId" -> upload.alias
- , "uploadUrl" -> Some(webCfg.baseurl + "#uid=" + upload.id)
- )
- val text = ctx.render(templ)
- val (subject, body) = text.span(_ != '\n')
- Mail(to = recipient
- , subject = subject
- , text = body)
- }
-
- def findRecipient(uploadId: String
- , alias: Alias
- , store: UploadStore
- , accounts: AccountStore): Stream[IO,(Upload,String)] =
- for {
- info <- {
- store.getUpload(uploadId, alias.login).
- filter(_.upload.alias == Some(alias.id))
- }
- receiver <- {
- accounts.getAccount(alias.login).
- filter(_.enabled).
- map(_.email).
- through(streams.optionToEmpty)
- }
- } yield (info.upload, receiver)
-}
diff --git a/modules/server/src/main/scala/sharry/server/paths.scala b/modules/server/src/main/scala/sharry/server/paths.scala
deleted file mode 100644
index 237055d2..00000000
--- a/modules/server/src/main/scala/sharry/server/paths.scala
+++ /dev/null
@@ -1,76 +0,0 @@
-package sharry.server
-
-import cats.effect.IO
-import spinoco.fs2.http.routing._
-
-/** Collection of paths used by the rest api and that is transferred
- * to the web client.*/
-object paths {
- val api1 = Path("api", "v1")
-
- val mounts = Map(
- "authLogin" -> api1/"auth"/"login",
- "authCookie" -> api1/"auth"/"cookie",
- "logout" -> api1/"auth"/"logout",
- "accounts" -> api1/"accounts",
- "profileEmail" -> api1/"profile"/"email",
- "profilePassword" -> api1/"profile"/"password",
- "uploads" -> api1/"uploads",
- "uploadData" -> api1/"upload-data",
- "uploadPublish" -> api1/"upload-publish",
- "uploadUnpublish" -> api1/"upload-unpublish",
- "download" -> api1/"dl"/"file",
- "downloadPublished" -> Path("dlp")/"file",
- "checkPassword" -> api1/"check-password",
- "aliases" -> api1/"aliases",
- "mailCheck" -> api1/"mail"/"check",
- "mailSend" -> api1/"mail"/"send",
- "mailDownloadTemplate" -> api1/"mail"/"download-template",
- "mailAliasTemplate" -> api1/"mail"/"alias-template",
- "uploadNotify" -> api1/"upload-notify",
- "manual" -> Path("manual"),
- "settings" -> api1/"settings"
- )
-
- def authLogin = mounts("authLogin").matcher
- def authCookie = mounts("authCookie").matcher
- def logout = mounts("logout")
- def accounts = mounts("accounts")
- def profileEmail = mounts("profileEmail")
- def profilePassword = mounts("profilePassword")
- def uploads = mounts("uploads")
- def uploadData = mounts("uploadData")
- def uploadPublish = mounts("uploadPublish")
- def uploadUnpublish = mounts("uploadUnpublish")
- def download = mounts("download")
- def downloadPublished = mounts("downloadPublished")
- def checkPassword = mounts("checkPassword")
- def aliases = mounts("aliases")
- def mailCheck = mounts("mailCheck")
- def mailSend = mounts("mailSend")
- def mailDownloadTemplate = mounts("mailDownloadTemplate")
- def mailAliasTemplate = mounts("mailAliasTemplate")
- def uploadNotify = mounts("uploadNotify")
- def manual = mounts("manual")
- def settings = mounts("settings")
-
- case class Path(segments: List[String]) {
- def matcherF[F[_]]: Matcher[F, String] = segments match {
- case Nil => empty.map(_ => "")
- case a :: Nil => a
- case a :: b :: Nil => a / b
- case a :: b :: rest => rest.foldLeft(a / b)(_ / _)
- }
- def matcher = matcherF[IO]
- def path = segments.mkString("/", "/", "")
-
- def /(next: String) = Path(segments :+ next)
- }
-
- object Path {
- val root = Path(Nil)
- def apply(segs: String*): Path =
- if (segs.isEmpty) root
- else Path(segs.toList)
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/UserId.scala b/modules/server/src/main/scala/sharry/server/routes/UserId.scala
deleted file mode 100644
index 47b6e8dd..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/UserId.scala
+++ /dev/null
@@ -1,21 +0,0 @@
-package sharry.server.routes
-
-import sharry.store.data.Alias
-
-sealed trait UserId {
- def login: String
- def alias: Option[Alias]
- def aliasId: Option[String] = alias.map(_.id)
-}
-case class Username(login: String) extends UserId {
- val alias = None
-}
-case class AliasId(a: Alias) extends UserId {
- override val aliasId = Some(a.id)
- val alias = Some(a)
- val login = a.login
-}
-object UserId {
- def apply(alias: Alias): UserId = AliasId(alias)
- def apply(login: String): UserId = Username(login)
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/account.scala b/modules/server/src/main/scala/sharry/server/routes/account.scala
deleted file mode 100644
index 034fad37..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/account.scala
+++ /dev/null
@@ -1,136 +0,0 @@
-package sharry.server.routes
-
-import cats.data.{Validated, ValidatedNel}
-import cats.implicits._
-import fs2.Stream
-import cats.effect.IO
-import com.github.t3hnar.bcrypt._
-import shapeless.{::,HNil}
-import spinoco.fs2.http.routing._
-
-import sharry.common.streams
-import sharry.common.data.Account
-import sharry.store.account.AccountStore
-import sharry.server.config.{AuthConfig, WebConfig}
-import sharry.server.authc._
-import sharry.server.paths
-import sharry.server.email.Address
-import sharry.server.routes.syntax._
-
-object account {
-
- def endpoint(auth: Authenticate, authCfg: AuthConfig, store: AccountStore, cfg: WebConfig) =
- choice2(listLogins(auth, store)
- , createAccount(auth, store)
- , modifyAccount(auth, store)
- , updateEmail(authCfg, store)
- , updatePassword(authCfg, store)
- , getAccount(auth, store))
-
- def createAccount(auth: Authenticate, store: AccountStore): Route[IO] =
- Put >> paths.accounts.matcher >> authz.admin(auth) >> jsonBody[Account] map { (a: Account) =>
- val acc = a.copy(
- password = a.password.map(_.bcrypt),
- email = a.email.filter(_.nonEmpty)
- )
- validateAccount(acc) match {
- case Validated.Invalid(errs) =>
- Stream.emit(BadRequest.message(s"Invalid account: ${errs.toList.mkString(", ")}"))
- case Validated.Valid(_) =>
- store.getAccount(acc.login).
- map(a => BadRequest.message("The account already exists")).
- through(streams.ifEmpty {
- store.createAccount(acc).
- map(_ => Created.body(acc.noPass))
- })
- }
- }
-
- def validateEmail(address: String): ValidatedNel[String, Unit] = {
- val parsed = Address.parse(address).
- map(_ => ()).
- attempt.
- map(_.leftMap(_.getMessage))
-
- Validated.
- fromEither(parsed.unsafeRunSync).
- toValidatedNel
- }
-
- def validateAccount(a: Account): ValidatedNel[String, Account] = {
- // validate email
- val v1 = a.email.map(validateEmail).getOrElse(Validated.valid(()).toValidatedNel)
-
- // validate rest of account
- val v2 = Account.validate(a).map(_ => ())
-
- (v1 |+| v2).map(_ => a)
- }
-
- def modifyAccount(auth: Authenticate, store: AccountStore): Route[IO] =
- Post >> paths.accounts.matcher >> authz.admin(auth) >> jsonBody[Account] map {
- (account: Account) =>
- validateAccount(account) match {
- case Validated.Invalid(errs) =>
- Stream.emit(BadRequest.message(s"Invalid account: ${errs.toList.mkString(", ")}"))
- case _ =>
- store.getAccount(account.login).
- map(dba => account.copy(
- password = account.password match {
- case Some(pw) if pw.nonEmpty => Some(pw.bcrypt)
- case _ => dba.password
- },
- email = account.email.filter(_.nonEmpty)
- )).
- flatMap(a => store.updateAccount(a).map(_ => a)).
- map(Ok.body(_)).
- through(NotFound.whenEmpty)
- }
- }
-
- def updateEmail(cfg: AuthConfig, store: AccountStore): Route[IO] =
- Post >> paths.profileEmail.matcher >> authz.user(cfg) :: jsonBody[Account] map {
- case login :: account :: HNil =>
- validateAccount(account) match {
- case Validated.Invalid(errs) =>
- Stream.emit(BadRequest.message(s"Invalid account: ${errs.toList.mkString(", ")}"))
- case _ =>
- store.updateEmail(login, account.email).
- flatMap {
- case true => store.getAccount(login).map(Ok.body(_))
- case false => Stream.emit(NotFound.noBody)
- }
- }
- }
-
- def updatePassword(cfg: AuthConfig, store: AccountStore): Route[IO] =
- Post >> paths.profilePassword.matcher >> authz.user(cfg) :: jsonBody[Account] map {
- case login :: account :: HNil =>
- validateAccount(account) match {
- case Validated.Invalid(errs) =>
- Stream.emit(BadRequest.message(s"Invalid account: ${errs.toList.mkString(", ")}"))
- case _ =>
- store.updatePassword(login, account.password.map(_.bcrypt)).
- flatMap {
- case true => store.getAccount(login).map(Ok.body(_))
- case false => Stream.emit(NotFound.noBody)
- }
- }
- }
-
- def listLogins(auth: Authenticate, store: AccountStore): Route[IO] =
- Get >> paths.accounts.matcher/empty >> authz.admin(auth) / param[String]("q").? map { (q: Option[String]) =>
- Stream.eval(store.listLogins(q.getOrElse(""), None).compile.toVector).
- map(Ok.body(_))
- }
-
- def getAccount(auth: Authenticate, store: AccountStore): Route[IO] =
- Get >> paths.accounts.matcher / as[String] authz.admin(auth) map { login =>
- Account.validateLogin(login) match {
- case Validated.Invalid(errs) =>
- Stream.emit(BadRequest.message(s"Invalid login: ${errs.toList.mkString(", ")}"))
- case _ =>
- store.getAccount(login).map(Ok.body(_))
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/alias.scala b/modules/server/src/main/scala/sharry/server/routes/alias.scala
deleted file mode 100644
index 49ba7703..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/alias.scala
+++ /dev/null
@@ -1,72 +0,0 @@
-package sharry.server.routes
-
-import shapeless.{::, HNil}
-import fs2.Stream
-import cats.effect.IO
-import cats.Order
-import spinoco.fs2.http.routing._
-
-import sharry.common.data._
-import sharry.common.duration._
-import sharry.common.streams
-import sharry.store.data._
-import sharry.server.paths
-import sharry.server.config._
-import sharry.store.upload.UploadStore
-import sharry.server.routes.syntax._
-
-object alias {
-
- def endpoint(auth: AuthConfig, uploadCfg: UploadConfig, store: UploadStore) =
- choice2(updateAlias(auth, uploadCfg, store)
- , createAlias(auth, uploadCfg, store)
- , getAlias(store)
- , listAliases(auth, store)
- , deleteAlias(auth, store))
-
- def updateAlias(authCfg: AuthConfig, cfg: UploadConfig, store: UploadStore): Route[IO] =
- Post >> paths.aliases.matcher / as[String] :: authz.user(authCfg) :: jsonBody[AliasUpdate] map {
- case aliasId :: login :: alias :: HNil =>
- val a = Alias.generate(login, alias.name, Duration.zero).
- copy(id = alias.id).
- copy(enable = alias.enable)
- Duration.parse(alias.validity).
- ensure("Validity time is too long.")(cfg.maxValidity >= _).
- map(v => a.copy(validity = v)).
- andThen(a => Alias.validateId(a.id).map(_ => a)).
- map(a => store.getAlias(a.id).
- filter(a => a.id != aliasId).
- map(_ => BadRequest.message(s"An alias with id '${a.id}' already exists.")).
- through(streams.ifEmpty(
- store.updateAlias(a, aliasId).
- map({ n => if (n == 0) NotFound.body("0") else Ok.body(a) })))).
- valueOr(msg => Stream.emit(BadRequest.message(msg)))
- }
-
- def createAlias(authCfg: AuthConfig, cfg: UploadConfig, store: UploadStore): Route[IO] =
- Post >> paths.aliases.matcher >> authz.user(authCfg) map { (login: String) =>
- val alias = Alias.generate(login, "New alias", Order[Duration].min(5.days, cfg.maxValidity))
- store.createAlias(alias).
- map(_ => Ok.body(alias))
- }
-
- def listAliases(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Get >> paths.aliases.matcher >> authz.user(authCfg) map { (login: String) =>
- Stream.eval(store.listAliases(login).compile.toVector).
- map(Ok.body(_))
- }
-
- def getAlias(store: UploadStore): Route[IO] =
- Get >> paths.aliases.matcher / as[String] map { (id: String) =>
- store.getActiveAlias(id).
- map(Ok.body(_)).
- through(NotFound.whenEmpty)
- }
-
- def deleteAlias(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Delete >> paths.aliases.matcher / as[String] :: authz.user(authCfg) map {
- case id :: login :: HNil =>
- store.deleteAlias(id, login).
- map({ n => if (n == 0) NotFound.body("0") else Ok.body(n.toString) })
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/authz.scala b/modules/server/src/main/scala/sharry/server/routes/authz.scala
deleted file mode 100644
index 8007f5b5..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/authz.scala
+++ /dev/null
@@ -1,54 +0,0 @@
-package sharry.server.routes
-
-import java.time.Instant
-
-import cats.effect.IO
-import spinoco.fs2.http.routing._
-import spinoco.protocol.http._
-
-import sharry.common.data.Account
-import sharry.store.data.Alias
-import sharry.store.upload.UploadStore
-import sharry.server.authc._
-import sharry.server.config._
-import sharry.server.routes.syntax._
-
-object authz {
- val aliasHeaderName = "X-Sharry-Alias"
-
- def user(cfg: AuthConfig): Matcher[IO, String] =
- if (!cfg.enable) Matcher.success(cfg.defaultUser)
- else login.sharryCookie.flatMap {
- case token if token.verify(Instant.now, cfg.appKey) =>
- Matcher.success(token.login)
- case _ =>
- Matcher.respond(Unauthorized.message("Not authenticated"))
- }
-
- def admin(auth: Authenticate): Matcher[IO, Account] =
- login.sharryCookie.
- evalMap(token => auth.authc(token, Instant.now).compile.last).
- flatMap {
- case Some(Right(account)) =>
- if (account.admin) Matcher.success(account)
- else Matcher.respond(Forbidden.message("Not authorized for admin actions"))
- case Some(Left(err)) =>
- Matcher.respond(Unauthorized.message("Not authenticated."))
- case None =>
- Matcher.respondWith(HttpStatusCode.InternalServerError)
- }
-
- def userId(cfg: AuthConfig, store: UploadStore): Matcher[IO, UserId] =
- // if alias page is used, it is preferred even if the user is logged in currently
- alias(store).map(UserId.apply) or user(cfg).map(UserId.apply)
-
-
- def alias(store: UploadStore): Matcher[IO, Alias] = {
- syntax.aliasId.
- evalMap(id => store.getActiveAlias(id).compile.last).
- flatMap {
- case Some(alias) => Matcher.success(alias)
- case None => Matcher.respondWith(HttpStatusCode.Forbidden)
- }
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/download.scala b/modules/server/src/main/scala/sharry/server/routes/download.scala
deleted file mode 100644
index dc42d441..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/download.scala
+++ /dev/null
@@ -1,174 +0,0 @@
-package sharry.server.routes
-
-import java.time.Instant
-import cats.data.Ior
-import fs2.{Pipe, Stream}
-import cats.effect.IO
-import shapeless.{::,HNil}
-import scodec.bits.{BitVector, ByteVector}
-import spinoco.protocol.mime.ContentType
-import spinoco.fs2.http.body.StreamBodyEncoder
-import spinoco.fs2.http.HttpResponse
-import spinoco.fs2.http.routing._
-import bitpeace.RangeDef
-
-import sharry.common.data._
-import sharry.common.mime._
-import sharry.common.streams
-import sharry.server.paths
-import sharry.server.config._
-import sharry.store.upload.UploadStore
-import sharry.server.routes.syntax._
-
-object download {
-
- type ResponseOr[A] = Either[HttpResponse[IO], A]
-
- def endpoint(auth: AuthConfig, webCfg: WebConfig, store: UploadStore) =
- choice2(download(auth, store)
- , downloadPublished(webCfg, store)
- , downloadHead(auth, store)
- , downloadPublishedHead(store)
- , checkPassword(webCfg, store))
-
-
- def download(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Get >> paths.download.matcher / as[String] :: range :: ifNoneMatch :: authz.user(authCfg) map {
- case id :: bytes :: noneMatch :: user :: HNil =>
- // get file if owned by user
- store.getUploadByFileId(id, user).
- map({ case (_, f) => Right(f) }).
- through(unmodifiedWhen(noneMatch, f => f.meta.id, standardHeaders)).
- through(bytes.map(deliverPartial(store)).getOrElse(deliver(store))).
- through(NotFound.whenEmpty)
- }
-
- def downloadHead(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Head >> paths.download.matcher / as[String] :: authz.user(authCfg) map {
- case id :: user :: HNil =>
- store.getUploadByFileId(id, user).
- map(_._2).
- map(standardHeaders).
- map(Ok.noBody ++ _).
- through(NotFound.whenEmpty)
- }
-
- def downloadPublished(webCfg: WebConfig, store: UploadStore): Route[IO] =
- Get >> paths.downloadPublished.matcher / as[String] :: range :: ifNoneMatch :: sharryPass map {
- case id :: bytes :: noneMatch :: pass :: HNil =>
- store.getPublishedUploadByFileId(id).
- through(checkDownloadFile(pass)).
- through(unmodifiedWhen(noneMatch, f => f.meta.id, standardHeaders)).
- through(bytes.map(deliverPartial(store)).getOrElse(deliver(store))).
- through(NotFound.whenEmpty)
- }
-
- def downloadPublishedHead(store: UploadStore): Route[IO] =
- Head >> paths.downloadPublished.matcher / as[String] :: sharryPass map {
- case id :: pass :: HNil =>
- store.getPublishedUploadByFileId(id).
- through(checkDownloadFile(pass)).
- map(_.map(Ok.noBody ++ standardHeaders(_))).
- map(_.fold(identity, identity)).
- through(NotFound.whenEmpty)
- }
-
-
- def checkPassword(cfg: WebConfig, store: UploadStore): Route[IO] =
- Post >> paths.checkPassword.matcher / as[String] :: jsonBody[Pass].? map {
- case id :: pass :: HNil =>
-
- val makeCookie = withCookie[IO](cfg.domain, paths.downloadPublished.path)(
- "sharry_dlpassword", pass.map(_.password).getOrElse(""))
-
- store.getPublishedUpload(id).map({ info =>
- Upload.checkPassword(info.upload, pass.map(_.password)).
- leftMap(err => List(err)).
- map(_ => List[String]()).
- toEither.
- fold(
- l => Ok.body(l),
- l => Ok.body(l) ++ makeCookie,
- )}).
- through(NotFound.whenEmpty)
- }
-
- private def sharryPass: Matcher[Nothing, Option[String]] =
- cookie("sharry_dlpassword").map(_.content).?
-
-
- private def deliverPartial(store: UploadStore)(bytes: Ior[Int, Int]): Pipe[IO, ResponseOr[UploadInfo.File], HttpResponse[IO]] =
- _.map({
- case Right(file) =>
- val data = store.fetchData(RangeDef.byteRange(bytes))(Stream.emit(file)).
- through(streams.toByteChunks)
-
- val mt = file.meta.mimetype
- PartialContent.streamBody(data)(encoder(mt)) ++
- withContentLength(bytes, file.meta.length) ++
- withContentRange(bytes, file.meta.length) ++
- withAcceptRanges ++
- withDisposition("inline", file.filename)
- case Left(r) => r
- })
-
- private def deliver(store: UploadStore): Pipe[IO, ResponseOr[UploadInfo.File], HttpResponse[IO]] =
- _.map({
- case Right(file) =>
- val data = store.fetchData(RangeDef.all)(Stream.emit(file)).
- through(streams.toByteChunks)
-
- val mt = file.meta.mimetype
- Ok.streamBody(data)(encoder(mt)) ++ standardHeaders(file)
- case Left(r) => r
- })
-
- private def unmodifiedWhen[A](tagOpt: Option[String]
- , id: A => String
- , modify: A => ResponseUpdate[IO]): Pipe[IO, ResponseOr[A], ResponseOr[A]] =
- tagOpt match {
- case None => identity
- case Some(tag) =>
- _.map(_.flatMap { a =>
- if (id(a) == tag) Left(NotModified.noBody ++ modify(a))
- else Right(a)
- })
- }
-
- private def checkDownload1[A](pass: Option[String]): Pipe[IO, (Upload, A), ResponseOr[(Upload, A)]] =
- _.map { case (upload, a) =>
- Upload.checkUpload(upload, Instant.now, upload.downloads, pass).
- leftMap(err => BadRequest.body(err.toList)).
- map(_ => (upload, a)).
- toEither
- }
-
- private def checkDownloadFile(pass: Option[String]): Pipe[IO, (Upload, UploadInfo.File), ResponseOr[UploadInfo.File]] =
- _.through(checkDownload1(pass)).
- map(_.map(_._2))
-
- // private def checkDownload[A](pass: Option[String]): Pipe[IO, UploadInfo, ResponseOr[UploadInfo]] =
- // _.map(u => (u.upload, u)).
- // through(checkDownload1(pass)).
- // map(_.map(_._2))
-
- private def encoder(mt: MimeType): StreamBodyEncoder[IO, ByteVector] =
- StreamBodyEncoder.byteVectorEncoder.withContentType(asContentType(mt))
-
- private def asContentType(mt: MimeType): ContentType =
- // TODO getOrElse octet-stream
- ContentType.codec.decodeValue(BitVector(mt.asString.getBytes)).require
-
- private def standardHeaders(file: UploadInfo.File): ResponseUpdate[IO] =
- _ ++ withContentLength(file.meta.length.toBytes) ++
- withAcceptRanges ++
- withETag(file.meta.id) ++
- withLastModified(file.meta.timestamp) ++
- withDisposition("inline", file.filename)
-
- // private def standardHeaders(info: UploadInfo): ResponseUpdate[IO] = {
- // _ ++ withLastModified(info.upload.created) ++
- // info.upload.publishId.map(withETag[IO]).getOrElse(ResponseUpdate.identity[IO])
- // }
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/login.scala b/modules/server/src/main/scala/sharry/server/routes/login.scala
deleted file mode 100644
index 0c408a9d..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/login.scala
+++ /dev/null
@@ -1,77 +0,0 @@
-package sharry.server.routes
-
-import java.time.Instant
-
-import scodec.bits.ByteVector
-import cats.syntax.either._
-import fs2.Stream
-import cats.effect.IO
-import spinoco.protocol.http.header.value.HttpCookie
-import spinoco.protocol.http.header.`Set-Cookie`
-import spinoco.fs2.http.routing._
-import spinoco.fs2.http.HttpResponse
-
-import sharry.common.duration._
-import sharry.common.data._
-import sharry.server.config.{AuthConfig, WebConfig}
-import sharry.server.paths
-import sharry.server.authc._
-import sharry.server.routes.syntax._
-
-object login {
-
- def endpoint(auth: Authenticate, cfg: WebConfig, authCfg: AuthConfig) = {
- val domain = cfg.domain
- choice(doLogin(byPass(auth), domain, authCfg), doLogin(byCookie(auth), domain, authCfg), removeCookie(domain))
- }
-
- def byPass(auth: Authenticate): Matcher[IO, Stream[IO, AuthResult]] =
- paths.authLogin >> jsonBody[UserPass] map { (up: UserPass) =>
- auth.authc(up.login, up.pass)
- }
-
- def byCookie(auth: Authenticate): Matcher[IO, Stream[IO, AuthResult]] =
- paths.authCookie >> sharryCookie map { (token: Token) =>
- auth.authc(token, Instant.now)
- }
-
- def sharryCookie: Matcher[IO, Token] =
- cookie(cookieName).map { (c: HttpCookie) =>
- Token.parse(c.content)
- }
-
-
- def doLogin(e: Matcher[IO, Stream[IO,AuthResult]], domain: String, cfg: AuthConfig): Route[IO] = {
- def makeResponse(ar: AuthResult): HttpResponse[IO] = ar.
- map(acc => Ok.body(acc.noPass).withHeader(`Set-Cookie`(makeCookie(acc, domain, cfg.maxCookieLifetime, cfg.appKey)))).
- valueOr(err => Unauthorized.message(err))
-
- Post >> e map { (s: Stream[IO,AuthResult]) =>
- s.map(makeResponse)
- }
- }
-
- def removeCookie(domain: String): Route[IO] =
- Get >> paths.logout.matcher map { _ =>
- val c = makeCookie(Token.invalid, domain).copy(maxAge = Some(0.seconds.asScala))
- Stream.emit(Ok.noBody.withHeader(`Set-Cookie`(c)))
- }
-
- def makeCookie(t: Token, domain: String): HttpCookie = {
- HttpCookie(name = cookieName
- , content = t.asString
- , httpOnly = true
- , maxAge = Some(Duration.between(Instant.now, t.ends).asScala)
- , path = Some(paths.api1.path)
- , domain = Some(domain)
- , params = Map.empty
- , expires = None
- , secure = false
- )
- }
-
- def makeCookie(a: Account, domain: String, cookieAge: Duration, appKey: ByteVector): HttpCookie =
- makeCookie(Token(a.login, Instant.now.plus(cookieAge.asJava), appKey), domain)
-
- val cookieName = "sharry_auth"
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/mail.scala b/modules/server/src/main/scala/sharry/server/routes/mail.scala
deleted file mode 100644
index 89feda4f..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/mail.scala
+++ /dev/null
@@ -1,105 +0,0 @@
-package sharry.server.routes
-
-import fs2.Stream
-import cats.effect.IO
-import shapeless.{::,HNil}
-import spinoco.fs2.http.routing._
-import yamusca.imports._
-import yamusca.implicits._
-import io.circe._, io.circe.generic.semiauto._
-
-import sharry.store.account._
-import sharry.server.paths
-import sharry.server.config._
-import sharry.server.email._
-import sharry.server.routes.syntax._
-
-object mail {
-
- def endpoint(auth: AuthConfig, smtp: GetSetting, mailCfg: WebmailConfig, store: AccountStore): Route[IO] =
- choice2(checkMailAddress(auth)
- , sendMail(auth, mailCfg, smtp, store)
- , getDownloadTemplate(auth, mailCfg)
- , getAliasTemplate(auth, mailCfg))
-
-
- def checkMailAddress(authCfg: AuthConfig): Route[IO] =
- Get >> paths.mailCheck.matcher >> authz.user(authCfg) >> param[String]("mail") map {
- (mail: String) =>
-
- Stream.eval(Address.parse(mail)).
- map(_ => Ok.message("Address is valid")).
- handleErrorWith(ex => Stream.emit(BadRequest.message(ex)))
- }
-
-
- def sendMail(authCfg: AuthConfig, cfg: WebmailConfig, smtp: GetSetting, store: AccountStore): Route[IO] =
- Post >> paths.mailSend.matcher >> authz.user(authCfg) :: jsonBody[SimpleMail] map {
- case user :: mail :: HNil =>
- if (!cfg.enable) Stream.emit(BadRequest.message("Sending mails is disabled."))
- else {
- val msg = for {
- msg <- mail.parse
- acc <- store.getAccount(user).compile.last
- reply <- acc.flatMap(_.email) match {
- case Some(em) => Address.parse(em).map(Some.apply)
- case None => IO.pure(None)
- }
- } yield reply.map(r => msg.withHeader(Header.GenericHeader("Reply-To", r.mail.toString))).getOrElse(msg)
- client.send(smtp)(msg).
- fold(SendResult.empty)({ (r, attempt) =>
- attempt.fold(r.addFailure, r.addSuccess)
- }).
- map({
- case r@SendResult(_, Nil, _) => Ok.body(r.withMessage("No mails could be send."))
- case r@SendResult(_, _, Nil) => Ok.body(r.withMessage("All mails have been sent."))
- case r => Ok.body(r.withMessage("Some mails could not be send."))
- })
- }
- }
-
-
- def getDownloadTemplate(authCfg: AuthConfig, cfg: WebmailConfig): Route[IO] =
- Get >> paths.mailDownloadTemplate.matcher >> getTemplate(cfg.findDownloadTemplate, authCfg, cfg)
-
- def getAliasTemplate(authCfg: AuthConfig, cfg: WebmailConfig): Route[IO] =
- Get >> paths.mailAliasTemplate.matcher >> getTemplate(cfg.findAliasTemplate, authCfg, cfg)
-
- private def getTemplate(f: String => Option[(String, Template)], authCfg: AuthConfig, cfg: WebmailConfig): Route[IO] =
- param[String]("url") :: param[String]("lang").? :: param[Boolean]("pass").? :: authz.user(authCfg) map {
- case url :: optLang :: pass :: login :: HNil =>
- val (lang, template) = optLang.
- flatMap(f).
- orElse(f(cfg.defaultLanguage)).
- getOrElse(optLang.getOrElse(cfg.defaultLanguage) -> Template(Literal("")))
-
- val data = Context("username" -> login.asMustacheValue
- , "url" -> url.asMustacheValue
- , "password" -> pass.asMustacheValue)
- val text = mustache.render(template)(data)
- val (subject, body) = text.span(_ != '\n')
-
- Stream.emit(Ok.body(Map("lang" -> lang, "text" -> body.trim, "subject" -> subject.trim)))
- }
-
-
- case class SimpleMail(to: List[String], subject: String, text: String) {
- def parse: IO[Mail] = Mail(to, subject, text)
- }
-
- object SimpleMail {
- implicit val _jsonDecoder: Decoder[SimpleMail] = deriveDecoder[SimpleMail]
- }
-
- case class SendResult(message: String, success: List[Address], failed: List[String]) {
- def addFailure(msg: Throwable) = copy(failed = msg.getMessage :: failed)
- def addSuccess(mail: Mail) = copy(success = mail.recipients ::: success)
- def withMessage(msg: String) = copy(message = msg)
- }
-
- object SendResult {
- val empty = SendResult("", Nil, Nil)
-
- implicit val _jsonEncoder: Encoder[SendResult] = deriveEncoder[SendResult]
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/package.scala b/modules/server/src/main/scala/sharry/server/routes/package.scala
deleted file mode 100644
index 52b194d5..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/package.scala
+++ /dev/null
@@ -1,46 +0,0 @@
-package sharry.server
-
-import cats.effect.IO
-import spinoco.fs2.http.body.{BodyDecoder, BodyEncoder}
-import spinoco.fs2.http.routing.{body => rbody}
-import spinoco.protocol.mime._
-import scodec.{Attempt, Err}
-import scodec.bits.ByteVector
-import io.circe.{Json, Encoder, Decoder}, io.circe.parser._, io.circe.syntax._
-
-package object routes {
-
- private def parseJson(b: ByteVector): Attempt[Json] =
- for {
- str <- b.decodeUtf8.attempt
- json <- parse(str).attempt
- } yield json
-
- private def decodeJson[A](b: ByteVector)(implicit dec: Decoder[A]): Attempt[A] =
- for {
- json <- parseJson(b)
- a <- dec.decodeJson(json).attempt
- } yield a
-
-
- implicit def jsonBodyDecoder[A](implicit jd: Decoder[A]): BodyDecoder[A] =
- BodyDecoder { (bs, ct) =>
- if (ct.mediaType == MediaType.`application/json`) decodeJson(bs)
- else Attempt.failure(Err(s"Unsupported content type: $ct"))
- }
-
- implicit def jsonBodyEncoder[A](implicit je: Encoder[A]): BodyEncoder[A] =
- BodyEncoder(ContentType.TextContent(MediaType.`application/json`, Some(MIMECharset.`UTF-8`))) { a =>
- ByteVector.encodeUtf8(a.asJson.spaces2).attempt
- }
-
- def jsonBody[A](implicit d: BodyDecoder[A]) = rbody[IO].as[A]
-
- implicit final class EitherAttempt[A, B](e: Either[A,B]) {
- def attempt: Attempt[B] = Attempt.fromEither(e.left.map(a => Err(a.toString)))
- }
-
- implicit final class StringOps(s: String) {
- def asNonEmpty: Option[String] = Option(s).map(_.trim).filter(_.nonEmpty)
- }
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/settings.scala b/modules/server/src/main/scala/sharry/server/routes/settings.scala
deleted file mode 100644
index 4a666b18..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/settings.scala
+++ /dev/null
@@ -1,22 +0,0 @@
-package sharry.server.routes
-
-import fs2.Stream
-import cats.effect.IO
-import spinoco.fs2.http.routing._
-
-import sharry.common.data._
-import sharry.server.paths
-import sharry.server.routes.syntax._
-
-object settings {
-
- def endpoint(rcfg: RemoteConfig): Route[IO] =
- remoteConfig(rcfg)
-
-
- def remoteConfig(rcfg: RemoteConfig): Route[IO] =
- Get >> paths.settings.matcher map { _ =>
- Stream.eval(IO { Ok.body(rcfg) })
- }
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/syntax.scala b/modules/server/src/main/scala/sharry/server/routes/syntax.scala
deleted file mode 100644
index 612aa3ba..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/syntax.scala
+++ /dev/null
@@ -1,180 +0,0 @@
-package sharry.server.routes
-
-import java.time.{Instant, ZoneId}
-import cats.data.Ior
-import fs2.{Pipe, Stream}
-import cats.effect.IO
-import spinoco.fs2.http._
-import spinoco.fs2.http.body.{BodyEncoder,StreamBodyEncoder}
-import spinoco.fs2.http.routing._
-import spinoco.protocol.mime.ContentDisposition
-import spinoco.protocol.http.header.value._
-import spinoco.protocol.http.header._
-import spinoco.protocol.http.{header =>_, _}
-
-import sharry.common.sizes._
-import sharry.common.streams
-
-object syntax {
-
- type Message = Map[String, String]
- object Message {
- def apply(msg: String): Message = Map("message" -> msg)
- def apply(ex: Throwable): Message = apply(ex.getMessage)
- }
-
- def emptyResponse[F[_]](status: HttpStatusCode): HttpResponse[F] =
- HttpResponse(
- HttpResponseHeader(
- status = status,
- reason = status.label,
- headers = Nil),
- Stream.empty
- )
-
- val Ok = HttpStatusCode.Ok
- val PartialContent = HttpStatusCode.PartialContent
- val NotFound = HttpStatusCode.NotFound
- val Unauthorized = HttpStatusCode.Unauthorized
- val Forbidden = HttpStatusCode.Forbidden
- val BadRequest = HttpStatusCode.BadRequest
- val Created = HttpStatusCode.Created
- val NoContent = HttpStatusCode.NoContent
- val NotModified = HttpStatusCode.NotModified
-
- /** Matches any supplied matcher or fails on first with status in `stop`.
- * This is a slightly modified version of original `choice`.
- */
- def choiceUntil[F[_],A](stop: Set[HttpStatusCode])(matcher: Matcher[F, A], matchers: Matcher[F, A]*): Matcher[F, A] = {
- def go(m: Matcher[F,A], next: Seq[Matcher[F, A]]): Matcher[F, A] = {
- next.headOption match {
- case None => m
- case Some(nm) => m.flatMapR {
- case MatchResult.Success(a) => Matcher.success(a)
- case f: MatchResult.Failed[F] if stop contains f.response.header.status => Matcher.respond(f.response)
- case f: MatchResult.Failed[F] => go(nm, next.tail)
- }
- }
- }
- go(matcher, matchers)
- }
-
- def choice2[F[_],A](matcher: Matcher[F, A], matchers: Matcher[F, A]*): Matcher[F, A] =
- choiceUntil[F,A](Set(Unauthorized, Forbidden))(matcher, matchers: _*)
-
- implicit final class ResponseBuilder(val status: HttpStatusCode) extends AnyVal {
- def noBody: HttpResponse[IO] = emptyResponse[IO](status)
-
- def body[A](body: A)(implicit enc: BodyEncoder[A]): HttpResponse[IO] =
- emptyResponse[IO](status).withBody(body)
-
- def streamBody[A](body: Stream[IO,A])(implicit enc: StreamBodyEncoder[IO,A]): HttpResponse[IO] =
- noBody.withStreamBody(body)(enc)
-
- def message(msg: String) = body(Message(msg))
- def message(err: Throwable) = body(Message(err))
-
- def whenEmpty:Pipe[IO,HttpResponse[IO],HttpResponse[IO]] =
- _.through(streams.ifEmpty(Stream.emit(emptyResponse[IO](status)).covary[IO]))
- }
-
-
- type ResponseUpdate[F[_]] = HttpResponse[F] => HttpResponse[F]
- object ResponseUpdate {
- def identity[F[_]]: ResponseUpdate[F] = identity
- }
-
- implicit final class ResponseOps[F[_]](val r: HttpResponse[F]) extends AnyVal {
- def ++(f: ResponseUpdate[F]): HttpResponse[F] = f(r)
- }
-
- def withContentLength[F[_]](len: Long): ResponseUpdate[F] =
- _.withHeader(`Content-Length`(len))
-
- def withContentLength[F[_]](value: Ior[Int, Int], length: Size): ResponseUpdate[F] =
- _.withHeader {
- value match {
- case Ior.Left(n) => `Content-Length`(length.toBytes - n)
- case Ior.Right(n) => `Content-Length`(n.toLong)
- case Ior.Both(a, b) => `Content-Length`((b - a).toLong + 1)
- }
- }
-
- def withAcceptRanges[F[_]]: ResponseUpdate[F] =
- _.withHeader(`Accept-Ranges`(Some(RangeUnit.Bytes)))
-
- def withETag[F[_]](id: String): ResponseUpdate[F] =
- _.withHeader(ETag(EntityTag(id, false)))
-
- def withLastModified[F[_]](time: Instant): ResponseUpdate[F] =
- _.withHeader(`Last-Modified`(time.atZone(ZoneId.of("UTC")).toLocalDateTime))
-
- def withDisposition[F[_]](value: String, filename: String): ResponseUpdate[F] =
- _.withHeader(`Content-Disposition`(ContentDisposition(value, Map("filename" -> filename))))
-
-
- def withContentRange[F[_]](bytes: Ior[Int, Int], length: Size): ResponseUpdate[F] =
- _.withHeader {
- bytes match {
- case Ior.Left(n) => `Content-Range`(n.toLong, length.toBytes -1, Some(length.toBytes))
- case Ior.Right(n) => `Content-Range`(0, n.toLong, Some(length.toBytes))
- case Ior.Both(a, b) => `Content-Range`(a.toLong, b.toLong, Some(length.toBytes))
- }
- }
-
- def withCookie[F[_]](domain: String, path: String)(name: String, value: String): ResponseUpdate[F] = {
- val cookie = HttpCookie(name = name
- , content = value
- , httpOnly = true
- , maxAge = None
- , path = Some(path)
- , domain = Some(domain)
- , params = Map.empty
- , expires = None
- , secure = false
- )
- _.withHeader(`Set-Cookie`(cookie))
- }
-
-
-
-
- def cookie[F[_]](name: String): Matcher[F, HttpCookie] =
- Matcher.Match[Nothing, HttpCookie] { (request, _) =>
- request.headers.collectFirst({ case Cookie(hc) if hc.name == name => hc}) match {
- case None => MatchResult.BadRequest
- case Some(h) => MatchResult.Success(h)
- }
- }
-
- def Head = method(HttpMethod.HEAD)
-
- def ifNoneMatch[F[_]]: Matcher[F, Option[String]] =
- header[`If-None-Match`].? map {
- case Some(`If-None-Match`(EntityTagRange.Range(List(EntityTag(tag, false))))) => Some(tag)
- case _ => None
- }
-
- def range: Matcher[Nothing, Option[Ior[Int, Int]]] =
- header[Range].?.map(_.map {
- case Range(ByteRange.Slice(first, last)) =>
- Ior.both(first.toInt, last.toInt)
- case Range(ByteRange.FromOffset(offset)) =>
- Ior.left(offset.toInt)
- case Range(ByteRange.Suffix(suffix)) =>
- Ior.right(suffix.toInt)
- })
-
-
- def aliasId: Matcher[Nothing, String] =
- Matcher.Match[Nothing, String] { (header, _) =>
- val h = header.headers.find { h =>
- h.name.toLowerCase == authz.aliasHeaderName.toLowerCase
- }
- h match {
- case Some(GenericHeader(_, value)) => MatchResult.success(value.trim)
- case _ => MatchResult.reply(HttpStatusCode.Unauthorized)
- }
- }
-
-}
diff --git a/modules/server/src/main/scala/sharry/server/routes/upload.scala b/modules/server/src/main/scala/sharry/server/routes/upload.scala
deleted file mode 100644
index cfae5608..00000000
--- a/modules/server/src/main/scala/sharry/server/routes/upload.scala
+++ /dev/null
@@ -1,270 +0,0 @@
-package sharry.server.routes
-
-import fs2.Stream
-import cats.effect.IO
-import shapeless.{::,HNil}
-import scala.util.Try
-import spinoco.fs2.http.routing._
-import com.github.t3hnar.bcrypt._
-import org.log4s._
-import bitpeace.{FileChunk, MimetypeHint}
-
-import sharry.store.data._
-import sharry.common.data._
-import sharry.common.sizes._
-import sharry.common.duration._
-import sharry.common.streams
-import sharry.common.sha
-import sharry.store.upload.UploadStore
-import sharry.server.paths
-import sharry.server.config._
-import sharry.server.notification
-import sharry.server.notification.Notifier
-import sharry.server.routes.syntax._
-
-object upload {
- private implicit val logger = getLogger
-
- def endpoint(auth: AuthConfig, uploadCfg: UploadConfig, store: UploadStore, notifier: Notifier) =
- choice2(testUploadChunk(auth, store)
- , createUpload(auth, uploadCfg, store)
- , uploadChunks(auth, uploadCfg, store)
- , publishUpload(auth, store)
- , unpublishUpload(auth, store)
- , getPublishedUpload(store)
- , getUpload(auth, store)
- , getAllUploads(auth, store)
- , deleteUpload(auth, uploadCfg, store)
- , notifyOnUpload(uploadCfg, store, notifier)
- , editUpload(auth, uploadCfg, store))
-
- def editUpload(authCfg: AuthConfig, cfg: UploadConfig, store: UploadStore): Route[IO] =
- Post >> paths.uploads.matcher / uploadId :: authz.user(authCfg) :: jsonBody[UploadUpdate] map {
- case id :: user :: up :: HNil =>
- store.updateUpload(id, up).map(_ => Ok.message("Upload updated"))
- }
-
-
- def createUpload(authCfg: AuthConfig, cfg: UploadConfig, store: UploadStore): Route[IO] =
- Post >> paths.uploads.matcher >> authz.userId(authCfg, store) :: jsonBody[UploadCreate] map {
- case account :: meta :: HNil =>
- checkValidity(meta, account.alias, cfg.maxValidity) match {
- case Right(v) =>
- if (meta.id.isEmpty) Stream.emit(BadRequest.message("The upload id must not be empty!"))
- else {
- val uc = Upload(
- id = meta.id,
- login = account.login,
- validity = v,
- maxDownloads = meta.maxdownloads,
- description = meta.description.asNonEmpty,
- password = meta.password.asNonEmpty.map(_.bcrypt),
- alias = account.aliasId
- )
- store.createUpload(uc).map(_ => Ok.message("Upload created"))
- }
- case Left(msg) =>
- Stream.emit(BadRequest.message(msg))
- }
- }
-
- private def checkValidity(meta: UploadCreate, alias: Option[Alias], maxValidity: Duration): Either[String, Duration] =
- alias.
- map(a => Right(a.validity)).
- getOrElse(UploadCreate.parseValidity(meta.validity)).
- flatMap { given =>
- if (maxValidity >= given) Right(given)
- else Left("Validity time is too long.")
- }
-
-
- private def checkDelete(id: String, alias: Alias, time: Duration, store: UploadStore) = {
- notification.checkAliasAccess(id, alias, time, store)
- }
-
- private def doDeleteUpload(store: UploadStore, id: String, login: String) =
- store.deleteUpload(id, login).
- map(n => Ok.body(Map("filesRemoved" -> n))).
- through(NotFound.whenEmpty)
-
- def deleteUpload(authCfg: AuthConfig, uploadCfg: UploadConfig, store: UploadStore): Route[IO] =
- Delete >> paths.uploads.matcher / uploadId :: authz.userId(authCfg, store) map {
- case id :: user :: HNil =>
- if (id.isEmpty) Stream.emit(BadRequest.message("id is empty"))
- else user match {
- case Username(login) => doDeleteUpload(store, id, login)
- case AliasId(alias) =>
- checkDelete(id, alias, uploadCfg.aliasDeleteTime, store).
- flatMap{
- case true =>
- logger.info(s"Delete upload $id as requested by alias $alias")
- doDeleteUpload(store, id, alias.login)
- case false =>
- logger.info(s"Not deleting upload $id as requested by alias $alias")
- Stream.emit(Forbidden.message("Not authorized for deletion."))
- }
- }
- }
-
- def getAllUploads(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Get >> paths.uploads.matcher >> authz.user(authCfg) map { user =>
- // add paging or cope with chunk responses in elm
- Stream.eval(store.listUploads(user).compile.toVector).
- map(Ok.body(_))
- }
-
- def getUpload(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Get >> paths.uploads.matcher / uploadId :: authz.user(authCfg) map {
- case id :: user :: HNil =>
- store.getUpload(id, user).
- map(processDescription(paths.download)).
- map(Ok.body(_)).
- through(NotFound.whenEmpty)
- }
-
- def getPublishedUpload(store: UploadStore): Route[IO] =
- Get >> paths.uploadPublish.matcher / uploadId map { id =>
- store.getPublishedUpload(id).
- map(processDescription(paths.downloadPublished)).
- map(Ok.body(_)).
- through(NotFound.whenEmpty)
- }
-
- private def processDescription(baseUrl: paths.Path)(u: UploadInfo): UploadInfo = {
- import yamusca.imports._, yamusca.implicits._
-
- implicit val fileConverter: ValueConverter[UploadInfo.File] = f => Map(
- "id" -> f.clientFileId,
- "filename" -> f.filename,
- "url" -> (baseUrl / f.meta.id).path,
- "mimetype" -> f.meta.mimetype.asString,
- "size" -> f.meta.length.asString
- ).asMustacheValue
-
- val ctx = Context.from { key => key match {
- case "id" => u.upload.publishId.map(Value.of)
- case "files" => Some(u.files.asMustacheValue)
- case name if name startsWith "file_" =>
- Try(name.drop(5).toInt).toOption match {
- case Some(i) if i < u.files.size =>
- Some(u.files(i).asMustacheValue)
- case _ =>
- None
- }
- case name if name startsWith "fileid_" =>
- Try(name.drop(7)).
- toOption.
- filter(_.trim.nonEmpty).
- flatMap(id => u.files.find(_.clientFileId == id)).
- map(_.asMustacheValue)
- case _ => None
- }}
-
- val desc = u.upload.description.map(mustache.parse) match {
- case Some(Right(t)) => Some(mustache.render(t)(ctx))
- case Some(Left(err)) => u.upload.description
- case None => None
- }
-
- u.copy(upload = u.upload.copy(description = desc))
- }
-
- def publishUpload(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Post >> paths.uploadPublish.matcher / uploadId :: authz.user(authCfg) map {
- case id :: user :: HNil =>
- store.publishUpload(id, user).flatMap {
- case Right(pid) => store.getPublishedUpload(pid).map(Ok.body(_))
- case Left(msg) => Stream.emit(BadRequest.message(msg))
- }
- }
-
- def unpublishUpload(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Post >> paths.uploadUnpublish.matcher / uploadId :: authz.user(authCfg) map {
- case id :: login :: HNil =>
- store.unpublishUpload(id, login).flatMap {
- case Right(_) => store.getUpload(id, login).map(Ok.body(_))
- case Left(msg) => Stream.emit(BadRequest.message(msg))
- }
- }
-
- def notifyOnUpload(cfg: UploadConfig, store: UploadStore, notifier: Notifier): Route[IO] =
- Post >> paths.uploadNotify.matcher / uploadId :: authz.alias(store) map {
- case id :: alias :: HNil =>
- if (cfg.enableUploadNotification) {
- notifier(id, alias, cfg.aliasDeleteTime + 30.seconds).drain ++
- Stream.emit(Ok.message("Notification scheduled."))
- } else {
- Stream.emit(Ok.message("Upload notifications disabled."))
- }
- }
-
- def testUploadChunk(authCfg: AuthConfig, store: UploadStore): Route[IO] =
- Get >> paths.uploadData.matcher >> authz.userId(authCfg, store) >> chunkInfo map { (info: ChunkInfo) =>
- val fileId = makeFileId(info)
- store.chunkExists(info.token, fileId, info.chunkNumber, info.currentChunkSize.bytes).map {
- case true =>
- Ok.noBody
- case false =>
- NoContent.noBody
- }
- }
-
- def uploadChunks(authCfg: AuthConfig, cfg: UploadConfig, store: UploadStore): Route[IO] =
- Post >> paths.uploadData.matcher >> authz.userId(authCfg, store) :: chunkInfo :: body[IO].bytes map {
- case user :: info :: bytes :: HNil =>
- // check totalChunks against totalLength/chunksize
- // think about using reported totalLength for size-check, but it should not be possible to trick uploading too much
-
- val fileId = makeFileId(info)
- val chunk = bytes.take(info.currentChunkSize.toLong).
- through(streams.append).
- map(data => FileChunk(fileId, info.chunkNumber -1L, data))
-
- val saveChunk = for {
- ch <- chunk
- out <- store.addChunk(info.token, ch, info.chunkSize, info.totalChunks, MimetypeHint.filename(info.filename))
- _ <- if (out.length.notZero) store.createUploadFile(info.token, fileId, info.filename, info.fileIdentifier) else Stream.emit(()).covary[IO]
- } yield ()
-
- val sizeCheck = store.getUploadSize(info.token).
- map({ case us@UploadSize(n, len) =>
- (us, n <= cfg.maxFiles && (len + info.currentChunkSize.bytes) <= cfg.maxFileSize)
- }).
- evalMap({ case (UploadSize(n, len), result) => IO {
- if (!result) {
- logger.info(s"Current upload chunk (${info.currentChunkSize.bytes.asString}) exceeds max size: size=${len + info.currentChunkSize.bytes} and count=$n")
- }
- result
- }}).
- through(streams.ifEmpty(Stream.emit(true)))
-
- sizeCheck.flatMap {
- case true =>
- saveChunk.drain ++ Stream.emit(Ok.noBody)
- case false =>
- logger.info("Uploading too many or too large files. Return with error.")
- // http 404,415,500,501 tells resumable.js to cancel entire upload (other codes let it retry)
- Stream.emit(NotFound.message("Size limit exceeded"))
- }
- }
-
-
- private def uploadId: Matcher[IO, String] =
- as[String].flatMap { s =>
- if (s.isEmpty) Matcher.respond(BadRequest.message("The upload token must not be empty!"))
- else Matcher.success(s)
- }
-
- private def makeFileId(info: ChunkInfo): String =
- sha(info.token + info.fileIdentifier)
-
- private def chunkInfo: Matcher[IO, ChunkInfo] =
- param[String]("token") :: param[Int]("resumableChunkNumber") ::
- param[Int]("resumableChunkSize") :: param[Int]("resumableCurrentChunkSize") ::
- param[Long]("resumableTotalSize") :: param[String]("resumableIdentifier") ::
- param[String]("resumableFilename") :: param[Int]("resumableTotalChunks") flatMap {
- case token :: num :: size :: currentSize :: totalSize :: ident :: file :: total :: HNil =>
- if (token.isEmpty) Matcher.respond[IO](BadRequest.message("Token is empty"))
- else Matcher.success(ChunkInfo(token, num, size, currentSize, totalSize, ident, file, total))
- }
-}
diff --git a/modules/server/src/test/rest/test.rest b/modules/server/src/test/rest/test.rest
deleted file mode 100644
index 8835560a..00000000
--- a/modules/server/src/test/rest/test.rest
+++ /dev/null
@@ -1,153 +0,0 @@
-# -*- restclient -*-
-
-# variables
-:base = http://127.0.0.1:9090/api/v1
-:public = http://127.0.0.1:9090
-
-# Login with username and password
-POST :base/auth/login
-Content-Type: application/json
-
-{"login":"admin", "pass":"admin"}
-#
-curl -i -H 'Content-Type: application/json' -XPOST 'http://127.0.0.1:9090/api/v1/auth/login' -d '{"login":"admin", "pass":"admin"}'
-
-# get all accounts
-GET :base/accounts?q=a
-Cookie: sharry_auth=$2a$10$TZEBhNY4UyGUb.rH6S0uR.%admin%2017-05-03T23:33:38.262Z%331328e501d4d7dc7c6f82e10408f5d8806fa7b82d1757e491d8a023377fa41a
-#
-
-# get account
-GET :base/accounts/admin
-Cookie: sharry_auth=$2a$10$TZEBhNY4UyGUb.rH6S0uR.%admin%2017-05-03T23:33:38.262Z%331328e501d4d7dc7c6f82e10408f5d8806fa7b82d1757e491d8a023377fa41a
-#
-
-
-# test chunk
-GET :base/upload-data?token=u4ihtpp5x1urb14um2v54f8&resumableChunkNumber=9&resumableChunkSize=262144&resumableCurrentChunkSize=262144&resumableTotalSize=4117764&resumableIdentifier=4117764-Alfine_11_Gangpdf&resumableFilename=Alfine_11_Gang.pdf&resumableRelativePath=Alfine_11_Gang.pdf&resumableTotalChunks=16
-Cookie: sharry_auth=$2a$10$.dhdWe7StqcNH2TsUlhN1O%admin%2017-10-28T23:03:42.573Z%b2d610385ececcf4aa1f76466786d543f0c0bd19200d59db85ac987b55c6d6e0
-#
-
-# publish upload
-POST :base/upload-publish/us4ai5ox9z58ljp2rwqazwe
-#
-
-# download ranges
-GET :base/dlp/0b53fa182c4c65bb1572d06d6cfe37e7ae1d35dd438c28302e3e43ada181688c
-Range: bytes=367886000-
-#
-
-# get all uploads
-GET :base/uploads
-#
-
-# get upload
-GET :base/uploads/us4ai5ox9z58ljp2rwqazwe
-#
-
-# delete upload
-DELETE :base/uploads/uvka5ab33opj5su1dto1t1b
-x-sharry-alias: VdKyo4obL9u1O2PpTpTI
-#
-
-# test download
-HEAD :public/dlp/0cb317edbc8a0a1a1ff7682fe0cfbef6445e46f7dbfd4bdc576f0ac3c40eb89a
-Cookie: sharry_dlpassword=test
-#
-
-
-# check password
-POST :base/check-password/rJ-KdLR21CQsEtFZis46GvfdHM2LdsHJ
-Content-Type: application/json
-
-{"password":"test"}
-#
-
-# test zip download
-GET :public/dlp/5BjfL5giB3Y1hUiqXSg6gMSTcorL6_lL43-UDcp/zip
-#
-
-# update email
-POST :base/account/email
-Content-Type: application/json
-Cookie: sharry_auth=$2a$10$bXVoVn/iG4euGK6cYjTzZ.%admin%2017-05-06T10:32:22.980Z%b64b09620922365ef0eaf73e54621cf03ae99319daeecb0a680119edec16c80a
-
-{
- "login": "admin",
- "password": null,
- "email": "admin@eknet.org",
- "enabled": true,
- "admin": true,
- "extern": true
-}
-#
-
-# update password
-POST :base/account/password
-Content-Type: application/json
-Cookie: sharry_auth=$2a$10$bXVoVn/iG4euGK6cYjTzZ.%admin%2017-05-06T10:32:22.980Z%b64b09620922365ef0eaf73e54621cf03ae99319daeecb0a680119edec16c80a
-
-{
- "login": "admin",
- "password": "admin",
- "email": "admin@eknet.org",
- "enabled": true,
- "admin": true,
- "extern": true
-}
-#
-
-
-# create alias
-POST :base/aliases
-Cookie: sharry_auth=$2a$10$1B.koVH2DoiD2pL0MOx31u%admin%2017-05-07T09:10:34.245Z%afbbbd5adf92ae1a090632eefc09e67e3e2fb1d57a0ad9cec861e500ca64ef48
-#
-
-# list aliases
-GET :base/aliases
-Cookie: sharry_auth=$2a$10$0AvuBdbZeqCO2CEz2FqFRe%admin%2017-11-16T20:45:06.778Z%a90a036e86b3ced8a67cf9cbb68f4f782eb2a49a02ebc7a342869023a0d6eaf4
-#
-
-# get single alias
-GET :base/aliases/kMp8xzNgH8wNYUJU
-Cookie: sharry_auth=$2a$10$1B.koVH2DoiD2pL0MOx31u%admin%2017-05-07T09:10:34.245Z%afbbbd5adf92ae1a090632eefc09e67e3e2fb1d57a0ad9cec861e500ca64ef48
-#
-
-# delete alias
-DELETE :base/aliases/RZQbT6jmQb8_TfPC3LC3F
-Cookie: sharry_auth=$2a$10$1B.koVH2DoiD2pL0MOx31u%admin%2017-05-07T09:10:34.245Z%afbbbd5adf92ae1a090632eefc09e67e3e2fb1d57a0ad9cec861e500ca64ef48
-#
-
-# update alias
-POST :base/aliases/5CCKwPuhB4BUCw_P
-Content-Type: application/json
-Cookie: sharry_auth=$2a$10$0AvuBdbZeqCO2CEz2FqFRe%admin%2017-11-16T20:45:06.778Z%a90a036e86b3ced8a67cf9cbb68f4f782eb2a49a02ebc7a342869023a0d6eaf4
-
-{
- "id": "a1234",
- "login": "admin",
- "name": "from Betty",
- "validity": "PT120H",
- "created": "2017-05-07T16:10:38Z",
- "enable": false
-}
-#
-
-
-# check mail
-GET :base/mail/check?mail=111
-Cookie: sharry_auth=$2a$10$LcCnbrgEu4SJjcruYjdYnu%admin%2017-05-09T17:33:35.740Z%76fcfe48fe13c45f128af9a7508590c23dd65d31379801e064e76222857dd0da
-#
-
-# send mail
-POST :base/mail/send
-Content-Type: application/json
-Cookie: sharry_auth=$2a$10$ceGcFCyJzhuWbLN6X6HuS.%admin%2017-05-09T18:32:34.025Z%1eaaa7a90d1cf4a986da19b5932ce06fe168fa78ae9f17dac3af73199b1087e6
-
-{"from":"noreply@eknet.org", "to":["a@uiaeuia.uiaeuae"], "subject":"This is a test", "text":"This is just a test mail"}
-#
-
-# get mail templates
-GET :base/mail/download-template?url=http&lang=de
-Cookie: sharry_auth=$2a$10$BAb.HiZOUXPLb13zpSFFv.%admin%2017-05-09T20:51:47.747Z%7451e7c0022cd4b124bdaa3f2074b8618a1fc4f28d0cb7ae54dd60674a644a29
-#
\ No newline at end of file
diff --git a/modules/server/src/test/scala/sharry/server/codec/HttpHeaderCodecSpec.scala b/modules/server/src/test/scala/sharry/server/codec/HttpHeaderCodecSpec.scala
deleted file mode 100644
index ec8421b2..00000000
--- a/modules/server/src/test/scala/sharry/server/codec/HttpHeaderCodecSpec.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package sharry.server.codec
-
-import org.scalatest._
-import scodec.bits.BitVector
-import scodec.Attempt
-import spinoco.protocol.http.header.GenericHeader
-
-class HttpHeaderCodecSpec extends FlatSpec with Matchers {
-
- "header codec" should "not fail on empty cookie headers" in {
- HttpHeaderCodec.codec(2000).decodeValue(BitVector.view("Cookie:".getBytes)) should be (
- Attempt.successful(GenericHeader("cookie", ""))
- )
-
- HttpHeaderCodec.codec(2000).decodeValue(BitVector.view("Cookie: ".getBytes)) should be (
- Attempt.successful(GenericHeader("cookie", ""))
- )
- }
-
-}
diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.0.0__initial.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.0.0__initial.sql
new file mode 100644
index 00000000..9a7c7488
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/mariadb/V1.0.0__initial.sql
@@ -0,0 +1,95 @@
+CREATE TABLE IF NOT EXISTS `filemeta` (
+ `id` varchar(254) not null primary key,
+ `timestamp` varchar(40) not null,
+ `mimetype` varchar(254) not null,
+ `length` bigint not null,
+ `checksum` varchar(254) not null,
+ `chunks` int not null,
+ `chunksize` int not null
+);
+
+CREATE TABLE IF NOT EXISTS `filechunk` (
+ fileId varchar(254) not null,
+ chunkNr int not null,
+ chunkLength int not null,
+ chunkData mediumblob not null,
+ primary key (fileId, chunkNr)
+);
+
+
+CREATE TABLE `account_` (
+ `id` varchar(254) not null primary key,
+ `login` varchar(254) not null,
+ `source` varchar(254) not null,
+ `state` varchar(254) not null,
+ `password` varchar(254) not null,
+ `email` varchar(254),
+ `admin` boolean not null,
+ `logincount` int not null,
+ `lastlogin` varchar(40),
+ `created` varchar(40) not null,
+ unique(`login`)
+);
+
+CREATE TABLE `invitation` (
+ `id` varchar(254) not null primary key,
+ `created` varchar(40) not null
+);
+
+CREATE TABLE `alias_` (
+ `id` varchar(254) not null primary key,
+ `account_id` varchar(254) not null,
+ `name_` varchar(254) not null,
+ `validity` int not null,
+ `enabled` boolean not null,
+ `created` varchar(40) not null,
+ foreign key (`account_id`) references `account_` (`id`)
+ on delete cascade
+);
+
+CREATE TABLE `share` (
+ `id` varchar(254) not null primary key,
+ `account_id` varchar(254) not null,
+ `alias_id` varchar(254),
+ `name_` varchar(254),
+ `validity` int not null,
+ `max_views` int not null,
+ `password` varchar(254),
+ `description` text,
+ `created` varchar(40) not null,
+ foreign key (`account_id`) references `account_` (`id`)
+ on delete cascade,
+ foreign key (`alias_id`) references `alias_` (`id`)
+ on delete set null
+);
+
+CREATE TABLE `publish_share` (
+ `id` varchar(254) not null primary key,
+ `share_id` varchar(254) not null,
+ `enabled` boolean not null,
+ `views` int not null,
+ `last_access` varchar(40),
+ `publish_date` varchar(40) not null,
+ `publish_until` varchar(40) not null,
+ `created` varchar(40) not null,
+ unique(`share_id`),
+ foreign key (`share_id`) references `share` (`id`)
+ on delete cascade
+);
+
+CREATE INDEX `publish_share_until_idx` ON `publish_share`(`publish_until`);
+CREATE INDEX `publish_share_date_idx` ON `publish_share`(`publish_date`);
+
+CREATE TABLE `share_file` (
+ `id` varchar(254) not null primary key,
+ `share_id` varchar(254) not null,
+ `file_id` varchar(254) not null,
+ `filename` varchar(2000),
+ `created` varchar(40) not null,
+ `real_size` bigint not null,
+ unique(`share_id`, `file_id`),
+ foreign key (`share_id`) references `share` (`id`)
+ on delete cascade,
+ foreign key (`file_id`) references `filemeta` (`id`)
+ on delete cascade
+);
diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.0.0__initial.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.0.0__initial.sql
new file mode 100644
index 00000000..76532a76
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/postgresql/V1.0.0__initial.sql
@@ -0,0 +1,94 @@
+CREATE TABLE IF NOT EXISTS "filemeta" (
+ "id" varchar(254) not null primary key,
+ "timestamp" varchar(40) not null,
+ "mimetype" varchar(254) not null,
+ "length" bigint not null,
+ "checksum" varchar(254) not null,
+ "chunks" int not null,
+ "chunksize" int not null
+);
+
+CREATE TABLE IF NOT EXISTS "filechunk" (
+ fileId varchar(254) not null,
+ chunkNr int not null,
+ chunkLength int not null,
+ chunkData bytea not null,
+ primary key (fileId, chunkNr)
+);
+
+CREATE TABLE "account_" (
+ "id" varchar(254) not null primary key,
+ "login" varchar(254) not null,
+ "source" varchar(254) not null,
+ "state" varchar(254) not null,
+ "password" varchar(254) not null,
+ "email" varchar(254),
+ "admin" boolean not null,
+ "logincount" int not null,
+ "lastlogin" varchar(40),
+ "created" varchar(40) not null,
+ unique("login")
+);
+
+CREATE TABLE "invitation" (
+ "id" varchar(254) not null primary key,
+ "created" varchar(40) not null
+);
+
+CREATE TABLE "alias_" (
+ "id" varchar(254) not null primary key,
+ "account_id" varchar(254) not null,
+ "name_" varchar(254) not null,
+ "validity" int not null,
+ "enabled" boolean not null,
+ "created" varchar(40) not null,
+ foreign key ("account_id") references "account_" ("id")
+ on delete cascade
+);
+
+CREATE TABLE "share" (
+ "id" varchar(254) not null primary key,
+ "account_id" varchar(254) not null,
+ "alias_id" varchar(254),
+ "name_" varchar(254),
+ "validity" int not null,
+ "max_views" int not null,
+ "password" varchar(254),
+ "description" text,
+ "created" varchar(40) not null,
+ foreign key ("account_id") references "account_" ("id")
+ on delete cascade,
+ foreign key ("alias_id") references "alias_" ("id")
+ on delete set null
+);
+
+CREATE TABLE "publish_share" (
+ "id" varchar(254) not null primary key,
+ "share_id" varchar(254) not null,
+ "enabled" boolean not null,
+ "views" int not null,
+ "last_access" varchar(40),
+ "publish_date" varchar(40) not null,
+ "publish_until" varchar(40) not null,
+ "created" varchar(40) not null,
+ unique("share_id"),
+ foreign key ("share_id") references "share" ("id")
+ on delete cascade
+);
+
+CREATE INDEX "publish_share_until_idx" ON "publish_share"("publish_until");
+CREATE INDEX "publish_share_date_idx" ON "publish_share"("publish_date");
+
+CREATE TABLE "share_file" (
+ "id" varchar(254) not null primary key,
+ "share_id" varchar(254) not null,
+ "file_id" varchar(254) not null,
+ "filename" varchar(2000),
+ "created" varchar(40) not null,
+ "real_size" bigint not null,
+ unique("share_id", "file_id"),
+ foreign key ("share_id") references "share" ("id")
+ on delete cascade,
+ foreign key ("file_id") references "filemeta" ("id")
+ on delete cascade
+);
diff --git a/modules/store/src/main/scala/sharry/store/AddResult.scala b/modules/store/src/main/scala/sharry/store/AddResult.scala
new file mode 100644
index 00000000..5bb7dd6b
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/AddResult.scala
@@ -0,0 +1,46 @@
+package sharry.store
+
+import AddResult._
+
+sealed trait AddResult {
+ def toEither: Either[Throwable, Unit]
+ def isSuccess: Boolean
+
+ def fold[A](fa: Success.type => A, fb: EntityExists => A, fc: Failure => A): A
+
+ def isError: Boolean =
+ !isSuccess
+}
+
+object AddResult {
+
+ def fromUpdateExpectChange(errMsg: String)(e: Either[Throwable, Int]): AddResult =
+ e.fold(Failure, n => if (n > 0) Success else Failure(new Exception(errMsg)))
+
+ def fromEither[B](e: Either[Throwable, B]): AddResult =
+ e.fold(Failure, _ => Success)
+
+ case object Success extends AddResult {
+ def toEither = Right(())
+ val isSuccess = true
+ def fold[A](fa: Success.type => A, fb: EntityExists => A, fc: Failure => A): A =
+ fa(this)
+ }
+
+ case class EntityExists(msg: String) extends AddResult {
+ def toEither = Left(new Exception(msg))
+ val isSuccess = false
+ def fold[A](fa: Success.type => A, fb: EntityExists => A, fc: Failure => A): A =
+ fb(this)
+
+ def withMsg(msg: String): EntityExists =
+ EntityExists(msg)
+ }
+
+ case class Failure(ex: Throwable) extends AddResult {
+ def toEither = Left(ex)
+ val isSuccess = false
+ def fold[A](fa: Success.type => A, fb: EntityExists => A, fc: Failure => A): A =
+ fc(this)
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/JdbcConfig.scala b/modules/store/src/main/scala/sharry/store/JdbcConfig.scala
new file mode 100644
index 00000000..dfa2397b
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/JdbcConfig.scala
@@ -0,0 +1,38 @@
+package sharry.store
+
+import sharry.common.LenientUri
+
+case class JdbcConfig(url: LenientUri, user: String, password: String) {
+
+ val dbmsName: Option[String] =
+ JdbcConfig.extractDbmsName(url)
+
+ def driverClass =
+ dbmsName match {
+ case Some("mariadb") =>
+ "org.mariadb.jdbc.Driver"
+ case Some("postgresql") =>
+ "org.postgresql.Driver"
+ case Some("h2") =>
+ "org.h2.Driver"
+ case Some("sqlite") =>
+ "org.sqlite.JDBC"
+ case Some(n) =>
+ sys.error(s"Unknown DBMS: $n")
+ case None =>
+ sys.error("No JDBC url specified")
+ }
+
+ override def toString: String =
+ s"JdbcConfig($url, $user, ***)"
+}
+
+object JdbcConfig {
+ def extractDbmsName(jdbcUrl: LenientUri): Option[String] =
+ jdbcUrl.scheme.head match {
+ case "jdbc" =>
+ jdbcUrl.scheme.tail.headOption
+ case _ =>
+ None
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/Limit.scala b/modules/store/src/main/scala/sharry/store/Limit.scala
deleted file mode 100644
index 2ef5ee07..00000000
--- a/modules/store/src/main/scala/sharry/store/Limit.scala
+++ /dev/null
@@ -1,14 +0,0 @@
-package sharry.store
-
-case class Limit(limit: Int, offset: Int)
-
-object Limit {
- def offset(n: Int) = Limit(0, n)
- def limit(n: Int) = Limit(n, 0)
- def limitOffset(limit: Int, offset: Int) = Limit(limit, offset)
-
- def page(size: Int, num: Int): Limit = {
- if (num <= 1) limit(size)
- else limitOffset(size, (num -1) * size)
- }
-}
diff --git a/modules/store/src/main/scala/sharry/store/Store.scala b/modules/store/src/main/scala/sharry/store/Store.scala
new file mode 100644
index 00000000..f89c8641
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/Store.scala
@@ -0,0 +1,47 @@
+package sharry.store
+
+import bitpeace.Bitpeace
+import cats.effect._
+import fs2._
+import _root_.doobie._
+import _root_.doobie.hikari.HikariTransactor
+import sharry.store.doobie.StoreImpl
+
+import scala.concurrent.ExecutionContext
+
+trait Store[F[_]] {
+
+ def transact[A](prg: ConnectionIO[A]): F[A]
+
+ def transact[A](prg: Stream[ConnectionIO, A]): Stream[F, A]
+
+ def bitpeace: Bitpeace[F]
+
+ def add(insert: ConnectionIO[Int], exists: ConnectionIO[Boolean]): F[AddResult]
+}
+
+object Store {
+
+ def create[F[_]: Effect: ContextShift](
+ jdbc: JdbcConfig,
+ connectEC: ExecutionContext,
+ blocker: Blocker,
+ runMigration: Boolean
+ ): Resource[F, Store[F]] = {
+
+ val hxa = HikariTransactor.newHikariTransactor[F](
+ jdbc.driverClass,
+ jdbc.url.asString,
+ jdbc.user,
+ jdbc.password,
+ connectEC,
+ blocker
+ )
+
+ for {
+ xa <- hxa
+ st = new StoreImpl[F](jdbc, xa)
+ _ <- if (runMigration) Resource.liftF(st.migrate) else Resource.pure(())
+ } yield st
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/account/AccountStore.scala b/modules/store/src/main/scala/sharry/store/account/AccountStore.scala
deleted file mode 100644
index 16838e20..00000000
--- a/modules/store/src/main/scala/sharry/store/account/AccountStore.scala
+++ /dev/null
@@ -1,35 +0,0 @@
-package sharry.store.account
-
-import fs2.Stream
-import cats.effect.IO
-import sharry.common.data.Account
-import sharry.store.Limit
-
-/** On top of `ContentStore` associate accounts to data.
- *
- * While stored data in sitebag is considered public, the association
- * of a data to an account is not. This store is for associating user
- * accounts to stored data. The data itself is shared across
- * accounts.
- */
-trait AccountStore {
-
- def accountExists(login: String): Stream[IO,Boolean]
-
- def getAccount(login: String): Stream[IO,Account]
-
- def createAccount(account: Account): Stream[IO,Unit]
-
- def updateAccount(account: Account): Stream[IO,Boolean]
-
- def setAccountEnabled(login: String, flag: Boolean): Stream[IO,Boolean]
-
- def updatePassword(login: String, password: Option[String]): Stream[IO,Boolean]
-
- def updateEmail(login: String, email: Option[String]): Stream[IO,Boolean]
-
- def deleteAccount(login: String): Stream[IO,Boolean]
-
- def listLogins(q: String, limit: Option[Limit]): Stream[IO,String]
-
-}
diff --git a/modules/store/src/main/scala/sharry/store/account/SqlAccountStore.scala b/modules/store/src/main/scala/sharry/store/account/SqlAccountStore.scala
deleted file mode 100644
index 2629d39a..00000000
--- a/modules/store/src/main/scala/sharry/store/account/SqlAccountStore.scala
+++ /dev/null
@@ -1,54 +0,0 @@
-package sharry.store.account
-
-import fs2.Stream
-import cats.effect.IO
-import doobie._, doobie.implicits._
-import sharry.common.data.Account
-import sharry.store.Limit
-
-class SqlAccountStore(xa: Transactor[IO]) extends AccountStore with SqlStatements {
-
- def accountExists(login: String): Stream[IO,Boolean] = Stream.eval {
- existsAccount(login).transact(xa)
- }
-
- def getAccount(login: String): Stream[IO,Account] =
- Stream.eval(selectAccount(login).transact(xa)).flatMap {
- case Some(a) => Stream(a)
- case None => Stream.empty
- }
-
- def createAccount(account: Account): Stream[IO,Unit] = {
- Stream.eval(insertAccount(account).run.transact(xa)).map(_ => ())
- }
-
- def updateAccount(account: Account): Stream[IO,Boolean] = Stream.eval {
- updateAccountSql(account).run.map(_ > 0).transact(xa)
- }
-
- def updatePassword(login: String, password: Option[String]): Stream[IO,Boolean] =
- Stream.eval(sqlUpdatePassword(login, password).run.map(_ > 0).transact(xa))
-
- def updateEmail(login: String, email: Option[String]): Stream[IO,Boolean] =
- Stream.eval(sqlUpdateEmail(login, email).run.map(_ > 0).transact(xa))
-
- def setAccountEnabled(login: String, flag: Boolean): Stream[IO,Boolean] = Stream.eval {
- updateEnabledSql(login, flag).run.map(_ > 0).transact(xa)
- }
-
- def deleteAccount(login: String): Stream[IO,Boolean] = Stream.eval {
- val t = for {
- n <- deleteAccountSql(login).run
- } yield n > 0
- t.transact(xa)
- }
-
- def listLogins(q: String, limit: Option[Limit]): Stream[IO,String] =
- selectLogins(q, limit).stream.transact(xa)
-
-}
-
-object SqlAccountStore {
- def apply(xa: Transactor[IO]): SqlAccountStore =
- new SqlAccountStore(xa)
-}
diff --git a/modules/store/src/main/scala/sharry/store/account/SqlStatements.scala b/modules/store/src/main/scala/sharry/store/account/SqlStatements.scala
deleted file mode 100644
index 33e574d7..00000000
--- a/modules/store/src/main/scala/sharry/store/account/SqlStatements.scala
+++ /dev/null
@@ -1,67 +0,0 @@
-package sharry.store.account
-
-import org.log4s._
-import doobie.implicits._
-import sharry.common.data.Account
-import sharry.store.Limit
-import sharry.store.columns._
-
-trait SqlStatements {
-
- implicit def logHandler(implicit l: Logger) = logSql(l)
-
- def insertAccount(a: Account) =
- sql"""INSERT INTO Account (login,password,email,admin,enabled,extern) VALUES(
- ${a.login}, ${a.password}, ${a.email}, ${a.admin}, ${a.enabled}, ${a.extern}
- )""".update
-
- def selectAccount(login: String) =
- sql"""SELECT login,password,email,enabled,admin,extern FROM Account WHERE login = ${login}""".
- query[Account].
- option
-
- def selectLogins(partial: String, limit: Option[Limit]) = {
- val q = {
- val s = fr"SELECT login FROM Account"
- val term = s"%${partial}%"
- if (partial.isEmpty) s ++ fr"ORDER BY login"
- else s ++ fr"WHERE login like $term ORDER BY login"
- }
- limit match {
- case None =>
- q.query[String]
- case Some(l) =>
- (q ++ fr"LIMIT ${l.limit} OFFSET ${l.offset}").query[String]
- }
- }
-
-
- def existsAccount(login: String) =
- sql"""SELECT count(login) FROM Account WHERE login = ${login}""".
- query[Int].
- unique.
- map(_ > 0)
-
-
- def deleteAccountSql(login: String) =
- sql"""DELETE FROM Account WHERE login = $login""".update
-
- def updateAccountSql(a: Account) =
- sql"""UPDATE Account SET
- password = ${a.password},
- email = ${a.email},
- admin = ${a.admin},
- enabled = ${a.enabled},
- extern = ${a.extern}
- WHERE login = ${a.login}""".update
-
- def updateEnabledSql(login: String, flag: Boolean) =
- sql"""UPDATE Account SET enabled = $flag WHERE login = $login""".update
-
- def sqlUpdateEmail(login: String, email: Option[String]) =
- sql"""UPDATE Account SET email = $email WHERE login = $login""".update
-
- def sqlUpdatePassword(login: String, password: Option[String]) =
- sql"""UPDATE Account SET password = $password WHERE login = $login AND extern = false""".update
-
-}
diff --git a/modules/store/src/main/scala/sharry/store/columns.scala b/modules/store/src/main/scala/sharry/store/columns.scala
deleted file mode 100644
index 50b424f7..00000000
--- a/modules/store/src/main/scala/sharry/store/columns.scala
+++ /dev/null
@@ -1,63 +0,0 @@
-package sharry.store
-
-import java.time.Instant
-import java.time.temporal._
-import org.log4s._
-import scodec.bits.ByteVector
-import sharry.common.mime.MimeType
-import sharry.common.sizes._
-import sharry.common.duration._
-import doobie._
-import doobie.util.log.{Success, ProcessingFailure, ExecFailure}
-
-object columns {
-
- implicit val bvMeta: Meta[ByteVector] =
- Meta[Array[Byte]].xmap(
- ar => ByteVector(ar),
- bv => bv.toArray
- )
-
- implicit val mimetypeMeta: Meta[MimeType] =
- Meta[String].xmap(MimeType.parse(_).get, _.asString)
-
- implicit val instantMeta: Meta[Instant] =
- Meta[String].xmap(Instant.parse, _.truncatedTo(ChronoUnit.SECONDS).toString)
-
- implicit val durationMeta: Meta[Duration] =
- Meta[String].xmap((java.time.Duration.parse _) andThen Duration.fromJava, _.asJava.toString)
-
- implicit val sizeMeta: Meta[Size] =
- Meta[Long].xmap[Size](n => Bytes(n), _.toBytes)
-
- def logSql(logger: Logger): LogHandler = LogHandler {
- case Success(s, a, e1, e2) =>
- logger.trace(s"""Successful Statement Execution:
- |
- | ${s.lines.dropWhile(_.trim.isEmpty).mkString("\n ")}
- |
- | arguments = [${a.mkString(", ")}]
- | elapsed = ${e1.toMillis} ms exec + ${e2.toMillis} ms processing (${(e1 + e2).toMillis} ms total)
- """.stripMargin)
-
- case ProcessingFailure(s, a, e1, e2, t) =>
- logger.error(s"""Failed Resultset Processing:
- |
- | ${s.lines.dropWhile(_.trim.isEmpty).mkString("\n ")}
- |
- | arguments = [${a.mkString(", ")}]
- | elapsed = ${e1.toMillis} ms exec + ${e2.toMillis} ms processing (failed) (${(e1 + e2).toMillis} ms total)
- | failure = ${t.getMessage}
- """.stripMargin)
-
- case ExecFailure(s, a, e1, t) =>
- logger.error(s"""Failed Statement Execution:
- |
- | ${s.lines.dropWhile(_.trim.isEmpty).mkString("\n ")}
- |
- | arguments = [${a.mkString(", ")}]
- | elapsed = ${e1.toMillis} ms exec (failed)
- | failure = ${t.getMessage}
- """.stripMargin)
- }
-}
diff --git a/modules/store/src/main/scala/sharry/store/data/Alias.scala b/modules/store/src/main/scala/sharry/store/data/Alias.scala
deleted file mode 100644
index 0c308569..00000000
--- a/modules/store/src/main/scala/sharry/store/data/Alias.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package sharry.store.data
-
-import java.time.Instant
-import cats.data.Validated
-import io.circe._, io.circe.generic.semiauto._
-import sharry.common.JsonCodec
-import sharry.common.rng._
-import sharry.common.duration._
-
-case class Alias(
- id: String
- ,login: String
- ,name: String
- ,validity: Duration
- ,created: Instant
- ,enable: Boolean
-)
-
-object Alias {
- import JsonCodec._
-
- def generate(login: String, name: String, validity: Duration): Alias =
- Alias(Gen.ident(16,24).generate(), login, name, validity, Instant.now, true)
-
- def validateId(id: String): Validated[String, String] = {
- val chars = (('a' to 'z') ++ ('A' to 'Z') ++ ('0' to '9') ++ "_-").toSet
- if (id.forall(chars.contains)) Validated.valid(id)
- else Validated.invalid(s"Not an alphanumeric identifier: $id")
- }
-
- implicit val _aliasDecoder: Decoder[Alias] = deriveDecoder[Alias]
- implicit val _aliasEncoder: Encoder[Alias] = deriveEncoder[Alias]
-
-}
diff --git a/modules/store/src/main/scala/sharry/store/data/UploadSize.scala b/modules/store/src/main/scala/sharry/store/data/UploadSize.scala
deleted file mode 100644
index 25f62554..00000000
--- a/modules/store/src/main/scala/sharry/store/data/UploadSize.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package sharry.store.data
-
-import sharry.common.sizes._
-
-case class UploadSize(
- files: Int
- , size: Size
-)
diff --git a/modules/store/src/main/scala/sharry/store/doobie/Column.scala b/modules/store/src/main/scala/sharry/store/doobie/Column.scala
new file mode 100644
index 00000000..e640d470
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/doobie/Column.scala
@@ -0,0 +1,61 @@
+package sharry.store.doobie
+
+import doobie._, doobie.implicits._
+
+case class Column(name: String, ns: String = "", alias: String = "") {
+
+ val f = {
+ val col =
+ if (ns.isEmpty) Fragment.const(name)
+ else Fragment.const(ns + "." + name)
+ if (alias.isEmpty) col
+ else col ++ fr"as" ++ Fragment.const(alias)
+ }
+
+ def ::(ns: String): Column =
+ Column(name, ns, alias)
+
+ def as(alias: String): Column =
+ Column(name, ns, alias)
+
+ def is[A: Put](value: A): Fragment =
+ f ++ fr" = $value"
+
+ def isNot[A: Put](value: A): Fragment =
+ f ++ fr"<> $value"
+
+ def is[A: Put](ov: Option[A]): Fragment = ov match {
+ case Some(v) => f ++ fr" = $v"
+ case None => f ++ fr"is null"
+ }
+
+ def isNull: Fragment =
+ f ++ fr"is null"
+
+ def is(c: Column): Fragment =
+ f ++ fr"=" ++ c.f
+
+ def like(value: String): Fragment = {
+ val str = value.toLowerCase
+ fr"LOWER(" ++ f ++ fr") LIKE $str"
+ }
+
+ def isGt[A: Put](a: A): Fragment =
+ f ++ fr"> $a"
+
+ def isLt[A: Put](a: A): Fragment =
+ f ++ fr"< $a"
+
+ def isGt(c: Column): Fragment =
+ f ++ fr">" ++ c.f
+
+ def increment(n: Int): Fragment =
+ f ++ fr"=" ++ f ++ fr"+ $n"
+
+ def setTo[A: Put](value: A): Fragment =
+ is(value)
+
+ def setTo[A: Put](va: Option[A]): Fragment =
+ f ++ fr" = $va"
+
+}
diff --git a/modules/store/src/main/scala/sharry/store/doobie/DoobieMeta.scala b/modules/store/src/main/scala/sharry/store/doobie/DoobieMeta.scala
new file mode 100644
index 00000000..62081a9e
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/doobie/DoobieMeta.scala
@@ -0,0 +1,59 @@
+package sharry.store.doobie
+
+import java.time.format.DateTimeFormatter
+import java.time.{Instant, LocalDate}
+
+import doobie._
+import doobie.util.log.Success
+import io.circe.{Decoder, Encoder}
+import sharry.common._
+import sharry.common.syntax.all._
+import bitpeace.Mimetype
+
+trait DoobieMeta {
+
+ implicit val sqlLogging = LogHandler({
+ case e @ Success(_, _, _, _) =>
+ DoobieMeta.logger.trace("SQL " + e)
+ case e =>
+ DoobieMeta.logger.error(s"SQL Failure: $e")
+ })
+
+ def jsonMeta[A](implicit d: Decoder[A], e: Encoder[A]): Meta[A] =
+ Meta[String].imap(str => str.parseJsonAs[A].fold(ex => throw ex, identity))(
+ a => e.apply(a).noSpaces
+ )
+
+ implicit val metaUserState: Meta[AccountState] =
+ Meta[String].timap(AccountState.unsafe)(AccountState.asString)
+
+ implicit val metaAccountSource: Meta[AccountSource] =
+ Meta[String].timap(AccountSource.unsafe)(_.name)
+
+ implicit val metaPassword: Meta[Password] =
+ Meta[String].timap(Password(_))(_.pass)
+
+ implicit val metaIdent: Meta[Ident] =
+ Meta[String].timap(Ident.unsafe)(_.id)
+
+ implicit val metaTimestamp: Meta[Timestamp] =
+ Meta[String].timap(s => Timestamp(Instant.parse(s)))(_.value.toString)
+
+ implicit val metaLocalDate: Meta[LocalDate] =
+ Meta[String].timap(str => LocalDate.parse(str))(_.format(DateTimeFormatter.ISO_DATE))
+
+ implicit val metaDuration: Meta[Duration] =
+ Meta[Long].timap(n => Duration.seconds(n))(_.seconds)
+
+ implicit val metaByteSize: Meta[ByteSize] =
+ Meta[Long].timap(n => ByteSize(n))(_.bytes)
+
+ implicit val metaMimetype: Meta[Mimetype] =
+ Meta[String].imap(Mimetype.parse(_).fold(ex => throw ex, identity))(_.asString)
+
+}
+
+object DoobieMeta extends DoobieMeta {
+ import org.log4s._
+ private val logger = getLogger
+}
diff --git a/modules/store/src/main/scala/sharry/store/doobie/Sql.scala b/modules/store/src/main/scala/sharry/store/doobie/Sql.scala
new file mode 100644
index 00000000..f64dde25
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/doobie/Sql.scala
@@ -0,0 +1,60 @@
+package sharry.store.doobie
+
+import doobie._
+import doobie.implicits._
+import sharry.common.Timestamp
+
+object Sql {
+
+ def commas(fs: Seq[Fragment]): Fragment =
+ fs.reduce(_ ++ Fragment.const(",") ++ _)
+
+ def commas(fa: Fragment, fas: Fragment*): Fragment =
+ commas(fa :: fas.toList)
+
+ def currentTime: ConnectionIO[Timestamp] =
+ Timestamp.current[ConnectionIO]
+
+ def insertRow(table: Fragment, cols: List[Column], vals: Fragment): Fragment =
+ Fragment.const("INSERT INTO ") ++ table ++ Fragment.const(" (") ++
+ commas(cols.map(_.f)) ++ Fragment.const(") VALUES (") ++ vals ++ Fragment.const(")")
+
+ def updateRow(table: Fragment, where: Fragment, setter: Fragment): Fragment =
+ Fragment.const("UPDATE ") ++ table ++ Fragment.const(" SET ") ++ setter ++ this.where(where)
+
+ def selectSimple(cols: Seq[Column], table: Fragment, where: Fragment): Fragment =
+ selectSimple(commas(cols.map(_.f)), table, where)
+
+ def selectSimple(cols: Fragment, table: Fragment, where: Fragment): Fragment =
+ Fragment.const("SELECT ") ++ cols ++
+ Fragment.const(" FROM ") ++ table ++ this.where(where)
+
+ def selectCount(col: Column, table: Fragment, where: Fragment): Fragment =
+ Fragment.const("SELECT COUNT(") ++ col.f ++ Fragment.const(") FROM ") ++ table ++ this.where(
+ where
+ )
+
+ def deleteFrom(table: Fragment, where: Fragment): Fragment =
+ fr"DELETE FROM" ++ table ++ this.where(where)
+
+ def where(fa: Fragment): Fragment =
+ if (isEmpty(fa)) Fragment.empty
+ else Fragment.const(" WHERE ") ++ fa
+
+ def isEmpty(fragment: Fragment): Boolean =
+ Fragment.empty.toString() == fragment.toString()
+
+ def and(fs: Seq[Fragment]): Fragment =
+ Fragment.const(" (") ++ fs
+ .filter(f => !isEmpty(f))
+ .reduce(_ ++ Fragment.const(" AND ") ++ _) ++ Fragment.const(") ")
+
+ def and(f0: Fragment, fs: Fragment*): Fragment =
+ and(f0 :: fs.toList)
+
+ def or(fs: Seq[Fragment]): Fragment =
+ Fragment.const(" (") ++ fs.reduce(_ ++ Fragment.const(" OR ") ++ _) ++ Fragment.const(") ")
+ def or(f0: Fragment, fs: Fragment*): Fragment =
+ or(f0 :: fs.toList)
+
+}
diff --git a/modules/store/src/main/scala/sharry/store/doobie/StoreImpl.scala b/modules/store/src/main/scala/sharry/store/doobie/StoreImpl.scala
new file mode 100644
index 00000000..112f36c0
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/doobie/StoreImpl.scala
@@ -0,0 +1,38 @@
+package sharry.store.doobie
+
+import bitpeace.{Bitpeace, BitpeaceConfig, TikaMimetypeDetect}
+import cats.effect.Effect
+import cats.implicits._
+import sharry.common.Ident
+import sharry.store.migrate.FlywayMigrate
+import sharry.store.{AddResult, JdbcConfig, Store}
+import doobie._
+import doobie.implicits._
+
+final class StoreImpl[F[_]: Effect](jdbc: JdbcConfig, xa: Transactor[F]) extends Store[F] {
+ val bitpeaceCfg =
+ BitpeaceConfig("filemeta", "filechunk", TikaMimetypeDetect, Ident.randomId[F].map(_.id))
+
+ def migrate: F[Int] =
+ FlywayMigrate.run[F](jdbc)
+
+ def transact[A](prg: doobie.ConnectionIO[A]): F[A] =
+ prg.transact(xa)
+
+ def transact[A](prg: fs2.Stream[doobie.ConnectionIO, A]): fs2.Stream[F, A] =
+ prg.transact(xa)
+
+ def bitpeace: Bitpeace[F] =
+ Bitpeace(bitpeaceCfg, xa)
+
+ def add(insert: ConnectionIO[Int], exists: ConnectionIO[Boolean]): F[AddResult] =
+ for {
+ save <- transact(insert).attempt
+ exist <- save.swap.traverse(ex => transact(exists).map(b => (ex, b)))
+ } yield exist.swap match {
+ case Right(_) => AddResult.Success
+ case Left((_, true)) =>
+ AddResult.EntityExists("Adding failed, because the entity already exists.")
+ case Left((ex, _)) => AddResult.Failure(ex)
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/evolution.scala b/modules/store/src/main/scala/sharry/store/evolution.scala
deleted file mode 100644
index e171abd2..00000000
--- a/modules/store/src/main/scala/sharry/store/evolution.scala
+++ /dev/null
@@ -1,193 +0,0 @@
-package sharry.store
-
-import org.log4s._
-import fs2.{Pure, Stream}
-import cats.effect.IO
-import cats.implicits._
-import doobie._, doobie.implicits._
-import sharry.common.streams
-
-object evolution {
-
- implicit private[this] val logger = getLogger
-
- type Change = Transactor[IO] => Stream[IO, Unit]
-
- object Change {
- def apply(update: Update0): Change =
- xa => {
- streams.slogT(_.info(update.sql)) ++
- Stream.eval(update.run.transact(xa)).map(_ => ())
- }
- }
-
- sealed trait Dbms {
- def blob: Fragment
- def currentTimestamp: Fragment
- def dropDatabase(db: String): Fragment
- }
- case object H2 extends Dbms {
- val blob = Fragment.const("blob")
- val currentTimestamp = Fragment.const("current_timestamp()")
-
- def dropDatabase(db: String) = fr"drop all objects delete files;"
-
- }
- case object Postgres extends Dbms {
- val blob = Fragment.const("bytea")
- val currentTimestamp = Fragment.const("current_timestamp")
-
- def dropDatabase(db: String) = fr"drop database $db; create database $db;"
- }
-
- object Dbms {
- def apply(jdbcUrl: String): Dbms =
- jdbcUrl.split(":").toList match {
- case _ :: "postgresql" :: _ => Postgres
- case _ :: "h2" :: _ => H2
- case _ => sys.error(s"unknown dbms: $jdbcUrl")
- }
- }
-
- def apply(jdbcUrl: String): Runner =
- new Runner(Dbms(jdbcUrl), "sitebagdev")
-
- def apply(dbms: Dbms, db: String): Runner =
- new Runner(dbms, db)
-
- final class Runner(dbms: Dbms, db: String) {
-
- private val changes = changesFor(dbms)
-
- /** Run all changes not yet applied */
- def runChanges(xa: Transactor[IO]): IO[Unit] = {
- Stream.eval(getState(xa)).flatMap { version =>
- changes.zipWithIndex.drop(version.toLong).flatMap {
- case (change, idx) =>
- change(xa) ++ Stream.eval(updateState(idx+1)(xa))
- }
- }.compile.drain
- }
-
- /** get the current state of the database */
- def getState(xa: Transactor[IO]): IO[Int] = {
- val version = sql"""SELECT max(version) FROM dbversion"""
- .query[Int]
- .unique
- .transact(xa)
- version.handleError(_ => 0)
- }
-
- def dropDatabase(xa: Transactor[IO]): IO[Unit] = {
- dbms.dropDatabase(db).update.run.transact(xa).map(_ => ())
- }
-
- private def updateState(version: Long)(xa: Transactor[IO]): IO[Unit] = {
- sql"""INSERT INTO dbversion (version) VALUES ($version)""".update
- .run.transact(xa).map(_ => ())
- }
- }
-
- def changesFor(dbms: Dbms): Stream[Pure, Change] = Stream(
- /* This table is used to track this list of changes. When the changes
- * are applied to a database, it can use this info to run only the
- * changes that have not been applied.
- */
- Change((fr"""CREATE TABLE IF NOT EXISTS dbversion (
- createdb timestamp not null default""" ++ dbms.currentTimestamp ++ fr""",
- version int not null,
- primary key (version)
- );
- """).update),
-
- /* BinaryStore. Following tables are for storing binary data. In order
- * to have some random access to the bytes, they are not stored as
- * one blob, but in chunks of blobs. Additionally, the
- * content-type is stored.
- */
- Change((fr"""
- CREATE TABLE IF NOT EXISTS FileMeta (
- id varchar(64) not null,
- timestamp varchar(40) not null,
- mimetype varchar(254) not null,
- length bigint not null,
- chunks int not null,
- chunksize int not null,
- primary key (id)
- );
- CREATE TABLE IF NOT EXISTS FileChunk (
- fileId varchar(64) not null,
- chunkNr int not null,
- chunkLength int not null,
- chunkData""" ++ dbms.blob ++ fr""" not null,
- primary key (fileId, chunkNr)
- );""").update),
-
- /* This table is used to maintain accounts to the application.
- */
- Change(sql"""
- CREATE TABLE IF NOT EXISTS Account (
- login varchar(254) not null,
- password varchar(254) null,
- email varchar(254) null,
- admin boolean not null,
- enabled boolean not null,
- extern boolean not null,
- primary key (login));
- CREATE INDEX account_email_idx ON Account(email);""".update),
-
- Change(sql"""
- CREATE TABLE IF NOT EXISTS Upload (
- id varchar(254) not null primary key,
- login varchar(254) not null,
- alias varchar(254),
- description text,
- validity varchar(50) not null,
- maxdownloads int,
- password varchar(254),
- created varchar(40) not null,
- downloads int,
- lastDownload varchar(40),
- publishId varchar(200),
- publishDate varchar(40),
- publishUntil varchar(40),
- foreign key (login) references Account(login) on delete cascade);
- CREATE INDEX uploadconfig_publishid_idx ON Upload(publishId);
- CREATE INDEX uploadconfig_publishuntil_idx ON Upload(publishUntil);
- CREATE INDEX uploadconfig_publishdate_idx ON Upload(publishDate);""".update),
-
- Change(sql"""
- CREATE TABLE IF NOT EXISTS UploadFile (
- uploadId varchar(254) not null,
- fileId varchar(64) not null,
- filename varchar(2000),
- downloads int,
- lastDownload varchar(40),
- primary key (uploadId, fileId),
- foreign key (uploadId) references Upload(id),
- foreign key (fileId) references FileMeta(id))""".update),
-
- Change(sql"""
- CREATE TABLE IF NOT EXISTS Alias (
- id varchar(254) not null primary key,
- login varchar(254) not null,
- name varchar(254) not null,
- validity varchar(50) not null,
- created varchar(40) not null,
- enable boolean not null
- )""".update),
-
- Change(sql"""
- ALTER TABLE UploadFile ADD COLUMN clientFileId varchar(512);
- UPDATE UploadFile SET clientFileId = fileId WHERE clientFileId is null;
- """.update),
-
- Change(sql"""
- ALTER TABLE FileMeta ADD COLUMN checksum varchar(254);
- UPDATE FileMeta SET checksum = id WHERE checksum is null;
- ALTER TABLE FileMeta ALTER COLUMN checksum set not null;
- """.update),
- Change(sql"""UPDATE FileChunk SET chunkNr = chunkNr - 1""".update),
- Change(sql"""ALTER TABLE Upload ADD COLUMN name varchar(254);""".update)
- )
-}
diff --git a/modules/store/src/main/scala/sharry/store/migrate/FlywayMigrate.scala b/modules/store/src/main/scala/sharry/store/migrate/FlywayMigrate.scala
new file mode 100644
index 00000000..e6407aac
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/migrate/FlywayMigrate.scala
@@ -0,0 +1,49 @@
+package sharry.store.migrate
+
+import cats.effect.Sync
+import sharry.store.JdbcConfig
+import org.flywaydb.core.Flyway
+import org.log4s._
+
+object FlywayMigrate {
+ private[this] val logger = getLogger
+
+ def run[F[_]: Sync](jdbc: JdbcConfig): F[Int] = Sync[F].delay {
+ logger.info("Running db migrations...")
+ val fw = makeFlyway(jdbc)
+ fw.repair()
+ fw.migrate()
+ }
+
+ def makeFlyway(jdbc: JdbcConfig) = {
+ val locations = findLocations(jdbc)
+ logger.info(s"Using migration locations: $locations")
+ Flyway
+ .configure()
+ .cleanDisabled(true)
+ .dataSource(jdbc.url.asString, jdbc.user, jdbc.password)
+ .locations(locations: _*)
+ .load()
+ }
+
+ def baselineFlyway(jdbc: JdbcConfig): Flyway = {
+ val locations = findLocations(jdbc)
+ Flyway
+ .configure()
+ .dataSource(jdbc.url.asString, jdbc.user, jdbc.password)
+ .baselineOnMigrate(true)
+ .locations(locations: _*)
+ .load()
+ }
+
+ def findLocations(jdbc: JdbcConfig) =
+ jdbc.dbmsName match {
+ case Some(dbtype) =>
+ val name = if (dbtype == "h2") "postgresql" else dbtype
+ List("classpath:db/migration/common", s"classpath:db/migration/${name}")
+ case None =>
+ logger.warn(s"Cannot read database name from jdbc url: ${jdbc.url}. Go with H2")
+ List("classpath:db/migration/common", "classpath:db/h2")
+ }
+
+}
diff --git a/modules/store/src/main/scala/sharry/store/migrate/MigrateFrom06.scala b/modules/store/src/main/scala/sharry/store/migrate/MigrateFrom06.scala
new file mode 100644
index 00000000..fdf34dde
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/migrate/MigrateFrom06.scala
@@ -0,0 +1,276 @@
+package sharry.store.migrate
+
+import fs2.Stream
+import doobie._
+import doobie.implicits._
+import cats.implicits._
+import cats.effect._
+import org.log4s.getLogger
+import scala.concurrent.ExecutionContext
+
+import sharry.common._
+import sharry.common.syntax.all._
+import sharry.store.Store
+import sharry.store.JdbcConfig
+import sharry.store.records._
+import sharry.store.doobie.Sql
+import sharry.store.doobie.DoobieMeta._
+import cats.data.OptionT
+
+final class MigrateFrom06[F[_]: Effect: ContextShift](
+ cfg: JdbcConfig,
+ store: Store[F],
+ blocker: Blocker
+) {
+ private[this] val logger = getLogger
+
+ def migrate: F[Unit] =
+ for {
+ a <- createTables
+ b <- copyAccounts
+ c <- copyAlias
+ d <- copyShare
+ e <- copyFiles
+ errs = a + b + c + d + e
+ _ <- if (errs == 0) dropOldTables *> flywayBaseline *> logger.finfo[F]("Migration done")
+ else logger.finfo[F]("Some error occured, you might try again")
+ } yield ()
+
+ def flywayBaseline: F[Unit] = Effect[F].delay {
+ val fw = FlywayMigrate.baselineFlyway(cfg)
+ fw.migrate()
+ ()
+ }
+
+ def dropOldTables: F[Unit] =
+ for {
+ _ <- logger.finfo[F]("Dropping old tables")
+ _ <- store.transact(sql"DROP TABLE dbversion".update.run)
+ _ <- store.transact(sql"DROP TABLE uploadfile".update.run)
+ _ <- store.transact(sql"DROP TABLE upload".update.run)
+ _ <- store.transact(sql"DROP TABLE alias".update.run)
+ _ <- store.transact(sql"DROP TABLE account".update.run)
+ } yield ()
+
+ def createTables: F[Int] = {
+ val db = cfg.dbmsName match {
+ case Some("h2") => "postgresql"
+ case Some(n) => n
+ case None => sys.error(s"Unknown dbms for url: ${cfg.url}")
+ }
+ val file = Option(getClass.getResource(s"/db/migration/$db/V1.0.0__initial.sql")) match {
+ case None => sys.error("Schema file not found")
+ case Some(f) => f
+ }
+ val text = fs2.io
+ .readInputStream(Effect[F].delay(file.openStream()), 8 * 1024, blocker)
+ .through(fs2.text.utf8Decode)
+ .fold1(_ + _)
+ .compile
+ .lastOrError
+
+ for {
+ stmt <- text
+ _ <- logger.finfo[F]("Creating new tables")
+ n <- result(store.transact(Fragment.const(stmt).update.run), "Error creating tables")
+ } yield n
+ }
+
+ def copyAlias: F[Int] = {
+ val next: Fragment =
+ sql"SELECT ROW_NUMBER() OVER() AS rn,t.* FROM alias t"
+
+ logger.finfo[F]("Copying aliases...") *>
+ loadChunks[OldAlias](next)(-1)
+ .evalMap(_.toRAlias)
+ .evalTap(a => logger.finfo[F](s"Inserting alias: $a"))
+ .evalMap(a => result(store.transact(RAlias.insert(a)), "Error inserting alias"))
+ .compile
+ .foldMonoid
+ }
+
+ def copyShare: F[Int] = {
+ val next: Fragment =
+ sql"SELECT ROW_NUMBER() OVER() AS rn, t.* FROM upload t"
+
+ logger.finfo[F]("Copying shares...") *>
+ loadChunks[Upload](next)(-1)
+ .evalMap(
+ u =>
+ for {
+ share <- u.toShare
+ psha <- u.toPublish.value
+ _ <- logger.finfo[F](s"Inserting share: $share")
+ n <- result(store.transact(RShare.insert(share)), "Error inserting share")
+ _ <- psha.map(p => store.transact(RPublishShare.insert(p))).getOrElse(0.pure[F])
+ } yield n
+ )
+ .compile
+ .foldMonoid
+ }
+
+ def copyFiles: F[Int] = {
+ val q: Fragment =
+ sql"SELECT ROW_NUMBER() OVER() AS rn,t.* FROM uploadfile t"
+
+ logger.finfo[F]("Copying files...") *>
+ loadChunks[UploadFile](q)(-1)
+ .evalMap(_.toRShareFile)
+ .evalTap(f => logger.finfo[F](s"Insert file: $f"))
+ .evalMap(f => result(store.transact(RShareFile.insert(f)), "Error inserting file"))
+ .compile
+ .foldMonoid
+ }
+
+ def copyAccounts: F[Int] = {
+ val next: Fragment =
+ sql"SELECT ROW_NUMBER() OVER() AS rn,t.* FROM account t"
+
+ logger.finfo[F]("Copying accounts...") *>
+ loadChunks[OldAccount](next)(-1)
+ .evalMap(_.toAccount)
+ .evalTap(a => logger.finfo[F](s"Insert account: $a"))
+ .evalMap(a => result(store.transact(RAccount.insert(a)), "Error inserting account"))
+ .compile
+ .foldMonoid
+ }
+
+ def loadChunks[A <: RowNum: Read](q: Fragment)(start: Long): Stream[F, A] = {
+ val query = fr"SELECT * FROM (" ++ q ++ fr") v WHERE v.rn > $start ORDER BY v.rn"
+
+ Stream.eval(store.transact(query.query[A].stream.take(50).compile.toVector)).flatMap { v =>
+ if (v.isEmpty) Stream.empty
+ else Stream.emits(v) ++ loadChunks(q)(v.last.rownum)
+ }
+ }
+
+
+ def result[A](fu: F[A], errmsg: => String): F[Int] =
+ fu.attempt.flatMap {
+ case Right(_) => 0.pure[F]
+ case Left(ex) =>
+ logger.ferror[F](ex)(errmsg).as(1)
+ }
+
+ def accountId(login: Ident): F[Ident] =
+ store.transact(
+ Sql
+ .selectSimple(Seq(RAccount.Columns.id), RAccount.table, RAccount.Columns.login.is(login))
+ .query[Ident]
+ .unique
+ )
+
+ def getFileLength(fid: Ident): F[ByteSize] =
+ store.transact(sql"SELECT length FROM filemeta WHERE id = $fid".query[ByteSize].unique)
+
+ trait RowNum {
+ def rownum: Long
+ }
+ case class OldAccount(
+ rownum: Long,
+ login: Ident,
+ password: Option[Password],
+ email: Option[String],
+ admin: Boolean,
+ enabled: Boolean,
+ extern: Boolean
+ ) extends RowNum {
+ def toAccount: F[RAccount] =
+ for {
+ now <- Timestamp.current[F]
+ id <- Ident.randomId[F]
+ } yield RAccount(
+ id,
+ login,
+ if (extern) AccountSource.Extern else AccountSource.Intern,
+ if (enabled) AccountState.Active else AccountState.Disabled,
+ password.getOrElse(Password.empty),
+ email,
+ admin,
+ 0,
+ None,
+ now
+ )
+
+ }
+
+ case class UploadFile(
+ rownum: Long,
+ id: Ident,
+ fileId: Ident,
+ filename: Option[String],
+ donwloads: Int,
+ lastDownload: Option[Timestamp]
+ ) extends RowNum{
+
+ def toRShareFile: F[RShareFile] =
+ for {
+ now <- Timestamp.current[F]
+ len <- getFileLength(fileId)
+ } yield RShareFile(fileId, id, fileId, filename, now, len)
+ }
+
+ case class Upload(
+ rownum: Long,
+ id: Ident,
+ login: Ident,
+ alias: Option[Ident],
+ descr: Option[String],
+ validity: java.time.Duration,
+ maxdl: Int,
+ password: Option[Password],
+ created: Timestamp,
+ downloads: Int,
+ lastdl: Option[Timestamp],
+ publishId: Option[Ident],
+ publishDate: Option[Timestamp],
+ publishUntil: Option[Timestamp],
+ name: Option[String]
+ ) extends RowNum {
+
+ def toShare: F[RShare] =
+ for {
+ accId <- accountId(login)
+ } yield RShare(id, accId, alias, name, Duration(validity), maxdl, password, descr, created)
+
+ def toPublish: OptionT[F, RPublishShare] =
+ for {
+ pid <- OptionT.fromOption[F](publishId)
+ pd <- OptionT.fromOption[F](publishDate)
+ pu <- OptionT.fromOption[F](publishUntil)
+ } yield RPublishShare(pid, id, true, downloads, lastdl, pd, pu, pd)
+
+ }
+
+ case class OldAlias(
+ rownum: Long,
+ id: Ident,
+ login: Ident,
+ name: String,
+ validity: java.time.Duration,
+ created: Timestamp,
+ enabled: Boolean
+ ) extends RowNum {
+
+ def toRAlias: F[RAlias] =
+ for {
+ accId <- accountId(login)
+ } yield RAlias(id, accId, name, Duration(validity), enabled, created)
+ }
+
+ implicit def metaJavaDuration: Meta[java.time.Duration] =
+ Meta[String].timap(s => java.time.Duration.parse(s))(_.toString)
+}
+
+object MigrateFrom06 {
+
+ def apply[F[_]: Effect: ContextShift](
+ cfg: JdbcConfig,
+ connectEC: ExecutionContext,
+ blocker: Blocker
+ ): Resource[F, MigrateFrom06[F]] =
+ for {
+ store <- Store.create(cfg, connectEC, blocker, false)
+ } yield new MigrateFrom06[F](cfg, store, blocker)
+
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/ModAccount.scala b/modules/store/src/main/scala/sharry/store/records/ModAccount.scala
new file mode 100644
index 00000000..fcc4daf2
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/ModAccount.scala
@@ -0,0 +1,10 @@
+package sharry.store.records
+
+import sharry.common._
+
+case class ModAccount(
+ state: AccountState,
+ admin: Boolean,
+ email: Option[String],
+ password: Option[Password]
+)
diff --git a/modules/store/src/main/scala/sharry/store/records/RAccount.scala b/modules/store/src/main/scala/sharry/store/records/RAccount.scala
new file mode 100644
index 00000000..641b62a3
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RAccount.scala
@@ -0,0 +1,140 @@
+package sharry.store.records
+
+import fs2.Stream
+import cats.implicits._
+import doobie._, doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+
+case class RAccount(
+ id: Ident,
+ login: Ident,
+ source: AccountSource,
+ state: AccountState,
+ password: Password,
+ email: Option[String],
+ admin: Boolean,
+ loginCount: Int,
+ lastLogin: Option[Timestamp],
+ created: Timestamp
+) {
+
+ def accountId(alias: Option[Ident]): AccountId =
+ AccountId(id, login, admin, alias)
+}
+
+object RAccount {
+ val table = fr"account_"
+
+ object Columns {
+ val id = Column("id")
+ val login = Column("login")
+ val source = Column("source")
+ val state = Column("state")
+ val password = Column("password")
+ val email = Column("email")
+ val admin = Column("admin")
+ val loginCount = Column("logincount")
+ val lastLogin = Column("lastlogin")
+ val created = Column("created")
+
+ val all = List(id, login, source, state, password, email, admin, loginCount, lastLogin, created)
+ }
+
+ import Columns._
+
+ def insert(v: RAccount): ConnectionIO[Int] = {
+ val sql = Sql.insertRow(
+ table,
+ all,
+ fr"${v.id},${v.login},${v.source},${v.state},${v.password},${v.email},${v.admin},${v.loginCount},${v.lastLogin},${v.created}"
+ )
+ sql.update.run
+ }
+
+ def update(aid: Ident, v: ModAccount): ConnectionIO[Int] = {
+ val up1 = Sql.updateRow(
+ table,
+ Sql.and(id.is(aid), source.is(AccountSource.intern)),
+ Sql.commas(
+ state.setTo(v.state),
+ email.setTo(v.email),
+ admin.setTo(v.admin),
+ password.setTo(v.password.getOrElse(Password.empty))
+ )
+ )
+
+ val up2 = Sql.updateRow(
+ table,
+ Sql.and(id.is(aid), source.is(AccountSource.intern)),
+ Sql.commas(state.setTo(v.state), email.setTo(v.email), admin.setTo(v.admin))
+ )
+
+ val up3 = Sql.updateRow(
+ table,
+ Sql.and(id.is(aid), source.isNot(AccountSource.intern)),
+ Sql.commas(state.setTo(v.state), email.setTo(v.email), admin.setTo(v.admin))
+ )
+
+ for {
+ n <- if (v.password.nonEmpty) up1.update.run else up2.update.run
+ k <- if (n == 0) up3.update.run else 0.pure[ConnectionIO]
+ } yield n + k
+ }
+
+ def setEmail(aid: Ident, v: Option[String]): ConnectionIO[Int] =
+ Sql.updateRow(table, id.is(aid), email.setTo(v)).update.run
+
+ def updatePassword(aid: Ident, pw: Password): ConnectionIO[Int] =
+ Sql.updateRow(table, id.is(aid), password.setTo(pw)).update.run
+
+ def updateStatsById(accId: Ident): ConnectionIO[Int] =
+ Sql.currentTime.flatMap(
+ t =>
+ Sql
+ .updateRow(
+ table,
+ id.is(accId),
+ Sql.commas(
+ loginCount.increment(1),
+ lastLogin.setTo(t)
+ )
+ )
+ .update
+ .run
+ )
+
+ def findByLogin(user: Ident): ConnectionIO[Option[RAccount]] =
+ Sql.selectSimple(all, table, login.is(user)).query[RAccount].option
+
+ def findById(uid: Ident): ConnectionIO[Option[RAccount]] =
+ Sql.selectSimple(all, table, id.is(uid)).query[RAccount].option
+
+ def findByAlias(alias: Ident): ConnectionIO[Option[RAccount]] = {
+ val aliasId = "n" :: RAlias.Columns.id
+ val aliasEnabled = "n" :: RAlias.Columns.enabled
+ val aliasAccount = "n" :: RAlias.Columns.account
+ val accId = "a" :: Columns.id
+ val from = table ++ fr"a INNER JOIN" ++ RAlias.table ++ fr"n ON" ++ accId.is(aliasAccount)
+ Sql
+ .selectSimple(
+ all.map("a" :: _),
+ from,
+ Sql.and(aliasId.is(alias), aliasEnabled.is(true))
+ )
+ .query[RAccount]
+ .option
+
+ }
+
+ def existsByLogin(user: Ident): ConnectionIO[Boolean] =
+ Sql.selectCount(login, table, login.is(user)).query[Int].map(_ > 0).unique
+
+ def findAll(loginQ: String): Stream[ConnectionIO, RAccount] = {
+ val q =
+ if (loginQ.isEmpty) Fragment.empty
+ else login.like("%" + loginQ + "%")
+ Sql.selectSimple(all, table, q).query[RAccount].stream
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/RAlias.scala b/modules/store/src/main/scala/sharry/store/records/RAlias.scala
new file mode 100644
index 00000000..f4a9bbd0
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RAlias.scala
@@ -0,0 +1,86 @@
+package sharry.store.records
+
+import fs2.Stream
+import cats.implicits._
+import doobie._, doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+import cats.effect.Sync
+
+case class RAlias(
+ id: Ident,
+ account: Ident,
+ name: String,
+ validity: Duration,
+ enabled: Boolean,
+ created: Timestamp
+)
+
+object RAlias {
+ val table = fr"alias_"
+
+ object Columns {
+ val id = Column("id")
+ val account = Column("account_id")
+ val name = Column("name_")
+ val validity = Column("validity")
+ val enabled = Column("enabled")
+ val created = Column("created")
+
+ val all = List(id, account, name, validity, enabled, created)
+ }
+
+ def createNew[F[_]: Sync](
+ account: Ident,
+ name: String,
+ validity: Duration,
+ enabled: Boolean
+ ): F[RAlias] =
+ for {
+ id <- Ident.randomId[F]
+ now <- Timestamp.current[F]
+ } yield RAlias(id, account, name, validity, enabled, now)
+
+ import Columns._
+
+ def insert(v: RAlias): ConnectionIO[Int] = {
+ val sql = Sql.insertRow(
+ table,
+ all,
+ fr"${v.id},${v.account},${v.name},${v.validity},${v.enabled},${v.created}"
+ )
+ sql.update.run
+ }
+
+ def update(aid: Ident, acc: Ident, v: RAlias): ConnectionIO[Int] =
+ Sql
+ .updateRow(
+ table,
+ Sql.and(id.is(aid), account.is(acc)),
+ Sql.commas(
+ id.setTo(v.id),
+ name.setTo(v.name),
+ validity.setTo(v.validity),
+ enabled.setTo(v.enabled)
+ )
+ )
+ .update
+ .run
+
+ def findById(aliasId: Ident, accId: Ident): ConnectionIO[Option[RAlias]] =
+ Sql.selectSimple(all, table, Sql.and(id.is(aliasId), account.is(accId))).query[RAlias].option
+
+ def existsById(aliasId: Ident): ConnectionIO[Boolean] =
+ Sql.selectCount(id, table, id.is(aliasId)).query[Int].map(_ > 0).unique
+
+ def findAll(acc: Ident, nameQ: String): Stream[ConnectionIO, RAlias] = {
+ val q =
+ if (nameQ.isEmpty) Fragment.empty
+ else name.like("%" + nameQ + "%")
+ Sql.selectSimple(all, table, Sql.and(account.is(acc), q)).query[RAlias].stream
+ }
+
+ def delete(aliasId: Ident, accId: Ident): ConnectionIO[Int] =
+ Sql.deleteFrom(table, Sql.and(account.is(accId), id.is(aliasId))).update.run
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/RInvitation.scala b/modules/store/src/main/scala/sharry/store/records/RInvitation.scala
new file mode 100644
index 00000000..754db3cf
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RInvitation.scala
@@ -0,0 +1,50 @@
+package sharry.store.records
+
+import cats.implicits._
+import cats.effect.Sync
+import doobie._
+import doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+
+case class RInvitation(id: Ident, created: Timestamp) {}
+
+object RInvitation {
+
+ val table = fr"invitation"
+
+ object Columns {
+ val id = Column("id")
+ val created = Column("created")
+ val all = List(id, created)
+ }
+ import Columns._
+
+ def generate[F[_]: Sync]: F[RInvitation] =
+ for {
+ c <- Timestamp.current[F]
+ i <- Ident.randomId[F]
+ } yield RInvitation(i, c)
+
+ def insert(v: RInvitation): ConnectionIO[Int] =
+ Sql.insertRow(table, all, fr"${v.id},${v.created}").update.run
+
+ def insertNew: ConnectionIO[RInvitation] =
+ generate[ConnectionIO].flatMap(v => insert(v).map(_ => v))
+
+ def findById(invite: Ident): ConnectionIO[Option[RInvitation]] =
+ Sql.selectSimple(all, table, id.is(invite)).query[RInvitation].option
+
+ def delete(invite: Ident): ConnectionIO[Int] =
+ Sql.deleteFrom(table, id.is(invite)).update.run
+
+ def useInvite(invite: Ident, minCreated: Timestamp): ConnectionIO[Boolean] = {
+ val get =
+ Sql.selectCount(id, table, Sql.and(id.is(invite), created.isGt(minCreated))).query[Int].unique
+ for {
+ inv <- get
+ _ <- delete(invite)
+ } yield inv > 0
+ }
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/RPublishShare.scala b/modules/store/src/main/scala/sharry/store/records/RPublishShare.scala
new file mode 100644
index 00000000..353d1bc1
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RPublishShare.scala
@@ -0,0 +1,97 @@
+package sharry.store.records
+
+//import cats.implicits._
+import doobie._, doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+import cats.effect.Sync
+
+case class RPublishShare(
+ id: Ident,
+ shareId: Ident,
+ enabled: Boolean,
+ views: Int,
+ lastAccess: Option[Timestamp],
+ publishDate: Timestamp,
+ publishUntil: Timestamp,
+ created: Timestamp
+)
+
+object RPublishShare {
+
+ val table = fr"publish_share"
+
+ object Columns {
+
+ val id = Column("id")
+ val shareId = Column("share_id")
+ val enabled = Column("enabled")
+ val views = Column("views")
+ val lastAccess = Column("last_access")
+ val publishDate = Column("publish_date")
+ val publishUntil = Column("publish_until")
+ val created = Column("created")
+
+ val all = List(id, shareId, enabled, views, lastAccess, publishDate, publishUntil, created)
+ }
+
+ import Columns._
+
+ def insert(v: RPublishShare): ConnectionIO[Int] =
+ Sql
+ .insertRow(
+ table,
+ all,
+ fr"${v.id},${v.shareId},${v.enabled},${v.views}," ++
+ fr"${v.lastAccess},${v.publishDate},${v.publishUntil}," ++
+ fr"${v.created}"
+ )
+ .update
+ .run
+
+ def update(v: RPublishShare): ConnectionIO[Int] =
+ Sql
+ .updateRow(
+ table,
+ shareId.is(v.shareId),
+ Sql.commas(
+ id.setTo(v.id),
+ enabled.setTo(v.enabled),
+ views.setTo(v.views),
+ lastAccess.setTo(v.lastAccess),
+ publishDate.setTo(v.publishDate),
+ publishUntil.setTo(v.publishUntil)
+ )
+ )
+ .update
+ .run
+
+ def existsByShare(share: Ident): ConnectionIO[Boolean] =
+ Sql.selectCount(id, table, shareId.is(share)).query[Int].unique.map(_ > 0)
+
+ def findByShare(share: Ident): ConnectionIO[Option[RPublishShare]] =
+ Sql.selectSimple(all, table, shareId.is(share)).query[RPublishShare].option
+
+ def initialInsert[F[_]: Sync](share: Ident): ConnectionIO[RPublishShare] =
+ for {
+ now <- Timestamp.current[ConnectionIO]
+ id <- Ident.randomId[ConnectionIO]
+ validity <- RShare.getDuration(share)
+ record = RPublishShare(id, share, true, 0, None, now, now.plus(validity), now)
+ n <- insert(record)
+ } yield record
+
+ def update(share: Ident, enable: Boolean, reuseId: Boolean): ConnectionIO[Int] =
+ for {
+ nid <- Ident.randomId[ConnectionIO]
+ validity <- RShare.getDuration(share)
+ now <- Timestamp.current[ConnectionIO]
+ sets = Seq(enabled.setTo(enable)) ++
+ (if (enable) Seq(publishDate.setTo(now), publishUntil.setTo(now.plus(validity)))
+ else Seq.empty) ++
+ (if (reuseId) Seq.empty else Seq(id.setTo(nid)))
+ frag <- Sql.updateRow(table, shareId.is(share), Sql.commas(sets)).update.run
+ } yield frag
+
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/RShare.scala b/modules/store/src/main/scala/sharry/store/records/RShare.scala
new file mode 100644
index 00000000..25faac55
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RShare.scala
@@ -0,0 +1,58 @@
+package sharry.store.records
+
+import doobie._, doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+
+case class RShare(
+ id: Ident,
+ accountId: Ident,
+ aliasId: Option[Ident],
+ name: Option[String],
+ validity: Duration,
+ maxViews: Int,
+ password: Option[Password],
+ description: Option[String],
+ created: Timestamp
+)
+
+object RShare {
+
+ val table = fr"share"
+
+ object Columns {
+
+ val id = Column("id")
+ val accountId = Column("account_id")
+ val aliasId = Column("alias_id")
+ val name = Column("name_")
+ val validity = Column("validity")
+ val maxViews = Column("max_views")
+ val password = Column("password")
+ val description = Column("description")
+ val created = Column("created")
+
+ val all = List(id, accountId, aliasId, name, validity, maxViews, password, description, created)
+ }
+
+ import Columns._
+
+ def insert(v: RShare): ConnectionIO[Int] =
+ Sql
+ .insertRow(
+ table,
+ all,
+ fr"${v.id},${v.accountId},${v.aliasId},${v.name}," ++
+ fr"${v.validity},${v.maxViews},${v.password}," ++
+ fr"${v.description},${v.created}"
+ )
+ .update
+ .run
+
+ def getDuration(share: Ident): ConnectionIO[Duration] =
+ Sql.selectSimple(Seq(validity), table, id.is(share)).query[Duration].unique
+
+ def delete(sid: Ident): ConnectionIO[Int] =
+ Sql.deleteFrom(table, id.is(sid)).update.run
+}
diff --git a/modules/store/src/main/scala/sharry/store/records/RShareFile.scala b/modules/store/src/main/scala/sharry/store/records/RShareFile.scala
new file mode 100644
index 00000000..62388b77
--- /dev/null
+++ b/modules/store/src/main/scala/sharry/store/records/RShareFile.scala
@@ -0,0 +1,53 @@
+package sharry.store.records
+
+import doobie._, doobie.implicits._
+import sharry.common._
+import sharry.store.doobie._
+import sharry.store.doobie.DoobieMeta._
+
+case class RShareFile(
+ id: Ident,
+ shareId: Ident,
+ fileId: Ident,
+ filename: Option[String],
+ created: Timestamp,
+ realSize: ByteSize
+)
+
+object RShareFile {
+
+ val table = fr"share_file"
+
+ object Columns {
+ val id = Column("id")
+ val shareId = Column("share_id")
+ val fileId = Column("file_id")
+ val filename = Column("filename")
+ val created = Column("created")
+ val realSize = Column("real_size")
+ val all = List(id, shareId, fileId, filename, created, realSize)
+ }
+
+ import Columns._
+
+ def insert(v: RShareFile): ConnectionIO[Int] =
+ Sql
+ .insertRow(table, all, fr"${v.id},${v.shareId},${v.fileId},${v.filename},${v.created},${v.realSize}")
+ .update
+ .run
+
+ def getFileMetaId(sfId: Ident): ConnectionIO[Option[Ident]] =
+ Sql.selectSimple(Seq(fileId), table, id.is(sfId)).query[Ident].option
+
+ def findById(fileId: Ident): ConnectionIO[Option[RShareFile]] =
+ Sql.selectSimple(all, table, id.is(fileId)).query[RShareFile].option
+
+ def delete(shareFileId: Ident): ConnectionIO[Int] =
+ Sql.deleteFrom(table, id.is(shareFileId)).update.run
+
+ def deleteByFileId(fid: Ident): ConnectionIO[Int] =
+ Sql.deleteFrom(table, fileId.is(fid)).update.run
+
+ def setRealSize(fid: Ident, size: ByteSize): ConnectionIO[Int] =
+ Sql.updateRow(table, id.is(fid), realSize.setTo(size)).update.run
+}
diff --git a/modules/store/src/main/scala/sharry/store/upload/SqlStatements.scala b/modules/store/src/main/scala/sharry/store/upload/SqlStatements.scala
deleted file mode 100644
index f985fe63..00000000
--- a/modules/store/src/main/scala/sharry/store/upload/SqlStatements.scala
+++ /dev/null
@@ -1,274 +0,0 @@
-package sharry.store.upload
-
-import java.time.Instant
-import cats.data.NonEmptyList
-import cats.implicits._
-import cats.effect.IO
-import doobie._, doobie.implicits._
-import org.log4s._
-import bitpeace.sql.Statements
-
-import sharry.common.mime._
-import sharry.common.sizes._
-import sharry.common.duration._
-import sharry.common.data._
-import sharry.store.columns._
-import sharry.store.data._
-
-trait SqlStatements extends Statements[IO] {
-
- private[this] val logger = getLogger
- private implicit val logHandler = logSql(logger)
-
- def insertUploadConfig(uc: Upload) = {
- // if this is an upload through an alias we set the „publishUntil”
- // field using the given validity so these uploads are garbage
- // collected although not published. If a user chooses to publish
- // this upload, this date is overwritten.
- //TODO: introduce a global validity for non-published uploads
- val until = uc.alias match {
- case Some(a) => Some(Instant.now plus uc.validity.asJava)
- case None => uc.validUntil
- }
- sql"""INSERT INTO Upload VALUES (
- ${uc.id}, ${uc.login}, ${uc.alias}, ${uc.description}, ${uc.validity},
- ${uc.maxDownloads}, ${uc.password}, ${uc.created},
- ${uc.downloads}, ${uc.lastDownload}, ${uc.publishId}, ${uc.publishDate}, ${until}, ${uc.name})""".update
- }
-
- def sqlSetUploadTimestamp(uploadId: String, fileId: String, time: Instant) =
- for {
- a <- sql"""UPDATE FileMeta SET timestamp = $time WHERE id = $fileId""".update.run
- b <- sql"""UPDATE Upload SET created = $time WHERE id = $uploadId""".update.run
- } yield a + b
-
- def sqlSetUploadName(uploadId: String, name: Option[String]) =
- sql"""UPDATE Upload SET name = $name WHERE id = $uploadId""".update
-
- def setFileMetaMimeType(fileId: String, mimetype: MimeType) =
- sql"""UPDATE FileMeta SET mimetype = ${mimetype.asString} WHERE id = $fileId""".update
-
- def sqlChunkExists(uploadId: String, fileId: String, chunkNr: Int, chunkLength: Size) = {
- val check = sql"""SELECT count(*) FROM UploadFile AS uf
- INNER JOIN FileChunk AS fc ON uf.fileId = fc.fileId
- WHERE uf.uploadId = $uploadId AND uf.fileId = $fileId AND fc.chunknr = $chunkNr""".
- query[Int].
- unique.
- map(_ > 0)
-
- for {
- b <- check
- f <- if (b) sqlChunkLengthCheckOrRemove(fileId, chunkNr, chunkLength) else b.pure[ConnectionIO]
- } yield f
- }
-
- def sqlChunkLengthCheckOrRemove(fileId: String, chunkNr: Int, chunkLength: Size) = {
- val query = sql"""SELECT count(*) FROM FileChunk
- WHERE fileId = $fileId AND chunknr = $chunkNr AND length(chunkData) != ${chunkLength.toBytes}""".
- query[Int].unique
-
- val delete = sql"""DELETE FROM FileChunk WHERE fileId = $fileId AND chunknr = $chunkNr""".update.run
-
- for {
- n <- query
- _ <- if (n == 1) delete else 0.pure[ConnectionIO]
- } yield n == 0
- }
-
- def insertUploadFile(f: UploadFile): Update0 =
- sql"""INSERT INTO UploadFile VALUES (${f.uploadId}, ${f.fileId}, ${f.filename}, ${f.downloads}, ${f.lastDownload}, ${f.clientFileId})""".update
-
- def insertUploadFile(id: String, fileId: String, filename: String, downloads: Int, lastDownload: Option[Instant], clientFileId: String): ConnectionIO[UploadFile] = {
- val uf = UploadFile(id, fileId, filename, downloads, lastDownload, clientFileId)
- for {
- _ <- insertUploadFile(uf).run
- } yield uf
- }
-
- def sqlListUploads(login: String) =
- sql"""SELECT up.id,up.login,up.validity,up.maxdownloads,up.alias,up.description,up.password,up.created,up.downloads,up.lastDownload,up.publishId,up.publishDate,al.name,up.name
- FROM Upload as up LEFT OUTER JOIN Alias as al ON up.alias = al.id
- WHERE up.login = $login ORDER BY created DESC""".
- query[Upload].
- stream
-
- def sqlGetUpload(id: String, login: String) =
- sql"""SELECT up.id,up.login,up.validity,up.maxdownloads,up.alias,up.description,up.password,up.created,up.downloads,up.lastDownload,up.publishId,up.publishDate,al.name,up.name
- FROM Upload as up LEFT OUTER JOIN Alias as al ON up.alias = al.id
- WHERE up.id = $id AND up.login = $login""".
- query[Upload].
- option
-
- def sqlGetPublishedUpload(id: String) =
- sql"""SELECT up.id,up.login,up.validity,up.maxdownloads,up.alias,up.description,up.password,up.created,up.downloads,up.lastDownload,up.publishId,up.publishDate,al.name,up.name
- FROM Upload as up LEFT OUTER JOIN Alias as al ON up.alias = al.id
- WHERE up.publishId = $id""".
- query[Upload].
- option
-
- def sqlGetPublishedUploadByFileId(fileId: String) =
- sql"""SELECT up.id,up.login,up.validity,up.maxdownloads,up.alias,up.description,up.password,up.created,up.downloads,up.lastDownload,up.publishId,up.publishDate,al.name,up.name,
- fm.id,fm.timestamp,fm.mimetype,fm.length,fm.checksum,fm.chunks,fm.chunksize, uf.filename, uf.clientFileId
- FROM Upload AS up
- INNER JOIN UploadFile AS uf ON uf.uploadId = up.id AND uf.fileId = $fileId
- INNER JOIN FileMeta AS fm ON fm.id = uf.fileId
- LEFT OUTER JOIN Alias as al ON up.alias = al.id
- WHERE up.publishId is not null""".
- query[(Upload, UploadInfo.File)].
- option
-
- def sqlGetUploadByFileId(fileId: String, login: String) =
- sql"""SELECT up.id,up.login,up.validity,up.maxdownloads,up.alias,up.description,up.password,up.created,up.downloads,up.lastDownload,up.publishId,up.publishDate,al.name,up.name,
- fm.id,fm.timestamp,fm.mimetype,fm.length,fm.checksum,fm.chunks,fm.chunksize, uf.filename, uf.clientFileId
- FROM Upload AS up
- INNER JOIN UploadFile AS uf ON uf.uploadId = up.id AND uf.fileId = $fileId
- INNER JOIN FileMeta AS fm ON fm.id = uf.fileId
- LEFT OUTER JOIN Alias AS al ON up.alias = al.id
- WHERE up.login = $login""".
- query[(Upload, UploadInfo.File)].
- option
-
- def sqlGetUploadFiles(id: String, login: String) =
- sql"""SELECT fm.id,fm.timestamp,fm.mimetype,fm.length,fm.checksum,fm.chunks,fm.chunksize, uf.filename, uf.clientFileId from UploadFile AS uf
- INNER JOIN FileMeta AS fm ON uf.fileId = fm.id
- INNER JOIN Upload AS up ON up.id = uf.uploadId
- WHERE uf.uploadId = $id AND up.login = $login""".
- query[UploadInfo.File].
- to[List]
-
- def sqlGetUploadInfo(id: String, login: String) =
- for {
- upload <- sqlGetUpload(id, login)
- files <- sqlGetUploadFiles(id, login)
- } yield upload.map(up => UploadInfo(up, files))
-
- def sqlPublishUpload(id: String, login: String, publishId: String, publishDate: Instant, valid: Duration) =
- sql"""UPDATE Upload SET publishId = $publishId, publishDate = $publishDate, publishUntil = ${publishDate.plus(valid.asJava)}
- WHERE publishId is null AND id = $id AND login = $login""".
- update
-
- def sqlUnpublishUpload(id: String, login: String) =
- sql"""UPDATE Upload SET publishId = null, publishDate = null, publishUntil = null
- WHERE id = $id AND login = $login""".
- update
-
- def sqlUpdateDownloadStats(publishId: String, inc: Int, last: Instant) =
- sql"""UPDATE Upload SET downloads = downloads + $inc, lastDownload = $last WHERE publishId = $publishId""".
- update
-
- def sqlUpdateFileDownloadStats(uploadId: String, fileId: String, inc: Int, last: Instant) =
- sql"""UPDATE UploadFile SET downloads = downloads + $inc, lastDownload = $last WHERE fileId = $fileId AND uploadId = $uploadId""".
- update
-
- def sqlSelectFileIds(uploadId: String) =
- sql"""SELECT fileId FROM UploadFile WHERE uploadId = $uploadId""".
- query[String].
- to[List]
-
- def sqlDeleteFileChunks(ids: NonEmptyList[String]) =
- (sql"""DELETE FROM FileChunk WHERE """ ++ Fragments.in(fr"fileId", ids)).update
-
- def sqlDeleteFileMeta(ids: NonEmptyList[String]) =
- (sql"""DELETE FROM FileMeta WHERE """ ++ Fragments.in(fr"id", ids)).update
-
- def sqlDeleteUploadFile(id: String) =
- sql"""DELETE FROM UploadFile WHERE uploadId = $id""".
- update
-
- def sqlDeleteUpload(id: String, fileIds: List[String]) =
- NonEmptyList.fromList(fileIds) match {
- case Some(ids) =>
- for {
- _ <- sqlDeleteUploadFile(id).run
- _ <- sql"""DELETE FROM Upload WHERE id = $id""".update.run
- n <- sqlDeleteFileMeta(ids).run
- _ <- sqlDeleteFileChunks(ids).run
- } yield n
-
- case None =>
- for {
- _ <- sql"""DELETE FROM Upload WHERE id = $id""".update.run
- _ <- sqlDeleteUploadFile(id).run
- } yield 0
-
- }
-
-
- def sqlListInvalidSince(since: Instant) =
- sql"""SELECT id,publishUntil FROM Upload WHERE publishUntil < $since""".
- query[(String, Instant)].
- stream
-
- def sqlInsertAlias(alias: Alias) =
- sql"""INSERT INTO Alias VALUES (${alias.id}, ${alias.login}, ${alias.name}, ${alias.validity}, ${alias.created}, ${alias.enable})""".
- update
-
- def sqlListAliases(login: String) =
- sql"""SELECT id,login,name,validity,created,enable
- FROM Alias WHERE login = $login
- ORDER BY created DESC""".
- query[Alias].
- stream
-
- def sqlGetAlias(id: String) =
- sql"""SELECT id,login,name,validity,created,enable
- FROM Alias WHERE id = $id""".
- query[Alias].
- option
-
- def sqlDeleteAlias(id: String, login: String) =
- sql"""DELETE FROM Alias WHERE id = $id AND login = $login""".update
-
- def sqlUpdateAlias(a: Alias, id: String) =
- sql"""UPDATE Alias SET id = ${a.id}, name = ${a.name}, validity = ${a.validity}, enable = ${a.enable}
- WHERE id = ${id} AND login = ${a.login}""".update
-
- def sqlGetActiveAlias(id: String) =
- sql"""SELECT al.id,al.login,al.name,al.validity,al.created,al.enable
- FROM Alias AS al
- INNER JOIN Account AS ac ON al.login = ac.login
- WHERE al.id = $id AND al.enable AND ac.enabled""".
- query[Alias].
- option
-
- def sqlGetUploadSize(id: String) =
- sql"""SELECT count(*), COALESCE(sum(length), 0)
- FROM UploadFile AS uf
- INNER JOIN FileMeta AS fm ON uf.fileid = fm.id
- WHERE uf.uploadid = $id""".
- query[UploadSize].
- unique
-
- def sqlGetUploadSizeFromChunks(id: String) =
- sql"""SELECT count(distinct fm.id), COALESCE(sum(length(fc.chunkdata)), 0)
- FROM UploadFile AS uf
- INNER JOIN FileMeta AS fm ON uf.fileid = fm.id
- INNER JOIN FileChunk AS fc ON fc.fileid = fm.id
- WHERE uf.uploadid = $id""".
- query[UploadSize].
- unique
-
- implicit class BitpeaceFilemeta(fm: FileMeta) {
- def asBitpeace: bitpeace.FileMeta = bitpeace.FileMeta(
- fm.id
- , fm.timestamp
- , bitpeace.Mimetype(fm.mimetype.primary , fm.mimetype.sub, fm.mimetype.params)
- , fm.length.toBytes
- , fm.checksum
- , fm.chunks
- , fm.chunksize.toBytes.toInt
- )
- }
-
- implicit class SharryFileMeta(fm: bitpeace.FileMeta) {
- def asSharry: FileMeta = FileMeta(
- fm.id
- , fm.timestamp
- , MimeType(fm.mimetype.primary, fm.mimetype.sub, fm.mimetype.params)
- , fm.length.bytes
- , fm.checksum
- , fm.chunks
- , fm.chunksize.bytes
- )
- }
-}
diff --git a/modules/store/src/main/scala/sharry/store/upload/SqlUploadStore.scala b/modules/store/src/main/scala/sharry/store/upload/SqlUploadStore.scala
deleted file mode 100644
index cc906b8a..00000000
--- a/modules/store/src/main/scala/sharry/store/upload/SqlUploadStore.scala
+++ /dev/null
@@ -1,166 +0,0 @@
-package sharry.store.upload
-
-import java.time.Instant
-import fs2.{Pipe, Stream}
-import cats.effect.IO
-import doobie._, doobie.implicits._
-import cats.implicits._
-import org.log4s._
-import bitpeace.{Bitpeace, BitpeaceConfig, MimetypeHint, FileChunk, RangeDef}
-import scala.concurrent.ExecutionContext
-
-import sharry.common.mime._
-import sharry.common.rng._
-import sharry.common.sizes._
-import sharry.common.streams
-import sharry.common.zip
-import sharry.common.data._
-import sharry.store.data._
-
-class SqlUploadStore(xa: Transactor[IO], val config: BitpeaceConfig[IO]) extends UploadStore with SqlStatements {
- private[this] val logger = getLogger
-
- private val binaryStore: Bitpeace[IO] = Bitpeace(config, xa)
-
- def updateUpload(id: String, up: UploadUpdate): Stream[IO, Unit] =
- Stream.eval(sqlSetUploadName(id, Option(up.name).map(_.trim).filter(_.nonEmpty)).run.transact(xa).map(_ => ()))
-
- def createUpload(up: Upload): Stream[IO, Unit] =
- Stream.eval(insertUploadConfig(up).run.transact(xa)).map(_ => ())
-
- def deleteUpload(id: String, login: String): Stream[IO, Int] = {
- for {
- fileIds <- Stream.eval(sqlSelectFileIds(id).transact(xa))
- _ <- getUpload(id, login)
- n <- Stream.eval(sqlDeleteUpload(id, fileIds).transact(xa))
- } yield n
- }
-
- def createUploadFile(uploadId: String, fileId: String, filename: String, clientFileId: String): Stream[IO, UploadFile] =
- Stream.eval(insertUploadFile(uploadId, fileId, filename, 0, None, clientFileId).transact(xa))
-
- def updateMime(fileId: String, mimeType: MimeType): Stream[IO, Int] =
- Stream.eval(setFileMetaMimeType(fileId, mimeType).run.transact(xa))
-
- def updateTimestamp(uploadId: String, fileId: String, time: Instant): Stream[IO, Int] =
- Stream.eval(sqlSetUploadTimestamp(uploadId, fileId, time).transact(xa))
-
- def addChunk(uploadId: String, fc: FileChunk, chunksize: Int, totalChunks: Int, hint: MimetypeHint): Stream[IO, FileMeta] =
- binaryStore.addChunk(fc, chunksize, totalChunks, hint).map(_.result.asSharry)
-
- def chunkExists(uploadId: String, fileId: String, chunkNr: Int, chunkLength: Size): Stream[IO, Boolean] =
- Stream.eval(sqlChunkExists(uploadId, fileId, chunkNr, chunkLength).transact(xa))
-
- def listUploads(login: String): Stream[IO, Upload] =
- sqlListUploads(login).transact(xa)
-
- def getUpload(id: String, login: String): Stream[IO, UploadInfo] =
- Stream.eval(sqlGetUploadInfo(id, login).transact(xa)).
- through(streams.optionToEmpty)
-
- def getPublishedUpload(id: String): Stream[IO, UploadInfo] = {
- val update = sqlUpdateDownloadStats(id, 1, Instant.now).run
- val get = for {
- up <- sqlGetPublishedUpload(id)
- files <- up match {
- case Some(u) => sqlGetUploadFiles(u.id, u.login)
- case None => Nil.pure[ConnectionIO]
- }
- } yield up.map(UploadInfo(_, files))
-
- val resp = Stream.eval {
- for {
- up <- get.transact(xa)
- _ <- update.transact(xa)
- } yield up
- }
- resp.through(streams.optionToEmpty)
- }
-
- def getUploadSize(id: String): Stream[IO, UploadSize] =
- Stream.eval(sqlGetUploadSizeFromChunks(id).transact(xa))
-
- def publishUpload(id: String, login: String): Stream[IO, Either[String, String]] = {
- Stream.eval(sqlGetUpload(id, login).transact(xa)).
- through(streams.optionToEmpty).
- flatMap { up =>
- up.publishId match {
- case Some(publishId) =>
- Stream.emit(Left(s"The upload $id is already published ($publishId)"))
- case None =>
- val publishId = Gen.ident(32, 42).generate()
- Stream.eval(sqlPublishUpload(id, login, publishId, Instant.now, up.validity).run.transact(xa)).
- map(n => {
- if (n == 1) Right(publishId)
- else Left("Internal error, published more than one upload")
- })
- }
- }
- }
-
- def unpublishUpload(id: String, login: String): Stream[IO,Either[String,Unit]] =
- Stream.eval(sqlGetUpload(id, login).transact(xa)).
- through(streams.optionToEmpty).
- flatMap { up =>
- up.publishId match {
- case None =>
- Stream.emit(Left(s"The upload $id is not published already."))
- case Some(_) =>
- Stream.eval(sqlUnpublishUpload(id, login).run.transact(xa)).
- map(n =>
- if (n == 1) Right(())
- else Left("Internal error: unpublished more than one upload"))
- }
- }
-
- def getUploadByFileId(fileId: String, login: String): Stream[IO, (Upload, UploadInfo.File)] =
- Stream.eval(sqlGetUploadByFileId(fileId, login).transact(xa)).
- through(streams.optionToEmpty)
-
- def getPublishedUploadByFileId(fileId: String): Stream[IO, (Upload, UploadInfo.File)] =
- Stream.eval(sqlGetPublishedUploadByFileId(fileId).transact(xa)).
- through(streams.optionToEmpty)
-
- def fetchData(range: RangeDef): Pipe[IO, UploadInfo.File, Byte] =
- _.map(_.meta.asBitpeace).through(binaryStore.fetchData(range))
-
- def fetchData2(range: RangeDef): Pipe[IO, UploadInfo.File, Byte] =
- _.map(_.meta.asBitpeace).through(binaryStore.fetchData2(range))
-
- def zipAll(chunkSize: Int)(implicit EC: ExecutionContext): Pipe[IO, UploadInfo, Byte] =
- _.flatMap(info => Stream.emits(info.files)).
- map(f => f.filename -> Stream.emit(f).covary[IO].through(fetchData2(RangeDef.all))).
- through(zip.zip(chunkSize))
-
-
- def cleanup(invalidSince: Instant): Stream[IO,Int] = {
- sqlListInvalidSince(invalidSince).transact(xa).flatMap { case (id, validUntil) =>
- logger.info(s"Cleanup invalid since $invalidSince removes upload $id (validUntil $validUntil")
- for {
- fileIds <- Stream.eval(sqlSelectFileIds(id).transact(xa))
- _ <- Stream.eval(sqlDeleteUpload(id, fileIds).transact(xa))
- } yield 1
- }
- }
-
- def createAlias(alias: Alias): Stream[IO, Unit] =
- Stream.eval(sqlInsertAlias(alias).run.map(_ => ()).transact(xa))
-
- def listAliases(login: String): Stream[IO, Alias] =
- sqlListAliases(login).transact(xa)
-
- def getAlias(id: String): Stream[IO, Alias] =
- Stream.eval(sqlGetAlias(id).transact(xa)).
- through(streams.optionToEmpty)
-
- def getActiveAlias(id: String): Stream[IO, Alias] =
- Stream.eval(sqlGetActiveAlias(id).transact(xa)).
- through(streams.optionToEmpty)
-
- def deleteAlias(id: String, login: String): Stream[IO, Int] =
- Stream.eval(sqlDeleteAlias(id, login).run.transact(xa))
-
- def updateAlias(alias: Alias, id: String): Stream[IO, Int] =
- Stream.eval(sqlUpdateAlias(alias, id).run.transact(xa))
-
-}
diff --git a/modules/store/src/main/scala/sharry/store/upload/UploadStore.scala b/modules/store/src/main/scala/sharry/store/upload/UploadStore.scala
deleted file mode 100644
index 21e1a9f0..00000000
--- a/modules/store/src/main/scala/sharry/store/upload/UploadStore.scala
+++ /dev/null
@@ -1,72 +0,0 @@
-package sharry.store.upload
-
-import java.time.Instant
-import fs2.{Pipe, Stream}
-import cats.effect.IO
-import bitpeace.{FileChunk, RangeDef, MimetypeHint}
-import scala.concurrent.ExecutionContext
-
-import sharry.common.mime._
-import sharry.common.sizes._
-import sharry.common.data._
-import sharry.store.data._
-
-trait UploadStore {
-
- def updateUpload(id: String, up: UploadUpdate): Stream[IO, Unit]
-
- def createUpload(up: Upload): Stream[IO, Unit]
-
- def createUploadFile(uploadId: String, fileId: String, filename: String, clientFileId: String): Stream[IO, UploadFile]
-
- def deleteUpload(id: String, login: String): Stream[IO, Int]
-
- def updateMime(fileId: String, mimeType: MimeType): Stream[IO, Int]
-
- def updateTimestamp(uploadId: String, fileId: String, time: Instant): Stream[IO, Int]
-
- def addChunk(uploadId: String, fc: FileChunk, chunksize: Int, totalChunks: Int, hint: MimetypeHint): Stream[IO, FileMeta]
-
- def chunkExists(uploadId: String, fileId: String, chunkNr: Int, chunkLength: Size): Stream[IO, Boolean]
-
- def listUploads(login: String): Stream[IO, Upload]
-
- def getUpload(id: String, login: String): Stream[IO, UploadInfo]
-
- def getPublishedUpload(id: String): Stream[IO, UploadInfo]
-
- def getUploadSize(id: String): Stream[IO, UploadSize]
-
- def publishUpload(id: String, login: String): Stream[IO, Either[String, String]]
-
- def unpublishUpload(id: String, login: String): Stream[IO,Either[String,Unit]]
-
- def getUploadByFileId(fileId: String, login: String): Stream[IO, (Upload, UploadInfo.File)]
-
- def getPublishedUploadByFileId(fileId: String): Stream[IO, (Upload, UploadInfo.File)]
-
- /** Fetch data using one connection per chunk. So connections are
- * closed immediately after reading a chunk. */
- def fetchData(range: RangeDef): Pipe[IO, UploadInfo.File, Byte]
-
- /** Fetch data using one connection for the whole stream. It is closed
- * once the stream terminates. */
- def fetchData2(range: RangeDef): Pipe[IO, UploadInfo.File, Byte]
-
- def zipAll(chunkSize: Int)(implicit EC: ExecutionContext): Pipe[IO, UploadInfo, Byte]
-
- def cleanup(invalidSince: Instant): Stream[IO,Int]
-
- def createAlias(alias: Alias): Stream[IO, Unit]
-
- def listAliases(login: String): Stream[IO, Alias]
-
- def getAlias(id: String): Stream[IO, Alias]
-
- /** Get an enabled alias whose referring account is enabled, too. */
- def getActiveAlias(id: String): Stream[IO, Alias]
-
- def deleteAlias(id: String, login: String): Stream[IO, Int]
-
- def updateAlias(alias: Alias, id: String): Stream[IO, Int]
-}
diff --git a/modules/store/src/test/resources/files/file.pdf b/modules/store/src/test/resources/files/file.pdf
deleted file mode 100644
index 616b4974..00000000
Binary files a/modules/store/src/test/resources/files/file.pdf and /dev/null differ
diff --git a/modules/store/src/test/scala/sharry/store/StoreFixtures.scala b/modules/store/src/test/scala/sharry/store/StoreFixtures.scala
deleted file mode 100644
index 60ef7189..00000000
--- a/modules/store/src/test/scala/sharry/store/StoreFixtures.scala
+++ /dev/null
@@ -1,46 +0,0 @@
-package sharry.store
-
-import java.io.InputStream
-import java.time.Instant
-import java.net.URL
-import fs2.Stream
-import cats.effect.IO
-import doobie._
-import sharry.common._
-import sharry.common.file._
-
-trait StoreFixtures {
- private def evo(db: String) = evolution(evolution.H2, db)
-
- def now = Instant.now
-
- def tx(db: String): Transactor[IO] =
- Transactor.fromDriverManager[IO](
- "org.h2.Driver", s"jdbc:h2:$db", "sa", ""
- )
-
- def newDb(xa: Transactor[IO], db: String): IO[Unit] = {
- (Stream.eval(evo(db).dropDatabase(xa)) ++ Stream.eval(evo(db).runChanges(xa))).compile.drain
- }
-
- def resource(name: String): IO[InputStream] =
- IO(Option(getClass.getResourceAsStream(name)).get)
-
- def resourceUrl(name: String): URL =
- Option(getClass.getResource(name)).get
-
- def newDb(code: Transactor[IO] => Any): Unit = {
- val name = rng.Gen.alphaNum(4, 12).generate()
- val db = file("target")/name
- val xa = tx(db.absolute.toString)
- try {
- newDb(xa, name).unsafeRunSync
- code(xa)
- } finally {
- db.parent.
- list.
- filter(_.name startsWith name).
- foreach(_.delete.unsafeRunSync)
- }
- }
-}
diff --git a/modules/store/src/test/scala/sharry/store/account/SqlAccountStoreTest.scala b/modules/store/src/test/scala/sharry/store/account/SqlAccountStoreTest.scala
deleted file mode 100644
index db070199..00000000
--- a/modules/store/src/test/scala/sharry/store/account/SqlAccountStoreTest.scala
+++ /dev/null
@@ -1,47 +0,0 @@
-package sharry.store.account
-
-import org.scalatest._
-import doobie.implicits._
-import sharry.store._
-import sharry.common.data._
-
-class SqlAccountStoreTest extends FlatSpec with Matchers with StoreFixtures {
-
- "create" should "create a new account" in newDb { xa =>
- val store = SqlAccountStore(xa)
- val acc = Account("test", Some("pass"))
- store.accountExists(acc.login).compile.last.unsafeRunSync.get should be (false)
- store.createAccount(acc).compile.drain.unsafeRunSync
- store.accountExists(acc.login).compile.last.unsafeRunSync.get should be (true)
- }
-
- it should "save all data of the account" in newDb { xa =>
- val store = SqlAccountStore(xa)
- val acc = Account.newInternal("test", "pass")
- store.createAccount(acc).compile.drain.unsafeRunSync
- store.getAccount(acc.login).compile.last.unsafeRunSync.get should be (acc)
- }
-
- "delete" should "remove an account" in newDb { xa =>
- val store = SqlAccountStore(xa)
- val acc = Account("test", Some("pass"))
- store.createAccount(acc).compile.drain.unsafeRunSync
-
- store.accountExists(acc.login).compile.last.unsafeRunSync.get should be (true)
- store.deleteAccount(acc.login).compile.last.unsafeRunSync.get should be (true)
- store.accountExists(acc.login).compile.last.unsafeRunSync.get should be (false)
-
- sql"""select count(*) from Upload""".query[Int].unique.transact(xa).unsafeRunSync should be (0)
- }
-
-
- "set enabled" should "set enabled flag" in newDb { xa =>
- val store = SqlAccountStore(xa)
- val acc = Account("test", Some("pass"), enabled = true)
- store.createAccount(acc).compile.drain.unsafeRunSync
-
- store.setAccountEnabled(acc.login, false).compile.last.unsafeRunSync.get should be (true)
- val accdb = store.getAccount(acc.login).compile.last.unsafeRunSync.get
- accdb should be (acc.copy(enabled = false))
- }
-}
diff --git a/modules/webapp/src/main/css/sharry.css b/modules/webapp/src/main/css/sharry.css
deleted file mode 100644
index 9f1a2eac..00000000
--- a/modules/webapp/src/main/css/sharry.css
+++ /dev/null
@@ -1,76 +0,0 @@
-body {
- background-color: #ffffff;
-}
-.ui.menu .item img.logo {
- margin-right: 1.5em;
-}
-.main.container {
- margin-top: 4em;
-}
-.wireframe {
- margin-top: 2em;
-}
-.ui.footer.segment {
- margin: 5em 0em 0em;
- padding: 5em 0em;
-}
-
-/* login page */
-.login-page-column {
- max-width: 450px;
-}
-body > .grid {
- height: 100%;
-}
-.login-page-image {
- margin-top: -100px;
-}
-
-/* share page */
-.sharry-dropzone {
- height: 10em;
- display: flex;
- align-items: center;
-}
-.sharry-dropzone > p.sharry-dropzone-text {
- margin-right: auto;
- margin-left: auto;
-}
-
-.sharry-footer {
- position: fixed;
- bottom: 0px;
- opacity: 0.5;
- font-size: 0.7em;
-}
-
-blockquote {
- padding-left: 0.8em;
- border-left: 4px solid #8b4513;
-}
-
-.sharry-md-edit {
- width: 100%;
- height: 30em;
- border: none;
- outline: none;
- border-right: 1px solid black;
- font-family: monospace;
- font-size: 1em;
-}
-
-.sharry-manual {
- font-size: 1.1em;
- line-height: normal;
- padding-bottom: 80px;
-}
-
-.sharry-manual pre {
- padding: 9px;
- border: 1px solid #ccc;
- border-radius: 4px;
-}
-
-.sharry-manual pre code, code {
- font-size: 0.8em;
-}
diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
new file mode 100644
index 00000000..2901da75
--- /dev/null
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -0,0 +1,572 @@
+module Api exposing
+ ( changePassword
+ , createAccount
+ , createAlias
+ , createEmptyShare
+ , createEmptyShareAlias
+ , deleteAlias
+ , deleteFile
+ , deleteShare
+ , fileOpenUrl
+ , fileSecUrl
+ , findShares
+ , getAlias
+ , getAliasTemplate
+ , getEmail
+ , getOpenShare
+ , getShare
+ , getShareTemplate
+ , listAccounts
+ , listAlias
+ , loadAccount
+ , login
+ , loginSession
+ , logout
+ , modifyAccount
+ , modifyAlias
+ , newInvite
+ , notifyAliasUpload
+ , oauthUrl
+ , publishShare
+ , refreshSession
+ , register
+ , sendMail
+ , setDescription
+ , setEmail
+ , setMaxViews
+ , setName
+ , setPassword
+ , setValidity
+ , unpublishShare
+ , versionInfo
+ )
+
+import Api.Model.AccountCreate exposing (AccountCreate)
+import Api.Model.AccountDetail exposing (AccountDetail)
+import Api.Model.AccountList exposing (AccountList)
+import Api.Model.AccountModify exposing (AccountModify)
+import Api.Model.AliasChange exposing (AliasChange)
+import Api.Model.AliasDetail exposing (AliasDetail)
+import Api.Model.AliasList exposing (AliasList)
+import Api.Model.AuthResult exposing (AuthResult)
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.EmailChange exposing (EmailChange)
+import Api.Model.EmailInfo exposing (EmailInfo)
+import Api.Model.GenInvite exposing (GenInvite)
+import Api.Model.IdResult exposing (IdResult)
+import Api.Model.InviteResult exposing (InviteResult)
+import Api.Model.MailTemplate exposing (MailTemplate)
+import Api.Model.OAuthItem exposing (OAuthItem)
+import Api.Model.PasswordChange exposing (PasswordChange)
+import Api.Model.PublishData exposing (PublishData)
+import Api.Model.Registration exposing (Registration)
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Api.Model.ShareList exposing (ShareList)
+import Api.Model.ShareProperties exposing (ShareProperties)
+import Api.Model.SimpleMail exposing (SimpleMail)
+import Api.Model.SingleNumber exposing (SingleNumber)
+import Api.Model.SingleString exposing (SingleString)
+import Api.Model.UserPass exposing (UserPass)
+import Api.Model.VersionInfo exposing (VersionInfo)
+import Data.Flags exposing (Flags)
+import Http
+import Task
+import Url
+import Util.Http as Http2
+
+
+getAliasTemplate :
+ Flags
+ -> String
+ -> (Result Http.Error MailTemplate -> msg)
+ -> Cmd msg
+getAliasTemplate flags aliasId receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/mail/template/alias/" ++ aliasId
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.MailTemplate.decoder
+ }
+
+
+getShareTemplate :
+ Flags
+ -> String
+ -> (Result Http.Error MailTemplate -> msg)
+ -> Cmd msg
+getShareTemplate flags shareId receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/mail/template/share/" ++ shareId
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.MailTemplate.decoder
+ }
+
+
+sendMail :
+ Flags
+ -> SimpleMail
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+sendMail flags mail receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/mail/send"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SimpleMail.encode mail)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+notifyAliasUpload :
+ Flags
+ -> String
+ -> String
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+notifyAliasUpload flags aliasId shareId receive =
+ Http2.aliasPost
+ { url = flags.config.baseUrl ++ "/api/v2/alias/mail/notify/" ++ shareId
+ , aliasId = aliasId
+ , body = Http.emptyBody
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+fileSecUrl : Flags -> String -> String -> String
+fileSecUrl flags share fid =
+ flags.config.baseUrl ++ "/api/v2/sec/share/" ++ share ++ "/file/" ++ fid
+
+
+fileOpenUrl : Flags -> String -> String -> String
+fileOpenUrl flags share fid =
+ flags.config.baseUrl ++ "/api/v2/open/share/" ++ share ++ "/file/" ++ fid
+
+
+setPassword :
+ Flags
+ -> String
+ -> Maybe String
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+setPassword flags id value receive =
+ case value of
+ Just name ->
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/password"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SingleString.encode (SingleString name))
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+ Nothing ->
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/password"
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+setMaxViews :
+ Flags
+ -> String
+ -> Int
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+setMaxViews flags id value receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/maxviews"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SingleNumber.encode (SingleNumber value))
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+setValidity :
+ Flags
+ -> String
+ -> Int
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+setValidity flags id value receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/validity"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SingleNumber.encode (SingleNumber value))
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+setName :
+ Flags
+ -> String
+ -> Maybe String
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+setName flags id value receive =
+ case value of
+ Just name ->
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/name"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SingleString.encode (SingleString name))
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+ Nothing ->
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/name"
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+setDescription :
+ Flags
+ -> String
+ -> String
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+setDescription flags id value receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/description"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SingleString.encode (SingleString value))
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+deleteShare : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+deleteShare flags id receive =
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+deleteFile :
+ Flags
+ -> String
+ -> String
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+deleteFile flags share file receive =
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ share ++ "/file/" ++ file
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+publishShare : Flags -> String -> PublishData -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+publishShare flags id pd receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/publish"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.PublishData.encode pd)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+unpublishShare : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+unpublishShare flags id receive =
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id ++ "/publish"
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+getShare : Flags -> String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg
+getShare flags id receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/" ++ id
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.ShareDetail.decoder
+ }
+
+
+getOpenShare : Flags -> String -> Maybe String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg
+getOpenShare flags id pass receive =
+ Http2.getH
+ { url = flags.config.baseUrl ++ "/api/v2/open/share/" ++ id
+ , headers =
+ case pass of
+ Just pw ->
+ [ Http.header "Sharry-Password" pw ]
+
+ Nothing ->
+ []
+ , expect = Http.expectJson receive Api.Model.ShareDetail.decoder
+ }
+
+
+findShares : Flags -> String -> (Result Http.Error ShareList -> msg) -> Cmd msg
+findShares flags query receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/share/search?q=" ++ Url.percentEncode query
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.ShareList.decoder
+ }
+
+
+createEmptyShareAlias :
+ Flags
+ -> String
+ -> ShareProperties
+ -> (Result Http.Error IdResult -> msg)
+ -> Cmd msg
+createEmptyShareAlias flags aliasId props receive =
+ Http2.aliasPost
+ { url = flags.config.baseUrl ++ "/api/v2/alias/upload/new"
+ , aliasId = aliasId
+ , body = Http.jsonBody (Api.Model.ShareProperties.encode props)
+ , expect = Http.expectJson receive Api.Model.IdResult.decoder
+ }
+
+
+createEmptyShare : Flags -> ShareProperties -> (Result Http.Error IdResult -> msg) -> Cmd msg
+createEmptyShare flags props receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/upload/new"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.ShareProperties.encode props)
+ , expect = Http.expectJson receive Api.Model.IdResult.decoder
+ }
+
+
+deleteAlias : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+deleteAlias flags id receive =
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v2/sec/alias/" ++ id
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+createAlias : Flags -> AliasChange -> (Result Http.Error IdResult -> msg) -> Cmd msg
+createAlias flags ac receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/alias"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.AliasChange.encode ac)
+ , expect = Http.expectJson receive Api.Model.IdResult.decoder
+ }
+
+
+modifyAlias : Flags -> String -> AliasChange -> (Result Http.Error IdResult -> msg) -> Cmd msg
+modifyAlias flags id ac receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/alias/" ++ id
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.AliasChange.encode ac)
+ , expect = Http.expectJson receive Api.Model.IdResult.decoder
+ }
+
+
+getAlias : Flags -> String -> (Result Http.Error AliasDetail -> msg) -> Cmd msg
+getAlias flags id receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/alias/" ++ id
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.AliasDetail.decoder
+ }
+
+
+listAlias : Flags -> String -> (Result Http.Error AliasList -> msg) -> Cmd msg
+listAlias flags q receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/alias?q=" ++ Url.percentEncode q
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.AliasList.decoder
+ }
+
+
+changePassword :
+ Flags
+ -> PasswordChange
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+changePassword flags pwc receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/settings/password"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.PasswordChange.encode pwc)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+getEmail : Flags -> (Result Http.Error EmailInfo -> msg) -> Cmd msg
+getEmail flags receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/sec/settings/email"
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.EmailInfo.decoder
+ }
+
+
+setEmail : Flags -> Maybe String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+setEmail flags memail receive =
+ let
+ url =
+ flags.config.baseUrl ++ "/api/v2/sec/settings/email"
+
+ acc =
+ getAccount flags
+
+ exp =
+ Http.expectJson receive Api.Model.BasicResult.decoder
+ in
+ case memail of
+ Just email ->
+ Http2.authPost
+ { url = url
+ , account = acc
+ , body = Http.jsonBody (Api.Model.EmailChange.encode (EmailChange email))
+ , expect = exp
+ }
+
+ Nothing ->
+ Http2.authDelete
+ { url = url
+ , account = acc
+ , expect = exp
+ }
+
+
+modifyAccount :
+ Flags
+ -> String
+ -> AccountModify
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+modifyAccount flags id input receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/admin/account/" ++ id
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.AccountModify.encode input)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+createAccount : Flags -> AccountCreate -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+createAccount flags input receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/admin/account"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.AccountCreate.encode input)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+loadAccount : Flags -> String -> (Result Http.Error AccountDetail -> msg) -> Cmd msg
+loadAccount flags id receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/admin/account/" ++ id
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.AccountDetail.decoder
+ }
+
+
+listAccounts : Flags -> String -> (Result Http.Error AccountList -> msg) -> Cmd msg
+listAccounts flags q receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v2/admin/account?q=" ++ Url.percentEncode q
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.AccountList.decoder
+ }
+
+
+register : Flags -> Registration -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+register flags reg receive =
+ Http.post
+ { url = flags.config.baseUrl ++ "/api/v2/open/signup/register"
+ , body = Http.jsonBody (Api.Model.Registration.encode reg)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+newInvite : Flags -> GenInvite -> (Result Http.Error InviteResult -> msg) -> Cmd msg
+newInvite flags req receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/admin/signup/newinvite"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.GenInvite.encode req)
+ , expect = Http.expectJson receive Api.Model.InviteResult.decoder
+ }
+
+
+login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg
+login flags up receive =
+ Http.post
+ { url = flags.config.baseUrl ++ "/api/v2/open/auth/login"
+ , body = Http.jsonBody (Api.Model.UserPass.encode up)
+ , expect = Http.expectJson receive Api.Model.AuthResult.decoder
+ }
+
+
+logout : Flags -> (Result Http.Error () -> msg) -> Cmd msg
+logout flags receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/auth/logout"
+ , account = getAccount flags
+ , body = Http.emptyBody
+ , expect = Http.expectWhatever receive
+ }
+
+
+loginSession : Flags -> (Result Http.Error AuthResult -> msg) -> Cmd msg
+loginSession flags receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v2/sec/auth/session"
+ , account = getAccount flags
+ , body = Http.emptyBody
+ , expect = Http.expectJson receive Api.Model.AuthResult.decoder
+ }
+
+
+versionInfo : Flags -> (Result Http.Error VersionInfo -> msg) -> Cmd msg
+versionInfo flags receive =
+ Http.get
+ { url = flags.config.baseUrl ++ "/api/v2/open/info/version"
+ , expect = Http.expectJson receive Api.Model.VersionInfo.decoder
+ }
+
+
+refreshSession : Flags -> (Result Http.Error AuthResult -> msg) -> Cmd msg
+refreshSession flags receive =
+ case flags.account of
+ Just acc ->
+ if acc.success && acc.validMs > 30000 then
+ let
+ delay =
+ acc.validMs - 30000 |> toFloat
+ in
+ Http2.executeIn delay receive (refreshSessionTask flags)
+
+ else
+ Cmd.none
+
+ Nothing ->
+ Cmd.none
+
+
+refreshSessionTask : Flags -> Task.Task Http.Error AuthResult
+refreshSessionTask flags =
+ Http2.authTask
+ { url = flags.config.baseUrl ++ "/api/v2/sec/auth/session"
+ , method = "POST"
+ , headers = []
+ , account = getAccount flags
+ , body = Http.emptyBody
+ , resolver = Http2.jsonResolver Api.Model.AuthResult.decoder
+ , timeout = Nothing
+ }
+
+
+getAccount : Flags -> AuthResult
+getAccount flags =
+ Maybe.withDefault Api.Model.AuthResult.empty flags.account
+
+
+oauthUrl : Flags -> OAuthItem -> String
+oauthUrl flags item =
+ flags.config.baseUrl ++ "/api/v2/open/auth/oauth/" ++ item.id
diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm
new file mode 100644
index 00000000..71fa3851
--- /dev/null
+++ b/modules/webapp/src/main/elm/App/Data.elm
@@ -0,0 +1,139 @@
+module App.Data exposing (..)
+
+import Api.Model.AuthResult exposing (AuthResult)
+import Api.Model.VersionInfo exposing (VersionInfo)
+import Browser exposing (UrlRequest)
+import Browser.Navigation exposing (Key)
+import Data.Flags exposing (Flags)
+import Data.UploadState exposing (UploadState)
+import Http
+import Page exposing (Page(..))
+import Page.Account.Data
+import Page.Alias.Data
+import Page.Detail.Data
+import Page.Home.Data
+import Page.Info.Data
+import Page.Login.Data
+import Page.NewInvite.Data
+import Page.OpenDetail.Data
+import Page.OpenShare.Data
+import Page.Register.Data
+import Page.Settings.Data
+import Page.Share.Data
+import Page.Upload.Data
+import Url exposing (Url)
+import Util.Maybe
+
+
+type alias Model =
+ { flags : Flags
+ , key : Key
+ , page : Page
+ , navMenuOpen : Bool
+ , version : VersionInfo
+ , homeModel : Page.Home.Data.Model
+ , loginModel : Page.Login.Data.Model
+ , registerModel : Page.Register.Data.Model
+ , newInviteModel : Page.NewInvite.Data.Model
+ , infoModel : Page.Info.Data.Model
+ , accountModel : Page.Account.Data.Model
+ , uploadModel : Page.Upload.Data.Model
+ , aliasModel : Page.Alias.Data.Model
+ , shareModel : Page.Share.Data.Model
+ , openShareModel : Page.OpenShare.Data.Model
+ , settingsModel : Page.Settings.Data.Model
+ , detailModel : Page.Detail.Data.Model
+ , openDetailModel : Page.OpenDetail.Data.Model
+ }
+
+
+init : Key -> Url -> Flags -> Model
+init key url flags =
+ let
+ page =
+ Page.fromUrl url |> Maybe.withDefault HomePage
+ in
+ { flags = flags
+ , key = key
+ , page = page
+ , navMenuOpen = False
+ , version = Api.Model.VersionInfo.empty
+ , homeModel = Page.Home.Data.emptyModel
+ , loginModel = Page.Login.Data.empty
+ , registerModel = Page.Register.Data.emptyModel
+ , newInviteModel = Page.NewInvite.Data.emptyModel
+ , infoModel = Page.Info.Data.emptyModel
+ , accountModel = Page.Account.Data.emptyModel
+ , uploadModel = Page.Upload.Data.emptyModel
+ , aliasModel = Page.Alias.Data.emptyModel flags
+ , shareModel = Page.Share.Data.emptyModel flags
+ , openShareModel = Page.OpenShare.Data.emptyModel
+ , settingsModel = Page.Settings.Data.emptyModel
+ , detailModel = Page.Detail.Data.emptyModel
+ , openDetailModel = Page.OpenDetail.Data.emptyModel
+ }
+
+
+type Msg
+ = NavRequest UrlRequest
+ | NavChange Url
+ | VersionResp (Result Http.Error VersionInfo)
+ | HomeMsg Page.Home.Data.Msg
+ | LoginMsg Page.Login.Data.Msg
+ | RegisterMsg Page.Register.Data.Msg
+ | NewInviteMsg Page.NewInvite.Data.Msg
+ | InfoMsg Page.Info.Data.Msg
+ | UploadMsg Page.Upload.Data.Msg
+ | AliasMsg Page.Alias.Data.Msg
+ | ShareMsg Page.Share.Data.Msg
+ | OpenShareMsg Page.OpenShare.Data.Msg
+ | AccountMsg Page.Account.Data.Msg
+ | SettingsMsg Page.Settings.Data.Msg
+ | DetailMsg Page.Detail.Data.Msg
+ | OpenDetailMsg Page.OpenDetail.Data.Msg
+ | Logout
+ | LogoutResp (Result Http.Error ())
+ | SessionCheckResp (Result Http.Error AuthResult)
+ | SetPage Page
+ | ToggleNavMenu
+ | UploadStateMsg (Result String UploadState)
+ | UploadStoppedMsg (Maybe String)
+
+
+isSignedIn : Flags -> Bool
+isSignedIn flags =
+ flags.account
+ |> Maybe.map .success
+ |> Maybe.withDefault False
+
+
+isAdmin : Flags -> Bool
+isAdmin flags =
+ flags.account
+ |> Util.Maybe.filter .success
+ |> Maybe.map .admin
+ |> Maybe.withDefault False
+
+
+checkPage : Flags -> Page -> Page
+checkPage flags page =
+ if Page.isAdmin page && not (isAdmin flags) then
+ InfoPage 0
+
+ else if Page.isSecured page && isSignedIn flags then
+ page
+
+ else if Page.isOpen page then
+ page
+
+ else
+ Page.loginPage page
+
+
+defaultPage : Flags -> Page
+defaultPage flags =
+ if isSignedIn flags then
+ HomePage
+
+ else
+ LoginPage ( Nothing, False )
diff --git a/modules/webapp/src/main/elm/App/Model.elm b/modules/webapp/src/main/elm/App/Model.elm
deleted file mode 100644
index 748334bc..00000000
--- a/modules/webapp/src/main/elm/App/Model.elm
+++ /dev/null
@@ -1,122 +0,0 @@
-module App.Model exposing (..)
-
-import Resumable
-import Data exposing (Alias, Account, RemoteConfig, UploadInfo, Upload, UploadId(..), accountDecoder)
-import Http
-import Time exposing (Time)
-import Pages.Login.Model as LoginModel
-import Pages.AccountEdit.Model as AccountEditModel
-import Pages.Upload.Model as UploadModel
-import PageLocation as PL
-
-import Widgets.DownloadView as DownloadView
-import Pages.Login.Update as LoginUpdate
-import Pages.Login.Data as LoginData
-import Pages.AccountEdit.Model as AccountEditModel
-import Pages.AccountEdit.Update as AccountEditUpdate
-import Pages.Upload.Model as UploadModel
-import Pages.Download.Model as DownloadModel
-import Pages.UploadList.Model as UploadListModel
-import Pages.Profile.Model as ProfileModel
-import Pages.AliasList.Model as AliasListModel
-import Pages.AliasUpload.Model as AliasUploadModel
-import Pages.Manual.Model as ManualModel
-import Pages.Error.Model as ErrorModel
-import Time exposing (Time)
-import Navigation
-
-type Msg
- = SetPage (Cmd Msg)
- | DeferredTick Time
- | LoginMsg LoginData.Msg
- | AccountEditMsg AccountEditModel.Msg
- | UploadMsg UploadModel.Msg
- | Logout
- | LoginRefresh Time
- | LoginRefreshDone (Result Http.Error Account)
- | ResumableMsg Resumable.Handle Resumable.Msg
- | RandomString String
- | UrlChange Navigation.Location
- | UploadData (Result Http.Error UploadInfo)
- | LoadUploadsResult (Result Http.Error (List Upload))
- | UploadListMsg UploadListModel.Msg
- | DownloadMsg DownloadModel.Msg
- | ProfileMsg ProfileModel.Msg
- | AliasListMsg AliasListModel.Msg
- | LoadAliasesResult (Result Http.Error (List Alias))
- | AliasUploadMsg AliasUploadModel.Msg
- | LoadAliasResult (Result Http.Error Alias)
- | ManualPageContent (Result Http.Error String)
- | ManualMsg ManualModel.Msg
-
-type Page
- = LoginPage
- | IndexPage
- | NewSharePage
- | AccountEditPage
- | DownloadPage
- | UploadListPage
- | ProfilePage
- | AliasListPage
- | AliasUploadPage
- | TimeoutPage
- | ManualPage
- | ErrorPage
-
-type alias Model =
- { page: Page
- , location: Navigation.Location
- , login: LoginModel.Model
- , accountEdit: AccountEditModel.Model
- , upload: UploadModel.Model
- , download: DownloadModel.Model
- , uploadList: UploadListModel.Model
- , profile: Maybe ProfileModel.Model
- , aliases: AliasListModel.Model
- , aliasUpload: AliasUploadModel.Model
- , manualModel: ManualModel.Model
- , errorModel: ErrorModel.Model
- , user: Maybe Account
- , serverConfig: RemoteConfig
- , deferred: List (Cmd Msg)
- }
-
-isPublicPage: Model -> Bool
-isPublicPage model =
- case model.page of
- LoginPage -> True
- AliasUploadPage -> True
- ManualPage -> True
- ErrorPage -> True
- DownloadPage ->
- case PL.downloadPageId model.location.hash of
- Just (Uid _) -> False
- _ -> True
- _ -> False
-
-initModel: RemoteConfig -> Maybe Account -> Navigation.Location -> Model
-initModel cfg acc location =
- { page = Maybe.withDefault LoginPage (Maybe.map (\x -> IndexPage) acc)
- , location = location
- , login = LoginModel.fromUrls cfg.urls cfg.welcomeMessage
- , accountEdit = AccountEditModel.emptyModel cfg.urls
- , upload = UploadModel.emptyModel cfg
- , download = DownloadModel.emptyModel
- , uploadList = UploadListModel.emptyModel cfg.urls
- , profile = Maybe.map (ProfileModel.makeModel cfg.urls) acc
- , aliases = AliasListModel.emptyModel cfg
- , aliasUpload = AliasUploadModel.emptyModel cfg acc
- , manualModel = ManualModel.makeModel ""
- , errorModel = ErrorModel.emptyModel
- , user = acc
- , serverConfig = cfg
- , deferred = []
- }
-
-clearModel: Model -> Model
-clearModel model =
- initModel model.serverConfig model.user model.location
-
-isAuthenticated: Model -> Bool
-isAuthenticated model =
- Data.isPresent model.user
diff --git a/modules/webapp/src/main/elm/App/Pages.elm b/modules/webapp/src/main/elm/App/Pages.elm
deleted file mode 100644
index 72b0d095..00000000
--- a/modules/webapp/src/main/elm/App/Pages.elm
+++ /dev/null
@@ -1,175 +0,0 @@
-module App.Pages exposing (withLocation)
-
-import Http
-import Navigation
-import Json.Decode as Decode
-
-import Ports
-import Data exposing (UploadId(..), RemoteConfig)
-import PageLocation as PL
-import App.Model exposing (..)
-import Pages.Profile.Model as ProfileModel
-
-pageExtracts: List (Model -> Maybe (Model, Cmd Msg))
-pageExtracts =
- [
- findNewSharePage
- ,findIndexPage
- ,findUploadsPage
- ,findDownloadPage
- ,findLoginPage
- ,findAccountEditPage
- ,findProfilePage
- ,findAliasListPage
- ,findAliasUploadPage
- ,findTimeoutPage
- ,findManualPage
- ,findErrorPage
- ]
-
-withLocation: Model -> (Model, Cmd Msg)
-withLocation model =
- let
- default = (model, Cmd.none)
- all = List.map (\f -> f model) pageExtracts
- result = List.foldl (Data.maybeOrElse) Nothing all
- in
- Maybe.withDefault default result
-
-
-
-httpGetUpload: RemoteConfig -> UploadId -> Cmd Msg
-httpGetUpload cfg id =
- let
- url = case id of
- Uid uid ->
- cfg.urls.uploads ++ "/" ++ uid
- Pid pid ->
- cfg.urls.uploadPublish ++ "/" ++ pid
- in
- Http.get url Data.decodeUploadInfo
- |> Http.send UploadData
-
-httpGetUploads: RemoteConfig -> Cmd Msg
-httpGetUploads cfg =
- Http.get cfg.urls.uploads (Decode.list Data.decodeUpload)
- |> Http.send LoadUploadsResult
-
-httpGetAliases: RemoteConfig -> Cmd Msg
-httpGetAliases cfg =
- Http.get cfg.urls.aliases (Decode.list Data.decodeAlias)
- |> Http.send LoadAliasesResult
-
-httpGetAlias: RemoteConfig -> String -> Cmd Msg
-httpGetAlias cfg id =
- Http.get (cfg.urls.aliases ++"/"++ id) Data.decodeAlias
- |> Http.send LoadAliasResult
-
-httpGetManualPage: RemoteConfig -> String -> Cmd Msg
-httpGetManualPage cfg page =
- Http.getString (cfg.urls.manual ++ "/" ++ page ++ "?mdLinkPrefix=%23manual/")
- |> Http.send ManualPageContent
-
-findIndexPage: Model -> Maybe (Model, Cmd Msg)
-findIndexPage model =
- if model.location.hash == PL.indexPageHref || model.location.hash == "" then
- {model|page = IndexPage} ! [] |> Just
- else
- Nothing
-
-findLoginPage: Model -> Maybe (Model, Cmd Msg)
-findLoginPage model =
- if String.startsWith PL.loginPageHref model.location.hash then
- let
- m = clearModel model
- in
- {m | page = LoginPage} ! [] |> Just
- else
- Nothing
-
-
-findUploadsPage: Model -> Maybe (Model, Cmd Msg)
-findUploadsPage model =
- if model.location.hash == PL.uploadsPageHref then
- {model | page = UploadListPage} ! [httpGetUploads model.serverConfig] |> Just
- else
- Nothing
-
-
-findDownloadPage: Model -> Maybe (Model, Cmd Msg)
-findDownloadPage model =
- let
- location = model.location
- mCmd = Maybe.map (httpGetUpload model.serverConfig) (PL.downloadPageId location.hash)
- f cmd = {model | page = DownloadPage} ! [cmd]
- in
- Maybe.map f mCmd
-
-
-findAccountEditPage: Model -> Maybe (Model, Cmd Msg)
-findAccountEditPage model =
- if model.location.hash == PL.accountEditPageHref then
- {model | page = AccountEditPage} ! [] |> Just
- else
- Nothing
-
-findNewSharePage: Model -> Maybe (Model, Cmd Msg)
-findNewSharePage model =
- if model.location.hash == PL.newSharePageHref then
- {model | page = NewSharePage} ! [] |> Just
- else
- Nothing
-
-findProfilePage: Model -> Maybe (Model, Cmd Msg)
-findProfilePage model =
- if model.location.hash == PL.profilePageHref then
- let
- default = model.user |> Maybe.map (ProfileModel.makeModel model.serverConfig.urls)
- pm = Data.maybeOrElse model.profile default
- in
- {model | page = ProfilePage, profile = pm} ! [] |> Just
- else
- Nothing
-
-findAliasListPage: Model -> Maybe (Model, Cmd Msg)
-findAliasListPage model =
- if model.location.hash == PL.aliasListPageHref then
- {model | page = AliasListPage} ! [httpGetAliases model.serverConfig] |> Just
- else
- Nothing
-
-findAliasUploadPage: Model -> Maybe (Model, Cmd Msg)
-findAliasUploadPage model =
- case PL.aliasUploadPageId model.location.hash of
- Just id ->
- {model | page = AliasUploadPage} ! [httpGetAlias model.serverConfig id] |> Just
- Nothing ->
- Nothing
-
-findTimeoutPage: Model -> Maybe (Model, Cmd Msg)
-findTimeoutPage model =
- if model.location.hash == PL.timeoutPageHref then
- let
- cmd = model.user
- |> Maybe.map Ports.removeAccount
- |> Maybe.withDefault Cmd.none
- model_ = initModel model.serverConfig Nothing model.location
- in
- {model_ | page = TimeoutPage} ! [cmd] |> Just
- else
- Nothing
-
-findManualPage: Model -> Maybe (Model, Cmd Msg)
-findManualPage model =
- case PL.manualPageName model.location.hash of
- Just name ->
- {model | page = ManualPage} ! [httpGetManualPage model.serverConfig name] |> Just
- Nothing ->
- Nothing
-
-findErrorPage: Model -> Maybe (Model, Cmd Msg)
-findErrorPage model =
- if PL.errorPageHref == model.location.hash then
- {model | page = ErrorPage} ! [] |> Just
- else
- Nothing
diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm
index ad0d752f..9c86c958 100644
--- a/modules/webapp/src/main/elm/App/Update.elm
+++ b/modules/webapp/src/main/elm/App/Update.elm
@@ -1,246 +1,395 @@
-module App.Update exposing (..)
-
-import Http
-
-import Resumable
-import Data exposing (accountDecoder)
-import App.Model exposing (..)
-import Ports exposing (..)
-import PageLocation as PL
-
-import App.Pages as Pages
-import Pages.Login.Update as LoginUpdate
-import Pages.AccountEdit.Update as AccountEditUpdate
-import Pages.Upload.Model as UploadModel
-import Pages.Upload.Update as UploadUpdate
-import Pages.Download.Model as DownloadModel
-import Pages.Download.Update as DownloadUpdate
-import Pages.UploadList.Model as UploadListModel
-import Pages.UploadList.Update as UploadListUpdate
-import Pages.Profile.Model as ProfileModel
-import Pages.Profile.Update as ProfileUpdate
-import Pages.AliasList.Model as AliasListModel
-import Pages.AliasList.Update as AliasListUpdate
-import Pages.AliasUpload.Model as AliasUploadModel
-import Pages.AliasUpload.Update as AliasUploadUpdate
-import Pages.Manual.Model as ManualModel
-import Pages.Error.Model as ErrorModel
-
-update: Msg -> Model -> (Model, Cmd Msg)
+module App.Update exposing (initPage, update)
+
+import Api
+import App.Data exposing (..)
+import Browser exposing (UrlRequest(..))
+import Browser.Navigation as Nav
+import Data.Flags
+import Page exposing (Page(..))
+import Page.Account.Data
+import Page.Account.Update
+import Page.Alias.Data
+import Page.Alias.Update
+import Page.Detail.Data
+import Page.Detail.Update
+import Page.Home.Data
+import Page.Home.Update
+import Page.Info.Data
+import Page.Info.Update
+import Page.Login.Data
+import Page.Login.Update
+import Page.NewInvite.Data
+import Page.NewInvite.Update
+import Page.OpenDetail.Data
+import Page.OpenDetail.Update
+import Page.OpenShare.Data
+import Page.OpenShare.Update
+import Page.Register.Data
+import Page.Register.Update
+import Page.Settings.Data
+import Page.Settings.Update
+import Page.Share.Data
+import Page.Share.Update
+import Page.Upload.Data
+import Page.Upload.Update
+import Ports
+import Url
+import Util.Update
+
+
+update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
- UrlChange loc ->
- let
- (model_, cmd) = Pages.withLocation {model | location = loc}
- in
- case model.user of
- Just _ ->
- -- unfortunately, the upload pages still needs some
- -- more: setup of resumable.js event handlers. This is
- -- tricky because the command must execute _after_ the
- -- dom elements are present.
- --
- -- putting the below in `findNewSharePage` function
- -- resulted in an uncaugh-type runtime error from
- -- within elm, that I couldn't resolve.
- --
- -- So now these special cases are handled here. In
- -- order to route the response back to the correct
- -- part of the model, we add a page attribute.
- if model_.page == NewSharePage then
- let
- cfg = Resumable.makeStandardConfig model_.serverConfig
- msgs = List.map UploadMsg (UploadModel.resumableMsg (Resumable.Initialize {cfg | page = "newshare"}))
- (model__, cmd_) = List.foldl combineResults (model_, Cmd.none) msgs
- in
- model__ ! [cmd, cmd_]
- else
- (model_, cmd)
-
- Nothing ->
- if isPublicPage model_ then
- (model_, cmd)
- else
- model_ ! [PL.loginPage loc]
-
- SetPage cmd ->
- model ! [cmd]
-
- DeferredTick time ->
- {model|deferred = []} ! model.deferred
-
- LoginMsg msg ->
- let
- (val, cmd, user) = LoginUpdate.update msg model.login
- redirect = PL.loginPageRedirect model.location
- in
- case user of
- Just n ->
- {model | login = val, page = IndexPage, user = Just n } ! [setAccount n, redirect]
- _ ->
- ({model | login = val}, Cmd.map LoginMsg cmd)
+ HomeMsg lm ->
+ updateHome lm model
- AccountEditMsg msg ->
- let
- (val, cmd) = AccountEditUpdate.update msg model.accountEdit
- in
- ({model | accountEdit = val}, Cmd.map AccountEditMsg cmd)
+ LoginMsg lm ->
+ updateLogin lm model
- UploadMsg msg ->
- let
- (val, cmd, cmdd) = UploadUpdate.update msg model.upload
- in
- ({model| upload = val, deferred = (Cmd.map UploadMsg cmdd) :: model.deferred}, Cmd.map UploadMsg cmd)
+ RegisterMsg lm ->
+ updateRegister lm model
- DownloadMsg msg ->
- let
- (val, cmd) = DownloadUpdate.update msg model.download
- in
- {model | download = val} ! [Cmd.map DownloadMsg cmd]
+ NewInviteMsg lm ->
+ updateNewInvite lm model
- Logout ->
- case model.user of
- Just acc ->
- initModel model.serverConfig Nothing model.location ! [removeAccount acc, PL.indexPage]
- Nothing ->
- (model, Cmd.none)
-
- LoginRefresh time ->
- case model.user of
- Just acc ->
- (model, refreshCookie model)
- Nothing ->
- (model, Cmd.none)
-
- LoginRefreshDone acc ->
- (model, Cmd.none)
-
- RandomString s ->
- update (UploadMsg (UploadModel.randomPasswordMsg s)) model
-
- ResumableMsg page rmsg ->
- -- we have to decide here which pages receive the resumable events
- let
- (model_, cmd_) =
- if page == "newshare" then
- let
- msgs = List.map UploadMsg (UploadModel.resumableMsg rmsg)
- in
- List.foldl combineResults (model, Cmd.none) msgs
- else if page == "aliasupload" then
- let
- msgs = List.map AliasUploadMsg (AliasUploadModel.makeResumableMsg rmsg)
- in
- List.foldl combineResults (model, Cmd.none) msgs
- else
- model ! []
- in
- model_ ! [cmd_]
+ InfoMsg lm ->
+ updateInfo lm model
- UploadData (Ok data) ->
- let
- dlmodel = DownloadModel.makeModel data model.serverConfig model.user
- defcmd = (Ports.initAccordionAndTabs ()) :: (Ports.initEmbeds ()) :: model.deferred
- in
- {model | download = dlmodel, page = DownloadPage, deferred = defcmd} ! []
+ AccountMsg lm ->
+ updateAccount lm model
- UploadData (Err error) ->
- let
- msg = Debug.log "Error getting published download " (Data.errorMessage error)
- in
- {model| page = ErrorPage, errorModel = ErrorModel.initModel msg} ! [PL.timeoutCmd error]
+ AliasMsg lm ->
+ updateAlias lm model
- LoadUploadsResult (Ok uploads) ->
- {model | uploadList = UploadListModel.makeModel model.serverConfig.urls uploads, page = UploadListPage} ! []
+ UploadMsg lm ->
+ updateUpload lm model
- LoadUploadsResult (Err error) ->
- let
- msg = Debug.log "Error getting list of uploads " (Data.errorMessage error)
- in
- {model| page = ErrorPage, errorModel = ErrorModel.initModel msg} ! [PL.timeoutCmd error]
+ ShareMsg lm ->
+ updateShare lm model
- LoadAliasesResult (Ok aliases) ->
- {model | aliases = AliasListModel.makeModel model.serverConfig aliases, page = AliasListPage} ! []
+ OpenShareMsg lm ->
+ updateOpenShare lm model
- LoadAliasesResult (Err error) ->
- let
- msg = Debug.log "Error getting list of aliases " (Data.errorMessage error)
- in
- {model| page = ErrorPage, errorModel = ErrorModel.initModel msg} ! [PL.timeoutCmd error]
+ SettingsMsg lm ->
+ updateSettings lm model
- LoadAliasResult (Ok alia) ->
- let
- cfg = Resumable.makeAliasConfig model.serverConfig alia.id
- msgs = List.map AliasUploadMsg (AliasUploadModel.makeResumableMsg (Resumable.Initialize {cfg | page = "aliasupload"}))
- (model_, cmd_) = List.foldl combineResults (model, Cmd.none) msgs
- in
- {model_ | aliasUpload = AliasUploadModel.makeModel model.serverConfig model.user alia, page = AliasUploadPage} ! [cmd_]
+ DetailMsg lm ->
+ updateDetail lm model
- LoadAliasResult (Err error) ->
- let
- msg = Debug.log "Error getting alias " (Data.errorMessage error)
- in
- --empty/invalid alias is handled at the page
- if Data.isNotFound error then
- model ! [PL.timeoutCmd error]
- else
- {model| page = ErrorPage, errorModel = ErrorModel.initModel msg} ! [PL.timeoutCmd error]
+ OpenDetailMsg lm ->
+ updateOpenDetail lm model
+
+ SetPage p ->
+ ( { model | page = p }
+ , Cmd.none
+ )
+
+ ToggleNavMenu ->
+ ( { model | navMenuOpen = not model.navMenuOpen }
+ , Cmd.none
+ )
+
+ UploadStateMsg (Ok lmsg) ->
+ Util.Update.andThen1
+ [ updateShare (Page.Share.Data.Uploading lmsg)
+ , updateOpenShare (Page.OpenShare.Data.Uploading lmsg)
+ , updateDetail (Page.Detail.Data.Uploading lmsg)
+ ]
+ model
- UploadListMsg msg ->
+ UploadStoppedMsg err ->
+ Util.Update.andThen1
+ [ updateShare (Page.Share.Data.UploadStopped err)
+ , updateOpenShare (Page.OpenShare.Data.UploadStopped err)
+ , updateDetail (Page.Detail.Data.UploadStopped err)
+ ]
+ model
+
+ UploadStateMsg (Err str) ->
let
- (ulm, ulc) = UploadListUpdate.update msg model.uploadList
+ _ =
+ Debug.log "upload err" str
in
- {model | uploadList = ulm} ! [Cmd.map UploadListMsg ulc]
+ ( model, Cmd.none )
+
+ VersionResp (Ok info) ->
+ ( { model | version = info }, Cmd.none )
- ProfileMsg msg ->
- case model.profile of
- Just um ->
+ VersionResp (Err _) ->
+ ( model, Cmd.none )
+
+ Logout ->
+ ( model
+ , Cmd.batch
+ [ Api.logout model.flags LogoutResp
+ , Ports.removeAccount ()
+ ]
+ )
+
+ LogoutResp _ ->
+ ( { model | loginModel = Page.Login.Data.empty }, Page.goto (LoginPage ( Nothing, False )) )
+
+ SessionCheckResp res ->
+ case res of
+ Ok lr ->
let
- (m, c) = ProfileUpdate.update msg um
+ newFlags =
+ if lr.success then
+ Data.Flags.withAccount model.flags lr
+
+ else
+ Data.Flags.withoutAccount model.flags
+
+ command =
+ if lr.success then
+ Api.refreshSession newFlags SessionCheckResp
+
+ else
+ Cmd.batch [ Ports.removeAccount (), Page.goto (Page.loginPage model.page) ]
in
- {model | profile = Just m} ! [Cmd.map ProfileMsg c]
- Nothing ->
- model ! []
+ ( { model | flags = newFlags }, command )
- AliasListMsg msg ->
- let
- (m, c) = AliasListUpdate.update msg model.aliases
- in
- {model | aliases = m} ! [Cmd.map AliasListMsg c]
+ Err _ ->
+ ( model, Cmd.batch [ Ports.removeAccount (), Page.goto (Page.loginPage model.page) ] )
- AliasUploadMsg msg ->
- let
- (val, cmd, cmdd) = AliasUploadUpdate.update msg model.aliasUpload
- in
- ({model| aliasUpload = val, deferred = (Cmd.map AliasUploadMsg cmdd) :: model.deferred}, Cmd.map AliasUploadMsg cmd)
+ NavRequest req ->
+ case req of
+ Internal url ->
+ let
+ urlStr =
+ Url.toString url
+
+ extern =
+ not <|
+ String.startsWith
+ (model.flags.config.baseUrl ++ "/app")
+ urlStr
+
+ isCurrent =
+ Page.fromUrl url
+ |> Maybe.map (\p -> p == model.page)
+ |> Maybe.withDefault True
+ in
+ ( model
+ , if extern then
+ Nav.load urlStr
- ManualPageContent (Ok cnt) ->
- let
- (mm, cmd) = ManualModel.update (ManualModel.Content cnt) model.manualModel
- in
- {model | manualModel = mm} ! [Cmd.map ManualMsg cmd]
+ else if isCurrent then
+ Cmd.none
- ManualPageContent (Err error) ->
- let
- msg = Debug.log "Error loading manual page " (Data.errorMessage error)
- in
- {model| page = ErrorPage, errorModel = ErrorModel.initModel msg} ! []
+ else
+ Nav.pushUrl model.key (Url.toString url)
+ )
+
+ External url ->
+ ( model
+ , Nav.load url
+ )
- ManualMsg msg ->
+ NavChange url ->
let
- (mm, cmd) = ManualModel.update msg model.manualModel
+ page =
+ Page.fromUrl url |> Maybe.withDefault HomePage
+
+ ( m, c ) =
+ initPage model page
in
- {model | manualModel = mm} ! [Cmd.map ManualMsg cmd]
+ ( { m | page = page }, c )
+
+
+updateOpenDetail : Page.OpenDetail.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateOpenDetail lmsg model =
+ let
+ ( lm, lc ) =
+ Page.OpenDetail.Update.update model.flags lmsg model.openDetailModel
+ in
+ ( { model | openDetailModel = lm }
+ , Cmd.map OpenDetailMsg lc
+ )
-combineResults: Msg -> (Model, Cmd Msg) -> (Model, Cmd Msg)
-combineResults msg (model, cmd) =
+updateDetail : Page.Detail.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateDetail lmsg model =
let
- (m_, c_) = update msg model
+ ( lm, lc ) =
+ Page.Detail.Update.update model.flags lmsg model.detailModel
in
- (m_, Cmd.batch [cmd, c_])
+ ( { model | detailModel = lm }
+ , Cmd.map DetailMsg lc
+ )
+
+
+updateSettings : Page.Settings.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateSettings lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Settings.Update.update model.flags lmsg model.settingsModel
+ in
+ ( { model | settingsModel = lm }
+ , Cmd.map SettingsMsg lc
+ )
+
+
+updateAlias : Page.Alias.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateAlias lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Alias.Update.update model.key model.flags lmsg model.aliasModel
+ in
+ ( { model | aliasModel = lm }
+ , Cmd.map AliasMsg lc
+ )
+
+
+updateUpload : Page.Upload.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateUpload lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Upload.Update.update model.key model.flags lmsg model.uploadModel
+ in
+ ( { model | uploadModel = lm }
+ , Cmd.map UploadMsg lc
+ )
+
+
+updateOpenShare : Page.OpenShare.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateOpenShare lmsg model =
+ let
+ aliasId =
+ case model.page of
+ OpenSharePage id ->
+ id
+
+ _ ->
+ ""
+
+ ( lm, lc ) =
+ Page.OpenShare.Update.update aliasId model.flags lmsg model.openShareModel
+ in
+ ( { model | openShareModel = lm }
+ , Cmd.map OpenShareMsg lc
+ )
+
+
+updateShare : Page.Share.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateShare lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Share.Update.update model.flags lmsg model.shareModel
+ in
+ ( { model | shareModel = lm }
+ , Cmd.map ShareMsg lc
+ )
+
+
+updateAccount : Page.Account.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateAccount lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Account.Update.update model.key model.flags lmsg model.accountModel
+ in
+ ( { model | accountModel = lm }
+ , Cmd.map AccountMsg lc
+ )
+
+
+updateRegister : Page.Register.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateRegister lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Register.Update.update model.flags lmsg model.registerModel
+ in
+ ( { model | registerModel = lm }
+ , Cmd.map RegisterMsg lc
+ )
+
+
+updateNewInvite : Page.NewInvite.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateNewInvite lmsg model =
+ let
+ ( lm, lc ) =
+ Page.NewInvite.Update.update model.flags lmsg model.newInviteModel
+ in
+ ( { model | newInviteModel = lm }
+ , Cmd.map NewInviteMsg lc
+ )
+
+
+updateLogin : Page.Login.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateLogin lmsg model =
+ let
+ ( lm, lc, ar ) =
+ Page.Login.Update.update (Page.loginPageReferrer model.page) model.flags lmsg model.loginModel
+
+ newFlags =
+ Maybe.map (Data.Flags.withAccount model.flags) ar
+ |> Maybe.withDefault model.flags
+ in
+ ( { model | loginModel = lm, flags = newFlags }
+ , Cmd.map LoginMsg lc
+ )
+
+
+updateHome : Page.Home.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateHome lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Home.Update.update model.flags lmsg model.homeModel
+ in
+ ( { model | homeModel = lm }
+ , Cmd.map HomeMsg lc
+ )
+
+
+updateInfo : Page.Info.Data.Msg -> Model -> ( Model, Cmd Msg )
+updateInfo lmsg model =
+ let
+ ( lm, lc ) =
+ Page.Info.Update.update model.flags lmsg model.infoModel
+ in
+ ( { model | infoModel = lm }
+ , Cmd.map InfoMsg lc
+ )
+
+
+initPage : Model -> Page -> ( Model, Cmd Msg )
+initPage model page =
+ case page of
+ HomePage ->
+ ( model, Cmd.none )
+
+ LoginPage _ ->
+ updateLogin Page.Login.Data.Init model
+
+ RegisterPage ->
+ ( model, Cmd.none )
+
+ NewInvitePage ->
+ ( model, Cmd.none )
+
+ InfoPage _ ->
+ ( model, Cmd.none )
+
+ AccountPage aid ->
+ updateAccount (Page.Account.Data.Init aid) model
+
+ AliasPage aid ->
+ updateAlias (Page.Alias.Data.Init aid) model
+
+ UploadPage ->
+ updateUpload Page.Upload.Data.Init model
+
+ SharePage ->
+ ( model, Cmd.none )
+
+ OpenSharePage _ ->
+ ( model, Cmd.none )
+
+ SettingsPage ->
+ updateSettings Page.Settings.Data.Init model
+
+ DetailPage id ->
+ updateDetail (Page.Detail.Data.Init id) model
-refreshCookie: Model -> Cmd Msg
-refreshCookie model =
- Http.post (model.serverConfig.urls.authCookie) Http.emptyBody accountDecoder
- |> Http.send LoginRefreshDone
+ OpenDetailPage id ->
+ updateOpenDetail (Page.OpenDetail.Data.Init id) model
diff --git a/modules/webapp/src/main/elm/App/View.elm b/modules/webapp/src/main/elm/App/View.elm
index 84a5eb75..c7f83d9e 100644
--- a/modules/webapp/src/main/elm/App/View.elm
+++ b/modules/webapp/src/main/elm/App/View.elm
@@ -1,202 +1,308 @@
-module App.View exposing (..)
+module App.View exposing (view)
-import Html exposing (Html, Attribute, button, div, text, span, h1, a, i, p)
-import Html.Attributes exposing (class, classList, href)
+import Api.Model.AuthResult exposing (AuthResult)
+import App.Data exposing (..)
+import Html exposing (..)
+import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
-import List
-import Color exposing (Color)
-
-import App.Update exposing (..)
-import App.Model exposing (..)
-import App.Pages exposing (..)
-
-import Data exposing (Account)
-import PageLocation as PL
-import Pages.Login.View as LoginView
-import Pages.AccountEdit.View as AccountEditView
-import Pages.Upload.View as UploadView
-import Pages.Download.View as DownloadView
-import Pages.UploadList.View as UploadListView
-import Pages.Profile.View as ProfileView
-import Pages.AliasList.View as AliasListView
-import Pages.AliasUpload.View as AliasUploadView
-import Pages.Timeout.View as TimeoutView
-import Pages.Manual.View as ManualView
-import Pages.Error.View as ErrorView
-
-view: Model -> Html Msg
+import Page exposing (Page(..))
+import Page.Account.View
+import Page.Alias.View
+import Page.Detail.View
+import Page.Home.View
+import Page.Info.View
+import Page.Login.View
+import Page.NewInvite.View
+import Page.OpenDetail.View
+import Page.OpenShare.View
+import Page.Register.View
+import Page.Settings.View
+import Page.Share.View
+import Page.Upload.View
+
+
+view : Model -> Html Msg
view model =
- case model.user of
- Nothing ->
- case model.page of
- DownloadPage ->
- div [class "ui container"]
- [Html.map DownloadMsg (DownloadView.view model.download)
- ,(footer model)
- ]
+ case model.page of
+ LoginPage _ ->
+ loginLayout model
- AliasUploadPage ->
- div [class "ui container"]
- [(Html.map AliasUploadMsg (AliasUploadView.view model.aliasUpload))
- ,(footer model)]
-
- TimeoutPage ->
- div [class "ui container"]
- [TimeoutView.view]
-
- ManualPage ->
- div [class "ui container"]
- [ManualView.view model.manualModel]
-
- ErrorPage ->
- div [class "ui container"]
- [ErrorView.view model.errorModel]
-
- _ ->
- Html.map LoginMsg (LoginView.view model.login)
-
- Just acc ->
- case model.page of
- LoginPage ->
- Html.map LoginMsg (LoginView.view model.login)
-
- IndexPage ->
- div [class "ui container"]
- [
- (navbar acc model)
- ,(indexView model)
- ,(footer model)
- ]
+ RegisterPage ->
+ registerLayout model
- NewSharePage ->
- div [class "ui container"]
- [
- (navbar acc model)
- , Html.map UploadMsg (UploadView.view model.upload)
- ,(footer model)
- ]
- AccountEditPage ->
- div [class "ui container"]
- [ (navbar acc model)
- , Html.map AccountEditMsg (AccountEditView.view model.accountEdit)
- ,(footer model)
- ]
+ _ ->
+ defaultLayout model
- DownloadPage ->
- div [class "ui container"]
- [ (navbar acc model)
- , Html.map DownloadMsg (DownloadView.view model.download)
- ,(footer model)
- ]
- UploadListPage ->
- div [class "ui container"]
- [ (navbar acc model)
- ,(Html.map UploadListMsg (UploadListView.view model.uploadList))
- ,(footer model)
- ]
+loginLayout : Model -> Html Msg
+loginLayout model =
+ div [ class "login-layout" ]
+ [ viewLogin model
+ , footer model
+ ]
- ProfilePage ->
- div [class "ui container"]
- [(navbar acc model)
- ,model.profile
- |> Maybe.map ProfileView.view
- |> Maybe.map (Html.map ProfileMsg)
- |> Maybe.withDefault (div[][])
- ,(footer model)
- ]
- AliasListPage ->
- div [class "ui container"]
- [(navbar acc model)
- ,(Html.map AliasListMsg (AliasListView.view model.aliases))
- ,(footer model)
- ]
+registerLayout : Model -> Html Msg
+registerLayout model =
+ div [ class "register-layout" ]
+ [ viewRegister model
+ , footer model
+ ]
- AliasUploadPage ->
- div [class "ui container"]
- [(navbar acc model)
- ,(Html.map AliasUploadMsg (AliasUploadView.view model.aliasUpload))
- ,(footer model)]
-
- TimeoutPage ->
- div [class "ui container"]
- [(navbar acc model)
- ,TimeoutView.view
- ,(footer model)
- ]
- ManualPage ->
- div [class "ui container"]
- [(navbar acc model)
- ,ManualView.view model.manualModel
- ,(footer model)
- ]
+defaultLayout : Model -> Html Msg
+defaultLayout model =
+ div [ class "default-layout" ]
+ [ div [ class "ui fixed top sticky attached large menu black-bg" ]
+ [ div [ class "ui fluid container" ]
+ [ a
+ [ class "header item narrow-item"
+ , case model.flags.account of
+ Just _ ->
+ Page.href HomePage
- ErrorPage ->
- div [class "ui container"]
- [(navbar acc model)
- ,ErrorView.view model.errorModel
- ,(footer model)
+ Nothing ->
+ href "#"
+ ]
+ [ img
+ [ src <| model.flags.config.assetsPath ++ "/img/icon.svg"
+ , class "ui image logo-icon"
]
+ []
+ , text model.flags.config.appName
+ ]
+ , loginInfo model
+ ]
+ ]
+ , div [ class "main-content" ]
+ [ case model.page of
+ HomePage ->
+ viewHome model
+
+ LoginPage _ ->
+ viewLogin model
+
+ RegisterPage ->
+ viewRegister model
+
+ NewInvitePage ->
+ viewNewInvite model
+
+ InfoPage n ->
+ viewInfo n model
+
+ AccountPage id ->
+ viewAccount id model
+
+ AliasPage id ->
+ viewAlias id model
+
+ UploadPage ->
+ viewUpload model
+
+ SharePage ->
+ viewShare model
+
+ OpenSharePage id ->
+ viewOpenShare id model
+
+ SettingsPage ->
+ viewSettings model
+
+ DetailPage id ->
+ viewDetail id model
+
+ OpenDetailPage id ->
+ viewOpenDetail id model
+ ]
+ , footer model
+ ]
+
+
+viewOpenDetail : String -> Model -> Html Msg
+viewOpenDetail id model =
+ Html.map OpenDetailMsg (Page.OpenDetail.View.view model.flags model.openDetailModel)
+
+
+viewDetail : String -> Model -> Html Msg
+viewDetail id model =
+ Html.map DetailMsg (Page.Detail.View.view model.flags model.detailModel)
-adminHtml: Account -> Html Msg -> Html Msg
-adminHtml account html =
- if account.admin then html else span[][]
-
-nonAdminHtml: Account -> Html Msg -> Html Msg
-nonAdminHtml account html =
- if not account.admin then html else span[][]
-
-
-navbar: Account -> Model -> Html Msg
-navbar account model =
- div [class "ui fixed compact menu"]
- [
- a [href PL.indexPageHref, class "header item"] [text model.serverConfig.appName]
- ,a [href PL.uploadsPageHref, class "item"] [text "My Uploads"]
- ,a [href PL.aliasListPageHref, class "item"][text "Aliases"]
- ,div [class "right menu"]
- [
- a [href PL.accountEditPageHref, class "item"] [text "Edit Accounts"] |> adminHtml account
- ,a [href PL.profilePageHref, class "item"][text "Profile"] |> nonAdminHtml account
- ,a [href (PL.manualPageHref "index.md"), class "item"][text "Manual"]
- ,a [onClick (Logout), class "item"][text "Logout"]
+viewSettings : Model -> Html Msg
+viewSettings model =
+ Html.map SettingsMsg (Page.Settings.View.view model.settingsModel)
+
+
+viewAlias : Maybe String -> Model -> Html Msg
+viewAlias id model =
+ Html.map AliasMsg (Page.Alias.View.view model.flags id model.aliasModel)
+
+
+viewUpload : Model -> Html Msg
+viewUpload model =
+ Html.map UploadMsg (Page.Upload.View.view model.uploadModel)
+
+
+viewOpenShare : String -> Model -> Html Msg
+viewOpenShare id model =
+ Html.map OpenShareMsg (Page.OpenShare.View.view model.flags id model.openShareModel)
+
+
+viewShare : Model -> Html Msg
+viewShare model =
+ Html.map ShareMsg (Page.Share.View.view model.flags model.shareModel)
+
+
+viewAccount : Maybe String -> Model -> Html Msg
+viewAccount id model =
+ Html.map AccountMsg (Page.Account.View.view id model.accountModel)
+
+
+viewInfo : Int -> Model -> Html Msg
+viewInfo msgnum model =
+ Html.map InfoMsg (Page.Info.View.view msgnum model.infoModel)
+
+
+viewNewInvite : Model -> Html Msg
+viewNewInvite model =
+ Html.map NewInviteMsg (Page.NewInvite.View.view model.flags model.newInviteModel)
+
+
+viewRegister : Model -> Html Msg
+viewRegister model =
+ Html.map RegisterMsg (Page.Register.View.view model.flags model.registerModel)
+
+
+viewLogin : Model -> Html Msg
+viewLogin model =
+ Html.map LoginMsg (Page.Login.View.view model.flags model.loginModel)
+
+
+viewHome : Model -> Html Msg
+viewHome model =
+ Html.map HomeMsg (Page.Home.View.view model.homeModel)
+
+
+loginInfo : Model -> Html Msg
+loginInfo model =
+ div [ class "right menu" ]
+ (case model.flags.account of
+ Just acc ->
+ [ userMenu model acc
+ ]
+
+ Nothing ->
+ [ a
+ [ class "item"
+ , Page.href (Page.loginPage model.page)
+ ]
+ [ text "Login"
+ ]
+ , a
+ [ class "item"
+ , Page.href RegisterPage
+ ]
+ [ text "Register"
+ ]
+ ]
+ )
+
+
+userMenu : Model -> AuthResult -> Html Msg
+userMenu model acc =
+ div
+ [ class "ui dropdown icon link item"
+ , onClick ToggleNavMenu
+ ]
+ [ i [ class "ui bars icon" ] []
+ , div
+ [ classList
+ [ ( "left menu", True )
+ , ( "transition visible", model.navMenuOpen )
+ ]
+ ]
+ [ menuEntry model
+ HomePage
+ [ img
+ [ class "image icon logo-icon"
+ , src (model.flags.config.assetsPath ++ "/img/icon.svg")
+ ]
+ []
+ , text "Home"
+ ]
+ , div [ class "divider" ] []
+ , menuEntry model
+ UploadPage
+ [ i [ class "ui upload icon" ] []
+ , text "Shares"
+ ]
+ , menuEntry model
+ (AliasPage Nothing)
+ [ i [ class "ui dot circle outline icon" ] []
+ , text "Aliases"
+ ]
+ , if acc.admin then
+ menuEntry model
+ (AccountPage Nothing)
+ [ i [ class "ui users icon" ] []
+ , text "Accounts"
+ ]
+
+ else
+ span [] []
+ , menuEntry model
+ SettingsPage
+ [ i [ class "ui cog icon" ] []
+ , text "Settings"
+ ]
+ , if acc.admin && model.flags.config.signupMode == "invite" then
+ menuEntry model
+ NewInvitePage
+ [ i [ class "ui key icon" ] []
+ , text "New Invites"
+ ]
+
+ else
+ span [] []
+ , div [ class "divider" ] []
+ , a
+ [ class "icon item"
+ , href ""
+ , onClick Logout
+ ]
+ [ i [ class "sign-out icon" ] []
+ , text "Logout ("
+ , text acc.user
+ , text ")"
+ ]
]
]
-indexView: Model -> Html Msg
-indexView model =
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- div [class "ui padded brown center aligned segment"]
- [
- h1 [class "ui header"][text model.serverConfig.appName]
- ,p [][text "Allows to easily share files with others! Click below to upload files and share the URL."]
- ,a [class "ui big basic primary button", onClick (SetPage PL.newSharePage)]
- [
- i [class "upload icon"][]
- ,text "New Share …"
- ]
- ]
- ]
+menuEntry : Model -> Page -> List (Html Msg) -> Html Msg
+menuEntry model page children =
+ a
+ [ classList
+ [ ( "icon item", True )
+ , ( "active", model.page == page )
+ ]
+ , Page.href page
]
+ children
+
-footer: Model -> Html msg
+footer : Model -> Html Msg
footer model =
- Html.footer [class "ui center aligned sharry-footer container"]
- [
- div []
- [
- text "You are using "
- ,a [href "https://github.com/eikek/sharry"]
- [
- i [class "disabled github icon"][]
- ,text model.serverConfig.projectName
- ]
- ]
+ div [ class "ui footer" ]
+ [ a [ href "https://eikek.github.io/sharry" ]
+ [ i [ class "ui github icon" ] []
+ ]
+ , span []
+ [ text "Sharry "
+ , text model.version.version
+ , text " (#"
+ , String.left 8 model.version.gitCommit |> text
+ , text ")"
+ ]
]
diff --git a/modules/webapp/src/main/elm/Comp/AccountForm.elm b/modules/webapp/src/main/elm/Comp/AccountForm.elm
new file mode 100644
index 00000000..460d954b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AccountForm.elm
@@ -0,0 +1,281 @@
+module Comp.AccountForm exposing
+ ( FormAction(..)
+ , Model
+ , Msg
+ , init
+ , initModify
+ , initNew
+ , update
+ , view
+ )
+
+import Api.Model.AccountCreate exposing (AccountCreate)
+import Api.Model.AccountDetail exposing (AccountDetail)
+import Api.Model.AccountModify exposing (AccountModify)
+import Comp.FixedDropdown
+import Comp.PasswordInput
+import Data.AccountState exposing (AccountState)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onCheck, onClick, onInput)
+import Util.Maybe
+
+
+type alias Model =
+ { existing : Maybe AccountDetail
+ , loginField : String
+ , emailField : Maybe String
+ , passwordModel : Comp.PasswordInput.Model
+ , passwordField : Maybe String
+ , stateModel : Comp.FixedDropdown.Model AccountState
+ , stateField : Comp.FixedDropdown.Item AccountState
+ , adminField : Bool
+ }
+
+
+init : Maybe AccountDetail -> Model
+init ma =
+ Maybe.map initModify ma
+ |> Maybe.withDefault initNew
+
+
+mkStateItem : AccountState -> Comp.FixedDropdown.Item AccountState
+mkStateItem state =
+ Comp.FixedDropdown.Item state (Data.AccountState.toString state)
+
+
+initNew : Model
+initNew =
+ { existing = Nothing
+ , loginField = ""
+ , emailField = Nothing
+ , passwordModel = Comp.PasswordInput.init
+ , passwordField = Nothing
+ , stateModel = Comp.FixedDropdown.initMap Data.AccountState.toString Data.AccountState.all
+ , stateField = mkStateItem Data.AccountState.Active
+ , adminField = False
+ }
+
+
+initModify : AccountDetail -> Model
+initModify acc =
+ { initNew
+ | existing = Just acc
+ , loginField = acc.login
+ , emailField = acc.email
+ , stateField =
+ Data.AccountState.fromStringOrActive acc.state
+ |> mkStateItem
+ , adminField = acc.admin
+ }
+
+
+type Msg
+ = SetLogin String
+ | SetEmail String
+ | PasswordMsg Comp.PasswordInput.Msg
+ | StateMsg (Comp.FixedDropdown.Msg AccountState)
+ | ToggleAdmin
+ | Cancel
+ | Submit
+
+
+type FormAction
+ = FormModified String AccountModify
+ | FormCreated AccountCreate
+ | FormCancelled
+ | FormNone
+
+
+isCreate : Model -> Bool
+isCreate model =
+ model.existing == Nothing
+
+
+isModify : Model -> Bool
+isModify model =
+ not (isCreate model)
+
+
+isIntern : Model -> Bool
+isIntern model =
+ Maybe.map .source model.existing
+ |> Maybe.map ((==) "intern")
+ |> Maybe.withDefault False
+
+
+formInvalid : Model -> Bool
+formInvalid model =
+ String.isEmpty model.loginField
+ || (isCreate model && model.passwordField == Nothing)
+
+
+update : Msg -> Model -> ( Model, FormAction )
+update msg model =
+ case msg of
+ SetLogin str ->
+ ( { model | loginField = str }, FormNone )
+
+ SetEmail str ->
+ ( { model
+ | emailField = Util.Maybe.fromString str
+ }
+ , FormNone
+ )
+
+ PasswordMsg lmsg ->
+ let
+ ( m, pw ) =
+ Comp.PasswordInput.update lmsg model.passwordModel
+ in
+ ( { model
+ | passwordModel = m
+ , passwordField = pw
+ }
+ , FormNone
+ )
+
+ StateMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.FixedDropdown.update lmsg model.stateModel
+ in
+ ( { model
+ | stateModel = m
+ , stateField =
+ Maybe.map mkStateItem sel
+ |> Maybe.withDefault model.stateField
+ }
+ , FormNone
+ )
+
+ ToggleAdmin ->
+ ( { model | adminField = not model.adminField }
+ , FormNone
+ )
+
+ Cancel ->
+ ( model, FormCancelled )
+
+ Submit ->
+ if formInvalid model then
+ ( model, FormNone )
+
+ else
+ case Maybe.map .id model.existing of
+ Just id ->
+ ( model
+ , FormModified id
+ { state = Data.AccountState.toString model.stateField.id
+ , admin = model.adminField
+ , email = model.emailField
+ , password = model.passwordField
+ }
+ )
+
+ Nothing ->
+ ( model
+ , FormCreated
+ { login = model.loginField
+ , state = Data.AccountState.toString model.stateField.id
+ , admin = model.adminField
+ , email = model.emailField
+ , password = Maybe.withDefault "" model.passwordField
+ }
+ )
+
+
+view : Model -> Html Msg
+view model =
+ div [ class "ui segments" ]
+ [ Html.form [ class "ui form segment" ]
+ [ div
+ [ classList
+ [ ( "disabled field", True )
+ , ( "invisible", isCreate model )
+ ]
+ ]
+ [ label [] [ text "Id" ]
+ , input
+ [ type_ "text"
+ , Maybe.map .id model.existing
+ |> Maybe.withDefault "-"
+ |> value
+ ]
+ []
+ ]
+ , div
+ [ classList
+ [ ( "required field", True )
+ , ( "disabled", isModify model )
+ , ( "error", String.isEmpty model.loginField )
+ ]
+ ]
+ [ label [] [ text "Login" ]
+ , input
+ [ type_ "text"
+ , value model.loginField
+ , onInput SetLogin
+ ]
+ []
+ ]
+ , div [ class "required field" ]
+ [ label [] [ text "State" ]
+ , Html.map StateMsg
+ (Comp.FixedDropdown.view
+ (Just model.stateField)
+ model.stateModel
+ )
+ ]
+ , div [ class "inline required field" ]
+ [ div [ class "ui checkbox" ]
+ [ input
+ [ type_ "checkbox"
+ , onCheck (\_ -> ToggleAdmin)
+ , checked model.adminField
+ ]
+ []
+ , label [] [ text "Admin" ]
+ ]
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "E-Mail" ]
+ , input
+ [ type_ "text"
+ , Maybe.withDefault "" model.emailField |> value
+ , onInput SetEmail
+ ]
+ []
+ ]
+ , div
+ [ classList
+ [ ( "field", True )
+ , ( "error", isCreate model && model.passwordField == Nothing )
+ , ( "required", isCreate model )
+ , ( "disabled", not (isCreate model || isIntern model) )
+ ]
+ ]
+ [ label [] [ text "Password" ]
+ , Html.map PasswordMsg
+ (Comp.PasswordInput.view model.passwordField
+ model.passwordModel
+ )
+ ]
+ ]
+ , div [ class "ui secondary segment" ]
+ [ button
+ [ type_ "button"
+ , class "ui primary button"
+ , onClick Submit
+ ]
+ [ text "Submit"
+ ]
+ , button
+ [ class "ui button"
+ , type_ "button"
+ , onClick Cancel
+ ]
+ [ text "Back"
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/AccountTable.elm b/modules/webapp/src/main/elm/Comp/AccountTable.elm
new file mode 100644
index 00000000..c8f568e8
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AccountTable.elm
@@ -0,0 +1,89 @@
+module Comp.AccountTable exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Api.Model.AccountDetail exposing (AccountDetail)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Util.Html
+import Util.Time
+
+
+type alias Model =
+ { selected : Maybe AccountDetail
+ }
+
+
+type Msg
+ = Select AccountDetail
+
+
+init : Model
+init =
+ { selected = Nothing
+ }
+
+
+update : Msg -> Model -> ( Model, Maybe AccountDetail )
+update msg model =
+ case msg of
+ Select acc ->
+ ( { model | selected = Just acc }, Just acc )
+
+
+view : List AccountDetail -> Model -> Html Msg
+view accounts model =
+ table [ class "ui selectable padded table" ]
+ [ thead []
+ [ tr []
+ [ th [] [ text "Login" ]
+ , th [] [ text "Source" ]
+ , th [] [ text "State" ]
+ , th [] [ text "#Shares" ]
+ , th [] [ text "Admin" ]
+ , th [] [ text "#Logins" ]
+ , th [] [ text "Last Login" ]
+ , th [] [ text "Created" ]
+ ]
+ ]
+ , tbody []
+ (List.map (viewTableLine model) accounts)
+ ]
+
+
+isSelected : Model -> AccountDetail -> Bool
+isSelected model acc =
+ Maybe.map .id model.selected
+ |> Maybe.map ((==) acc.id)
+ |> Maybe.withDefault False
+
+
+viewTableLine : Model -> AccountDetail -> Html Msg
+viewTableLine model acc =
+ tr
+ [ onClick (Select acc)
+ , classList [ ( "active", isSelected model acc ) ]
+ ]
+ [ td [] [ text acc.login ]
+ , td [] [ text acc.source ]
+ , td [] [ text acc.state ]
+ , td [] [ String.fromInt acc.shares |> text ]
+ , td []
+ [ Util.Html.checkbox acc.admin
+ ]
+ , td [] [ String.fromInt acc.loginCount |> text ]
+ , td []
+ [ Maybe.map Util.Time.formatIsoDateTime acc.lastLogin
+ |> Maybe.withDefault ""
+ |> text
+ ]
+ , td []
+ [ Util.Time.formatIsoDateTime acc.created
+ |> text
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/AliasForm.elm b/modules/webapp/src/main/elm/Comp/AliasForm.elm
new file mode 100644
index 00000000..c5ab4765
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AliasForm.elm
@@ -0,0 +1,272 @@
+module Comp.AliasForm exposing
+ ( FormAction(..)
+ , Model
+ , Msg
+ , init
+ , initModify
+ , initNew
+ , update
+ , view
+ )
+
+import Api.Model.AliasChange exposing (AliasChange)
+import Api.Model.AliasDetail exposing (AliasDetail)
+import Comp.ValidityField
+import Comp.YesNoDimmer
+import Data.AccountState exposing (AccountState)
+import Data.Flags exposing (Flags)
+import Data.ValidityValue exposing (ValidityValue(..))
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onCheck, onClick, onInput)
+import Util.Maybe
+
+
+type alias Model =
+ { existing : Maybe AliasDetail
+ , nameField : String
+ , idField : Maybe String
+ , validityModel : Comp.ValidityField.Model
+ , validityField : ValidityValue
+ , enabledField : Bool
+ , yesNoModel : Comp.YesNoDimmer.Model
+ }
+
+
+init : Flags -> Maybe AliasDetail -> Model
+init flags ma =
+ Maybe.map (initModify flags) ma
+ |> Maybe.withDefault (initNew flags)
+
+
+initNew : Flags -> Model
+initNew flags =
+ { existing = Nothing
+ , idField = Nothing
+ , nameField = ""
+ , validityModel = Comp.ValidityField.init flags
+ , validityField = Data.ValidityValue.Days 2
+ , enabledField = False
+ , yesNoModel = Comp.YesNoDimmer.emptyModel
+ }
+
+
+initModify : Flags -> AliasDetail -> Model
+initModify flags alias_ =
+ let
+ m =
+ initNew flags
+ in
+ { m
+ | existing = Just alias_
+ , idField = Just alias_.id
+ , nameField = alias_.name
+ , validityField = Data.ValidityValue.Millis alias_.validity
+ , enabledField = alias_.enabled
+ }
+
+
+type Msg
+ = SetName String
+ | SetId String
+ | ValidityMsg Comp.ValidityField.Msg
+ | ToggleEnabled
+ | Cancel
+ | Submit
+ | RequestDelete
+ | YesNoMsg Comp.YesNoDimmer.Msg
+
+
+type FormAction
+ = FormModified String AliasChange
+ | FormCreated AliasChange
+ | FormCancelled
+ | FormDelete String
+ | FormNone
+
+
+isCreate : Model -> Bool
+isCreate model =
+ model.existing == Nothing
+
+
+formInvalid : Model -> Bool
+formInvalid model =
+ Util.Maybe.fromString model.nameField == Nothing
+
+
+update : Msg -> Model -> ( Model, FormAction )
+update msg model =
+ case msg of
+ SetName str ->
+ ( { model | nameField = str }, FormNone )
+
+ SetId str ->
+ ( { model | idField = Util.Maybe.fromString str }, FormNone )
+
+ ValidityMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.ValidityField.update lmsg model.validityModel
+ in
+ ( { model
+ | validityModel = m
+ , validityField = Maybe.withDefault model.validityField sel
+ }
+ , FormNone
+ )
+
+ ToggleEnabled ->
+ ( { model | enabledField = not model.enabledField }
+ , FormNone
+ )
+
+ YesNoMsg lmsg ->
+ let
+ ( m, confirmed ) =
+ Comp.YesNoDimmer.update lmsg model.yesNoModel
+
+ id =
+ Maybe.map .id model.existing
+ in
+ ( { model | yesNoModel = m }
+ , if confirmed then
+ Maybe.map FormDelete id
+ |> Maybe.withDefault FormNone
+
+ else
+ FormNone
+ )
+
+ RequestDelete ->
+ let
+ m =
+ Comp.YesNoDimmer.activate model.yesNoModel
+ in
+ ( { model | yesNoModel = m }, FormNone )
+
+ Cancel ->
+ ( model, FormCancelled )
+
+ Submit ->
+ if formInvalid model then
+ ( model, FormNone )
+
+ else
+ let
+ ac =
+ { id =
+ if isCreate model then
+ Nothing
+
+ else
+ model.idField
+ , name = model.nameField
+ , validity = Data.ValidityValue.toMillis model.validityField
+ , enabled = model.enabledField
+ }
+ in
+ case Maybe.map .id model.existing of
+ Just id ->
+ ( model, FormModified id ac )
+
+ Nothing ->
+ ( model, FormCreated ac )
+
+
+view : Model -> Html Msg
+view model =
+ div []
+ [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.yesNoModel)
+ , Html.form [ class "ui top attached form segment" ]
+ [ div
+ [ classList
+ [ ( "field", True )
+ , ( "invisible", isCreate model )
+ ]
+ ]
+ [ label [] [ text "Id" ]
+ , input
+ [ type_ "text"
+ , Maybe.withDefault "" model.idField
+ |> value
+ , onInput SetId
+ ]
+ []
+ , div
+ [ classList
+ [ ( "ui message", True )
+ , ( "invisible hidden", isCreate model )
+ ]
+ ]
+ [ div [ class "header" ]
+ [ text "Note to Ids"
+ ]
+ , p []
+ [ text "This ID is part of the url where "
+ , em [] [ text "everyone" ]
+ , text " can upload files. It is recommended to use"
+ , text " something random. The id can be changed to "
+ , text "any value, but if it is left empty, a random "
+ , text "one will be generated."
+ ]
+ ]
+ ]
+ , div
+ [ classList
+ [ ( "required field", True )
+ , ( "error", String.isEmpty model.nameField )
+ ]
+ ]
+ [ label [] [ text "Name" ]
+ , input
+ [ type_ "text"
+ , value model.nameField
+ , onInput SetName
+ ]
+ []
+ ]
+ , div [ class "required field" ]
+ [ label [] [ text "Validity" ]
+ , Html.map ValidityMsg
+ (Comp.ValidityField.view
+ model.validityField
+ model.validityModel
+ )
+ ]
+ , div [ class "inline required field" ]
+ [ div [ class "ui checkbox" ]
+ [ input
+ [ type_ "checkbox"
+ , onCheck (\_ -> ToggleEnabled)
+ , checked model.enabledField
+ ]
+ []
+ , label [] [ text "Enabled" ]
+ ]
+ ]
+ ]
+ , div [ class "ui secondary bottom attached segment" ]
+ [ button
+ [ type_ "button"
+ , class "ui primary button"
+ , onClick Submit
+ ]
+ [ text "Submit"
+ ]
+ , button
+ [ class "ui button"
+ , type_ "button"
+ , onClick Cancel
+ ]
+ [ text "Back"
+ ]
+ , button
+ [ class "ui right floated red button"
+ , type_ "button"
+ , onClick RequestDelete
+ ]
+ [ text "Delete"
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/AliasTable.elm b/modules/webapp/src/main/elm/Comp/AliasTable.elm
new file mode 100644
index 00000000..54cdc651
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AliasTable.elm
@@ -0,0 +1,83 @@
+module Comp.AliasTable exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Api.Model.AliasDetail exposing (AliasDetail)
+import Data.ValidityOptions exposing (findValidityItemMillis)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Util.Duration
+import Util.Html
+import Util.Time
+
+
+type alias Model =
+ { selected : Maybe AliasDetail
+ }
+
+
+type Msg
+ = Select AliasDetail
+
+
+init : Model
+init =
+ { selected = Nothing
+ }
+
+
+update : Msg -> Model -> ( Model, Maybe AliasDetail )
+update msg model =
+ case msg of
+ Select alias_ ->
+ ( { model | selected = Just alias_ }, Just alias_ )
+
+
+view : List AliasDetail -> Model -> Html Msg
+view aliases model =
+ table [ class "ui selectable padded table" ]
+ [ thead []
+ [ tr []
+ [ th [] [ text "Name" ]
+ , th [] [ text "Enabled" ]
+ , th [] [ text "Validity" ]
+ , th [] [ text "Created" ]
+ ]
+ ]
+ , tbody []
+ (List.map (viewTableLine model) aliases)
+ ]
+
+
+isSelected : Model -> AliasDetail -> Bool
+isSelected model alias_ =
+ Maybe.map .id model.selected
+ |> Maybe.map ((==) alias_.id)
+ |> Maybe.withDefault False
+
+
+viewTableLine : Model -> AliasDetail -> Html Msg
+viewTableLine model alias_ =
+ tr
+ [ onClick (Select alias_)
+ , classList [ ( "active", isSelected model alias_ ) ]
+ ]
+ [ td [] [ text alias_.name ]
+ , td []
+ [ Util.Html.checkbox alias_.enabled
+ ]
+ , td []
+ [ findValidityItemMillis alias_.validity
+ |> Tuple.first
+ |> text
+ ]
+ , td []
+ [ Util.Time.formatIsoDateTime alias_.created
+ |> text
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/Dropzone2.elm b/modules/webapp/src/main/elm/Comp/Dropzone2.elm
new file mode 100644
index 00000000..3a898357
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/Dropzone2.elm
@@ -0,0 +1,292 @@
+module Comp.Dropzone2 exposing
+ ( FileState(..)
+ , Model
+ , Msg
+ , SelectedFiles
+ , ViewSettings
+ , init
+ , mkViewSettings
+ , update
+ , view
+ )
+
+import Data.UploadDict exposing (UploadDict)
+import Data.UploadState
+import Dict
+import File exposing (File)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Decode as D
+import Util.List
+import Util.Size
+
+
+type alias Model =
+ { hover : Bool
+ }
+
+
+init : Model
+init =
+ { hover = False
+ }
+
+
+type Msg
+ = DragEnter
+ | DragLeave
+ | GotFiles (List ( D.Value, File ))
+ | DeleteFile Int
+
+
+type alias SelectedFiles =
+ List ( D.Value, File )
+
+
+type FileState
+ = Done
+ | Failed
+ | Uploading
+ | Waiting
+
+
+type alias ViewSettings =
+ { files : SelectedFiles
+ , active : Bool
+ , fileState : Int -> FileState
+ }
+
+
+update : SelectedFiles -> Msg -> Model -> ( Model, Cmd Msg, SelectedFiles )
+update current msg model =
+ case msg of
+ DragEnter ->
+ ( { model | hover = True }, Cmd.none, current )
+
+ DragLeave ->
+ ( { model | hover = False }, Cmd.none, current )
+
+ GotFiles list ->
+ ( { model | hover = False }, Cmd.none, list )
+
+ DeleteFile index ->
+ ( model, Cmd.none, Util.List.remove index current )
+
+
+mkViewSettings : Bool -> UploadDict -> ViewSettings
+mkViewSettings active uploads =
+ let
+ getState index =
+ Dict.get index uploads.uploads
+ |> Maybe.map .state
+
+ fileState index =
+ case getState index of
+ Just Data.UploadState.Complete ->
+ Done
+
+ Just (Data.UploadState.Progress _ _) ->
+ Uploading
+
+ Just (Data.UploadState.Failed _) ->
+ Failed
+
+ Nothing ->
+ Waiting
+ in
+ { files = uploads.selectedFiles
+ , active = active
+ , fileState = fileState
+ }
+
+
+view : ViewSettings -> Model -> Html Msg
+view sett model =
+ let
+ files =
+ List.unzip sett.files
+ |> Tuple.second
+
+ allsize =
+ List.map File.size files
+ |> List.sum
+ |> toFloat
+ |> Util.Size.bytesReadable Util.Size.B
+ in
+ div [ class "dropzone" ]
+ [ div
+ [ classList
+ [ ( "ui top attached indicating progress", True )
+ , ( "invisible", files == [] )
+ ]
+ , id "all-progress"
+ ]
+ [ div [ class "bar" ]
+ []
+ ]
+ , div
+ [ classList
+ [ ( "ui placeholder segment", True )
+ , ( "on-drop", model.hover )
+ , ( "attached", files /= [] )
+ , ( "disabled", not sett.active )
+ ]
+ , hijackOn "dragenter" (D.succeed DragEnter)
+ , hijackOn "dragover" (D.succeed DragEnter)
+ , hijackOn "dragleave" (D.succeed DragLeave)
+ , hijackOn "drop" dropDecoder
+ ]
+ [ div [ class "ui icon header" ]
+ [ i [ class "mouse pointer icon" ] []
+ , div [ class "content" ]
+ [ text "Drop files here"
+ ]
+ ]
+ , case List.length files of
+ 0 ->
+ span [] []
+
+ n ->
+ div [ class "inline" ]
+ [ String.fromInt n |> text
+ , text " files selected ("
+ , text allsize
+ , text ")"
+ ]
+ , div [ class "ui horizontal divider" ]
+ [ text "Or"
+ ]
+ , div [ class "custom-upload" ]
+ [ label [ class "ui basic primary button" ]
+ [ i [ class "folder open icon" ] []
+ , text "Select Files ..."
+ , input
+ [ type_ "file"
+ , multiple True
+ , disabled <| not sett.active
+ , onFiles GotFiles
+ ]
+ []
+ ]
+ ]
+ ]
+ , table
+ [ classList
+ [ ( "ui bottom attached table", True )
+ , ( "invisible", files == [] )
+ ]
+ ]
+ [ tbody []
+ (List.indexedMap (renderFile sett) files)
+ ]
+ ]
+
+
+renderFile : ViewSettings -> Int -> File -> Html Msg
+renderFile sett index file =
+ let
+ size =
+ File.size file
+ |> toFloat
+ |> Util.Size.bytesReadable Util.Size.B
+
+ name =
+ File.name file
+
+ fileState =
+ sett.fileState index
+
+ icon =
+ case fileState of
+ Done ->
+ "ui green check icon"
+
+ Waiting ->
+ "ui file outline icon"
+
+ Uploading ->
+ "ui loading spinner icon"
+
+ Failed ->
+ "ui red bolt icon"
+ in
+ tr
+ [ class ("file-" ++ String.fromInt index)
+ , attribute "data-index" (String.fromInt index)
+ ]
+ [ td [ class "collapsing" ]
+ [ i [ class icon ] []
+ ]
+ , td []
+ [ div
+ [ classList
+ [ ( "ui small indicating progress", True )
+ , ( "invisible", fileState /= Uploading )
+ ]
+ , id ("file-progress-" ++ String.fromInt index)
+ ]
+ [ div [ class "bar" ] []
+ , div [ class "label" ]
+ [ text name
+ ]
+ ]
+ , span
+ [ classList
+ [ ( "invisible", fileState == Uploading )
+ ]
+ ]
+ [ text name
+ ]
+ ]
+ , td [ class "collapsing" ]
+ [ text size
+ ]
+ , td [ class "collapsing" ]
+ [ a
+ [ classList
+ [ ( "ui primary mini icon button", True )
+ , ( "disabled", not sett.active )
+ ]
+ , href "#"
+ , onClick (DeleteFile index)
+ ]
+ [ i [ class "ui trash icon" ] []
+ ]
+ ]
+ ]
+
+
+dropDecoder : D.Decoder Msg
+dropDecoder =
+ D.at [ "dataTransfer", "files" ] (D.list (attach File.decoder))
+ |> D.map GotFiles
+
+
+hijackOn : String -> D.Decoder msg -> Attribute msg
+hijackOn event decoder =
+ preventDefaultOn event (D.map hijack decoder)
+
+
+hijack : msg -> ( msg, Bool )
+hijack msg =
+ ( msg, True )
+
+
+onFiles : (List ( D.Value, File ) -> msg) -> Attribute msg
+onFiles tomsg =
+ let
+ decmsg =
+ D.at [ "target", "files" ] (D.list (attach File.decoder))
+ |> D.map tomsg
+ in
+ hijackOn "change" decmsg
+
+
+attach : D.Decoder a -> D.Decoder ( D.Value, a )
+attach deca =
+ let
+ mkTuple v =
+ D.map (Tuple.pair v) deca
+ in
+ D.andThen mkTuple D.value
diff --git a/modules/webapp/src/main/elm/Comp/FixedDropdown.elm b/modules/webapp/src/main/elm/Comp/FixedDropdown.elm
new file mode 100644
index 00000000..586dff32
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/FixedDropdown.elm
@@ -0,0 +1,108 @@
+module Comp.FixedDropdown exposing
+ ( Item
+ , Model
+ , Msg
+ , init
+ , initMap
+ , initString
+ , initTuple
+ , update
+ , view
+ )
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+
+
+type alias Item a =
+ { id : a
+ , display : String
+ }
+
+
+type alias Model a =
+ { options : List (Item a)
+ , menuOpen : Bool
+ }
+
+
+type Msg a
+ = SelectItem (Item a)
+ | ToggleMenu
+
+
+init : List (Item a) -> Model a
+init options =
+ { options = options
+ , menuOpen = False
+ }
+
+
+initString : List String -> Model String
+initString strings =
+ init <| List.map (\s -> Item s s) strings
+
+
+initMap : (a -> String) -> List a -> Model a
+initMap elToString els =
+ init <| List.map (\a -> Item a (elToString a)) els
+
+
+initTuple : List ( String, a ) -> Model a
+initTuple tuples =
+ let
+ mkItem ( txt, id ) =
+ Item id txt
+ in
+ init <| List.map mkItem tuples
+
+
+update : Msg a -> Model a -> ( Model a, Maybe a )
+update msg model =
+ case msg of
+ ToggleMenu ->
+ ( { model | menuOpen = not model.menuOpen }, Nothing )
+
+ SelectItem item ->
+ ( model, Just item.id )
+
+
+view : Maybe (Item a) -> Model a -> Html (Msg a)
+view selected model =
+ div
+ [ classList
+ [ ( "ui selection dropdown", True )
+ , ( "open", model.menuOpen )
+ ]
+ , onClick ToggleMenu
+ ]
+ [ input [ type_ "hidden" ] []
+ , i [ class "dropdown icon" ] []
+ , div
+ [ classList
+ [ ( "default", selected == Nothing )
+ , ( "text", True )
+ ]
+ ]
+ [ Maybe.map .display selected
+ |> Maybe.withDefault "Select…"
+ |> text
+ ]
+ , div
+ [ classList
+ [ ( "menu transition", True )
+ , ( "hidden", not model.menuOpen )
+ , ( "visible", model.menuOpen )
+ ]
+ ]
+ <|
+ List.map renderItems model.options
+ ]
+
+
+renderItems : Item a -> Html (Msg a)
+renderItems item =
+ div [ class "item", onClick (SelectItem item) ]
+ [ text item.display
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/IntField.elm b/modules/webapp/src/main/elm/Comp/IntField.elm
new file mode 100644
index 00000000..8e519034
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/IntField.elm
@@ -0,0 +1,108 @@
+module Comp.IntField exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onInput)
+
+
+type alias Model =
+ { min : Maybe Int
+ , max : Maybe Int
+ , label : String
+ , error : Maybe String
+ , lastInput : String
+ }
+
+
+type Msg
+ = SetValue String
+
+
+init : Maybe Int -> Maybe Int -> String -> Model
+init min max label =
+ { min = min
+ , max = max
+ , label = label
+ , error = Nothing
+ , lastInput = ""
+ }
+
+
+tooLow : Model -> Int -> Bool
+tooLow model n =
+ Maybe.map ((<) n) model.min
+ |> Maybe.withDefault False
+
+
+tooHigh : Model -> Int -> Bool
+tooHigh model n =
+ Maybe.map ((>) n) model.max
+ |> Maybe.withDefault False
+
+
+update : Msg -> Model -> ( Model, Maybe Int )
+update msg model =
+ let
+ tooHighError =
+ Maybe.withDefault 0 model.max
+ |> String.fromInt
+ |> (++) "Number must be <= "
+
+ tooLowError =
+ Maybe.withDefault 0 model.min
+ |> String.fromInt
+ |> (++) "Number must be >= "
+ in
+ case msg of
+ SetValue str ->
+ let
+ m =
+ { model | lastInput = str }
+ in
+ case String.toInt str of
+ Just n ->
+ if tooLow model n then
+ ( { m | error = Just tooLowError }
+ , Nothing
+ )
+
+ else if tooHigh model n then
+ ( { m | error = Just tooHighError }
+ , Nothing
+ )
+
+ else
+ ( { m | error = Nothing }, Just n )
+
+ Nothing ->
+ ( { m | error = Just ("'" ++ str ++ "' is not a valid number!") }
+ , Nothing
+ )
+
+
+view : Maybe Int -> Model -> Html Msg
+view nval model =
+ div
+ [ classList
+ [ ( "field", True )
+ , ( "error", model.error /= Nothing )
+ ]
+ ]
+ [ label [] [ text model.label ]
+ , input
+ [ type_ "text"
+ , Maybe.map String.fromInt nval
+ |> Maybe.withDefault model.lastInput
+ |> value
+ , onInput SetValue
+ ]
+ []
+ , div
+ [ classList
+ [ ( "ui pointing red basic label", True )
+ , ( "hidden", model.error == Nothing )
+ ]
+ ]
+ [ Maybe.withDefault "" model.error |> text
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/IntInput.elm b/modules/webapp/src/main/elm/Comp/IntInput.elm
new file mode 100644
index 00000000..2d276ef8
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/IntInput.elm
@@ -0,0 +1,75 @@
+module Comp.IntInput exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onInput)
+
+
+type alias Model =
+ { min : Maybe Int
+ , max : Maybe Int
+ , lastInput : String
+ , isError : Bool
+ }
+
+
+type Msg
+ = SetValue String
+
+
+init : Maybe Int -> Maybe Int -> Model
+init min max =
+ { min = min
+ , max = max
+ , lastInput = ""
+ , isError = False
+ }
+
+
+tooLow : Model -> Int -> Bool
+tooLow model n =
+ Maybe.map ((<) n) model.min
+ |> Maybe.withDefault False
+
+
+tooHigh : Model -> Int -> Bool
+tooHigh model n =
+ Maybe.map ((>) n) model.max
+ |> Maybe.withDefault False
+
+
+update : Msg -> Model -> ( Model, Maybe Int )
+update msg model =
+ case msg of
+ SetValue str ->
+ let
+ m =
+ { model | lastInput = str }
+ in
+ case String.toInt str of
+ Just n ->
+ if tooLow model n then
+ ( { m | isError = True }, Nothing )
+
+ else if tooHigh model n then
+ ( { m | isError = True }, Nothing )
+
+ else
+ ( { m | isError = False }, Just n )
+
+ Nothing ->
+ ( { m | isError = True }
+ , Nothing
+ )
+
+
+view : Maybe Int -> Model -> Html Msg
+view nval model =
+ input
+ [ type_ "text"
+ , Maybe.map String.fromInt nval
+ |> Maybe.withDefault model.lastInput
+ |> value
+ , onInput SetValue
+ ]
+ []
diff --git a/modules/webapp/src/main/elm/Comp/MailForm.elm b/modules/webapp/src/main/elm/Comp/MailForm.elm
new file mode 100644
index 00000000..447f91ac
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/MailForm.elm
@@ -0,0 +1,125 @@
+module Comp.MailForm exposing
+ ( FormAction(..)
+ , Model
+ , Msg
+ , init
+ , initWith
+ , update
+ , view
+ )
+
+import Api.Model.MailTemplate exposing (MailTemplate)
+import Api.Model.SimpleMail exposing (SimpleMail)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+
+
+type alias Model =
+ { subject : String
+ , body : String
+ , receiver : String
+ }
+
+
+init : Model
+init =
+ { subject = ""
+ , body = ""
+ , receiver = ""
+ }
+
+
+initWith : MailTemplate -> Model
+initWith tpl =
+ { subject = tpl.subject
+ , body = tpl.body
+ , receiver = ""
+ }
+
+
+type Msg
+ = SetSubject String
+ | SetBody String
+ | SetReceiver String
+ | Cancel
+ | Send
+
+
+type FormAction
+ = FormSend SimpleMail
+ | FormCancel
+ | FormNone
+
+
+update : Msg -> Model -> ( Model, FormAction )
+update msg model =
+ case msg of
+ SetSubject str ->
+ ( { model | subject = str }, FormNone )
+
+ SetBody str ->
+ ( { model | body = str }, FormNone )
+
+ SetReceiver str ->
+ ( { model | receiver = str }, FormNone )
+
+ Cancel ->
+ ( model, FormCancel )
+
+ Send ->
+ let
+ rec =
+ String.split "," model.receiver
+
+ sm =
+ SimpleMail rec model.subject model.body
+ in
+ ( model, FormSend sm )
+
+
+view : Model -> Html Msg
+view model =
+ div [ class "ui form" ]
+ [ div [ class "field" ]
+ [ label []
+ [ text "Receiver(s)"
+ , span [ class "muted" ]
+ [ text "Separate multiple recipients by comma" ]
+ ]
+ , input
+ [ type_ "text"
+ , onInput SetReceiver
+ , value model.receiver
+ ]
+ []
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Subject" ]
+ , input
+ [ type_ "text"
+ , onInput SetSubject
+ , value model.subject
+ ]
+ []
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Body" ]
+ , textarea [] [ text model.body ]
+ ]
+ , button
+ [ classList
+ [ ( "ui primary button", True )
+ , ( "disabled", model.receiver == "" )
+ ]
+ , onClick Send
+ ]
+ [ text "Send"
+ ]
+ , button
+ [ class "ui secondary button"
+ , onClick Cancel
+ ]
+ [ text "Cancel"
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/MailSend.elm b/modules/webapp/src/main/elm/Comp/MailSend.elm
new file mode 100644
index 00000000..d5c6a66b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/MailSend.elm
@@ -0,0 +1,175 @@
+module Comp.MailSend exposing
+ ( Action(..)
+ , Model
+ , Msg
+ , emptyModel
+ , init
+ , update
+ , view
+ )
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.MailTemplate exposing (MailTemplate)
+import Comp.MailForm exposing (FormAction(..))
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Http
+import Util.Http
+
+
+type alias Model =
+ { mailForm : Comp.MailForm.Model
+ , result : Maybe BasicResult
+ , loader : Loader
+ }
+
+
+type alias Loader =
+ { active : Bool
+ , message : String
+ }
+
+
+sendingLoader : Loader
+sendingLoader =
+ { active = True
+ , message = "Sending mail ..."
+ }
+
+
+templateLoader : Loader
+templateLoader =
+ { active = True
+ , message = "Loading template ..."
+ }
+
+
+noLoader : Loader
+noLoader =
+ { active = False
+ , message = ""
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { mailForm = Comp.MailForm.init
+ , result = Nothing
+ , loader = noLoader
+ }
+
+
+init :
+ ((Result Http.Error MailTemplate -> Msg)
+ -> Cmd Msg
+ )
+ -> ( Model, Cmd Msg )
+init getTpl =
+ ( { emptyModel | loader = templateLoader }, getTpl MailTplResp )
+
+
+type Msg
+ = MailFormMsg Comp.MailForm.Msg
+ | MailTplResp (Result Http.Error MailTemplate)
+ | MailSendResp (Result Http.Error BasicResult)
+
+
+type Action
+ = Run (Cmd Msg)
+ | Cancelled
+ | Sent
+
+
+update : Flags -> Msg -> Model -> ( Model, Action )
+update flags msg model =
+ case msg of
+ MailFormMsg lmsg ->
+ let
+ ( mm, act ) =
+ Comp.MailForm.update lmsg model.mailForm
+ in
+ case act of
+ Comp.MailForm.FormNone ->
+ ( { model | mailForm = mm }, Run Cmd.none )
+
+ Comp.MailForm.FormCancel ->
+ ( { model | result = Nothing }
+ , Cancelled
+ )
+
+ Comp.MailForm.FormSend mail ->
+ ( { model | mailForm = mm, loader = sendingLoader }
+ , Run (Api.sendMail flags mail MailSendResp)
+ )
+
+ MailTplResp (Ok templ) ->
+ ( { model | mailForm = Comp.MailForm.initWith templ, loader = noLoader }
+ , Run Cmd.none
+ )
+
+ MailTplResp (Err err) ->
+ ( { model
+ | result = Just (BasicResult False (Util.Http.errorToString err))
+ , loader = noLoader
+ }
+ , Run Cmd.none
+ )
+
+ MailSendResp (Ok br) ->
+ ( { model
+ | result =
+ if br.success then
+ Nothing
+
+ else
+ Just br
+ , loader = noLoader
+ }
+ , if br.success then
+ Sent
+
+ else
+ Run Cmd.none
+ )
+
+ MailSendResp (Err err) ->
+ ( { model
+ | result = Just (BasicResult False (Util.Http.errorToString err))
+ , loader = noLoader
+ }
+ , Run Cmd.none
+ )
+
+
+view : List ( String, Bool ) -> Model -> Html Msg
+view classes model =
+ div [ classList classes ]
+ [ div
+ [ classList
+ [ ( "ui dimmer", True )
+ , ( "active", model.loader.active )
+ ]
+ ]
+ [ div [ class "ui text loader" ]
+ [ text model.loader.message
+ ]
+ ]
+ , div
+ [ classList
+ [ ( "ui message", True )
+ , ( "hidden invisible", model.result == Nothing )
+ , ( "error"
+ , Maybe.map .success model.result
+ |> Maybe.map not
+ |> Maybe.withDefault False
+ )
+ ]
+ ]
+ [ Maybe.map .message model.result
+ |> Maybe.withDefault ""
+ |> text
+ ]
+ , Html.map MailFormMsg (Comp.MailForm.view model.mailForm)
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/MarkdownInput.elm b/modules/webapp/src/main/elm/Comp/MarkdownInput.elm
new file mode 100644
index 00000000..9a6fff06
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/MarkdownInput.elm
@@ -0,0 +1,131 @@
+module Comp.MarkdownInput exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Markdown
+
+
+type Display
+ = Edit
+ | Preview
+ | Split
+
+
+type alias Model =
+ { display : Display
+ , cheatSheetUrl : String
+ }
+
+
+init : Model
+init =
+ { display = Edit
+ , cheatSheetUrl = "https://www.markdownguide.org/cheat-sheet"
+ }
+
+
+type Msg
+ = SetText String
+ | SetDisplay Display
+
+
+update : String -> Msg -> Model -> ( Model, String )
+update txt msg model =
+ case msg of
+ SetText str ->
+ ( model, str )
+
+ SetDisplay dsp ->
+ ( { model | display = dsp }, txt )
+
+
+view : String -> Model -> Html Msg
+view txt model =
+ div []
+ [ div [ class "ui top attached tabular mini menu" ]
+ [ a
+ [ classList
+ [ ( "ui link item", True )
+ , ( "active", model.display == Edit )
+ ]
+ , onClick (SetDisplay Edit)
+ , href "#"
+ ]
+ [ text "Edit"
+ ]
+ , a
+ [ classList
+ [ ( "ui link item", True )
+ , ( "active", model.display == Preview )
+ ]
+ , onClick (SetDisplay Preview)
+ , href "#"
+ ]
+ [ text "Preview"
+ ]
+ , a
+ [ classList
+ [ ( "ui link item", True )
+ , ( "active", model.display == Split )
+ ]
+ , onClick (SetDisplay Split)
+ , href "#"
+ ]
+ [ text "Split"
+ ]
+ , a
+ [ class "ui right floated help-link link item"
+ , target "_new"
+ , href model.cheatSheetUrl
+ ]
+ [ i [ class "ui help icon" ] []
+ , text "Supports Markdown"
+ ]
+ ]
+ , div [ class "ui bottom attached segment" ]
+ [ case model.display of
+ Edit ->
+ editDisplay txt
+
+ Preview ->
+ previewDisplay txt
+
+ Split ->
+ splitDisplay txt
+ ]
+ ]
+
+
+editDisplay : String -> Html Msg
+editDisplay txt =
+ textarea
+ [ class "markdown-editor"
+ , onInput SetText
+ ]
+ [ text txt ]
+
+
+previewDisplay : String -> Html Msg
+previewDisplay txt =
+ Markdown.toHtml [ class "markdown-preview" ] txt
+
+
+splitDisplay : String -> Html Msg
+splitDisplay txt =
+ div [ class "ui grid" ]
+ [ div [ class "row" ]
+ [ div [ class "eight wide column markdown-split" ]
+ [ editDisplay txt
+ ]
+ , div [ class "eight wide column markdown-split" ]
+ [ previewDisplay txt
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/PasswordInput.elm b/modules/webapp/src/main/elm/Comp/PasswordInput.elm
new file mode 100644
index 00000000..06f8fc94
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/PasswordInput.elm
@@ -0,0 +1,74 @@
+module Comp.PasswordInput exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Util.Maybe
+
+
+type alias Model =
+ { show : Bool
+ }
+
+
+init : Model
+init =
+ { show = False
+ }
+
+
+type Msg
+ = ToggleShow (Maybe String)
+ | SetPassword String
+
+
+update : Msg -> Model -> ( Model, Maybe String )
+update msg model =
+ case msg of
+ ToggleShow pw ->
+ ( { model | show = not model.show }
+ , pw
+ )
+
+ SetPassword str ->
+ let
+ pw =
+ Util.Maybe.fromString str
+ in
+ ( model, pw )
+
+
+view : Maybe String -> Model -> Html Msg
+view pw model =
+ div [ class "ui left action input" ]
+ [ button
+ [ class "ui icon button"
+ , type_ "button"
+ , onClick (ToggleShow pw)
+ ]
+ [ i
+ [ classList
+ [ ( "ui eye icon", True )
+ , ( "slash", model.show )
+ ]
+ ]
+ []
+ ]
+ , input
+ [ type_ <|
+ if model.show then
+ "text"
+
+ else
+ "password"
+ , onInput SetPassword
+ , Maybe.withDefault "" pw |> value
+ ]
+ []
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/ShareFileList.elm b/modules/webapp/src/main/elm/Comp/ShareFileList.elm
new file mode 100644
index 00000000..b2f98fcb
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/ShareFileList.elm
@@ -0,0 +1,368 @@
+module Comp.ShareFileList exposing
+ ( FileAction(..)
+ , Model
+ , Msg(..)
+ , Settings
+ , ViewMode(..)
+ , init
+ , previewPossible
+ , reset
+ , update
+ , view
+ )
+
+import Api.Model.ShareFile exposing (ShareFile)
+import Comp.YesNoDimmer
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Set exposing (Set)
+import Util.Size
+
+
+type alias Model =
+ { embedOn : Set String
+ , requestDelete : Maybe ShareFile
+ , yesNoModel : Comp.YesNoDimmer.Model
+ }
+
+
+type Msg
+ = Select ShareFile
+ | EmbedFile ShareFile
+ | ReqDelete ShareFile
+ | YesNoMsg Comp.YesNoDimmer.Msg
+
+
+type FileAction
+ = FileNone
+ | FileClick ShareFile
+ | FileDelete ShareFile
+
+
+type ViewMode
+ = ViewList
+ | ViewCard
+
+
+init : Model
+init =
+ { embedOn = Set.empty
+ , requestDelete = Nothing
+ , yesNoModel = Comp.YesNoDimmer.emptyModel
+ }
+
+
+dimmerSettings : Comp.YesNoDimmer.Settings
+dimmerSettings =
+ Comp.YesNoDimmer.defaultSettings
+
+
+reset : Model -> Model
+reset model =
+ { model | embedOn = Set.empty }
+
+
+type alias Settings =
+ { baseUrl : String
+ , viewMode : ViewMode
+ , delete : Bool
+ }
+
+
+update : Msg -> Model -> ( Model, FileAction )
+update msg model =
+ case msg of
+ Select sf ->
+ ( model
+ , if previewPossible sf.mimetype then
+ FileClick sf
+
+ else
+ FileNone
+ )
+
+ EmbedFile sf ->
+ ( { model | embedOn = Set.insert sf.id model.embedOn }
+ , FileNone
+ )
+
+ ReqDelete sf ->
+ ( { model
+ | requestDelete = Just sf
+ , yesNoModel = Comp.YesNoDimmer.activate model.yesNoModel
+ }
+ , FileNone
+ )
+
+ YesNoMsg lmsg ->
+ let
+ ( ym, flag ) =
+ Comp.YesNoDimmer.update lmsg model.yesNoModel
+
+ action =
+ case model.requestDelete of
+ Just sf ->
+ if flag then
+ FileDelete sf
+
+ else
+ FileNone
+
+ Nothing ->
+ FileNone
+ in
+ ( { model | yesNoModel = ym }, action )
+
+
+view : Settings -> List ShareFile -> Model -> Html Msg
+view settings files model =
+ case settings.viewMode of
+ ViewList ->
+ fileTable settings model files
+
+ ViewCard ->
+ fileCards settings model files
+
+
+fileCards : Settings -> Model -> List ShareFile -> Html Msg
+fileCards settings model files =
+ div [ class "ui centered cards" ] <|
+ List.map (fileCard settings model) files
+
+
+fileCard : Settings -> Model -> ShareFile -> Html Msg
+fileCard settings model file =
+ div [ class "ui card", id file.id ]
+ [ Html.map YesNoMsg
+ (Comp.YesNoDimmer.view2
+ (model.requestDelete == Just file)
+ dimmerSettings
+ model.yesNoModel
+ )
+ , div [ class "image" ]
+ [ fileEmbed settings model file
+ ]
+ , div [ class "content" ]
+ [ text file.filename
+ , text " ("
+ , toFloat file.size |> Util.Size.bytesReadable Util.Size.B |> text
+ , text ")"
+ ]
+ , div [ class "extra content" ]
+ [ a
+ [ class "ui basic icon button"
+ , title "Download to disk"
+ , download file.filename
+ , href (settings.baseUrl ++ file.id)
+ ]
+ [ i [ class "download icon" ] []
+ ]
+ , a
+ [ classList
+ [ ( "ui basic icon button", True )
+ , ( "invisible", not <| previewPossible file.mimetype )
+ ]
+ , title "View in browser"
+ , href "#"
+ , onClick (Select file)
+ ]
+ [ i [ class "eye icon" ] []
+ ]
+ , incompleteLabel file
+ , a
+ [ classList
+ [ ( "ui right floated basic red icon button", True )
+ , ( "invisible", not settings.delete )
+ ]
+ , title "Delete the file."
+ , href "#"
+ , onClick (ReqDelete file)
+ ]
+ [ i [ class "trash icon" ] []
+ ]
+ ]
+ ]
+
+
+incompleteLabel : ShareFile -> Html msg
+incompleteLabel file =
+ let
+ perc =
+ (toFloat file.storedSize / toFloat file.size * 100) |> round
+ in
+ div
+ [ classList
+ [ ( "ui red basic icon label", True )
+ , ( "invisible", file.size == file.storedSize )
+ ]
+ ]
+ [ i [ class "red bolt icon" ] []
+ , text "The file is incomplete ("
+ , String.fromInt perc |> text
+ , text "%). Try uploading again."
+ ]
+
+
+previewDeferred : List String
+previewDeferred =
+ [ "video/", "audio/" ]
+
+
+previewDirect : List String
+previewDirect =
+ [ "text/", "application/pdf" ]
+
+
+previewFor : List String -> String -> Bool
+previewFor mimeList mime =
+ List.any (\x -> String.startsWith x mime) mimeList
+
+
+previewPossible : String -> Bool
+previewPossible mime =
+ previewFor (previewDeferred ++ previewDirect ++ [ "image/" ]) mime
+
+
+fileEmbed : Settings -> Model -> ShareFile -> Html Msg
+fileEmbed settings model file =
+ let
+ mime =
+ file.mimetype
+ in
+ if previewFor previewDirect mime || Set.member file.id model.embedOn then
+ embed
+ [ src (settings.baseUrl ++ file.id)
+ ]
+ []
+
+ else if previewFor previewDeferred mime then
+ div [ class "ui embed" ]
+ [ button
+ [ type_ "button"
+ , class "ui large secondary icon button"
+ , onClick (EmbedFile file)
+ ]
+ [ i [ class "large play circle outline icon" ] []
+ ]
+ ]
+
+ else if String.startsWith "image/" mime then
+ img
+ [ src (settings.baseUrl ++ file.id)
+ , class "preview-image"
+ ]
+ []
+
+ else
+ div [ class "ui placeholder segment preview-image" ]
+ [ div [ class "ui icon header" ]
+ [ i [ class (fileIcon file) ] []
+ , text "Preview not supported"
+ ]
+ ]
+
+
+fileTable : Settings -> Model -> List ShareFile -> Html Msg
+fileTable settings model files =
+ let
+ yesNo =
+ case model.requestDelete of
+ Just sf ->
+ Html.map YesNoMsg
+ (Comp.YesNoDimmer.view2
+ True
+ dimmerSettings
+ model.yesNoModel
+ )
+
+ Nothing ->
+ span [] []
+ in
+ div []
+ [ yesNo
+ , table [ class "ui very basic table" ]
+ [ tbody [] <|
+ List.map (fileRow settings model) files
+ ]
+ ]
+
+
+fileRow : Settings -> Model -> ShareFile -> Html Msg
+fileRow { baseUrl, delete } model file =
+ tr [ id file.id ]
+ [ td [ class "collapsing" ]
+ [ i [ class ("large " ++ fileIcon file) ] []
+ ]
+ , td []
+ [ text file.filename
+ , text " ("
+ , toFloat file.size |> Util.Size.bytesReadable Util.Size.B |> text
+ , text ") "
+ , incompleteLabel file
+ ]
+ , td []
+ [ a
+ [ class "ui mini right floated basic icon button"
+ , title "Download to disk"
+ , href "#"
+ , download file.filename
+ , href (baseUrl ++ file.id)
+ ]
+ [ i [ class "download icon" ] []
+ ]
+ , a
+ [ classList
+ [ ( "ui mini right floated basic icon button", True )
+ , ( "invisible", not <| previewPossible file.mimetype )
+ ]
+ , title "View in browser"
+ , href "#"
+ , onClick (Select file)
+ ]
+ [ i [ class "eye icon" ] []
+ ]
+ , a
+ [ classList
+ [ ( "ui mini red right floated basic icon button", True )
+ , ( "invisible", not delete )
+ ]
+ , title "Delete file"
+ , href "#"
+ , onClick (ReqDelete file)
+ ]
+ [ i [ class "trash icon" ] []
+ ]
+ ]
+ ]
+
+
+fileIcon : ShareFile -> String
+fileIcon file =
+ let
+ mime =
+ file.mimetype
+ in
+ if file.size /= file.storedSize then
+ "red bolt icon"
+
+ else if mime == "application/pdf" then
+ "file pdf outline icon"
+
+ else if mime == "application/zip" then
+ "file archive outline icon"
+
+ else if String.startsWith "image/" mime then
+ "file image outline icon"
+
+ else if String.startsWith "video/" mime then
+ "file video outline icon"
+
+ else if String.startsWith "audio/" mime then
+ "file audio outline icon"
+
+ else if String.startsWith "text/" mime then
+ "file alternate outline icon"
+
+ else
+ "file outline icon"
diff --git a/modules/webapp/src/main/elm/Comp/ShareTable.elm b/modules/webapp/src/main/elm/Comp/ShareTable.elm
new file mode 100644
index 00000000..33d54844
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/ShareTable.elm
@@ -0,0 +1,105 @@
+module Comp.ShareTable exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Api.Model.ShareListItem exposing (ShareListItem)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Util.Html
+import Util.Size
+import Util.String
+import Util.Time
+
+
+type alias Model =
+ { selected : Maybe ShareListItem
+ }
+
+
+type Msg
+ = Select ShareListItem
+
+
+init : Model
+init =
+ { selected = Nothing
+ }
+
+
+update : Msg -> Model -> ( Model, Maybe ShareListItem )
+update msg model =
+ case msg of
+ Select acc ->
+ ( { model | selected = Just acc }, Just acc )
+
+
+view : List ShareListItem -> Model -> Html Msg
+view accounts model =
+ table [ class "ui selectable table" ]
+ [ thead []
+ [ tr []
+ [ th [] [ text "Name/Id" ]
+ , th [] [ text "Alias" ]
+ , th [ class "collapsing" ] [ text "Max Views" ]
+ , th [ class "collapsing" ] [ text "Published" ]
+ , th [ class "collapsing" ] [ text "#Files" ]
+ , th [ class "collapsing" ] [ text "Size" ]
+ , th [ class "collapsing" ] [ text "Created" ]
+ ]
+ ]
+ , tbody []
+ (List.map (viewTableLine model) accounts)
+ ]
+
+
+isSelected : Model -> ShareListItem -> Bool
+isSelected model item =
+ Maybe.map .id model.selected
+ |> Maybe.map ((==) item.id)
+ |> Maybe.withDefault False
+
+
+viewTableLine : Model -> ShareListItem -> Html Msg
+viewTableLine model item =
+ tr
+ [ onClick (Select item)
+ , classList [ ( "active", isSelected model item ) ]
+ ]
+ [ td [] [ Maybe.withDefault (Util.String.shorten 12 item.id) item.name |> text ]
+ , td [] [ Maybe.withDefault "-" item.aliasName |> text ]
+ , td [ class "collapsing" ] [ String.fromInt item.maxViews |> text ]
+ , td [ class "collapsing" ]
+ [ publishedState item
+ ]
+ , td [ class "collapsing" ]
+ [ String.fromInt item.files |> text
+ ]
+ , td [ class "collapsing" ]
+ [ toFloat item.size
+ |> Util.Size.bytesReadable Util.Size.B
+ |> text
+ ]
+ , td [ class "collapsing" ]
+ [ Util.Time.formatDateTime item.created
+ |> text
+ ]
+ ]
+
+
+publishedState : ShareListItem -> Html Msg
+publishedState item =
+ case item.published of
+ Just flag ->
+ if flag then
+ Util.Html.checkbox flag
+
+ else
+ i [ class "ui bolt icon" ] []
+
+ Nothing ->
+ Util.Html.checkboxUnchecked
diff --git a/modules/webapp/src/main/elm/Comp/ValidityField.elm b/modules/webapp/src/main/elm/Comp/ValidityField.elm
new file mode 100644
index 00000000..dc1eaf76
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/ValidityField.elm
@@ -0,0 +1,57 @@
+module Comp.ValidityField exposing
+ ( Model
+ , Msg
+ , init
+ , update
+ , view
+ )
+
+import Comp.FixedDropdown
+import Data.Flags exposing (Flags)
+import Data.ValidityOptions
+ exposing
+ ( findValidityItem
+ , validityOptions
+ )
+import Data.ValidityValue exposing (ValidityValue)
+import Html exposing (..)
+
+
+type alias Model =
+ Comp.FixedDropdown.Model ValidityValue
+
+
+init : Flags -> Model
+init flags =
+ Comp.FixedDropdown.initTuple (validityOptions flags)
+
+
+type Msg
+ = ValidityMsg (Comp.FixedDropdown.Msg ValidityValue)
+
+
+update : Msg -> Model -> ( Model, Maybe ValidityValue )
+update msg model =
+ case msg of
+ ValidityMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.FixedDropdown.update lmsg model
+ in
+ ( m, sel )
+
+
+mkValidityItem : ( String, ValidityValue ) -> Comp.FixedDropdown.Item ValidityValue
+mkValidityItem ( text, id ) =
+ Comp.FixedDropdown.Item id text
+
+
+view : ValidityValue -> Model -> Html Msg
+view validity model =
+ let
+ value =
+ findValidityItem validity
+ |> mkValidityItem
+ in
+ Html.map ValidityMsg
+ (Comp.FixedDropdown.view (Just value) model)
diff --git a/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm
new file mode 100644
index 00000000..ac102d2b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm
@@ -0,0 +1,117 @@
+module Comp.YesNoDimmer exposing
+ ( Model
+ , Msg
+ , Settings
+ , activate
+ , defaultSettings
+ , disable
+ , emptyModel
+ , update
+ , view
+ , view2
+ )
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+
+
+type alias Model =
+ { active : Bool
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { active = False
+ }
+
+
+type Msg
+ = Activate
+ | Disable
+ | ConfirmDelete
+
+
+type alias Settings =
+ { message : String
+ , headerIcon : String
+ , headerClass : String
+ , confirmButton : String
+ , cancelButton : String
+ , invertedDimmer : Bool
+ }
+
+
+defaultSettings : Settings
+defaultSettings =
+ { message = "Delete this item permanently?"
+ , headerIcon = "exclamation icon"
+ , headerClass = "ui inverted icon header"
+ , confirmButton = "Yes, do it!"
+ , cancelButton = "No"
+ , invertedDimmer = False
+ }
+
+
+activate : Model -> Model
+activate model =
+ update Activate model
+ |> Tuple.first
+
+
+disable : Model -> Model
+disable model =
+ update Disable model
+ |> Tuple.first
+
+
+update : Msg -> Model -> ( Model, Bool )
+update msg model =
+ case msg of
+ Activate ->
+ ( { model | active = True }, False )
+
+ Disable ->
+ ( { model | active = False }, False )
+
+ ConfirmDelete ->
+ ( { model | active = False }, True )
+
+
+view : Model -> Html Msg
+view model =
+ view2 True defaultSettings model
+
+
+view2 : Bool -> Settings -> Model -> Html Msg
+view2 active settings model =
+ div
+ [ classList
+ [ ( "ui dimmer", True )
+ , ( "inverted", settings.invertedDimmer )
+ , ( "active", active && model.active )
+ ]
+ ]
+ [ div [ class "content" ]
+ [ h3 [ class settings.headerClass ]
+ [ if settings.headerIcon == "" then
+ span [] []
+
+ else
+ i [ class settings.headerIcon ] []
+ , text settings.message
+ ]
+ ]
+ , div [ class "content" ]
+ [ div [ class "ui buttons" ]
+ [ a [ class "ui primary button", onClick ConfirmDelete, href "" ]
+ [ text settings.confirmButton
+ ]
+ , div [ class "or" ] []
+ , a [ class "ui secondary button", onClick Disable, href "" ]
+ [ text settings.cancelButton
+ ]
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/Zoom.elm b/modules/webapp/src/main/elm/Comp/Zoom.elm
new file mode 100644
index 00000000..dd39a95c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/Zoom.elm
@@ -0,0 +1,112 @@
+module Comp.Zoom exposing (FileUrl, view)
+
+import Api
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Api.Model.ShareFile exposing (ShareFile)
+import Comp.ShareFileList exposing (ViewMode(..), previewPossible)
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Util.List
+import Util.Size
+
+
+type alias FileUrl =
+ String -> String
+
+
+view :
+ FileUrl
+ -> { m | share : ShareDetail, zoom : Maybe ShareFile }
+ -> (ShareFile -> msg)
+ -> msg
+ -> Html msg
+view fileUrl model onSelect onQuit =
+ div
+ [ classList
+ [ ( "ui dimmer", True )
+ , ( "active", model.zoom /= Nothing )
+ ]
+ ]
+ [ case model.zoom of
+ Just file ->
+ let
+ files =
+ List.filter (\f -> previewPossible f.mimetype) model.share.files
+
+ prev =
+ Util.List.findPrev (\e -> e.id == file.id) files
+
+ next =
+ Util.List.findNext (\e -> e.id == file.id) files
+ in
+ div [ class "ui container full-height" ]
+ [ div [ class "ui top attached centered mini menu" ]
+ [ button
+ [ type_ "button"
+ , class "ui button"
+ , onClick onQuit
+ ]
+ [ text "Back"
+ ]
+ , div [ class "text item" ]
+ [ text file.filename
+ , text " ("
+ , toFloat file.size |> Util.Size.bytesReadable Util.Size.B |> text
+ , text ")"
+ ]
+ , div [ class "right menu" ]
+ [ div [ class "ui buttons" ]
+ [ button
+ [ type_ "button"
+ , classList
+ [ ( "ui icon button", True )
+ , ( "disabled", prev == Nothing )
+ ]
+ , onClick (onSelect (Maybe.withDefault file prev))
+ ]
+ [ i [ class "arrow left icon" ] []
+ ]
+ , button
+ [ type_ "button"
+ , classList
+ [ ( "ui icon button", True )
+ , ( "disabled", next == Nothing )
+ ]
+ , onClick (onSelect (Maybe.withDefault file next))
+ ]
+ [ i [ class "arrow right icon" ] []
+ ]
+ ]
+ ]
+ ]
+ , filePreview fileUrl model file
+ ]
+
+ Nothing ->
+ span [] []
+ ]
+
+
+filePreview : FileUrl -> { m | share : ShareDetail, zoom : Maybe ShareFile } -> ShareFile -> Html msg
+filePreview fileUrl model file =
+ let
+ url =
+ fileUrl file.id
+ in
+ if String.startsWith "image/" file.mimetype then
+ img
+ [ src url
+ , class "full-width"
+ ]
+ []
+
+ else
+ iframe
+ [ src url
+ , class "full-embed"
+ , attribute "width" "100%"
+ , attribute "height" "100%"
+ ]
+ []
diff --git a/modules/webapp/src/main/elm/Data.elm b/modules/webapp/src/main/elm/Data.elm
deleted file mode 100644
index d215112a..00000000
--- a/modules/webapp/src/main/elm/Data.elm
+++ /dev/null
@@ -1,439 +0,0 @@
-module Data exposing (..)
-
-import Date
-import Html exposing (Html)
-import Http
-import Json.Decode as Decode exposing(field, at)
-import Json.Decode.Pipeline as JP
-import Json.Encode as Encode
-import List
-import Markdown
-
--- Account type
-
-type alias Account =
- { login: String
- , password: Maybe String
- , email: Maybe String
- , enabled: Bool
- , admin: Bool
- , extern: Bool
- }
-
-emptyAccount: Account
-emptyAccount =
- Account "" Nothing Nothing True False False
-
-fromLogin: String -> Account
-fromLogin login =
- {emptyAccount | login = login}
-
-accountEncoder: Account -> Encode.Value
-accountEncoder acc =
- Encode.object
- [ ("login", Encode.string acc.login)
- , ("password", Encode.string (Maybe.withDefault "" acc.password))
- , ("email", Encode.string (Maybe.withDefault "" acc.email))
- , ("enabled", Encode.bool acc.enabled)
- , ("admin", Encode.bool acc.admin)
- , ("extern", Encode.bool acc.extern)
- ]
-
-accountDecoder: Decode.Decoder Account
-accountDecoder =
- Decode.map6 Account
- (field "login" Decode.string)
- (field "password" (Decode.maybe Decode.string))
- (field "email" (Decode.maybe Decode.string))
- (field "enabled" Decode.bool)
- (field "admin" Decode.bool)
- (field "extern" Decode.bool)
-
--- Alias types
-
-type alias Alias =
- {id: String
- ,login: String
- ,name: String
- ,validity: String
- ,created: String
- ,enable: Bool
- }
-
-decodeAlias: Decode.Decoder Alias
-decodeAlias =
- Decode.map6 Alias
- (field "id" Decode.string)
- (field "login" Decode.string)
- (field "name" Decode.string)
- (field "validity" Decode.string)
- (field "created" Decode.string)
- (field "enable" Decode.bool)
-
-encodeAlias: Alias -> Encode.Value
-encodeAlias alia =
- Encode.object
- [("id", Encode.string alia.id)
- ,("login", Encode.string alia.login)
- ,("name", Encode.string alia.name)
- ,("validity", Encode.string alia.validity)
- ,("created", Encode.string alia.created)
- ,("enable", Encode.bool alia.enable)
- ]
-
--- Upload types
-
-{-| An upload can be identified by an public id (pid) and its standard id (uid).
--}
-type UploadId
- = Uid String
- | Pid String
-
-type alias File =
- {id: String
- ,timestamp: String
- ,mimetype: String
- ,length: Int
- ,chunks: Int
- ,chunksize: Int
- ,filename: String
- }
-
-type alias Upload =
- {id: String
- ,login: String
- ,alia: Maybe String
- ,aliasName: Maybe String
- ,validity: String
- ,maxDownloads: Int
- ,requiresPassword: Bool
- ,validated: List String
- ,description: Maybe String
- ,created: String
- ,downloads: Int
- ,lastDownload: Maybe String
- ,publishId: Maybe String
- ,publishDate: Maybe String
- ,validUntil: Maybe String
- ,name: Maybe String
- }
-
-isValidUpload: Upload -> Bool
-isValidUpload upload =
- List.isEmpty upload.validated
-
-isPublishedUpload: Upload -> Bool
-isPublishedUpload upload =
- isPresent upload.publishId
-
-
-type alias UploadInfo =
- {upload: Upload
- ,files: List File
- }
-
-decodeFile: Decode.Decoder File
-decodeFile =
- Decode.map7 File
- (at ["meta","id"] Decode.string)
- (at ["meta","timestamp"] Decode.string)
- (at ["meta","mimetype"] Decode.string)
- (at ["meta","length"] Decode.int)
- (at ["meta","chunks"] Decode.int)
- (at ["meta","chunksize"] Decode.int)
- (at ["filename"] Decode.string)
-
-decodeUpload: Decode.Decoder Upload
-decodeUpload =
- JP.decode Upload
- |> JP.required "id" Decode.string
- |> JP.required "login" Decode.string
- |> JP.required "alias" (Decode.maybe Decode.string)
- |> JP.required "aliasName" (Decode.maybe Decode.string)
- |> JP.required "validity" Decode.string
- |> JP.required "maxDownloads" Decode.int
- |> JP.required "requiresPassword" Decode.bool
- |> JP.required "validated" (Decode.list Decode.string)
- |> JP.required "description" (Decode.maybe Decode.string)
- |> JP.required "created" Decode.string
- |> JP.required "downloads" Decode.int
- |> JP.required "lastDownload" (Decode.maybe Decode.string)
- |> JP.required "publishId" (Decode.maybe Decode.string)
- |> JP.required "publishDate" (Decode.maybe Decode.string)
- |> JP.required "validUntil" (Decode.maybe Decode.string)
- |> JP.required "name" (Decode.maybe Decode.string)
-
-decodeUploadInfo: Decode.Decoder UploadInfo
-decodeUploadInfo =
- Decode.map2 UploadInfo
- (field "upload" decodeUpload)
- (field "files" (Decode.list decodeFile))
-
-
-
--- Outcome type
-type alias Outcome a =
- { state: String
- , result: a
- }
-
-outcomeDecoder: Decode.Decoder a -> Decode.Decoder (Outcome a)
-outcomeDecoder adec =
- Decode.map2 Outcome
- (field "state" Decode.string)
- (field "result" adec)
-
--- Flag types
-
-type alias RemoteUrls =
- {baseUrl: String
- ,authLogin: String
- ,authCookie: String
- ,logout: String
- ,accounts: String
- ,uploads: String
- ,uploadData: String
- ,uploadPublish: String
- ,uploadUnpublish: String
- ,download: String
- ,downloadPublished: String
- ,profileEmail: String
- ,profilePassword: String
- ,checkPassword: String
- ,aliases: String
- ,mailDownloadTemplate: String
- ,mailAliasTemplate: String
- ,mailSend: String
- ,uploadNotify: String
- ,manual: String
- }
-
-type alias RemoteConfig =
- { authEnabled: Bool
- , appName: String
- , cookieAge: Float
- , chunkSize: Int
- , simultaneousUploads: Int
- , maxFiles: Int
- , maxFileSize: Int
- , maxValidity: String
- , urls: RemoteUrls
- , projectName: String
- , aliasHeaderName: String
- , mailEnabled: Bool
- , welcomeMessage: String
- }
-
-
--- utility stuff
-
-httpPut: String -> Http.Body -> Decode.Decoder a -> (Http.Request a)
-httpPut url body dec =
- Http.request
- { method = "PUT"
- , headers = []
- , url = url
- , body = body
- , expect = Http.expectJson dec
- , timeout = Nothing
- , withCredentials = False
- }
-
-httpDelete: String -> Http.Body -> Decode.Decoder a -> (Http.Request a)
-httpDelete url body dec =
- Http.request
- { method = "DELETE"
- , headers = []
- , url = url
- , body = body
- , expect = Http.expectJson dec
- , timeout = Nothing
- , withCredentials = False
- }
-
-
-errorMessage: Http.Error -> String
-errorMessage err =
- case err of
- Http.Timeout ->
- "There was a network timeout!"
- Http.NetworkError ->
- "There was a network error!"
- Http.BadStatus resp ->
- (decodeError resp)
- Http.BadPayload msg resp ->
- msg ++ "; Response: " ++ (decodeError resp)
- Http.BadUrl msg ->
- "Internal error: invalid url for request."
-
-isStatusCode: Int -> Http.Error -> Bool
-isStatusCode status err =
- case err of
- Http.BadStatus resp ->
- resp.status.code == status
- _ ->
- False
-
-isUnauthorized: Http.Error -> Bool
-isUnauthorized = isStatusCode 401
-
-isNotFound: Http.Error -> Bool
-isNotFound = isStatusCode 404
-
-decodeError: Http.Response String -> String
-decodeError resp =
- let
- msg =
- Decode.decodeString (field "message" Decode.string) resp.body
- text =
- case msg of
- Ok msg -> msg
- _ -> resp.body
- in
- if ((String.length text) > 0) then text
- else if (resp.status.code == 404) then "The object was not found."
- else "Some error occured at the server without giving specific error message: " ++ (toString resp.status)
-
-
-
-nonEmpty: List a -> Bool
-nonEmpty list =
- not (List.isEmpty list)
-
-
-type SizeUnit = G|M|K|B
-
-prettyNumber: Float -> String
-prettyNumber n =
- let
- parts = String.split "." (toString n)
- in
- case parts of
- n :: d :: [] -> n ++ "." ++ (String.left 2 d)
- _ -> String.join "." parts
-
-bytesReadable: SizeUnit -> Float -> String
-bytesReadable unit n =
- let
- k = n / 1024
- num = prettyNumber n
- in
- case unit of
- G -> num ++ "G"
- M -> if k > 1 then (bytesReadable G k) else num ++ "M"
- K -> if k > 1 then (bytesReadable M k) else num ++ "K"
- B -> if k > 1 then (bytesReadable K k) else num ++ "B"
-
-defer: Cmd m -> (a,b) -> (a, b, Cmd m)
-defer c (a,b) =
- (a, b, c)
-
-htmlList: List (Bool, Html msg) -> List (Html msg)
-htmlList tupleList =
- List.filterMap (\(a, b) -> if a then Just b else Nothing) tupleList
-
-
-maybeOrElse: Maybe a -> Maybe a -> Maybe a
-maybeOrElse a b =
- case a of
- Just _ -> a
- Nothing -> b
-
-nonEmptyStr: String -> Maybe String
-nonEmptyStr str =
- if str == "" then Nothing else Just str
-
-parseMime: String -> (String, String)
-parseMime mime =
- let
- unknown = ("application", "octet-stream")
- in
- case String.split ";" mime of
- x :: [] ->
- case String.split "/" x of
- media :: sub :: [] ->
- (media, sub)
- _ -> unknown
- _ -> unknown
-
-isPresent: Maybe a -> Bool
-isPresent mb =
- Maybe.map (\_ -> True) mb
- |> Maybe.withDefault False
-
-parseDuration: String -> Maybe (Int, String)
-parseDuration str =
- let
- lower = String.toLower str
- in
- if String.startsWith "pt" lower then
- case String.toInt (String.dropLeft 2 lower |> String.dropRight 1) of
- Ok n ->
- (n, String.right 1 lower) |> Just
- _ ->
- Nothing
- else
- Nothing
-
-formatDuration: String -> String
-formatDuration str =
- case parseDuration str of
- Just (n, "h") ->
- if rem n 24 == 0 then
- (toString (n // 24)) ++ "d"
- else
- (toString n) ++ "h"
- Just (n, unit) ->
- (toString n) ++ unit
- Nothing ->
- str
-
-messagesToHtml: List String -> Html msg
-messagesToHtml messages =
- case messages of
- [] -> Html.span[][]
- m :: [] -> Html.span[][Html.text m]
- _ ->
- let
- f m = Html.li [][Html.text m]
- in
- Html.ul [] (List.map f messages)
-
-formatInt2: Int -> String
-formatInt2 n =
- if n < 10 then "0" ++ (toString n)
- else toString n
-
-formatDate: String -> String
-formatDate str =
- case (Date.fromString str) of
- Ok d ->
- let
- year = Date.year d |> toString
- month = Date.month d |> toString
- dow = Date.dayOfWeek d |> toString
- day = Date.day d |> formatInt2
- hour = Date.hour d |> formatInt2
- min = Date.minute d |> formatInt2
- in
- dow ++ ", " ++ day ++ ". " ++ month ++ " " ++ year ++ ", " ++ hour ++ ":" ++ min
- _ ->
- str
-
-markdownHtml: String -> Html msg
-markdownHtml str =
- let
- defaultOpts = Markdown.defaultOptions
- markedOptions = {defaultOpts | sanitize = True, smartypants = True, githubFlavored = Just { tables = True, breaks = False}}
- in
- Markdown.toHtmlWith markedOptions [] str
-
-type alias UploadUpdate =
- { name: String
- }
-
-uploadUpdateEncoder: UploadUpdate -> Encode.Value
-uploadUpdateEncoder up =
- Encode.object
- [ ("name", Encode.string up.name)
- ]
diff --git a/modules/webapp/src/main/elm/Data/AccountState.elm b/modules/webapp/src/main/elm/Data/AccountState.elm
new file mode 100644
index 00000000..8d9e13c0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/AccountState.elm
@@ -0,0 +1,52 @@
+module Data.AccountState exposing
+ ( AccountState(..)
+ , all
+ , fromString
+ , fromStringDefault
+ , fromStringOrActive
+ , toString
+ )
+
+
+type AccountState
+ = Active
+ | Disabled
+
+
+fromString : String -> Maybe AccountState
+fromString str =
+ case String.toLower str of
+ "active" ->
+ Just Active
+
+ "disabled" ->
+ Just Disabled
+
+ _ ->
+ Nothing
+
+
+fromStringDefault : AccountState -> String -> AccountState
+fromStringDefault default str =
+ fromString str
+ |> Maybe.withDefault default
+
+
+fromStringOrActive : String -> AccountState
+fromStringOrActive str =
+ fromStringDefault Active str
+
+
+toString : AccountState -> String
+toString state =
+ case state of
+ Active ->
+ "Active"
+
+ Disabled ->
+ "Disabled"
+
+
+all : List AccountState
+all =
+ [ Active, Disabled ]
diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm
new file mode 100644
index 00000000..88ec852a
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/Flags.elm
@@ -0,0 +1,40 @@
+module Data.Flags exposing (..)
+
+import Api.Model.AppConfig exposing (AppConfig)
+import Api.Model.AuthResult exposing (AuthResult)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Util.Size
+
+
+type alias Flags =
+ { account : Maybe AuthResult
+ , config : AppConfig
+ }
+
+
+getToken : Flags -> Maybe String
+getToken flags =
+ flags.account
+ |> Maybe.andThen (\a -> a.token)
+
+
+withAccount : Flags -> AuthResult -> Flags
+withAccount flags acc =
+ { flags | account = Just acc }
+
+
+withoutAccount : Flags -> Flags
+withoutAccount flags =
+ { flags | account = Nothing }
+
+
+limitsMessage : Flags -> List (Html.Attribute msg) -> Html msg
+limitsMessage flags attr =
+ div attr
+ [ text "Uploads are possible up to "
+ , toFloat flags.config.maxSize
+ |> Util.Size.bytesReadable Util.Size.B
+ |> text
+ , text "."
+ ]
diff --git a/modules/webapp/src/main/elm/Data/UploadData.elm b/modules/webapp/src/main/elm/Data/UploadData.elm
new file mode 100644
index 00000000..8ce84055
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/UploadData.elm
@@ -0,0 +1,28 @@
+module Data.UploadData exposing (UploadData, encode)
+
+import Json.Decode as D
+import Json.Encode as E
+
+
+{-| Values of this type are send via ports to JS to run chunked
+uploads via tus-js-client library.
+-}
+type alias UploadData =
+ { url : String
+ , id : String
+ , files : List D.Value
+ , aliasId : Maybe String
+ }
+
+
+encode : UploadData -> D.Value
+encode data =
+ E.object
+ [ ( "url", E.string data.url )
+ , ( "id", E.string data.id )
+ , ( "files", E.list identity data.files )
+ , ( "aliasId"
+ , Maybe.map E.string data.aliasId
+ |> Maybe.withDefault E.null
+ )
+ ]
diff --git a/modules/webapp/src/main/elm/Data/UploadDict.elm b/modules/webapp/src/main/elm/Data/UploadDict.elm
new file mode 100644
index 00000000..b60f160e
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/UploadDict.elm
@@ -0,0 +1,142 @@
+module Data.UploadDict exposing
+ ( UploadDict
+ , UploadProgress(..)
+ , allDone
+ , countDone
+ , empty
+ , size
+ , trackUpload
+ , updateFiles
+ )
+
+import Data.UploadState exposing (UploadState)
+import Dict exposing (Dict)
+import File exposing (File)
+import Json.Decode as D
+
+
+type alias UploadDict =
+ { selectedFiles : List ( D.Value, File )
+ , uploads : Dict Int UploadState
+ }
+
+
+empty : UploadDict
+empty =
+ { selectedFiles = []
+ , uploads = Dict.empty
+ }
+
+
+updateFiles : UploadDict -> List ( D.Value, File ) -> UploadDict
+updateFiles model files =
+ { model | selectedFiles = files }
+
+
+type UploadProgress
+ = FileProgress Int Int
+ | AllProgress Int
+
+
+size : UploadDict -> Int
+size up =
+ List.map Tuple.second up.selectedFiles
+ |> List.map File.size
+ |> List.sum
+
+
+allDone : UploadDict -> Bool
+allDone up =
+ let
+ ( succ, err ) =
+ countDone up
+ in
+ succ + err == List.length up.selectedFiles
+
+
+countDone : UploadDict -> ( Int, Int )
+countDone { selectedFiles, uploads } =
+ let
+ tupleAdd t1 t2 =
+ ( Tuple.first t1 + Tuple.first t2
+ , Tuple.second t1 + Tuple.second t2
+ )
+
+ count index file =
+ Dict.get index uploads
+ |> Maybe.map .state
+ |> Maybe.map
+ (\s ->
+ case s of
+ Data.UploadState.Complete ->
+ ( 1, 0 )
+
+ Data.UploadState.Progress _ _ ->
+ ( 0, 0 )
+
+ Data.UploadState.Failed _ ->
+ ( 0, 1 )
+ )
+ |> Maybe.withDefault ( 0, 0 )
+ in
+ List.indexedMap count selectedFiles
+ |> List.foldl tupleAdd ( 0, 0 )
+
+
+trackUpload : UploadDict -> UploadState -> ( UploadDict, List UploadProgress )
+trackUpload model state =
+ let
+ next =
+ Dict.insert state.file state model.uploads
+
+ sizeOf index file =
+ Dict.get index next
+ |> Maybe.map .state
+ |> Maybe.map
+ (\s ->
+ case s of
+ Data.UploadState.Complete ->
+ File.size file
+
+ Data.UploadState.Progress n _ ->
+ n
+
+ Data.UploadState.Failed _ ->
+ File.size file
+ )
+ |> Maybe.withDefault 0
+
+ allsize =
+ List.unzip model.selectedFiles
+ |> Tuple.second
+ |> List.map File.size
+ |> List.sum
+
+ currsize =
+ List.unzip model.selectedFiles
+ |> Tuple.second
+ |> List.indexedMap sizeOf
+ |> List.sum
+
+ mkPercent : Int -> Int -> Int
+ mkPercent c t =
+ (toFloat c / toFloat t) * 100 |> round
+
+ filePerc =
+ case state.state of
+ Data.UploadState.Progress cur total ->
+ [ FileProgress state.file (mkPercent cur total)
+ ]
+
+ _ ->
+ []
+
+ allPerc =
+ [ AllProgress (mkPercent currsize allsize)
+ ]
+ in
+ ( { model
+ | uploads = next
+ }
+ , filePerc ++ allPerc
+ )
diff --git a/modules/webapp/src/main/elm/Data/UploadState.elm b/modules/webapp/src/main/elm/Data/UploadState.elm
new file mode 100644
index 00000000..0dd0db4c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/UploadState.elm
@@ -0,0 +1,72 @@
+module Data.UploadState exposing
+ ( FileProgress(..)
+ , UploadState
+ , decode
+ )
+
+import Json.Decode as D
+
+
+{-| Values of this type are received from the JS side to inform about
+upload state.
+-}
+type alias UploadState =
+ { id : String
+ , file : Int
+ , state : FileProgress
+ }
+
+
+type FileProgress
+ = Complete
+ | Progress Int Int
+ | Failed String
+
+
+decode : D.Value -> Result String UploadState
+decode json =
+ D.decodeValue decoder json
+ |> Result.mapError D.errorToString
+
+
+decoder : D.Decoder UploadState
+decoder =
+ D.map3 UploadState
+ (D.field "id" D.string)
+ (D.field "file" D.int)
+ (D.field "progress" progressDecoder)
+
+
+progressDecoder : D.Decoder FileProgress
+progressDecoder =
+ let
+ complete =
+ D.map (\_ -> Complete)
+ (D.field "state" (constant "complete"))
+
+ failed =
+ D.map2 (\e -> \_ -> Failed e)
+ (D.field "error" D.string)
+ (D.field "state" (constant "failed"))
+
+ progress =
+ D.map3 (\a -> \b -> \_ -> Progress a b)
+ (D.field "uploaded" D.int)
+ (D.field "total" D.int)
+ (D.field "state" (constant "progress"))
+ in
+ D.oneOf [ complete, failed, progress ]
+
+
+constant : String -> D.Decoder ()
+constant str =
+ let
+ check s =
+ if String.toLower str == s then
+ D.succeed ()
+
+ else
+ D.fail ("Expected " ++ str ++ " but got: " ++ s)
+ in
+ D.map String.toLower D.string
+ |> D.andThen check
diff --git a/modules/webapp/src/main/elm/Data/ValidityOptions.elm b/modules/webapp/src/main/elm/Data/ValidityOptions.elm
new file mode 100644
index 00000000..43f20f69
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/ValidityOptions.elm
@@ -0,0 +1,90 @@
+module Data.ValidityOptions exposing
+ ( findValidityItem
+ , findValidityItemMillis
+ , validityOptions
+ )
+
+import Data.Flags exposing (Flags)
+import Data.ValidityValue exposing (ValidityValue(..))
+
+
+allValidityOptions : List ( String, ValidityValue )
+allValidityOptions =
+ [ ( "1/2 hour", Minutes 30 )
+ , ( "1 hour", Hours 1 )
+ , ( "2 hours", Hours 2 )
+ , ( "4 hours", Hours 4 )
+ , ( "8 hours", Hours 8 )
+ , ( "16 hours", Hours 16 )
+ , ( "1 day", Days 1 )
+ , ( "2 days", Days 2 )
+ , ( "4 days", Days 4 )
+ , ( "1 week", Days 7 )
+ , ( "2 weeks", Days 14 )
+ , ( "1 month", Days 30 )
+ , ( "2 months", Days 60 )
+ , ( "4 months", Days <| 4 * 30 )
+ , ( "8 months", Days <| 8 * 30 )
+ , ( "12 months", Days 365 )
+ ]
+
+
+validityOptions : Flags -> List ( String, ValidityValue )
+validityOptions flags =
+ let
+ fun ( _, v ) =
+ Data.ValidityValue.toMillis v <= flags.config.maxValidity
+ in
+ List.filter fun allValidityOptions
+
+
+defaultValidity : ( String, ValidityValue )
+defaultValidity =
+ ( "2 days", Days 2 )
+
+
+findValidityItemMillis : Int -> ( String, ValidityValue )
+findValidityItemMillis millis =
+ findValidityItem (Millis millis)
+
+
+{-| Finds the item from the list of options that best matches the
+given validity value.
+-}
+findValidityItem : ValidityValue -> ( String, ValidityValue )
+findValidityItem vv =
+ let
+ ld =
+ List.repeat (List.length allValidityOptions) vv
+
+ diff t a =
+ ( Data.ValidityValue.sub (Tuple.second t) a |> abs, t )
+ in
+ List.map2 diff allValidityOptions ld
+ |> findMinimum
+ |> Maybe.map Tuple.second
+ |> Maybe.withDefault defaultValidity
+
+
+findMinimum :
+ List ( Int, ( String, ValidityValue ) )
+ -> Maybe ( Int, ( String, ValidityValue ) )
+findMinimum list =
+ case list of
+ [] ->
+ Nothing
+
+ x :: xs ->
+ let
+ getmin :
+ ( Int, ( String, ValidityValue ) )
+ -> ( Int, ( String, ValidityValue ) )
+ -> ( Int, ( String, ValidityValue ) )
+ getmin a b =
+ if Tuple.first a < Tuple.first b then
+ a
+
+ else
+ b
+ in
+ Just (List.foldl getmin x xs)
diff --git a/modules/webapp/src/main/elm/Data/ValidityValue.elm b/modules/webapp/src/main/elm/Data/ValidityValue.elm
new file mode 100644
index 00000000..8a0b0d81
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/ValidityValue.elm
@@ -0,0 +1,60 @@
+module Data.ValidityValue exposing
+ ( ValidityValue(..)
+ , gte
+ , lte
+ , sub
+ , toMillis
+ )
+
+
+type ValidityValue
+ = Millis Int
+ | Minutes Int
+ | Hours Int
+ | Days Int
+
+
+toMillis : ValidityValue -> Int
+toMillis v =
+ case v of
+ Millis n ->
+ n
+
+ Minutes n ->
+ n * minutesToMillis
+
+ Hours n ->
+ n * hourToMillis
+
+ Days n ->
+ n * dayToMillis
+
+
+sub : ValidityValue -> ValidityValue -> Int
+sub v1 v2 =
+ toMillis v1 - toMillis v2
+
+
+lte : ValidityValue -> ValidityValue -> Bool
+lte v1 v2 =
+ toMillis v1 <= toMillis v2
+
+
+gte : ValidityValue -> ValidityValue -> Bool
+gte v1 v2 =
+ lte v2 v1
+
+
+minutesToMillis : Int
+minutesToMillis =
+ 60 * 1000
+
+
+hourToMillis : Int
+hourToMillis =
+ 60 * 60 * 1000
+
+
+dayToMillis : Int
+dayToMillis =
+ 24 * hourToMillis
diff --git a/modules/webapp/src/main/elm/Main.elm b/modules/webapp/src/main/elm/Main.elm
index d7c1acec..757951f2 100644
--- a/modules/webapp/src/main/elm/Main.elm
+++ b/modules/webapp/src/main/elm/Main.elm
@@ -1,88 +1,109 @@
module Main exposing (..)
-import AnimationFrame
-import Time exposing (Time, millisecond)
-import App.Model exposing (..)
-import App.Update
-import App.View
-import Data exposing (Account, RemoteConfig)
-import Pages.Login.Model as LoginModel
-import Pages.Login.Commands as LoginCmd
-import Pages.Upload.Model as UploadModel
-import Resumable
+import Api
+import App.Data exposing (..)
+import App.Update exposing (..)
+import App.View exposing (..)
+import Browser exposing (Document)
+import Browser.Navigation exposing (Key)
+import Data.Flags exposing (Flags)
+import Data.UploadState exposing (UploadState)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Page exposing (Page(..))
import Ports
-import Navigation
+import Url exposing (Url)
+
+
+
+-- MAIN
+
+
+main : Program Flags Model Msg
+main =
+ Browser.application
+ { init = init
+ , view = viewDoc
+ , update = update
+ , subscriptions = subscriptions
+ , onUrlRequest = NavRequest
+ , onUrlChange = NavChange
+ }
+
-type alias Flags =
- { account: Maybe Account
- , remoteConfig: RemoteConfig
- }
-init: Flags -> Navigation.Location -> (Model, Cmd Msg)
-init flags location =
+-- MODEL
+
+
+init : Flags -> Url -> Key -> ( Model, Cmd Msg )
+init flags url key =
let
- hasAccount = Maybe.map (\a -> True) flags.account |> Maybe.withDefault False
- model = initModel flags.remoteConfig flags.account location
- (model_, cmd_) = App.Update.update (UrlChange location) model
- cmd = Cmd.batch
- [
- if flags.remoteConfig.authEnabled || hasAccount then
- Cmd.none
- else
- Cmd.map LoginMsg (LoginCmd.authenticate (LoginModel.sharryModel flags.remoteConfig.urls flags.remoteConfig.welcomeMessage))
- ,cmd_
- ]
+ im =
+ App.Data.init key url flags
+
+ page =
+ checkPage flags im.page
+
+ ( m, cmd ) =
+ if im.page == page then
+ App.Update.initPage im page
+
+ else
+ ( im, Page.goto page )
+
+ sessionCheck =
+ case m.flags.account of
+ Just _ ->
+ Api.loginSession flags SessionCheckResp
+
+ Nothing ->
+ Cmd.none
in
- (model_, cmd)
+ ( m, Cmd.batch [ cmd, Api.versionInfo flags VersionResp, sessionCheck ] )
+
+
+viewDoc : Model -> Document Msg
+viewDoc model =
+ { title = model.flags.config.appName
+ , body = [ view model ]
+ }
-fileAddedMsg: (String, Resumable.File) -> Msg
-fileAddedMsg (page, f) =
- ResumableMsg page (Resumable.FileAdded f)
-fileProgressMsg: (String, Float) -> Msg
-fileProgressMsg (page, percent) =
- ResumableMsg page (Resumable.Progress percent)
+-- SUBSCRIPTIONS
-fileErrorMsg: (String, String, Resumable.File) -> Msg
-fileErrorMsg (page, msg, file) =
- ResumableMsg page (Resumable.FileError file msg)
-fileSuccessMsg: (String, Resumable.File) -> Msg
-fileSuccessMsg (page, file) =
- ResumableMsg page (Resumable.FileSuccess file)
+uploadStateSub : Sub Msg
+uploadStateSub =
+ Ports.uploadState (Data.UploadState.decode >> UploadStateMsg)
-fileMaxSizeError: (String, Resumable.File) -> Msg
-fileMaxSizeError (page, file) =
- ResumableMsg page (Resumable.FileError file "The maximum size limit is exceeded!")
-fileMaxCountError: (String, Resumable.File) -> Msg
-fileMaxCountError (page, file) =
- ResumableMsg page (Resumable.FileError file "The maximum file count limit is exceeded!")
+uploadStopped : Sub Msg
+uploadStopped =
+ Ports.uploadStopped UploadStoppedMsg
-subscriptions: Model -> Sub Msg
+
+subscriptions : Model -> Sub Msg
subscriptions model =
- Sub.batch
- [ Time.every (model.serverConfig.cookieAge * millisecond * 0.9) LoginRefresh
- , if model.deferred == [] then Sub.none else AnimationFrame.times DeferredTick
- , Ports.randomString RandomString
- , Ports.resumableHandle (\(page, h) -> ResumableMsg page (Resumable.SetHandle h))
- , Ports.resumableFileAdded fileAddedMsg
- , Ports.resumableProgress fileProgressMsg
- , Ports.resumableError fileErrorMsg
- , Ports.resumableFileSuccess fileSuccessMsg
- , Ports.resumableComplete (\h -> ResumableMsg h Resumable.UploadComplete)
- , Ports.resumableStarted (\h -> ResumableMsg h Resumable.UploadStarted)
- , Ports.resumablePaused (\h -> ResumableMsg h Resumable.UploadPaused)
- , Ports.resumableMaxFilesError fileMaxCountError
- , Ports.resumableMaxFileSizeError fileMaxSizeError
- ]
+ case model.page of
+ SharePage ->
+ Sub.batch
+ [ uploadStateSub
+ , uploadStopped
+ ]
+ OpenSharePage _ ->
+ Sub.batch
+ [ uploadStateSub
+ , uploadStopped
+ ]
-main =
- Navigation.programWithFlags UrlChange
- { init = init
- , view = App.View.view
- , update = App.Update.update
- , subscriptions = subscriptions
- }
+ DetailPage _ ->
+ Sub.batch
+ [ uploadStateSub
+ , uploadStopped
+ ]
+
+ _ ->
+ Sub.none
diff --git a/modules/webapp/src/main/elm/Page.elm b/modules/webapp/src/main/elm/Page.elm
new file mode 100644
index 00000000..65ec2c58
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page.elm
@@ -0,0 +1,252 @@
+module Page exposing
+ ( Page(..)
+ , fromUrl
+ , goto
+ , href
+ , isAdmin
+ , isOpen
+ , isSecured
+ , loginPage
+ , loginPageReferrer
+ , pageFromString
+ , pageToString
+ , set
+ )
+
+import Browser.Navigation as Nav
+import Html exposing (Attribute)
+import Html.Attributes as Attr
+import Url exposing (Url)
+import Url.Parser as Parser exposing ((>), (>), Parser, oneOf, s, string)
+import Url.Parser.Query as Query
+import Util.Maybe
+
+
+type Page
+ = HomePage
+ | LoginPage ( Maybe String, Bool )
+ | RegisterPage
+ | NewInvitePage
+ | InfoPage Int
+ | AccountPage (Maybe String)
+ | AliasPage (Maybe String)
+ | UploadPage
+ | SharePage
+ | OpenSharePage String
+ | SettingsPage
+ | DetailPage String
+ | OpenDetailPage String
+
+
+isSecured : Page -> Bool
+isSecured page =
+ case page of
+ HomePage ->
+ True
+
+ LoginPage _ ->
+ False
+
+ RegisterPage ->
+ False
+
+ NewInvitePage ->
+ True
+
+ InfoPage _ ->
+ False
+
+ AccountPage _ ->
+ True
+
+ AliasPage _ ->
+ True
+
+ UploadPage ->
+ True
+
+ SharePage ->
+ True
+
+ OpenSharePage _ ->
+ False
+
+ SettingsPage ->
+ True
+
+ DetailPage _ ->
+ True
+
+ OpenDetailPage _ ->
+ False
+
+
+isAdmin : Page -> Bool
+isAdmin page =
+ case page of
+ NewInvitePage ->
+ True
+
+ AccountPage _ ->
+ True
+
+ _ ->
+ False
+
+
+isOpen : Page -> Bool
+isOpen page =
+ not (isSecured page || isAdmin page)
+
+
+loginPageReferrer : Page -> ( Maybe Page, Bool )
+loginPageReferrer page =
+ case page of
+ LoginPage ( r, flag ) ->
+ ( Maybe.andThen pageFromString r, flag )
+
+ _ ->
+ ( Nothing, False )
+
+
+loginPage : Page -> Page
+loginPage p =
+ case p of
+ LoginPage _ ->
+ LoginPage ( Nothing, False )
+
+ _ ->
+ LoginPage ( Just (pageToString p), False )
+
+
+pageToString : Page -> String
+pageToString page =
+ case page of
+ HomePage ->
+ "/app/home"
+
+ LoginPage ( referer, oauth ) ->
+ Maybe.map (\p -> "?r=" ++ p) referer
+ |> Maybe.withDefault ""
+ |> (++) "/app/login"
+
+ RegisterPage ->
+ "/app/register"
+
+ NewInvitePage ->
+ "/app/newinvite"
+
+ InfoPage n ->
+ "/app/info/" ++ String.fromInt n
+
+ AccountPage mid ->
+ let
+ path =
+ "/app/account"
+ in
+ Maybe.map (\id -> path ++ "/" ++ id) mid
+ |> Maybe.withDefault path
+
+ AliasPage mid ->
+ let
+ path =
+ "/app/alias"
+ in
+ Maybe.map (\id -> path ++ "/" ++ id) mid
+ |> Maybe.withDefault path
+
+ UploadPage ->
+ "/app/uploads"
+
+ SharePage ->
+ "/app/share"
+
+ OpenSharePage id ->
+ "/app/share/" ++ id
+
+ SettingsPage ->
+ "/app/settings"
+
+ DetailPage id ->
+ "/app/upload/" ++ id
+
+ OpenDetailPage id ->
+ "/app/open/" ++ id
+
+
+pageFromString : String -> Maybe Page
+pageFromString str =
+ let
+ urlNormed =
+ if String.startsWith str "http" then
+ str
+
+ else
+ "http://somehost" ++ str
+
+ url =
+ Url.fromString urlNormed
+ in
+ Maybe.andThen (Parser.parse parser) url
+
+
+href : Page -> Attribute msg
+href page =
+ Attr.href (pageToString page)
+
+
+goto : Page -> Cmd msg
+goto page =
+ Nav.load (pageToString page)
+
+
+set : Nav.Key -> Page -> Cmd msg
+set key page =
+ Nav.pushUrl key (pageToString page)
+
+
+pathPrefix : String
+pathPrefix =
+ "app"
+
+
+parser : Parser (Page -> a) a
+parser =
+ oneOf
+ [ Parser.map HomePage Parser.top
+ , Parser.map HomePage (s pathPrefix > s "home")
+ , Parser.map LoginPage (s pathPrefix > s "login" > loginPageParser)
+ , Parser.map RegisterPage (s pathPrefix > s "register")
+ , Parser.map NewInvitePage (s pathPrefix > s "newinvite")
+ , Parser.map InfoPage (s pathPrefix > s "info" > Parser.int)
+ , Parser.map (\s -> AccountPage (Just s)) (s pathPrefix > s "account" > string)
+ , Parser.map (AccountPage Nothing) (s pathPrefix > s "account")
+ , Parser.map (\s -> AliasPage (Just s)) (s pathPrefix > s "alias" > string)
+ , Parser.map (AliasPage Nothing) (s pathPrefix > s "alias")
+ , Parser.map UploadPage (s pathPrefix > s "uploads")
+ , Parser.map DetailPage (s pathPrefix > s "upload" > string)
+ , Parser.map OpenSharePage (s pathPrefix > s "share" > string)
+ , Parser.map SharePage (s pathPrefix > s "share")
+ , Parser.map SettingsPage (s pathPrefix > s "settings")
+ , Parser.map OpenDetailPage (s pathPrefix > s "open" > string)
+ ]
+
+
+fromUrl : Url -> Maybe Page
+fromUrl url =
+ Parser.parse parser url
+
+
+loginPageOAuthQuery : Query.Parser Bool
+loginPageOAuthQuery =
+ Query.map Util.Maybe.nonEmpty (Query.string "oauth")
+
+
+loginPageReferrerQuery : Query.Parser (Maybe String)
+loginPageReferrerQuery =
+ Query.string "r"
+
+
+loginPageParser : Query.Parser ( Maybe String, Bool )
+loginPageParser =
+ Query.map2 Tuple.pair loginPageReferrerQuery loginPageOAuthQuery
diff --git a/modules/webapp/src/main/elm/Page/Account/Data.elm b/modules/webapp/src/main/elm/Page/Account/Data.elm
new file mode 100644
index 00000000..86338532
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Account/Data.elm
@@ -0,0 +1,43 @@
+module Page.Account.Data exposing
+ ( Model
+ , Msg(..)
+ , emptyModel
+ )
+
+import Api.Model.AccountDetail exposing (AccountDetail)
+import Api.Model.AccountList exposing (AccountList)
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.AccountForm
+import Comp.AccountTable
+import Http
+
+
+type alias Model =
+ { selected : Maybe AccountDetail
+ , searchResult : List AccountDetail
+ , query : String
+ , tableModel : Comp.AccountTable.Model
+ , formModel : Comp.AccountForm.Model
+ , saveResult : Maybe BasicResult
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { selected = Nothing
+ , searchResult = []
+ , query = ""
+ , saveResult = Nothing
+ , tableModel = Comp.AccountTable.init
+ , formModel = Comp.AccountForm.initNew
+ }
+
+
+type Msg
+ = Init (Maybe String)
+ | SearchResp (Result Http.Error AccountList)
+ | LoadResp (Result Http.Error AccountDetail)
+ | SetQuery String
+ | AccountTableMsg Comp.AccountTable.Msg
+ | AccountFormMsg Comp.AccountForm.Msg
+ | SaveResp (Result Http.Error BasicResult)
diff --git a/modules/webapp/src/main/elm/Page/Account/Update.elm b/modules/webapp/src/main/elm/Page/Account/Update.elm
new file mode 100644
index 00000000..725c6de6
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Account/Update.elm
@@ -0,0 +1,120 @@
+module Page.Account.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Browser.Navigation as Nav
+import Comp.AccountForm exposing (FormAction(..))
+import Comp.AccountTable
+import Data.Flags exposing (Flags)
+import Page exposing (Page(..))
+import Page.Account.Data exposing (Model, Msg(..))
+import Util.Http
+
+
+update : Nav.Key -> Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update key flags msg model =
+ case msg of
+ Init (Just id) ->
+ let
+ current =
+ Maybe.map .id model.selected
+ |> Maybe.map ((==) id)
+ |> Maybe.withDefault False
+ in
+ if id == "new" then
+ ( { model
+ | selected = Nothing
+ , formModel = Comp.AccountForm.initNew
+ }
+ , Cmd.none
+ )
+
+ else if current then
+ ( model, Cmd.none )
+
+ else
+ ( model, Api.loadAccount flags id LoadResp )
+
+ Init Nothing ->
+ ( { model
+ | selected = Nothing
+ , formModel = Comp.AccountForm.initNew
+ }
+ , Api.listAccounts flags model.query SearchResp
+ )
+
+ SearchResp (Ok list) ->
+ ( { model | searchResult = list.items }, Cmd.none )
+
+ SearchResp (Err err) ->
+ ( model, Cmd.none )
+
+ LoadResp (Ok acc) ->
+ ( { model
+ | selected = Just acc
+ , formModel = Comp.AccountForm.initModify acc
+ }
+ , Cmd.none
+ )
+
+ LoadResp (Err err) ->
+ ( model, Cmd.none )
+
+ SetQuery str ->
+ ( { model | query = str }
+ , Api.listAccounts flags str SearchResp
+ )
+
+ AccountTableMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.AccountTable.update lmsg model.tableModel
+
+ cmd =
+ Page.set key (AccountPage (Maybe.map .id sel))
+ in
+ ( { model
+ | tableModel = m
+ , selected = sel
+ , formModel = Comp.AccountForm.init sel
+ }
+ , cmd
+ )
+
+ AccountFormMsg lmsg ->
+ let
+ ( m, action ) =
+ Comp.AccountForm.update lmsg model.formModel
+
+ cmd =
+ case action of
+ FormCreated ac ->
+ Api.createAccount flags ac SaveResp
+
+ FormModified id am ->
+ Api.modifyAccount flags id am SaveResp
+
+ FormCancelled ->
+ Page.set key (AccountPage Nothing)
+
+ FormNone ->
+ Cmd.none
+ in
+ ( { model
+ | formModel = m
+ , saveResult = Nothing
+ }
+ , cmd
+ )
+
+ SaveResp (Ok r) ->
+ ( { model | saveResult = Just r }, Cmd.none )
+
+ SaveResp (Err err) ->
+ let
+ errmsg =
+ Util.Http.errorToString err
+ in
+ ( { model | saveResult = Just <| BasicResult False errmsg }
+ , Cmd.none
+ )
diff --git a/modules/webapp/src/main/elm/Page/Account/View.elm b/modules/webapp/src/main/elm/Page/Account/View.elm
new file mode 100644
index 00000000..fa242002
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Account/View.elm
@@ -0,0 +1,102 @@
+module Page.Account.View exposing (view)
+
+import Api.Model.AccountDetail exposing (AccountDetail)
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.AccountForm
+import Comp.AccountTable
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onInput)
+import Page exposing (Page(..))
+import Page.Account.Data exposing (Model, Msg(..))
+import Util.Html
+
+
+view : Maybe String -> Model -> Html Msg
+view id model =
+ div
+ [ classList
+ [ ( "ui container account-page", True )
+ , ( "text", id /= Nothing )
+ ]
+ ]
+ <|
+ case model.selected of
+ Just acc ->
+ viewModify model acc
+
+ Nothing ->
+ if id == Just "new" then
+ viewCreate model
+
+ else
+ viewList model
+
+
+viewCreate : Model -> List (Html Msg)
+viewCreate model =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui user circle outline icon" ] []
+ , text "Create a new internal account"
+ ]
+ , div [ class "" ]
+ [ Html.map AccountFormMsg (Comp.AccountForm.view model.formModel)
+ ]
+ , Maybe.map Util.Html.resultMsg model.saveResult
+ |> Maybe.withDefault Util.Html.noElement
+ ]
+
+
+viewModify : Model -> AccountDetail -> List (Html Msg)
+viewModify model acc =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui user circle icon" ] []
+ , text acc.login
+ ]
+ , div [ class "" ]
+ [ Html.map AccountFormMsg (Comp.AccountForm.view model.formModel)
+ ]
+ , Maybe.map Util.Html.resultMsg model.saveResult
+ |> Maybe.withDefault Util.Html.noElement
+ ]
+
+
+viewList : Model -> List (Html Msg)
+viewList model =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui users icon" ] []
+ , text "Accounts"
+ ]
+ , searchArea model
+ , Html.map AccountTableMsg (Comp.AccountTable.view model.searchResult model.tableModel)
+ ]
+
+
+searchArea : Model -> Html Msg
+searchArea model =
+ div [ class "ui secondary menu" ]
+ [ div [ class "ui container" ]
+ [ div [ class "fitted-item" ]
+ [ div [ class "ui icon input" ]
+ [ input
+ [ type_ "text"
+ , onInput SetQuery
+ , placeholder "Search…"
+ ]
+ []
+ , i [ class "ui search icon" ]
+ []
+ ]
+ ]
+ , div [ class "right menu" ]
+ [ div [ class "fitted-item" ]
+ [ a
+ [ class "ui primary button"
+ , Page.href (AccountPage (Just "new"))
+ ]
+ [ text "New Account"
+ ]
+ ]
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Page/Alias/Data.elm b/modules/webapp/src/main/elm/Page/Alias/Data.elm
new file mode 100644
index 00000000..136f3419
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Alias/Data.elm
@@ -0,0 +1,48 @@
+module Page.Alias.Data exposing (Model, Msg(..), emptyModel)
+
+import Api.Model.AliasDetail exposing (AliasDetail)
+import Api.Model.AliasList exposing (AliasList)
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.IdResult exposing (IdResult)
+import Api.Model.MailTemplate exposing (MailTemplate)
+import Comp.AliasForm
+import Comp.AliasTable
+import Comp.MailSend
+import Data.Flags exposing (Flags)
+import Http
+
+
+type alias Model =
+ { selected : Maybe AliasDetail
+ , searchResult : List AliasDetail
+ , query : String
+ , tableModel : Comp.AliasTable.Model
+ , formModel : Comp.AliasForm.Model
+ , saveResult : Maybe BasicResult
+ , mailForm : Maybe Comp.MailSend.Model
+ }
+
+
+emptyModel : Flags -> Model
+emptyModel flags =
+ { selected = Nothing
+ , searchResult = []
+ , query = ""
+ , tableModel = Comp.AliasTable.init
+ , formModel = Comp.AliasForm.initNew flags
+ , saveResult = Nothing
+ , mailForm = Nothing
+ }
+
+
+type Msg
+ = Init (Maybe String)
+ | SearchResp (Result Http.Error AliasList)
+ | LoadResp (Result Http.Error AliasDetail)
+ | SetQuery String
+ | AliasTableMsg Comp.AliasTable.Msg
+ | AliasFormMsg Comp.AliasForm.Msg
+ | SaveResp (Result Http.Error IdResult)
+ | DeleteResp (Result Http.Error BasicResult)
+ | MailFormMsg Comp.MailSend.Msg
+ | InitMail
diff --git a/modules/webapp/src/main/elm/Page/Alias/Update.elm b/modules/webapp/src/main/elm/Page/Alias/Update.elm
new file mode 100644
index 00000000..b6d49ef7
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Alias/Update.elm
@@ -0,0 +1,190 @@
+module Page.Alias.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Browser.Navigation as Nav
+import Comp.AliasForm exposing (FormAction(..))
+import Comp.AliasTable
+import Comp.MailSend
+import Data.Flags exposing (Flags)
+import Page exposing (Page(..))
+import Page.Alias.Data exposing (Model, Msg(..))
+import Util.Http
+
+
+update : Nav.Key -> Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update key flags msg model =
+ case msg of
+ Init (Just id) ->
+ let
+ current =
+ Maybe.map .id model.selected
+ |> Maybe.map ((==) id)
+ |> Maybe.withDefault False
+ in
+ if id == "new" then
+ ( { model
+ | selected = Nothing
+ , formModel = Comp.AliasForm.initNew flags
+ }
+ , Cmd.none
+ )
+
+ else if current then
+ ( model, Cmd.none )
+
+ else
+ ( model, Api.getAlias flags id LoadResp )
+
+ Init Nothing ->
+ ( { model
+ | selected = Nothing
+ , formModel = Comp.AliasForm.initNew flags
+ }
+ , Api.listAlias flags model.query SearchResp
+ )
+
+ SearchResp (Ok list) ->
+ ( { model | searchResult = list.items }, Cmd.none )
+
+ SearchResp (Err err) ->
+ ( { model | saveResult = Just <| BasicResult False (Util.Http.errorToString err) }
+ , Cmd.none
+ )
+
+ LoadResp (Ok alias_) ->
+ ( { model
+ | selected = Just alias_
+ , formModel = Comp.AliasForm.initModify flags alias_
+ }
+ , Cmd.none
+ )
+
+ LoadResp (Err err) ->
+ ( model, Cmd.none )
+
+ SetQuery str ->
+ ( { model | query = str }
+ , Api.listAlias flags str SearchResp
+ )
+
+ AliasTableMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.AliasTable.update lmsg model.tableModel
+
+ cmd =
+ Page.set key (AliasPage (Maybe.map .id sel))
+ in
+ ( { model
+ | tableModel = m
+ , selected = sel
+ , formModel = Comp.AliasForm.init flags sel
+ }
+ , cmd
+ )
+
+ AliasFormMsg lmsg ->
+ let
+ ( m, action ) =
+ Comp.AliasForm.update lmsg model.formModel
+
+ cmd =
+ case action of
+ FormCreated ac ->
+ Api.createAlias flags ac SaveResp
+
+ FormModified id am ->
+ Api.modifyAlias flags id am SaveResp
+
+ FormCancelled ->
+ Page.set key (AliasPage Nothing)
+
+ FormDelete id ->
+ Api.deleteAlias flags id DeleteResp
+
+ FormNone ->
+ Cmd.none
+ in
+ ( { model
+ | formModel = m
+ , saveResult = Nothing
+ }
+ , cmd
+ )
+
+ SaveResp (Ok r) ->
+ ( { model | saveResult = Just <| BasicResult r.success r.message }
+ , if Maybe.map .id model.selected /= Just r.id && r.success then
+ Page.goto (AliasPage (Just r.id))
+
+ else
+ Cmd.none
+ )
+
+ SaveResp (Err err) ->
+ let
+ errmsg =
+ Util.Http.errorToString err
+ in
+ ( { model | saveResult = Just <| BasicResult False errmsg }
+ , Cmd.none
+ )
+
+ DeleteResp (Ok r) ->
+ ( { model | saveResult = Just r }
+ , if r.success then
+ Page.goto (AliasPage Nothing)
+
+ else
+ Cmd.none
+ )
+
+ DeleteResp (Err err) ->
+ let
+ errmsg =
+ Util.Http.errorToString err
+ in
+ ( { model | saveResult = Just <| BasicResult False errmsg }
+ , Cmd.none
+ )
+
+ MailFormMsg lmsg ->
+ case model.mailForm of
+ Nothing ->
+ ( model, Cmd.none )
+
+ Just msm ->
+ let
+ ( mm, act ) =
+ Comp.MailSend.update flags lmsg msm
+ in
+ case act of
+ Comp.MailSend.Run c ->
+ ( { model | mailForm = Just mm }, Cmd.map MailFormMsg c )
+
+ Comp.MailSend.Cancelled ->
+ ( { model | mailForm = Nothing }
+ , Cmd.none
+ )
+
+ Comp.MailSend.Sent ->
+ ( { model | mailForm = Nothing }
+ , Cmd.none
+ )
+
+ InitMail ->
+ let
+ aliasId =
+ Maybe.map .id model.selected
+ |> Maybe.withDefault ""
+
+ getTpl =
+ Api.getAliasTemplate flags aliasId
+
+ ( mm, mc ) =
+ Comp.MailSend.init getTpl
+ in
+ ( { model | mailForm = Just mm }
+ , Cmd.map MailFormMsg mc
+ )
diff --git a/modules/webapp/src/main/elm/Page/Alias/View.elm b/modules/webapp/src/main/elm/Page/Alias/View.elm
new file mode 100644
index 00000000..4c3d17c0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Alias/View.elm
@@ -0,0 +1,173 @@
+module Page.Alias.View exposing (view)
+
+import Api.Model.AliasDetail exposing (AliasDetail)
+import Comp.AliasForm
+import Comp.AliasTable
+import Comp.MailSend
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Page exposing (Page(..))
+import Page.Alias.Data exposing (Model, Msg(..))
+import QRCode
+import Util.Html
+
+
+view : Flags -> Maybe String -> Model -> Html Msg
+view flags id model =
+ div
+ [ classList
+ [ ( "ui container alias-page", True )
+ , ( "text", id /= Nothing )
+ , ( "one column grid", model.selected /= Nothing )
+ ]
+ ]
+ <|
+ case model.selected of
+ Just alias_ ->
+ viewModify flags model alias_
+
+ Nothing ->
+ if id == Just "new" then
+ viewCreate model
+
+ else
+ viewList model
+
+
+viewCreate : Model -> List (Html Msg)
+viewCreate model =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui upload icon" ] []
+ , text "Create New Alias Page"
+ ]
+ , Html.map AliasFormMsg (Comp.AliasForm.view model.formModel)
+ , Maybe.map Util.Html.resultMsg model.saveResult
+ |> Maybe.withDefault Util.Html.noElement
+ ]
+
+
+viewModify : Flags -> Model -> AliasDetail -> List (Html Msg)
+viewModify flags model alias_ =
+ [ div [ class "row" ]
+ [ div [ class "column" ]
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui upload icon" ] []
+ , text "Alias Page: "
+ , text alias_.name
+ ]
+ , Html.map AliasFormMsg (Comp.AliasForm.view model.formModel)
+ , Maybe.map Util.Html.resultMsg model.saveResult
+ |> Maybe.withDefault Util.Html.noElement
+ ]
+ ]
+ , div [ class "row" ]
+ [ div [ class "column" ]
+ [ shareText flags model alias_
+ ]
+ ]
+ ]
+
+
+viewList : Model -> List (Html Msg)
+viewList model =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui users icon" ] []
+ , text "Alias Pages"
+ ]
+ , searchArea model
+ , Html.map AliasTableMsg (Comp.AliasTable.view model.searchResult model.tableModel)
+ ]
+
+
+searchArea : Model -> Html Msg
+searchArea model =
+ div [ class "ui secondary menu" ]
+ [ div [ class "ui container" ]
+ [ div [ class "fitted-item" ]
+ [ div [ class "ui icon input" ]
+ [ input
+ [ type_ "text"
+ , onInput SetQuery
+ , placeholder "Search…"
+ ]
+ []
+ , i [ class "ui search icon" ]
+ []
+ ]
+ ]
+ , div [ class "right menu" ]
+ [ div [ class "fitted-item" ]
+ [ a
+ [ class "ui primary button"
+ , Page.href (AliasPage (Just "new"))
+ ]
+ [ text "New Alias Page"
+ ]
+ ]
+ ]
+ ]
+ ]
+
+
+qrCodeView : String -> Html msg
+qrCodeView message =
+ QRCode.encode message
+ |> Result.map QRCode.toSvg
+ |> Result.withDefault
+ (Html.text "Error while encoding to QRCode.")
+
+
+shareText : Flags -> Model -> AliasDetail -> Html Msg
+shareText flags model alias_ =
+ let
+ url =
+ flags.config.baseUrl ++ Page.pageToString (OpenSharePage alias_.id)
+ in
+ div [ class "segments" ]
+ [ div [ class "ui top attached header segment" ]
+ [ text "Share this link"
+ ]
+ , div [ class "ui attached message segment" ]
+ [ text "The alias page is now at: "
+ , pre [ class "url" ]
+ [ code []
+ [ text url
+ ]
+ ]
+ , text "You can share this URL with others to receive files from them."
+ ]
+ , case model.mailForm of
+ Just msm ->
+ Html.map MailFormMsg (Comp.MailSend.view [ ( "ui bottom attached segment", True ) ] msm)
+
+ Nothing ->
+ shareInfo flags model url
+ ]
+
+
+shareInfo : Flags -> Model -> String -> Html Msg
+shareInfo flags model url =
+ div [ class "ui bottom attached segment" ]
+ [ div [ class "ui two column stackable center aligned grid" ]
+ [ div [ class "ui vertical divider" ] [ text "Or" ]
+ , div [ class "middle aligned row" ]
+ [ div [ class "column" ]
+ [ qrCodeView url
+ ]
+ , div [ class "column" ]
+ [ a
+ [ classList
+ [ ( "ui primary button", True )
+ , ( "disabled", not flags.config.mailEnabled )
+ ]
+ , onClick InitMail
+ , href "#"
+ ]
+ [ text "Send E-Mail"
+ ]
+ ]
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Page/Detail/Data.elm b/modules/webapp/src/main/elm/Page/Detail/Data.elm
new file mode 100644
index 00000000..8da6f4e0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Detail/Data.elm
@@ -0,0 +1,222 @@
+module Page.Detail.Data exposing
+ ( EditField(..)
+ , LoaderModel
+ , Model
+ , Msg(..)
+ , Property(..)
+ , PublishState(..)
+ , TopMenuState(..)
+ , deleteLoader
+ , emptyModel
+ , getLoader
+ , isEdit
+ , isPublished
+ , mkEditField
+ , noLoader
+ )
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Api.Model.ShareFile exposing (ShareFile)
+import Comp.Dropzone2
+import Comp.IntInput
+import Comp.MailSend
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ShareFileList
+import Comp.ValidityField
+import Comp.YesNoDimmer
+import Data.Flags exposing (Flags)
+import Data.UploadDict exposing (UploadDict)
+import Data.UploadState exposing (UploadState)
+import Data.ValidityValue exposing (ValidityValue)
+import Http
+
+
+type alias Model =
+ { share : ShareDetail
+ , topMenu : TopMenuState
+ , fileListModel : Comp.ShareFileList.Model
+ , message : Maybe BasicResult
+ , fileView : Comp.ShareFileList.ViewMode
+ , zoom : Maybe ShareFile
+ , yesNoModel : Comp.YesNoDimmer.Model
+ , descEdit : Maybe ( Comp.MarkdownInput.Model, String )
+ , editField : Maybe ( Property, EditField )
+ , dropzone : Comp.Dropzone2.Model
+ , uploads : UploadDict
+ , addFilesOpen : Bool
+ , uploading : Bool
+ , uploadPaused : Bool
+ , uploadFormState : BasicResult
+ , loader : LoaderModel
+ , mailForm : Maybe Comp.MailSend.Model
+ }
+
+
+type TopMenuState
+ = TopClosed
+ | TopDetail
+ | TopShare
+
+
+type PublishState
+ = Unpublished
+ | PublishOk
+ | PublishExpired
+ | MaxViewsExceeded
+
+
+type Property
+ = Name
+ | MaxViews
+ | Validity
+ | Password
+
+
+type alias LoaderModel =
+ { active : Bool
+ , message : String
+ }
+
+
+type EditField
+ = EditName (Maybe String)
+ | EditMaxViews ( Comp.IntInput.Model, Maybe Int )
+ | EditValidity ( Comp.ValidityField.Model, ValidityValue )
+ | EditPassword ( Comp.PasswordInput.Model, Maybe String )
+
+
+emptyModel : Model
+emptyModel =
+ { share = Api.Model.ShareDetail.empty
+ , topMenu = TopClosed
+ , fileListModel = Comp.ShareFileList.init
+ , message = Nothing
+ , fileView = Comp.ShareFileList.ViewList
+ , zoom = Nothing
+ , yesNoModel = Comp.YesNoDimmer.emptyModel
+ , descEdit = Nothing
+ , editField = Nothing
+ , dropzone = Comp.Dropzone2.init
+ , uploads = Data.UploadDict.empty
+ , addFilesOpen = False
+ , uploading = False
+ , uploadPaused = True
+ , uploadFormState = BasicResult True ""
+ , loader = noLoader
+ , mailForm = Nothing
+ }
+
+
+deleteLoader : LoaderModel
+deleteLoader =
+ { active = True
+ , message = "Deleting share. Please wait."
+ }
+
+
+getLoader : LoaderModel
+getLoader =
+ { active = True
+ , message = "Loading data..."
+ }
+
+
+noLoader : LoaderModel
+noLoader =
+ { active = False
+ , message = ""
+ }
+
+
+mkEditField : Flags -> Model -> Property -> EditField
+mkEditField flags model prop =
+ case prop of
+ Name ->
+ EditName model.share.name
+
+ MaxViews ->
+ EditMaxViews
+ ( Comp.IntInput.init (Just 1) Nothing
+ , Just model.share.maxViews
+ )
+
+ Validity ->
+ EditValidity
+ ( Comp.ValidityField.init flags
+ , Data.ValidityValue.Millis model.share.validity
+ )
+
+ Password ->
+ EditPassword
+ ( Comp.PasswordInput.init
+ , Nothing
+ )
+
+
+isEdit : Model -> Property -> Maybe EditField
+isEdit model prop =
+ Maybe.andThen
+ (\t ->
+ if Tuple.first t == prop then
+ Just (Tuple.second t)
+
+ else
+ Nothing
+ )
+ model.editField
+
+
+type Msg
+ = Init String
+ | DetailResp (Result Http.Error ShareDetail)
+ | SetTopMenu TopMenuState
+ | PublishShare Bool
+ | BasicResp (Result Http.Error BasicResult)
+ | FileListMsg Comp.ShareFileList.Msg
+ | SetFileView Comp.ShareFileList.ViewMode
+ | QuitZoom
+ | SetZoom ShareFile
+ | RequestDelete
+ | YesNoMsg Comp.YesNoDimmer.Msg
+ | DeleteResp (Result Http.Error BasicResult)
+ | ToggleEditDesc
+ | DescEditMsg Comp.MarkdownInput.Msg
+ | SaveDescription
+ | ReqEdit Property
+ | SetName String
+ | MaxViewMsg Comp.IntInput.Msg
+ | ValidityEditMsg Comp.ValidityField.Msg
+ | PasswordEditMsg Comp.PasswordInput.Msg
+ | SaveEdit
+ | CancelEdit
+ | ToggleFilesMenu
+ | DropzoneMsg Comp.Dropzone2.Msg
+ | ResetFileForm
+ | SubmitFiles
+ | Uploading UploadState
+ | UploadStopped (Maybe String)
+ | StartStopUpload
+ | MailFormMsg Comp.MailSend.Msg
+ | InitMail
+
+
+isPublished : ShareDetail -> PublishState
+isPublished share =
+ case share.publishInfo of
+ Nothing ->
+ Unpublished
+
+ Just info ->
+ if not info.enabled then
+ Unpublished
+
+ else if info.expired then
+ PublishExpired
+
+ else if info.views >= share.maxViews then
+ MaxViewsExceeded
+
+ else
+ PublishOk
diff --git a/modules/webapp/src/main/elm/Page/Detail/Update.elm b/modules/webapp/src/main/elm/Page/Detail/Update.elm
new file mode 100644
index 00000000..2784e298
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Detail/Update.elm
@@ -0,0 +1,508 @@
+module Page.Detail.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.PublishData exposing (PublishData)
+import Comp.Dropzone2
+import Comp.IntInput
+import Comp.MailSend
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ShareFileList
+import Comp.ValidityField
+import Comp.YesNoDimmer
+import Data.Flags exposing (Flags)
+import Data.UploadData exposing (UploadData)
+import Data.UploadDict
+import Data.UploadState exposing (UploadState)
+import Data.ValidityValue
+import Page exposing (Page(..))
+import Page.Detail.Data
+ exposing
+ ( EditField(..)
+ , LoaderModel
+ , Model
+ , Msg(..)
+ , PublishState(..)
+ , TopMenuState(..)
+ , deleteLoader
+ , getLoader
+ , isEdit
+ , isPublished
+ , mkEditField
+ , noLoader
+ )
+import Ports
+import Util.Http
+import Util.Maybe
+import Util.Share
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ Init id ->
+ ( { model | loader = getLoader }, Api.getShare flags id DetailResp )
+
+ DetailResp (Ok details) ->
+ ( { model
+ | share = details
+ , message = Nothing
+ , descEdit = Nothing
+ , loader = noLoader
+ }
+ , Cmd.none
+ )
+
+ DetailResp (Err err) ->
+ let
+ m =
+ Util.Http.errorToString err
+ in
+ ( { model
+ | message = Just (BasicResult False m)
+ , loader = noLoader
+ }
+ , Cmd.none
+ )
+
+ SetTopMenu state ->
+ let
+ newState =
+ if model.topMenu == state then
+ TopClosed
+
+ else
+ state
+ in
+ ( { model
+ | topMenu = newState
+ , descEdit = Nothing
+ , message = Nothing
+ }
+ , Cmd.none
+ )
+
+ PublishShare flag ->
+ let
+ cmd =
+ case isPublished model.share of
+ Unpublished ->
+ Api.publishShare flags model.share.id (PublishData flag) BasicResp
+
+ _ ->
+ Api.unpublishShare flags model.share.id BasicResp
+ in
+ ( model, cmd )
+
+ BasicResp (Ok res) ->
+ if res.success then
+ update flags (Init model.share.id) model
+
+ else
+ ( { model | message = Just res }, Cmd.none )
+
+ BasicResp (Err err) ->
+ let
+ m =
+ Util.Http.errorToString err
+ in
+ ( { model | message = Just (BasicResult False m) }
+ , Cmd.none
+ )
+
+ FileListMsg lmsg ->
+ let
+ ( m, action ) =
+ Comp.ShareFileList.update lmsg model.fileListModel
+ in
+ case action of
+ Comp.ShareFileList.FileClick sf ->
+ ( { model | fileListModel = m, zoom = Just sf }
+ , Ports.scrollTop ()
+ )
+
+ Comp.ShareFileList.FileDelete sf ->
+ ( { model | fileListModel = m, zoom = Nothing }
+ , Api.deleteFile flags model.share.id sf.id BasicResp
+ )
+
+ Comp.ShareFileList.FileNone ->
+ ( { model | fileListModel = m }, Cmd.none )
+
+ SetFileView mode ->
+ ( { model
+ | fileView = mode
+ , fileListModel = Comp.ShareFileList.reset model.fileListModel
+ }
+ , Cmd.none
+ )
+
+ QuitZoom ->
+ case model.zoom of
+ Just file ->
+ ( { model | zoom = Nothing }, Ports.scrollToElem file.id )
+
+ Nothing ->
+ ( { model | zoom = Nothing }, Cmd.none )
+
+ SetZoom sf ->
+ ( { model | zoom = Just sf }, Cmd.none )
+
+ RequestDelete ->
+ ( { model
+ | yesNoModel = Comp.YesNoDimmer.activate model.yesNoModel
+ }
+ , Cmd.none
+ )
+
+ YesNoMsg lmsg ->
+ let
+ ( m, flag ) =
+ Comp.YesNoDimmer.update lmsg model.yesNoModel
+
+ cmd =
+ if flag then
+ Api.deleteShare flags model.share.id DeleteResp
+
+ else
+ Cmd.none
+
+ loading =
+ if flag then
+ deleteLoader
+
+ else
+ noLoader
+ in
+ ( { model | yesNoModel = m, loader = loading }, cmd )
+
+ DeleteResp (Ok res) ->
+ if res.success then
+ ( { model | loader = noLoader }, Page.goto UploadPage )
+
+ else
+ ( { model | message = Just res, loader = noLoader }, Cmd.none )
+
+ DeleteResp (Err err) ->
+ let
+ m =
+ Util.Http.errorToString err
+ in
+ ( { model | message = Just (BasicResult False m), loader = noLoader }
+ , Cmd.none
+ )
+
+ ToggleEditDesc ->
+ case model.descEdit of
+ Just _ ->
+ ( { model | descEdit = Nothing }, Cmd.none )
+
+ Nothing ->
+ ( { model
+ | descEdit =
+ Just
+ ( Comp.MarkdownInput.init
+ , Maybe.withDefault "" model.share.descriptionRaw
+ )
+ , topMenu = TopClosed
+ }
+ , Cmd.none
+ )
+
+ DescEditMsg lmsg ->
+ case model.descEdit of
+ Just ( dm, _ ) ->
+ let
+ txt =
+ Maybe.withDefault "" model.share.descriptionRaw
+
+ ( m, str ) =
+ Comp.MarkdownInput.update txt lmsg dm
+ in
+ ( { model | descEdit = Just ( m, str ) }
+ , Cmd.none
+ )
+
+ Nothing ->
+ ( model, Cmd.none )
+
+ SaveDescription ->
+ case model.descEdit of
+ Just ( dm, str ) ->
+ ( model, Api.setDescription flags model.share.id str BasicResp )
+
+ Nothing ->
+ ( model, Cmd.none )
+
+ ReqEdit prop ->
+ let
+ next =
+ if isEdit model prop /= Nothing then
+ Nothing
+
+ else
+ Just ( prop, mkEditField flags model prop )
+ in
+ ( { model | editField = next }, Cmd.none )
+
+ SetName str ->
+ case model.editField of
+ Just ( p, EditName _ ) ->
+ ( { model | editField = Just ( p, EditName (Util.Maybe.fromString str) ) }
+ , Cmd.none
+ )
+
+ _ ->
+ ( model, Cmd.none )
+
+ MaxViewMsg lmsg ->
+ case model.editField of
+ Just ( p, EditMaxViews ( im, c ) ) ->
+ let
+ ( m, mi ) =
+ Comp.IntInput.update lmsg im
+ in
+ ( { model | editField = Just ( p, EditMaxViews ( m, mi ) ) }
+ , Cmd.none
+ )
+
+ _ ->
+ ( model, Cmd.none )
+
+ ValidityEditMsg lmsg ->
+ case model.editField of
+ Just ( p, EditValidity ( m, v ) ) ->
+ let
+ ( nm, nv ) =
+ Comp.ValidityField.update lmsg m
+
+ dv =
+ Maybe.withDefault v nv
+ in
+ ( { model | editField = Just ( p, EditValidity ( nm, dv ) ) }
+ , Cmd.none
+ )
+
+ _ ->
+ ( model, Cmd.none )
+
+ PasswordEditMsg lmsg ->
+ case model.editField of
+ Just ( p, EditPassword ( m, _ ) ) ->
+ let
+ ( nm, nv ) =
+ Comp.PasswordInput.update lmsg m
+ in
+ ( { model | editField = Just ( p, EditPassword ( nm, nv ) ) }
+ , Cmd.none
+ )
+
+ _ ->
+ ( model, Cmd.none )
+
+ CancelEdit ->
+ ( { model | editField = Nothing }, Cmd.none )
+
+ SaveEdit ->
+ let
+ nm =
+ { model | editField = Nothing }
+ in
+ case model.editField of
+ Just ( p, EditName name ) ->
+ ( nm, Api.setName flags model.share.id name BasicResp )
+
+ Just ( p, EditMaxViews ( _, Just value ) ) ->
+ ( nm, Api.setMaxViews flags model.share.id value BasicResp )
+
+ Just ( p, EditMaxViews ( _, Nothing ) ) ->
+ ( nm, Cmd.none )
+
+ Just ( p, EditValidity ( _, value ) ) ->
+ ( nm
+ , Api.setValidity flags
+ model.share.id
+ (Data.ValidityValue.toMillis value)
+ BasicResp
+ )
+
+ Just ( p, EditPassword ( _, pw ) ) ->
+ ( nm, Api.setPassword flags model.share.id pw BasicResp )
+
+ Nothing ->
+ ( nm, Cmd.none )
+
+ ToggleFilesMenu ->
+ ( { model | addFilesOpen = not model.addFilesOpen }
+ , Cmd.none
+ )
+
+ DropzoneMsg lmsg ->
+ let
+ ( m, c, fs ) =
+ Comp.Dropzone2.update model.uploads.selectedFiles lmsg model.dropzone
+ in
+ ( { model
+ | dropzone = m
+ , uploads = Data.UploadDict.updateFiles model.uploads fs
+ , uploadFormState = BasicResult True ""
+ }
+ , Cmd.batch [ Cmd.map DropzoneMsg c ]
+ )
+
+ ResetFileForm ->
+ ( { model
+ | dropzone = Comp.Dropzone2.init
+ , uploads = Data.UploadDict.empty
+ , uploading = False
+ , uploadFormState = BasicResult True ""
+ }
+ , Cmd.none
+ )
+
+ SubmitFiles ->
+ let
+ ( native, _ ) =
+ List.unzip model.uploads.selectedFiles
+
+ uploadUrl =
+ flags.config.baseUrl ++ "/api/v2/sec/upload/" ++ model.share.id ++ "/files/tus"
+
+ submit =
+ if native == [] then
+ Cmd.none
+
+ else
+ UploadData uploadUrl model.share.id native Nothing
+ |> Data.UploadData.encode
+ |> Ports.submitFiles
+
+ valid =
+ Util.Share.validate flags
+ (Just model.share)
+ { descField = "", uploads = model.uploads }
+ in
+ if native == [] then
+ ( model, Cmd.none )
+
+ else if valid.success then
+ ( { model | uploading = True, uploadFormState = BasicResult True "" }, submit )
+
+ else
+ ( { model | uploadFormState = valid }, Cmd.none )
+
+ Uploading state ->
+ if state.id == model.share.id then
+ let
+ ( nm, nc ) =
+ trackUpload model state
+
+ ( _, err ) =
+ Data.UploadDict.countDone nm.uploads
+
+ rm =
+ { nm
+ | dropzone = Comp.Dropzone2.init
+ , uploads = Data.UploadDict.empty
+ , uploading = False
+ }
+
+ ( im, ic ) =
+ update flags (Init model.share.id) rm
+ in
+ if Data.UploadDict.allDone nm.uploads then
+ if err == 0 then
+ ( im, Cmd.batch [ nc, ic ] )
+
+ else
+ ( rm, nc )
+
+ else
+ ( nm, nc )
+
+ else
+ ( model, Cmd.none )
+
+ UploadStopped err ->
+ ( { model | uploadPaused = err == Nothing }, Cmd.none )
+
+ StartStopUpload ->
+ ( model
+ , if model.uploadPaused then
+ Ports.startUpload model.share.id
+
+ else
+ Ports.stopUpload model.share.id
+ )
+
+ MailFormMsg lmsg ->
+ case model.mailForm of
+ Nothing ->
+ ( model, Cmd.none )
+
+ Just msm ->
+ let
+ ( mm, act ) =
+ Comp.MailSend.update flags lmsg msm
+ in
+ case act of
+ Comp.MailSend.Run c ->
+ ( { model | mailForm = Just mm }, Cmd.map MailFormMsg c )
+
+ Comp.MailSend.Cancelled ->
+ ( { model | mailForm = Nothing }
+ , Cmd.none
+ )
+
+ Comp.MailSend.Sent ->
+ ( { model | mailForm = Nothing }
+ , Cmd.none
+ )
+
+ InitMail ->
+ let
+ getTpl =
+ Api.getShareTemplate flags model.share.id
+
+ ( mm, mc ) =
+ Comp.MailSend.init getTpl
+ in
+ ( { model | mailForm = Just mm }
+ , Cmd.map MailFormMsg mc
+ )
+
+
+trackUpload : Model -> UploadState -> ( Model, Cmd Msg )
+trackUpload model state =
+ let
+ ( next, progress ) =
+ Data.UploadDict.trackUpload model.uploads state
+
+ progressCmd p =
+ case p of
+ Data.UploadDict.FileProgress index perc ->
+ [ ( "file-progress-" ++ String.fromInt index
+ , perc
+ )
+ ]
+
+ Data.UploadDict.AllProgress perc ->
+ [ ( "all-progress", perc )
+ ]
+
+ infoMsg =
+ case state.state of
+ Data.UploadState.Failed em ->
+ BasicResult False em
+
+ _ ->
+ model.uploadFormState
+ in
+ ( { model
+ | uploads = next
+ , uploadPaused = False
+ , uploadFormState = infoMsg
+ }
+ , Ports.setProgress (List.concatMap progressCmd progress)
+ )
diff --git a/modules/webapp/src/main/elm/Page/Detail/View.elm b/modules/webapp/src/main/elm/Page/Detail/View.elm
new file mode 100644
index 00000000..800695cd
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Detail/View.elm
@@ -0,0 +1,731 @@
+module Page.Detail.View exposing (view)
+
+import Api
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Comp.Dropzone2
+import Comp.IntInput
+import Comp.MailSend
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ShareFileList exposing (ViewMode(..))
+import Comp.ValidityField
+import Comp.YesNoDimmer
+import Comp.Zoom
+import Data.Flags exposing (Flags)
+import Data.ValidityOptions
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Markdown
+import Page exposing (Page(..))
+import Page.Detail.Data
+ exposing
+ ( EditField(..)
+ , Model
+ , Msg(..)
+ , Property(..)
+ , PublishState(..)
+ , TopMenuState(..)
+ , isEdit
+ , isPublished
+ )
+import QRCode
+import Util.Html
+import Util.Share
+import Util.Size
+import Util.Time
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ let
+ ( head, desc ) =
+ Util.Share.splitDescription model.share
+ in
+ div [ class "ui grid container detail-page" ]
+ [ Comp.Zoom.view (Api.fileSecUrl flags model.share.id) model SetZoom QuitZoom
+ , deleteLoader model
+ , div [ class "row" ]
+ [ div [ class "sixteen wide column" ]
+ ([ Markdown.toHtml [] head
+ , topMenu model
+ ]
+ ++ shareProps model
+ ++ shareLink flags model
+ ++ [ messageDiv model
+ , descriptionView model desc
+ , middleMenu model
+ , dropzone flags model
+ , fileList flags model
+ ]
+ )
+ ]
+ ]
+
+
+descriptionView : Model -> String -> Html Msg
+descriptionView model desc =
+ case model.descEdit of
+ Just ( dm, str ) ->
+ div [ class "ui form" ]
+ [ Html.map DescEditMsg
+ (Comp.MarkdownInput.view str dm)
+ , div [ class "ui secondary menu" ]
+ [ a
+ [ class "link item"
+ , onClick SaveDescription
+ , href "#"
+ ]
+ [ i [ class "disk icon" ] []
+ , text "Save"
+ ]
+ ]
+ ]
+
+ Nothing ->
+ Markdown.toHtml [ class "share-description ui basic segment" ] desc
+
+
+fileList : Flags -> Model -> Html Msg
+fileList flags model =
+ let
+ sett =
+ Comp.ShareFileList.Settings
+ (Api.fileSecUrl flags model.share.id "")
+ model.fileView
+ True
+
+ sorted =
+ List.sortBy .filename model.share.files
+ in
+ Html.map FileListMsg <|
+ Comp.ShareFileList.view sett sorted model.fileListModel
+
+
+shareLink : Flags -> Model -> List (Html Msg)
+shareLink flags model =
+ case isPublished model.share of
+ Unpublished ->
+ shareLinkNotPublished model
+
+ PublishOk ->
+ shareLinkPublished flags model
+
+ PublishExpired ->
+ shareLinkExpired model
+
+ MaxViewsExceeded ->
+ shareLinkMaxViewsExeeded model
+
+
+shareLinkMaxViewsExeeded : Model -> List (Html Msg)
+shareLinkMaxViewsExeeded model =
+ [ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui attached warning message segment", True )
+ ]
+ ]
+ [ text "The share has been published, but its max-views has been reached. You can "
+ , text "increase this property if you want to have this published for another while."
+ ]
+ ]
+
+
+shareLinkNotPublished : Model -> List (Html Msg)
+shareLinkNotPublished model =
+ [ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui attached info message segment", True )
+ ]
+ ]
+ [ text "In order to share this with others, you need to publish "
+ , text "this share. Then everyone you'll send the generated link "
+ , text "can access this data."
+ ]
+ ]
+
+
+shareLinkExpired : Model -> List (Html Msg)
+shareLinkExpired model =
+ [ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui attached warning message segment", True )
+ ]
+ ]
+ [ text "The share has been published, but it is now expired. You can "
+ , text "first unpublish and then publish it again."
+ ]
+ ]
+
+
+shareLinkPublished : Flags -> Model -> List (Html Msg)
+shareLinkPublished flags model =
+ let
+ share =
+ model.share
+
+ pid =
+ Maybe.map .id share.publishInfo
+ |> Maybe.withDefault ""
+
+ url =
+ flags.config.baseUrl ++ Page.pageToString (OpenDetailPage pid)
+
+ qrCodeView : String -> Html msg
+ qrCodeView message =
+ QRCode.encode message
+ |> Result.map QRCode.toSvg
+ |> Result.withDefault
+ (Html.text "Error while encoding to QRCode.")
+ in
+ [ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui attached segment", True )
+ ]
+ ]
+ [ text "The share is publicly available at"
+ , pre [ class "url" ]
+ [ code []
+ [ text url
+ ]
+ ]
+ , text "You can share this link to all you'd like to access this data."
+ ]
+ , case model.mailForm of
+ Just mf ->
+ Html.map MailFormMsg
+ (Comp.MailSend.view
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui bottom attached segment", True )
+ ]
+ mf
+ )
+
+ Nothing ->
+ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopShare )
+ , ( "ui bottom attached segment", True )
+ ]
+ ]
+ [ div [ class "ui two column stackable center aligned grid" ]
+ [ div [ class "ui vertical divider" ] [ text "Or" ]
+ , div [ class "middle aligned row" ]
+ [ div [ class "column" ]
+ [ qrCodeView url
+ ]
+ , div [ class "column" ]
+ [ a
+ [ classList
+ [ ( "ui primary button", True )
+ , ( "disabled", not flags.config.mailEnabled )
+ ]
+ , href "#"
+ , onClick InitMail
+ ]
+ [ text "Send E-Mail"
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+
+
+messageDiv : Model -> Html Msg
+messageDiv model =
+ Util.Html.resultMsgMaybe model.message
+
+
+shareProps : Model -> List (Html Msg)
+shareProps model =
+ let
+ share =
+ model.share
+
+ propertyDisplay : String -> String -> List (Html Msg)
+ propertyDisplay icon content =
+ [ i [ class icon ] []
+ , text content
+ ]
+ in
+ [ div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopDetail )
+ , ( "ui attached segment", True )
+ ]
+ ]
+ [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.yesNoModel)
+ , div [ class "ui stackable two column grid" ]
+ [ div [ class "column" ]
+ [ div [ class "ui items" ]
+ [ property
+ { label = "Name"
+ , content =
+ isEdit model Name
+ |> Maybe.map propertyEdit
+ |> Maybe.withDefault
+ (propertyDisplay "comment outline icon"
+ (Maybe.withDefault "" share.name)
+ )
+ , editAction = Just (ReqEdit Name)
+ }
+ , property
+ { label = "Validity Time"
+ , content =
+ isEdit model Validity
+ |> Maybe.map propertyEdit
+ |> Maybe.withDefault
+ (propertyDisplay "hourglass half icon"
+ (Data.ValidityOptions.findValidityItemMillis share.validity
+ |> Tuple.first
+ )
+ )
+ , editAction = Just (ReqEdit Validity)
+ }
+ , property
+ { label = "Max. Views"
+ , content =
+ isEdit model MaxViews
+ |> Maybe.map propertyEdit
+ |> Maybe.withDefault
+ (propertyDisplay "eye icon" (String.fromInt share.maxViews))
+ , editAction = Just (ReqEdit MaxViews)
+ }
+ , property
+ { label = "Password"
+ , content =
+ isEdit model Password
+ |> Maybe.map propertyEdit
+ |> Maybe.withDefault
+ (propertyDisplay
+ (if share.password then
+ "lock icon"
+
+ else
+ "unlock icon"
+ )
+ (if share.password then
+ "Password Protected"
+
+ else
+ "None"
+ )
+ )
+ , editAction = Just (ReqEdit Password)
+ }
+ , property
+ { label = "#/Size"
+ , content =
+ propertyDisplay "file icon"
+ (String.fromInt (List.length model.share.files)
+ ++ "/"
+ ++ (List.map .size model.share.files
+ |> List.sum
+ |> toFloat
+ |> Util.Size.bytesReadable Util.Size.B
+ )
+ )
+ , editAction = Nothing
+ }
+ , property
+ { label = "Created"
+ , content =
+ propertyDisplay "calendar icon"
+ (Util.Time.formatDateTime share.created)
+ , editAction = Nothing
+ }
+ ]
+ ]
+ , div [ class "column" ]
+ [ div [ class "ui items" ]
+ [ property
+ { label = "Alias"
+ , content =
+ propertyDisplay "dot circle outline icon" (Maybe.withDefault "-" share.aliasName)
+ , editAction = Nothing
+ }
+ , property
+ { label = "Published on"
+ , content =
+ propertyDisplay (Tuple.first <| publishIconLabel share)
+ (Maybe.map .publishDate share.publishInfo
+ |> Maybe.map Util.Time.formatDateTime
+ |> Maybe.withDefault "-"
+ )
+ , editAction = Nothing
+ }
+ , property
+ { label = "Published until"
+ , content =
+ propertyDisplay "hourglass icon"
+ (Maybe.map .publishUntil share.publishInfo
+ |> Maybe.map Util.Time.formatDateTime
+ |> Maybe.withDefault "-"
+ )
+ , editAction = Nothing
+ }
+ , property
+ { label = "Last Access"
+ , content =
+ propertyDisplay "calendar outline icon"
+ (Maybe.andThen .lastAccess share.publishInfo
+ |> Maybe.map Util.Time.formatDateTime
+ |> Maybe.withDefault "-"
+ )
+ , editAction = Nothing
+ }
+ , property
+ { label = "Views"
+ , content =
+ propertyDisplay "eye icon"
+ (Maybe.map .views share.publishInfo
+ |> Maybe.map String.fromInt
+ |> Maybe.withDefault "-"
+ )
+ , editAction = Nothing
+ }
+ ]
+ ]
+ ]
+ ]
+ , div
+ [ classList
+ [ ( "invisible", model.topMenu /= TopDetail )
+ , ( "ui bottom attached secondary segment", True )
+ ]
+ ]
+ [ div [ class "item" ]
+ [ button
+ [ type_ "button"
+ , classList
+ [ ( "ui secondary button", True )
+ , ( "invisible", isPublished share /= Unpublished )
+ ]
+ , onClick (PublishShare False)
+ ]
+ [ text "Publish with new Link"
+ ]
+ , button
+ [ type_ "button"
+ , class "ui red button"
+ , onClick RequestDelete
+ ]
+ [ i [ class "trash icon" ] []
+ , text "Delete"
+ ]
+ ]
+ ]
+ ]
+
+
+property :
+ { label : String
+ , content : List (Html Msg)
+ , editAction : Maybe Msg
+ }
+ -> Html Msg
+property rec =
+ div [ class "item" ]
+ [ div [ class "content" ]
+ [ div [ class "header" ] <|
+ rec.content
+ , div [ class "meta" ]
+ [ case rec.editAction of
+ Just msg ->
+ a
+ [ class "ui link"
+ , href "#"
+ , title "Edit"
+ , onClick msg
+ ]
+ [ i [ class "edit icon" ] []
+ , text " "
+ ]
+
+ Nothing ->
+ text ""
+ , text rec.label
+ ]
+ ]
+ ]
+
+
+propertyEdit : EditField -> List (Html Msg)
+propertyEdit ef =
+ let
+ saveButton =
+ a
+ [ class "ui primary icon button"
+ , href "#"
+ , onClick SaveEdit
+ ]
+ [ i [ class "check icon" ] []
+ ]
+
+ cancelButton =
+ a
+ [ class "ui secondary icon button"
+ , href "#"
+ , onClick CancelEdit
+ ]
+ [ i [ class "delete icon" ] []
+ ]
+ in
+ case ef of
+ EditName v ->
+ [ div [ class "ui mini action input" ]
+ [ input
+ [ type_ "text"
+ , placeholder "Name"
+ , onInput SetName
+ , Maybe.withDefault "" v |> value
+ ]
+ []
+ , saveButton
+ , cancelButton
+ ]
+ ]
+
+ EditMaxViews ( im, n ) ->
+ [ div
+ [ classList
+ [ ( "ui mini action input", True )
+ , ( "error", n == Nothing )
+ ]
+ ]
+ [ Html.map MaxViewMsg (Comp.IntInput.view n im)
+ , saveButton
+ , cancelButton
+ ]
+ ]
+
+ EditValidity ( vm, v ) ->
+ [ div [ class "ui mini action input" ]
+ [ Html.map ValidityEditMsg (Comp.ValidityField.view v vm)
+ , saveButton
+ , cancelButton
+ ]
+ ]
+
+ EditPassword ( pm, p ) ->
+ [ div [ class "ui mini action input" ]
+ [ Html.map PasswordEditMsg (Comp.PasswordInput.view p pm)
+ , saveButton
+ , cancelButton
+ ]
+ ]
+
+
+topMenu : Model -> Html Msg
+topMenu model =
+ let
+ share =
+ model.share
+
+ ( publishIcon, label ) =
+ publishIconLabel share
+ in
+ div
+ [ classList
+ [ ( "ui pointing menu", True )
+ , ( "top attached", model.topMenu /= TopClosed )
+ ]
+ ]
+ [ topMenuLink model TopDetail "Details"
+ , topMenuLink model TopShare "Share Link"
+ , div [ class "right menu" ]
+ [ a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.descEdit /= Nothing )
+ ]
+ , href "#"
+ , title "Edit description"
+ , onClick ToggleEditDesc
+ ]
+ [ i [ class "ui edit icon" ] []
+ ]
+ , a
+ [ class "link item"
+ , href "#"
+ , onClick (PublishShare True)
+ ]
+ [ i [ class publishIcon ] []
+ , text label
+ ]
+ ]
+ ]
+
+
+publishIconLabel : ShareDetail -> ( String, String )
+publishIconLabel share =
+ case isPublished share of
+ Unpublished ->
+ ( "circle outline icon", "Publish" )
+
+ PublishExpired ->
+ ( "red bolt icon", "Unpublish" )
+
+ MaxViewsExceeded ->
+ ( "red bolt icon", "Unpublish" )
+
+ PublishOk ->
+ ( "green circle icon", "Unpublish" )
+
+
+topMenuLink : Model -> TopMenuState -> String -> Html Msg
+topMenuLink model state label =
+ a
+ [ classList
+ [ ( "active", model.topMenu == state )
+ , ( "link item", True )
+ ]
+ , href "#"
+ , onClick (SetTopMenu state)
+ ]
+ [ text label
+ ]
+
+
+middleMenu : Model -> Html Msg
+middleMenu model =
+ div
+ [ classList
+ [ ( "ui menu", True )
+ , ( "attached", model.addFilesOpen )
+ ]
+ ]
+ [ a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.fileView == ViewList )
+ ]
+ , href "#"
+ , onClick (SetFileView ViewList)
+ , title "List View"
+ ]
+ [ i [ class "ui list icon" ] []
+ ]
+ , a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.fileView == ViewCard )
+ ]
+ , href "#"
+ , onClick (SetFileView ViewCard)
+ , title "Card View"
+ ]
+ [ i [ class "th icon" ] []
+ ]
+ , div [ class "right menu" ]
+ [ a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.addFilesOpen )
+ ]
+ , href "#"
+ , onClick ToggleFilesMenu
+ ]
+ [ i [ class "icons" ]
+ [ i [ class "file outline icon" ] []
+ , i [ class "corner add icon" ] []
+ ]
+ ]
+ ]
+ ]
+
+
+dropzone : Flags -> Model -> Html Msg
+dropzone flags model =
+ let
+ viewSettings =
+ Comp.Dropzone2.mkViewSettings (not model.uploading) model.uploads
+ in
+ div
+ [ classList
+ [ ( "ui bottom attached segment", True )
+ , ( "hidden invisible", not model.addFilesOpen )
+ ]
+ ]
+ [ div [ class "ui secondary menu" ]
+ [ a
+ [ class "primary item"
+ , href "#"
+ , onClick SubmitFiles
+ ]
+ [ i [ class "upload icon" ] []
+ , text "Submit"
+ ]
+ , a
+ [ class "item"
+ , href "#"
+ , onClick ResetFileForm
+ ]
+ [ i [ class "undo icon" ] []
+ , text "Clear"
+ ]
+ , div [ class "right floated menu" ]
+ [ a
+ [ classList
+ [ ( "item", True )
+ , ( "disabled", not model.uploading )
+ ]
+ , href "#"
+ , onClick StartStopUpload
+ ]
+ [ i
+ [ class
+ (if model.uploadPaused then
+ "play icon"
+
+ else
+ "pause icon"
+ )
+ ]
+ []
+ , text
+ (if model.uploadPaused then
+ "Resume"
+
+ else
+ "Pause"
+ )
+ ]
+ ]
+ ]
+ , p []
+ [ text "All uploads must not be greater than "
+ , toFloat flags.config.maxSize
+ |> Util.Size.bytesReadable Util.Size.B
+ |> text
+ , text "."
+ ]
+ , div
+ [ classList
+ [ ( "ui error message", True )
+ , ( "invisible hidden", model.uploadFormState.success )
+ ]
+ ]
+ [ text model.uploadFormState.message
+ ]
+ , Html.map DropzoneMsg (Comp.Dropzone2.view viewSettings model.dropzone)
+ ]
+
+
+deleteLoader : Model -> Html Msg
+deleteLoader model =
+ div
+ [ classList
+ [ ( "ui dimmer", True )
+ , ( "active", model.loader.active )
+ ]
+ ]
+ [ div [ class "ui indeterminate text loader" ]
+ [ text model.loader.message
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
new file mode 100644
index 00000000..65b4fce1
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -0,0 +1,14 @@
+module Page.Home.Data exposing (..)
+
+
+type alias Model =
+ {}
+
+
+emptyModel : Model
+emptyModel =
+ {}
+
+
+type Msg
+ = Dummy
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
new file mode 100644
index 00000000..30f2c025
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -0,0 +1,9 @@
+module Page.Home.Update exposing (update)
+
+import Data.Flags exposing (Flags)
+import Page.Home.Data exposing (..)
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ ( model, Cmd.none )
diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm
new file mode 100644
index 00000000..facc37f1
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Home/View.elm
@@ -0,0 +1,33 @@
+module Page.Home.View exposing (view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Page exposing (Page(..))
+import Page.Home.Data exposing (Model)
+
+
+view : Model -> Html msg
+view model =
+ div [ class "ui container home-page" ]
+ [ div [ class "ui red raised placeholder segment" ]
+ [ h1 [ class "ui icon header" ]
+ [ i [ class "ui share alternate square icon" ] []
+ , text "Share files with others"
+ ]
+ , div [ class "inline" ]
+ [ a
+ [ class "ui large primary button"
+ , Page.href SharePage
+ ]
+ [ text "Create Share"
+ ]
+ , a
+ [ class "ui large secondary button"
+ , Page.href UploadPage
+ ]
+ [ text "View Shares"
+ ]
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Page/Info/Data.elm b/modules/webapp/src/main/elm/Page/Info/Data.elm
new file mode 100644
index 00000000..2c1eeecc
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Info/Data.elm
@@ -0,0 +1,28 @@
+module Page.Info.Data exposing (..)
+
+
+type alias Mesg =
+ { head : String
+ , text : String
+ }
+
+
+type alias Model =
+ List Mesg
+
+
+emptyModel : Model
+emptyModel =
+ [ { head = "Forbidden"
+ , text = """
+You don't have enough permission to access this site.
+"""
+ }
+ , { head = "Expired"
+ , text = "This resource is expired or doesn't exist."
+ }
+ ]
+
+
+type Msg
+ = Dummy
diff --git a/modules/webapp/src/main/elm/Page/Info/Update.elm b/modules/webapp/src/main/elm/Page/Info/Update.elm
new file mode 100644
index 00000000..d713e559
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Info/Update.elm
@@ -0,0 +1,9 @@
+module Page.Info.Update exposing (update)
+
+import Data.Flags exposing (Flags)
+import Page.Info.Data exposing (Model, Msg(..))
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ ( model, Cmd.none )
diff --git a/modules/webapp/src/main/elm/Page/Info/View.elm b/modules/webapp/src/main/elm/Page/Info/View.elm
new file mode 100644
index 00000000..de9f9789
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Info/View.elm
@@ -0,0 +1,32 @@
+module Page.Info.View exposing (view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Page.Info.Data exposing (Model, Msg(..))
+import Util.List
+
+
+view : Int -> Model -> Html Msg
+view msg model =
+ case Util.List.get model msg of
+ Just m ->
+ div [ class "info-page" ]
+ [ div [ class "ui centered grid" ]
+ [ div [ class "row" ]
+ [ div [ class "eight wide column basic ui segment" ]
+ [ h1 [ class "ui header" ]
+ [ i [ class "ui info icon" ] []
+ , div [ class "content" ]
+ [ text m.head
+ ]
+ ]
+ , p []
+ [ text m.text
+ ]
+ ]
+ ]
+ ]
+ ]
+
+ Nothing ->
+ div [] []
diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm
new file mode 100644
index 00000000..577570da
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Login/Data.elm
@@ -0,0 +1,27 @@
+module Page.Login.Data exposing (..)
+
+import Api.Model.AuthResult exposing (AuthResult)
+import Http
+
+
+type alias Model =
+ { username : String
+ , password : String
+ , result : Maybe AuthResult
+ }
+
+
+empty : Model
+empty =
+ { username = ""
+ , password = ""
+ , result = Nothing
+ }
+
+
+type Msg
+ = SetUsername String
+ | SetPassword String
+ | Authenticate
+ | AuthResp (Result Http.Error AuthResult)
+ | Init
diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm
new file mode 100644
index 00000000..17954229
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Login/Update.elm
@@ -0,0 +1,78 @@
+module Page.Login.Update exposing (update)
+
+import Api
+import Api.Model.AuthResult exposing (AuthResult)
+import Api.Model.UserPass exposing (UserPass)
+import Data.Flags exposing (Flags)
+import Page exposing (Page(..))
+import Page.Login.Data exposing (..)
+import Ports
+import Util.Http
+import Util.List
+
+
+update : ( Maybe Page, Bool ) -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
+update ( referrer, oauth ) flags msg model =
+ case msg of
+ -- after logging in via some provider, a cookie has been sent
+ -- with a redirection to the login page. So then there must be
+ -- another call to get the account data.
+ Init ->
+ if oauth && Util.List.nonEmpty flags.config.oauthConfig then
+ ( model, Api.loginSession flags AuthResp, Nothing )
+
+ else
+ ( model, Cmd.none, Nothing )
+
+ SetUsername str ->
+ ( { model | username = str }, Cmd.none, Nothing )
+
+ SetPassword str ->
+ ( { model | password = str }, Cmd.none, Nothing )
+
+ Authenticate ->
+ ( model, Api.login flags (UserPass model.username model.password) AuthResp, Nothing )
+
+ AuthResp (Ok lr) ->
+ if lr.success then
+ loginSuccess referrer lr model
+
+ else
+ ( { model | result = Just lr, password = "" }
+ , Ports.removeAccount ()
+ , Just lr
+ )
+
+ AuthResp (Err err) ->
+ let
+ empty =
+ Api.Model.AuthResult.empty
+
+ lr =
+ { empty | message = Util.Http.errorToString err }
+ in
+ ( { model | password = "", result = Just lr }, Ports.removeAccount (), Just empty )
+
+
+loginSuccess : Maybe Page -> AuthResult -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
+loginSuccess referrer res model =
+ let
+ ar =
+ Just res
+
+ gotoRef =
+ Maybe.withDefault HomePage referrer |> Page.goto
+ in
+ ( { model | result = ar, password = "" }
+ , Cmd.batch [ setAccount res, gotoRef ]
+ , ar
+ )
+
+
+setAccount : AuthResult -> Cmd msg
+setAccount result =
+ if result.success then
+ Ports.setAccount result
+
+ else
+ Ports.removeAccount ()
diff --git a/modules/webapp/src/main/elm/Page/Login/View.elm b/modules/webapp/src/main/elm/Page/Login/View.elm
new file mode 100644
index 00000000..26743271
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Login/View.elm
@@ -0,0 +1,129 @@
+module Page.Login.View exposing (view)
+
+import Api
+import Api.Model.OAuthItem exposing (OAuthItem)
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput, onSubmit)
+import Page exposing (Page(..))
+import Page.Login.Data exposing (..)
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ div [ class "login-page" ]
+ [ div [ class "ui centered grid" ]
+ [ div [ class "row" ]
+ [ div [ class "six wide column ui segment login-view" ]
+ [ h1 [ class "ui center aligned icon header" ]
+ [ img
+ [ class "ui logo image"
+ , src (flags.config.assetsPath ++ "/img/logo.png")
+ ]
+ []
+ ]
+ , Html.form
+ [ class "ui large error raised form segment"
+ , onSubmit Authenticate
+ , autocomplete False
+ ]
+ [ div [ class "field" ]
+ [ label [] [ text "Username" ]
+ , div [ class "ui left icon input" ]
+ [ input
+ [ type_ "text"
+ , autocomplete False
+ , onInput SetUsername
+ , value model.username
+ , placeholder "Login"
+ , autofocus True
+ ]
+ []
+ , i [ class "user icon" ] []
+ ]
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Password" ]
+ , div [ class "ui left icon input" ]
+ [ input
+ [ type_ "password"
+ , autocomplete False
+ , onInput SetPassword
+ , value model.password
+ , placeholder "Password"
+ ]
+ []
+ , i [ class "lock icon" ] []
+ ]
+ ]
+ , button
+ [ class "ui primary fluid button"
+ , type_ "submit"
+ ]
+ [ text "Login"
+ ]
+ ]
+ , if List.isEmpty flags.config.oauthConfig then
+ div [] []
+
+ else
+ renderOAuthButtons flags model
+ , resultMessage model
+ , div [ class "ui very basic right aligned segment" ]
+ [ text "No account? "
+ , a [ class "ui icon link", Page.href RegisterPage ]
+ [ i [ class "edit icon" ] []
+ , text "Sign up!"
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+
+
+renderOAuthButtons : Flags -> Model -> Html Msg
+renderOAuthButtons flags model =
+ div []
+ [ div [ class "ui horizontal divider" ] [ text "Or" ]
+ , div [ class "ui buttons" ]
+ (List.map (renderOAuthButton flags) flags.config.oauthConfig)
+ ]
+
+
+renderOAuthButton : Flags -> OAuthItem -> Html Msg
+renderOAuthButton flags item =
+ let
+ icon =
+ "ui icon " ++ Maybe.withDefault "user outline" item.icon
+
+ url =
+ Api.oauthUrl flags item
+ in
+ a
+ [ class "ui basic primary button"
+ , href url
+ ]
+ [ i [ class icon ] []
+ , text "via "
+ , text item.name
+ ]
+
+
+resultMessage : Model -> Html Msg
+resultMessage model =
+ case model.result of
+ Just r ->
+ if r.success then
+ div [ class "ui success message" ]
+ [ text "Login successful."
+ ]
+
+ else
+ div [ class "ui error message" ]
+ [ text r.message
+ ]
+
+ Nothing ->
+ span [] []
diff --git a/modules/webapp/src/main/elm/Page/NewInvite/Data.elm b/modules/webapp/src/main/elm/Page/NewInvite/Data.elm
new file mode 100644
index 00000000..722a73e1
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/NewInvite/Data.elm
@@ -0,0 +1,50 @@
+module Page.NewInvite.Data exposing (..)
+
+import Api.Model.InviteResult exposing (InviteResult)
+import Http
+
+
+type alias Model =
+ { password : String
+ , result : State
+ }
+
+
+type State
+ = Empty
+ | Failed String
+ | Success InviteResult
+
+
+isFailed : State -> Bool
+isFailed state =
+ case state of
+ Failed _ ->
+ True
+
+ _ ->
+ False
+
+
+isSuccess : State -> Bool
+isSuccess state =
+ case state of
+ Success _ ->
+ True
+
+ _ ->
+ False
+
+
+emptyModel : Model
+emptyModel =
+ { password = ""
+ , result = Empty
+ }
+
+
+type Msg
+ = SetPassword String
+ | GenerateInvite
+ | Reset
+ | InviteResp (Result Http.Error InviteResult)
diff --git a/modules/webapp/src/main/elm/Page/NewInvite/Update.elm b/modules/webapp/src/main/elm/Page/NewInvite/Update.elm
new file mode 100644
index 00000000..40d9a479
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/NewInvite/Update.elm
@@ -0,0 +1,30 @@
+module Page.NewInvite.Update exposing (update)
+
+import Api
+import Api.Model.GenInvite exposing (GenInvite)
+import Data.Flags exposing (Flags)
+import Page.NewInvite.Data exposing (..)
+import Util.Http
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ SetPassword str ->
+ ( { model | password = str }, Cmd.none )
+
+ Reset ->
+ ( emptyModel, Cmd.none )
+
+ GenerateInvite ->
+ ( model, Api.newInvite flags (GenInvite model.password) InviteResp )
+
+ InviteResp (Ok res) ->
+ if res.success then
+ ( { model | result = Success res }, Cmd.none )
+
+ else
+ ( { model | result = Failed res.message }, Cmd.none )
+
+ InviteResp (Err err) ->
+ ( { model | result = Failed (Util.Http.errorToString err) }, Cmd.none )
diff --git a/modules/webapp/src/main/elm/Page/NewInvite/View.elm b/modules/webapp/src/main/elm/Page/NewInvite/View.elm
new file mode 100644
index 00000000..95063923
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/NewInvite/View.elm
@@ -0,0 +1,113 @@
+module Page.NewInvite.View exposing (view)
+
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput, onSubmit)
+import Page.NewInvite.Data exposing (..)
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ div [ class "newinvite-page" ]
+ [ div [ class "ui text container" ]
+ [ h1 [ class "ui cener aligned header" ]
+ [ i [ class "pencil alternate icon" ] []
+ , div [ class "content" ]
+ [ text "Create new invitations"
+ ]
+ ]
+ , inviteMessage flags
+ , Html.form
+ [ classList
+ [ ( "ui large form raised segment", True )
+ , ( "error", isFailed model.result )
+ , ( "success", isSuccess model.result )
+ ]
+ , onSubmit GenerateInvite
+ ]
+ [ div [ class "required field" ]
+ [ label [] [ text "New Invitation Password" ]
+ , div [ class "ui left icon input" ]
+ [ input
+ [ type_ "password"
+ , onInput SetPassword
+ , value model.password
+ , autofocus True
+ ]
+ []
+ , i [ class "key icon" ] []
+ ]
+ ]
+ , button
+ [ class "ui primary button"
+ , type_ "submit"
+ ]
+ [ text "Submit"
+ ]
+ , a [ class "ui right floated button", href "", onClick Reset ]
+ [ text "Reset"
+ ]
+ , resultMessage model
+ ]
+ ]
+ ]
+
+
+resultMessage : Model -> Html Msg
+resultMessage model =
+ div
+ [ classList
+ [ ( "ui message", True )
+ , ( "error", isFailed model.result )
+ , ( "success", isSuccess model.result )
+ , ( "hidden", model.result == Empty )
+ ]
+ ]
+ [ case model.result of
+ Failed m ->
+ div [ class "content" ]
+ [ div [ class "header" ] [ text "Error" ]
+ , p [] [ text m ]
+ ]
+
+ Success r ->
+ div [ class "content" ]
+ [ div [ class "header" ] [ text "Success" ]
+ , p [] [ text r.message ]
+ , p [] [ text "Invitation Key:" ]
+ , pre []
+ [ Maybe.withDefault "" r.key |> text
+ ]
+ ]
+
+ Empty ->
+ span [] []
+ ]
+
+
+inviteMessage : Flags -> Html Msg
+inviteMessage flags =
+ div
+ [ classList
+ [ ( "ui message", True )
+ , ( "hidden", flags.config.signupMode /= "invite" )
+ ]
+ ]
+ [ p []
+ [ text
+ """Sharry requires an invite when signing up. You can
+ create these invites here and send them to friends so
+ they can signup with Sharry."""
+ ]
+ , p []
+ [ text
+ """Each invite can only be used once. You'll need to
+ create one key for each person you want to invite."""
+ ]
+ , p []
+ [ text
+ """Creating an invite requires providing the password
+ from the configuration."""
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Page/OpenDetail/Data.elm b/modules/webapp/src/main/elm/Page/OpenDetail/Data.elm
new file mode 100644
index 00000000..4d2e9a9c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenDetail/Data.elm
@@ -0,0 +1,62 @@
+module Page.OpenDetail.Data exposing
+ ( Model
+ , Msg(..)
+ , emptyModel
+ , emptyPassModel
+ )
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Api.Model.ShareFile exposing (ShareFile)
+import Comp.PasswordInput
+import Comp.ShareFileList
+import Http
+
+
+type alias Model =
+ { share : ShareDetail
+ , fileListModel : Comp.ShareFileList.Model
+ , message : Maybe BasicResult
+ , fileView : Comp.ShareFileList.ViewMode
+ , zoom : Maybe ShareFile
+ , password : PassModel
+ }
+
+
+type alias PassModel =
+ { model : Comp.PasswordInput.Model
+ , field : Maybe String
+ , enabled : Bool
+ , badPassword : Bool
+ }
+
+
+emptyPassModel : PassModel
+emptyPassModel =
+ { model = Comp.PasswordInput.init
+ , field = Nothing
+ , enabled = False
+ , badPassword = False
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { share = Api.Model.ShareDetail.empty
+ , fileListModel = Comp.ShareFileList.init
+ , message = Nothing
+ , fileView = Comp.ShareFileList.ViewList
+ , zoom = Nothing
+ , password = emptyPassModel
+ }
+
+
+type Msg
+ = Init String
+ | DetailResp (Result Http.Error ShareDetail)
+ | FileListMsg Comp.ShareFileList.Msg
+ | SetFileView Comp.ShareFileList.ViewMode
+ | QuitZoom
+ | SetZoom ShareFile
+ | PasswordMsg Comp.PasswordInput.Msg
+ | SubmitPassword
diff --git a/modules/webapp/src/main/elm/Page/OpenDetail/Update.elm b/modules/webapp/src/main/elm/Page/OpenDetail/Update.elm
new file mode 100644
index 00000000..aba1126c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenDetail/Update.elm
@@ -0,0 +1,118 @@
+module Page.OpenDetail.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.PasswordInput
+import Comp.ShareFileList
+import Data.Flags exposing (Flags)
+import Http
+import Page.OpenDetail.Data exposing (Model, Msg(..), emptyPassModel)
+import Ports
+import Util.Http
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ Init id ->
+ let
+ share =
+ model.share
+
+ withId =
+ { share | id = id }
+ in
+ ( { model | share = withId }
+ , Api.getOpenShare flags id model.password.field DetailResp
+ )
+
+ DetailResp (Ok details) ->
+ ( { model
+ | share = details
+ , message = Nothing
+ , password = emptyPassModel
+ }
+ , Cmd.none
+ )
+
+ DetailResp (Err err) ->
+ let
+ pwm =
+ model.password
+
+ m =
+ Util.Http.errorToString err
+ in
+ case err of
+ Http.BadStatus 401 ->
+ ( { model
+ | password = { pwm | enabled = True, badPassword = False }
+ }
+ , Cmd.none
+ )
+
+ Http.BadStatus 403 ->
+ ( { model
+ | password = { pwm | enabled = True, badPassword = True }
+ }
+ , Cmd.none
+ )
+
+ _ ->
+ ( { model | message = Just (BasicResult False m) }
+ , Cmd.none
+ )
+
+ FileListMsg lmsg ->
+ let
+ ( m, action ) =
+ Comp.ShareFileList.update lmsg model.fileListModel
+ in
+ case action of
+ Comp.ShareFileList.FileClick sf ->
+ ( { model | fileListModel = m, zoom = Just sf }
+ , Ports.scrollTop ()
+ )
+
+ Comp.ShareFileList.FileDelete sf ->
+ ( model
+ , Cmd.none
+ )
+
+ Comp.ShareFileList.FileNone ->
+ ( { model | fileListModel = m }, Cmd.none )
+
+ SetFileView mode ->
+ ( { model
+ | fileView = mode
+ , fileListModel = Comp.ShareFileList.reset model.fileListModel
+ }
+ , Cmd.none
+ )
+
+ QuitZoom ->
+ case model.zoom of
+ Just file ->
+ ( { model | zoom = Nothing }, Ports.scrollToElem file.id )
+
+ Nothing ->
+ ( { model | zoom = Nothing }, Cmd.none )
+
+ SetZoom sf ->
+ ( { model | zoom = Just sf }, Cmd.none )
+
+ PasswordMsg lmsg ->
+ let
+ current =
+ model.password
+
+ ( pm, pw ) =
+ Comp.PasswordInput.update lmsg current.model
+
+ next =
+ { current | model = pm, field = pw }
+ in
+ ( { model | password = next }, Cmd.none )
+
+ SubmitPassword ->
+ update flags (Init model.share.id) model
diff --git a/modules/webapp/src/main/elm/Page/OpenDetail/View.elm b/modules/webapp/src/main/elm/Page/OpenDetail/View.elm
new file mode 100644
index 00000000..b88727a3
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenDetail/View.elm
@@ -0,0 +1,142 @@
+module Page.OpenDetail.View exposing (view)
+
+import Api
+import Comp.PasswordInput
+import Comp.ShareFileList exposing (ViewMode(..))
+import Comp.Zoom
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Markdown
+import Page.OpenDetail.Data exposing (Model, Msg(..))
+import Util.Html
+import Util.Share
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ div [ class "ui grid container detail-page" ]
+ [ zoomView flags model
+ , passwordDialog model
+ , div [ class "row" ]
+ [ div [ class "sixteen wide column" ]
+ [ descriptionView model
+ , messageDiv model
+ , middleMenu model
+ , fileList flags model
+ ]
+ ]
+ ]
+
+
+zoomView : Flags -> Model -> Html Msg
+zoomView flags model =
+ Comp.Zoom.view (Api.fileOpenUrl flags (shareId model)) model SetZoom QuitZoom
+
+
+passwordDialog : Model -> Html Msg
+passwordDialog model =
+ div
+ [ classList
+ [ ( "ui dimmer", True )
+ , ( "active", model.password.enabled )
+ ]
+ ]
+ [ div [ class "inline content" ]
+ [ h2 [ class "ui inverted icon header" ]
+ [ i [ class "lock icon" ] []
+ , text "Password required"
+ ]
+ , div [ class "ui basic segment" ]
+ [ div [ class "ui action input" ]
+ [ Html.map PasswordMsg
+ (Comp.PasswordInput.view
+ model.password.field
+ model.password.model
+ )
+ , a
+ [ class "ui primary button"
+ , href "#"
+ , onClick SubmitPassword
+ ]
+ [ text "Submit"
+ ]
+ ]
+ , div
+ [ classList
+ [ ( "ui error message", True )
+ , ( "invisible hidden", not model.password.badPassword )
+ ]
+ ]
+ [ text "Password invalid"
+ ]
+ ]
+ ]
+ ]
+
+
+messageDiv : Model -> Html Msg
+messageDiv model =
+ Util.Html.resultMsgMaybe model.message
+
+
+descriptionView : Model -> Html Msg
+descriptionView model =
+ let
+ ( title, desc ) =
+ Util.Share.splitDescription model.share
+ in
+ div [ class "ui container share-description" ]
+ [ Markdown.toHtml [] title
+ , Markdown.toHtml [] desc
+ ]
+
+
+middleMenu : Model -> Html Msg
+middleMenu model =
+ div
+ [ class "ui menu"
+ ]
+ [ a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.fileView == ViewList )
+ ]
+ , href "#"
+ , onClick (SetFileView ViewList)
+ , title "List View"
+ ]
+ [ i [ class "ui list icon" ] []
+ ]
+ , a
+ [ classList
+ [ ( "icon link item", True )
+ , ( "active", model.fileView == ViewCard )
+ ]
+ , href "#"
+ , onClick (SetFileView ViewCard)
+ , title "Card View"
+ ]
+ [ i [ class "th icon" ] []
+ ]
+ ]
+
+
+fileList : Flags -> Model -> Html Msg
+fileList flags model =
+ let
+ sett =
+ Comp.ShareFileList.Settings
+ (Api.fileOpenUrl flags (shareId model) "")
+ model.fileView
+ False
+ in
+ Html.map FileListMsg <|
+ Comp.ShareFileList.view sett model.share.files model.fileListModel
+
+
+shareId : Model -> String
+shareId model =
+ Maybe.map .id model.share.publishInfo
+ |> Maybe.withDefault ""
diff --git a/modules/webapp/src/main/elm/Page/OpenShare/Data.elm b/modules/webapp/src/main/elm/Page/OpenShare/Data.elm
new file mode 100644
index 00000000..bc5918ab
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenShare/Data.elm
@@ -0,0 +1,50 @@
+module Page.OpenShare.Data exposing (Model, Msg(..), emptyModel)
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.IdResult exposing (IdResult)
+import Comp.Dropzone2
+import Comp.MarkdownInput
+import Data.UploadDict exposing (UploadDict)
+import Data.UploadState exposing (UploadState)
+import Dict exposing (Dict)
+import File exposing (File)
+import Http
+import Json.Decode as D
+
+
+type alias Model =
+ { dropzoneModel : Comp.Dropzone2.Model
+ , uploads : UploadDict
+ , descModel : Comp.MarkdownInput.Model
+ , descField : String
+ , formState : BasicResult
+ , uploading : Bool
+ , shareId : Maybe String
+ , uploadPaused : Bool
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { dropzoneModel = Comp.Dropzone2.init
+ , uploads = Data.UploadDict.empty
+ , descModel = Comp.MarkdownInput.init
+ , descField = ""
+ , formState = BasicResult True ""
+ , uploading = False
+ , shareId = Nothing
+ , uploadPaused = False
+ }
+
+
+type Msg
+ = DropzoneMsg Comp.Dropzone2.Msg
+ | DescMsg Comp.MarkdownInput.Msg
+ | ClearFiles
+ | Submit
+ | CreateShareResp (Result Http.Error IdResult)
+ | Uploading UploadState
+ | StartStopUpload
+ | UploadStopped (Maybe String)
+ | ResetForm
+ | NotifyResp (Result Http.Error BasicResult)
diff --git a/modules/webapp/src/main/elm/Page/OpenShare/Update.elm b/modules/webapp/src/main/elm/Page/OpenShare/Update.elm
new file mode 100644
index 00000000..dc88f52a
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenShare/Update.elm
@@ -0,0 +1,188 @@
+module Page.OpenShare.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.ShareProperties exposing (ShareProperties)
+import Comp.Dropzone2
+import Comp.MarkdownInput
+import Data.Flags exposing (Flags)
+import Data.UploadData exposing (UploadData)
+import Data.UploadDict
+import Data.UploadState exposing (UploadState)
+import Http
+import Page exposing (Page(..))
+import Page.OpenShare.Data exposing (Model, Msg(..))
+import Ports
+import Util.Http
+import Util.Share
+
+
+update : String -> Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update aliasId flags msg model =
+ case msg of
+ DropzoneMsg lmsg ->
+ let
+ ( m, c, fs ) =
+ Comp.Dropzone2.update model.uploads.selectedFiles lmsg model.dropzoneModel
+ in
+ ( { model
+ | dropzoneModel = m
+ , uploads = Data.UploadDict.updateFiles model.uploads fs
+ , formState = BasicResult True ""
+ }
+ , Cmd.batch [ Cmd.map DropzoneMsg c ]
+ )
+
+ DescMsg lmsg ->
+ let
+ ( m, txt ) =
+ Comp.MarkdownInput.update model.descField lmsg model.descModel
+ in
+ ( { model
+ | descModel = m
+ , descField = txt
+ , formState = BasicResult True ""
+ }
+ , Cmd.none
+ )
+
+ ClearFiles ->
+ ( { model | uploads = Data.UploadDict.updateFiles model.uploads [] }, Cmd.none )
+
+ Submit ->
+ let
+ valid =
+ Util.Share.validate flags Nothing model
+ in
+ if valid.success then
+ ( { model | uploading = True }
+ , Api.createEmptyShareAlias flags aliasId (makeProps model) CreateShareResp
+ )
+
+ else
+ ( { model | formState = valid }
+ , Cmd.none
+ )
+
+ CreateShareResp (Ok idres) ->
+ let
+ ( native, files ) =
+ List.unzip model.uploads.selectedFiles
+
+ uploadUrl =
+ flags.config.baseUrl ++ "/api/v2/alias/upload/" ++ idres.id ++ "/files/tus"
+
+ submit =
+ if native == [] then
+ Cmd.none
+
+ else
+ UploadData uploadUrl idres.id native (Just aliasId)
+ |> Data.UploadData.encode
+ |> Ports.submitFiles
+ in
+ if idres.success then
+ ( { model | shareId = Just idres.id }, submit )
+
+ else
+ ( { model | formState = BasicResult False idres.message }
+ , Cmd.none
+ )
+
+ CreateShareResp (Err err) ->
+ case err of
+ Http.BadStatus 403 ->
+ ( model, Page.goto (InfoPage 1) )
+
+ _ ->
+ ( { model
+ | formState = BasicResult False (Util.Http.errorToString err)
+ , uploading = False
+ }
+ , Cmd.none
+ )
+
+ Uploading state ->
+ if Just state.id == model.shareId then
+ trackUpload flags aliasId model state
+
+ else
+ ( model, Cmd.none )
+
+ StartStopUpload ->
+ case model.shareId of
+ Just id ->
+ ( model
+ , if model.uploadPaused then
+ Ports.startUpload id
+
+ else
+ Ports.stopUpload id
+ )
+
+ Nothing ->
+ ( model, Cmd.none )
+
+ UploadStopped err ->
+ ( { model | uploadPaused = err == Nothing }, Cmd.none )
+
+ ResetForm ->
+ ( Page.OpenShare.Data.emptyModel, Cmd.none )
+
+ NotifyResp _ ->
+ ( model, Cmd.none )
+
+
+trackUpload : Flags -> String -> Model -> UploadState -> ( Model, Cmd Msg )
+trackUpload flags aliasId model state =
+ let
+ ( next, progress ) =
+ Data.UploadDict.trackUpload model.uploads state
+
+ progressCmd p =
+ case p of
+ Data.UploadDict.FileProgress index perc ->
+ [ ( "file-progress-" ++ String.fromInt index
+ , perc
+ )
+ ]
+
+ Data.UploadDict.AllProgress perc ->
+ [ ( "all-progress", perc )
+ ]
+
+ infoMsg =
+ case state.state of
+ Data.UploadState.Failed em ->
+ BasicResult False em
+
+ _ ->
+ model.formState
+
+ notifyCmd =
+ if Data.UploadDict.allDone next then
+ Api.notifyAliasUpload flags
+ aliasId
+ (Maybe.withDefault "" model.shareId)
+ NotifyResp
+
+ else
+ Cmd.none
+ in
+ ( { model
+ | uploads = next
+ , uploadPaused = False
+ , formState = infoMsg
+ }
+ , Cmd.batch [ Ports.setProgress (List.concatMap progressCmd progress), notifyCmd ]
+ )
+
+
+makeProps : Model -> ShareProperties
+makeProps model =
+ { name = Nothing
+ , validity = 0
+ , description = Just model.descField
+ , maxViews = 10
+ , password = Nothing
+ }
diff --git a/modules/webapp/src/main/elm/Page/OpenShare/View.elm b/modules/webapp/src/main/elm/Page/OpenShare/View.elm
new file mode 100644
index 00000000..f5eeac6a
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/OpenShare/View.elm
@@ -0,0 +1,163 @@
+module Page.OpenShare.View exposing (view)
+
+import Comp.Dropzone2
+import Comp.MarkdownInput
+import Data.Flags exposing (Flags)
+import Data.UploadDict exposing (countDone)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Page exposing (Page(..))
+import Page.OpenShare.Data exposing (Model, Msg(..))
+
+
+view : Flags -> String -> Model -> Html Msg
+view flags id model =
+ let
+ counts =
+ countDone model.uploads
+
+ allDone =
+ model.shareId
+ /= Nothing
+ && Tuple.first counts
+ + Tuple.second counts
+ == List.length model.uploads.selectedFiles
+ in
+ div []
+ [ div [ class "ui container" ]
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui upload icon" ] []
+ , text "Send files"
+ ]
+ ]
+ , div [ class "ui container" ]
+ [ p [] []
+ , div
+ [ classList
+ [ ( "ui small form", True )
+ , ( "error", not model.formState.success || Tuple.second counts > 0 )
+ , ( "success", Tuple.second counts == 0 )
+ ]
+ ]
+ [ if allDone then
+ doneMessageBox counts model
+
+ else
+ controls model
+ , Data.Flags.limitsMessage flags
+ [ class "ui info message" ]
+ , div [ class "ui error message" ]
+ [ text model.formState.message
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Description" ]
+ , Html.map DescMsg
+ (Comp.MarkdownInput.view
+ model.descField
+ model.descModel
+ )
+ ]
+ , Html.map DropzoneMsg
+ (Comp.Dropzone2.view
+ (mkViewSettings model)
+ model.dropzoneModel
+ )
+ ]
+ ]
+ ]
+
+
+doneMessageBox : ( Int, Int ) -> Model -> Html Msg
+doneMessageBox ( succ, err ) model =
+ let
+ buttons =
+ div [ class "" ]
+ [ a
+ [ class "ui primary button"
+ , href "#"
+ , onClick ResetForm
+ ]
+ [ text "Send more files"
+ ]
+ ]
+
+ success =
+ div [ class "ui success icon message" ]
+ [ i [ class "ui check icon" ] []
+ , div [ class "content" ]
+ [ div [ class "ui header" ]
+ [ text "All files uploaded"
+ ]
+ , div [ class "ui divider" ] []
+ , buttons
+ ]
+ ]
+
+ error =
+ div [ class "ui error icon message" ]
+ [ i [ class "ui meh icon" ] []
+ , div [ class "content" ]
+ [ div [ class "header" ]
+ [ text "Some files failed"
+ ]
+ , p []
+ [ text "Some files failed to upload…. You can try uploading them again."
+ ]
+ , div [ class "ui divider" ] []
+ , buttons
+ ]
+ ]
+ in
+ if err > 0 then
+ error
+
+ else
+ success
+
+
+controls : Model -> Html Msg
+controls model =
+ div
+ [ class "field"
+ ]
+ [ button
+ [ type_ "button"
+ , classList
+ [ ( "ui primary button", True )
+ , ( "disabled", model.uploading )
+ ]
+ , onClick Submit
+ ]
+ [ text "Submit"
+ ]
+ , button
+ [ type_ "button"
+ , onClick ClearFiles
+ , classList
+ [ ( "ui button", True )
+ , ( "disabled", model.uploading )
+ ]
+ ]
+ [ text "Clear Files"
+ ]
+ , button
+ [ type_ "button"
+ , classList
+ [ ( "ui right floated button", True )
+ , ( "disabled", not model.uploading )
+ ]
+ , onClick StartStopUpload
+ ]
+ [ if model.uploadPaused then
+ text "Resume"
+
+ else
+ text "Pause"
+ ]
+ ]
+
+
+mkViewSettings : Model -> Comp.Dropzone2.ViewSettings
+mkViewSettings model =
+ Comp.Dropzone2.mkViewSettings (not model.uploading) model.uploads
diff --git a/modules/webapp/src/main/elm/Page/Register/Data.elm b/modules/webapp/src/main/elm/Page/Register/Data.elm
new file mode 100644
index 00000000..c613782f
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Register/Data.elm
@@ -0,0 +1,44 @@
+module Page.Register.Data exposing (..)
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Http
+
+
+type alias Model =
+ { result : Maybe BasicResult
+ , login : String
+ , pass1 : String
+ , pass2 : String
+ , showPass1 : Bool
+ , showPass2 : Bool
+ , errorMsg : List String
+ , loading : Bool
+ , successMsg : String
+ , invite : Maybe String
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { result = Nothing
+ , login = ""
+ , pass1 = ""
+ , pass2 = ""
+ , showPass1 = False
+ , showPass2 = False
+ , errorMsg = []
+ , successMsg = ""
+ , loading = False
+ , invite = Nothing
+ }
+
+
+type Msg
+ = SetLogin String
+ | SetPass1 String
+ | SetPass2 String
+ | SetInvite String
+ | RegisterSubmit
+ | ToggleShowPass1
+ | ToggleShowPass2
+ | SubmitResp (Result Http.Error BasicResult)
diff --git a/modules/webapp/src/main/elm/Page/Register/Update.elm b/modules/webapp/src/main/elm/Page/Register/Update.elm
new file mode 100644
index 00000000..b662beeb
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Register/Update.elm
@@ -0,0 +1,119 @@
+module Page.Register.Update exposing (update)
+
+import Api
+import Api.Model.Registration exposing (Registration)
+import Data.Flags exposing (Flags)
+import Page exposing (Page(..))
+import Page.Register.Data exposing (..)
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ RegisterSubmit ->
+ case model.errorMsg of
+ [] ->
+ let
+ reg =
+ { login = model.login
+ , password = model.pass1
+ , invite = model.invite
+ }
+ in
+ ( model, Api.register flags reg SubmitResp )
+
+ _ ->
+ ( model, Cmd.none )
+
+ SetLogin str ->
+ let
+ m =
+ { model | login = str }
+
+ err =
+ validateForm m
+ in
+ ( { m | errorMsg = err }, Cmd.none )
+
+ SetPass1 str ->
+ let
+ m =
+ { model | pass1 = str }
+
+ err =
+ validateForm m
+ in
+ ( { m | errorMsg = err }, Cmd.none )
+
+ SetPass2 str ->
+ let
+ m =
+ { model | pass2 = str }
+
+ err =
+ validateForm m
+ in
+ ( { m | errorMsg = err }, Cmd.none )
+
+ SetInvite str ->
+ ( { model
+ | invite =
+ if str == "" then
+ Nothing
+
+ else
+ Just str
+ }
+ , Cmd.none
+ )
+
+ ToggleShowPass1 ->
+ ( { model | showPass1 = not model.showPass1 }, Cmd.none )
+
+ ToggleShowPass2 ->
+ ( { model | showPass2 = not model.showPass2 }, Cmd.none )
+
+ SubmitResp (Ok r) ->
+ let
+ m =
+ emptyModel
+
+ cmd =
+ if r.success then
+ Page.goto (LoginPage ( Nothing, False ))
+
+ else
+ Cmd.none
+ in
+ ( { m
+ | result =
+ if r.success then
+ Nothing
+
+ else
+ Just r
+ }
+ , cmd
+ )
+
+ SubmitResp (Err err) ->
+ ( model, Cmd.none )
+
+
+validateForm : Model -> List String
+validateForm model =
+ if
+ model.login
+ == ""
+ || model.pass1
+ == ""
+ || model.pass2
+ == ""
+ then
+ [ "All fields are required!" ]
+
+ else if model.pass1 /= model.pass2 then
+ [ "The passwords do not match." ]
+
+ else
+ []
diff --git a/modules/webapp/src/main/elm/Page/Register/View.elm b/modules/webapp/src/main/elm/Page/Register/View.elm
new file mode 100644
index 00000000..44be077b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Register/View.elm
@@ -0,0 +1,150 @@
+module Page.Register.View exposing (view)
+
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput, onSubmit)
+import Page exposing (Page(..))
+import Page.Register.Data exposing (..)
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ div [ class "register-page" ]
+ [ div [ class "ui centered grid" ]
+ [ div [ class "row" ]
+ [ div [ class "six wide column ui segment register-view" ]
+ [ h1 [ class "ui cener aligned icon header" ]
+ [ img
+ [ class "ui logo image"
+ , src (flags.config.assetsPath ++ "/img/logo.png")
+ ]
+ []
+ , div [ class "content" ]
+ [ text "Sign up"
+ ]
+ ]
+ , Html.form
+ [ class "ui large error form raised segment"
+ , onSubmit RegisterSubmit
+ , autocomplete False
+ ]
+ [ div [ class "required field" ]
+ [ label [] [ text "User Login" ]
+ , div [ class "ui left icon input" ]
+ [ input
+ [ type_ "text"
+ , autocomplete False
+ , onInput SetLogin
+ , value model.login
+ ]
+ []
+ , i [ class "user icon" ] []
+ ]
+ ]
+ , div
+ [ class "required field"
+ ]
+ [ label [] [ text "Password" ]
+ , div [ class "ui left icon action input" ]
+ [ input
+ [ type_ <|
+ if model.showPass1 then
+ "text"
+
+ else
+ "password"
+ , autocomplete False
+ , onInput SetPass1
+ , value model.pass1
+ ]
+ []
+ , i [ class "lock icon" ] []
+ , button [ class "ui icon button", onClick ToggleShowPass1 ]
+ [ i [ class "eye icon" ] []
+ ]
+ ]
+ ]
+ , div
+ [ class "required field"
+ ]
+ [ label [] [ text "Password (repeat)" ]
+ , div [ class "ui left icon action input" ]
+ [ input
+ [ type_ <|
+ if model.showPass2 then
+ "text"
+
+ else
+ "password"
+ , autocomplete False
+ , onInput SetPass2
+ , value model.pass2
+ ]
+ []
+ , i [ class "lock icon" ] []
+ , button [ class "ui icon button", onClick ToggleShowPass2 ]
+ [ i [ class "eye icon" ] []
+ ]
+ ]
+ ]
+ , div
+ [ classList
+ [ ( "field", True )
+ , ( "invisible", flags.config.signupMode /= "invite" )
+ ]
+ ]
+ [ label [] [ text "Invitation Key" ]
+ , div [ class "ui left icon input" ]
+ [ input
+ [ type_ "text"
+ , autocomplete False
+ , onInput SetInvite
+ , model.invite |> Maybe.withDefault "" |> value
+ ]
+ []
+ , i [ class "key icon" ] []
+ ]
+ ]
+ , button
+ [ class "ui primary button"
+ , type_ "submit"
+ ]
+ [ text "Submit"
+ ]
+ ]
+ , resultMessage model
+ , div [ class "ui very basic right aligned segment" ]
+ [ text "Already signed up? "
+ , a [ class "ui link", Page.href (LoginPage ( Nothing, False )) ]
+ [ i [ class "sign-in icon" ] []
+ , text "Sign in"
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+
+
+resultMessage : Model -> Html Msg
+resultMessage model =
+ case model.result of
+ Just r ->
+ if r.success then
+ div [ class "ui success message" ]
+ [ text "Registration successful."
+ ]
+
+ else
+ div [ class "ui error message" ]
+ [ text r.message
+ ]
+
+ Nothing ->
+ if List.isEmpty model.errorMsg then
+ span [ class "invisible" ] []
+
+ else
+ div [ class "ui error message" ]
+ (List.map (\s -> div [] [ text s ]) model.errorMsg)
diff --git a/modules/webapp/src/main/elm/Page/Settings/Data.elm b/modules/webapp/src/main/elm/Page/Settings/Data.elm
new file mode 100644
index 00000000..b124bf2c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Settings/Data.elm
@@ -0,0 +1,56 @@
+module Page.Settings.Data exposing
+ ( Banner
+ , Model
+ , Msg(..)
+ , emptyModel
+ )
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.EmailInfo exposing (EmailInfo)
+import Comp.PasswordInput
+import Http
+
+
+type alias Banner =
+ { success : Bool
+ , text : String
+ }
+
+
+type alias Model =
+ { oldPasswordModel : Comp.PasswordInput.Model
+ , oldPasswordField : Maybe String
+ , newPasswordModel1 : Comp.PasswordInput.Model
+ , newPasswordField1 : Maybe String
+ , newPasswordModel2 : Comp.PasswordInput.Model
+ , newPasswordField2 : Maybe String
+ , emailField : Maybe String
+ , currentEmail : Maybe String
+ , banner : Maybe Banner
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { oldPasswordModel = Comp.PasswordInput.init
+ , oldPasswordField = Nothing
+ , newPasswordModel1 = Comp.PasswordInput.init
+ , newPasswordField1 = Nothing
+ , newPasswordModel2 = Comp.PasswordInput.init
+ , newPasswordField2 = Nothing
+ , emailField = Nothing
+ , currentEmail = Nothing
+ , banner = Nothing
+ }
+
+
+type Msg
+ = Init
+ | SetEmail String
+ | SubmitEmail
+ | SetOldPassword Comp.PasswordInput.Msg
+ | SetNewPassword1 Comp.PasswordInput.Msg
+ | SetNewPassword2 Comp.PasswordInput.Msg
+ | SubmitPassword
+ | GetEmailResp (Result Http.Error EmailInfo)
+ | SaveResp (Result Http.Error BasicResult)
diff --git a/modules/webapp/src/main/elm/Page/Settings/Update.elm b/modules/webapp/src/main/elm/Page/Settings/Update.elm
new file mode 100644
index 00000000..2d85a380
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Settings/Update.elm
@@ -0,0 +1,136 @@
+module Page.Settings.Update exposing (update)
+
+import Api
+import Api.Model.PasswordChange exposing (PasswordChange)
+import Comp.PasswordInput
+import Data.Flags exposing (Flags)
+import Page.Settings.Data exposing (Banner, Model, Msg(..))
+import Util.Http
+import Util.Maybe
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ Init ->
+ ( { model | banner = Nothing }
+ , Api.getEmail flags GetEmailResp
+ )
+
+ GetEmailResp (Ok r) ->
+ ( { model
+ | currentEmail = r.email
+ , emailField =
+ if model.emailField == Nothing then
+ r.email
+
+ else
+ model.emailField
+ }
+ , Cmd.none
+ )
+
+ GetEmailResp (Err err) ->
+ ( { model
+ | banner =
+ Just <|
+ Banner False <|
+ "Error retrieving current email: "
+ ++ Util.Http.errorToString err
+ }
+ , Cmd.none
+ )
+
+ SetEmail str ->
+ let
+ em =
+ Util.Maybe.fromString str
+ in
+ ( { model | emailField = em, banner = Nothing }, Cmd.none )
+
+ SubmitEmail ->
+ if model.currentEmail == model.emailField then
+ ( { model
+ | banner =
+ Just <|
+ Banner False "E-Mail has not changed."
+ }
+ , Cmd.none
+ )
+
+ else
+ ( model, Api.setEmail flags model.emailField SaveResp )
+
+ SaveResp (Ok r) ->
+ ( { model | banner = Just <| Banner r.success r.message }, Cmd.none )
+
+ SaveResp (Err err) ->
+ ( { model
+ | banner =
+ Just <|
+ Banner False <|
+ "Error on submit: "
+ ++ Util.Http.errorToString err
+ }
+ , Cmd.none
+ )
+
+ SetOldPassword lmsg ->
+ let
+ ( m, pw ) =
+ Comp.PasswordInput.update lmsg model.oldPasswordModel
+ in
+ ( { model
+ | oldPasswordModel = m
+ , oldPasswordField = pw
+ , banner = Nothing
+ }
+ , Cmd.none
+ )
+
+ SetNewPassword1 lmsg ->
+ let
+ ( m, pw ) =
+ Comp.PasswordInput.update lmsg model.newPasswordModel1
+ in
+ ( { model
+ | newPasswordModel1 = m
+ , newPasswordField1 = pw
+ , banner = Nothing
+ }
+ , Cmd.none
+ )
+
+ SetNewPassword2 lmsg ->
+ let
+ ( m, pw ) =
+ Comp.PasswordInput.update lmsg model.newPasswordModel2
+ in
+ ( { model
+ | newPasswordModel2 = m
+ , newPasswordField2 = pw
+ , banner = Nothing
+ }
+ , Cmd.none
+ )
+
+ SubmitPassword ->
+ let
+ bothEqual =
+ model.newPasswordField1
+ == model.newPasswordField2
+ && model.newPasswordField1
+ /= Nothing
+
+ pwc =
+ PasswordChange
+ (Maybe.withDefault "" model.oldPasswordField)
+ (Maybe.withDefault "" model.newPasswordField1)
+ in
+ if bothEqual then
+ ( model, Api.changePassword flags pwc SaveResp )
+
+ else
+ ( { model | banner = Just <| Banner False "Passwords don't match." }
+ , Cmd.none
+ )
diff --git a/modules/webapp/src/main/elm/Page/Settings/View.elm b/modules/webapp/src/main/elm/Page/Settings/View.elm
new file mode 100644
index 00000000..a6d53ae5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Settings/View.elm
@@ -0,0 +1,118 @@
+module Page.Settings.View exposing (view)
+
+import Comp.PasswordInput
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Page.Settings.Data exposing (Model, Msg(..))
+
+
+view : Model -> Html Msg
+view model =
+ div [ class "ui text container account-page" ]
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui cog icon" ] []
+ , text "Settings"
+ ]
+ , banner model
+ , emailForm model
+ , changePasswordForm model
+ ]
+
+
+emailForm : Model -> Html Msg
+emailForm model =
+ div [ class "ui segments" ]
+ [ div [ class "ui segment" ]
+ [ h2 [ class "ui header" ]
+ [ text "Change your E-Mail"
+ ]
+ , Html.form [ class "ui form" ]
+ [ div [ class "ui field" ]
+ [ label [] [ text "New E-Mail" ]
+ , input
+ [ type_ "text"
+ , placeholder "E-Mail address"
+ , onInput SetEmail
+ , Maybe.withDefault "" model.emailField
+ |> value
+ ]
+ []
+ ]
+ , p []
+ [ text "Submitting an empty form deletes the E-Mail address."
+ ]
+ ]
+ ]
+ , div [ class "ui secondary segment" ]
+ [ button
+ [ type_ "button"
+ , class "ui primary button"
+ , onClick SubmitEmail
+ ]
+ [ text "Submit"
+ ]
+ ]
+ ]
+
+
+changePasswordForm : Model -> Html Msg
+changePasswordForm model =
+ div [ class "ui segments" ]
+ [ div [ class "ui segment" ]
+ [ h2 [ class "ui header" ]
+ [ text "Change Password"
+ ]
+ , Html.form [ class "ui form" ]
+ [ div [ class "ui required field" ]
+ [ label [] [ text "Current Password" ]
+ , Html.map SetOldPassword
+ (Comp.PasswordInput.view
+ model.oldPasswordField
+ model.oldPasswordModel
+ )
+ ]
+ , div [ class "ui required field" ]
+ [ label [] [ text "New Password" ]
+ , Html.map SetNewPassword1
+ (Comp.PasswordInput.view
+ model.newPasswordField1
+ model.newPasswordModel1
+ )
+ ]
+ , div [ class "ui required field" ]
+ [ label [] [ text "New Password (Repeat)" ]
+ , Html.map SetNewPassword2
+ (Comp.PasswordInput.view
+ model.newPasswordField2
+ model.newPasswordModel2
+ )
+ ]
+ ]
+ ]
+ , div [ class "ui secondary segment" ]
+ [ button
+ [ type_ "button"
+ , class "ui primary button"
+ , onClick SubmitPassword
+ ]
+ [ text "Submit"
+ ]
+ ]
+ ]
+
+
+banner : Model -> Html Msg
+banner model =
+ div
+ [ classList
+ [ ( "ui message", True )
+ , ( "hidden invisible", model.banner == Nothing )
+ , ( "error", Maybe.map .success model.banner == Just False )
+ , ( "success", Maybe.map .success model.banner == Just True )
+ ]
+ ]
+ [ Maybe.map .text model.banner
+ |> Maybe.withDefault ""
+ |> text
+ ]
diff --git a/modules/webapp/src/main/elm/Page/Share/Data.elm b/modules/webapp/src/main/elm/Page/Share/Data.elm
new file mode 100644
index 00000000..73dd64ec
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Share/Data.elm
@@ -0,0 +1,83 @@
+module Page.Share.Data exposing (Model, Msg(..), emptyModel, makeProps)
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.IdResult exposing (IdResult)
+import Api.Model.ShareProperties exposing (ShareProperties)
+import Comp.Dropzone2
+import Comp.IntField
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ValidityField
+import Data.Flags exposing (Flags)
+import Data.UploadDict exposing (UploadDict)
+import Data.UploadState exposing (UploadState)
+import Data.ValidityValue exposing (ValidityValue)
+import Http
+
+
+type alias Model =
+ { dropzoneModel : Comp.Dropzone2.Model
+ , uploads : UploadDict
+ , validityModel : Comp.ValidityField.Model
+ , validityField : ValidityValue
+ , passwordModel : Comp.PasswordInput.Model
+ , passwordField : Maybe String
+ , maxViewModel : Comp.IntField.Model
+ , maxViewField : Maybe Int
+ , descModel : Comp.MarkdownInput.Model
+ , descField : String
+ , nameField : Maybe String
+ , formState : BasicResult
+ , uploading : Bool
+ , shareId : Maybe String
+ , showDetails : Bool
+ , uploadPaused : Bool
+ }
+
+
+emptyModel : Flags -> Model
+emptyModel flags =
+ { dropzoneModel = Comp.Dropzone2.init
+ , uploads = Data.UploadDict.empty
+ , validityModel = Comp.ValidityField.init flags
+ , validityField = Data.ValidityValue.Days 2
+ , passwordModel = Comp.PasswordInput.init
+ , passwordField = Nothing
+ , maxViewModel = Comp.IntField.init (Just 1) Nothing "Maximum Public Views"
+ , maxViewField = Just 30
+ , descModel = Comp.MarkdownInput.init
+ , descField = ""
+ , nameField = Nothing
+ , formState = BasicResult True ""
+ , uploading = False
+ , shareId = Nothing
+ , showDetails = False
+ , uploadPaused = False
+ }
+
+
+type Msg
+ = DropzoneMsg Comp.Dropzone2.Msg
+ | ValidityMsg Comp.ValidityField.Msg
+ | PasswordMsg Comp.PasswordInput.Msg
+ | MaxViewMsg Comp.IntField.Msg
+ | DescMsg Comp.MarkdownInput.Msg
+ | SetName String
+ | ClearFiles
+ | Submit
+ | CreateShareResp (Result Http.Error IdResult)
+ | Uploading UploadState
+ | ToggleDetails
+ | StartStopUpload
+ | UploadStopped (Maybe String)
+ | ResetForm
+
+
+makeProps : Model -> ShareProperties
+makeProps model =
+ { name = model.nameField
+ , validity = Data.ValidityValue.toMillis model.validityField
+ , description = Just model.descField
+ , maxViews = Maybe.withDefault 10 model.maxViewField
+ , password = model.passwordField
+ }
diff --git a/modules/webapp/src/main/elm/Page/Share/Update.elm b/modules/webapp/src/main/elm/Page/Share/Update.elm
new file mode 100644
index 00000000..70e076c7
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Share/Update.elm
@@ -0,0 +1,221 @@
+module Page.Share.Update exposing (update)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.Dropzone2
+import Comp.IntField
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ValidityField
+import Data.Flags exposing (Flags)
+import Data.UploadData exposing (UploadData)
+import Data.UploadDict
+import Data.UploadState exposing (UploadState)
+import Dict
+import File
+import Page.Share.Data exposing (Model, Msg(..), makeProps)
+import Ports
+import Util.Http
+import Util.Maybe
+import Util.Share
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ case msg of
+ DropzoneMsg lmsg ->
+ let
+ ( m, c, fs ) =
+ Comp.Dropzone2.update model.uploads.selectedFiles lmsg model.dropzoneModel
+ in
+ ( { model
+ | dropzoneModel = m
+ , uploads = Data.UploadDict.updateFiles model.uploads fs
+ , formState = BasicResult True ""
+ }
+ , Cmd.batch [ Cmd.map DropzoneMsg c ]
+ )
+
+ ValidityMsg lmsg ->
+ let
+ ( m, sel ) =
+ Comp.ValidityField.update lmsg model.validityModel
+ in
+ ( { model
+ | validityModel = m
+ , validityField = Maybe.withDefault model.validityField sel
+ }
+ , Cmd.none
+ )
+
+ PasswordMsg lmsg ->
+ let
+ ( m, pw ) =
+ Comp.PasswordInput.update lmsg model.passwordModel
+ in
+ ( { model | passwordModel = m, passwordField = pw }
+ , Cmd.none
+ )
+
+ MaxViewMsg lmsg ->
+ let
+ ( m, v ) =
+ Comp.IntField.update lmsg model.maxViewModel
+ in
+ ( { model
+ | maxViewModel = m
+ , maxViewField = v
+ }
+ , Cmd.none
+ )
+
+ DescMsg lmsg ->
+ let
+ ( m, txt ) =
+ Comp.MarkdownInput.update model.descField lmsg model.descModel
+ in
+ ( { model
+ | descModel = m
+ , descField = txt
+ , formState = BasicResult True ""
+ }
+ , Cmd.none
+ )
+
+ SetName str ->
+ ( { model | nameField = Util.Maybe.fromString str }
+ , Cmd.none
+ )
+
+ ClearFiles ->
+ ( { model | uploads = Data.UploadDict.updateFiles model.uploads [] }, Cmd.none )
+
+ Submit ->
+ let
+ valid =
+ Util.Share.validate flags Nothing model
+ in
+ if valid.success then
+ ( { model
+ | uploading = True
+ , showDetails = False
+ }
+ , Api.createEmptyShare flags (makeProps model) CreateShareResp
+ )
+
+ else
+ ( { model | formState = valid }
+ , Cmd.none
+ )
+
+ CreateShareResp (Ok idres) ->
+ let
+ ( native, files ) =
+ List.unzip model.uploads.selectedFiles
+
+ uploadUrl =
+ flags.config.baseUrl ++ "/api/v2/sec/upload/" ++ idres.id ++ "/files/tus"
+
+ submit =
+ if native == [] then
+ Cmd.none
+
+ else
+ UploadData uploadUrl idres.id native Nothing
+ |> Data.UploadData.encode
+ |> Ports.submitFiles
+ in
+ if idres.success then
+ ( { model | shareId = Just idres.id }, submit )
+
+ else
+ ( { model | formState = BasicResult False idres.message }
+ , Cmd.none
+ )
+
+ CreateShareResp (Err err) ->
+ ( { model
+ | formState = BasicResult False (Util.Http.errorToString err)
+ , uploading = False
+ }
+ , Cmd.none
+ )
+
+ Uploading state ->
+ if Just state.id == model.shareId then
+ trackUpload model state
+
+ else
+ ( model, Cmd.none )
+
+ ToggleDetails ->
+ ( { model | showDetails = not model.showDetails }, Cmd.none )
+
+ StartStopUpload ->
+ case model.shareId of
+ Just id ->
+ ( model
+ , if model.uploadPaused then
+ Ports.startUpload id
+
+ else
+ Ports.stopUpload id
+ )
+
+ Nothing ->
+ ( model, Cmd.none )
+
+ UploadStopped err ->
+ let
+ infoMsg =
+ case err of
+ Just m ->
+ BasicResult False m
+
+ Nothing ->
+ model.formState
+ in
+ ( { model
+ | uploadPaused = err == Nothing
+ , formState = infoMsg
+ }
+ , Cmd.none
+ )
+
+ ResetForm ->
+ ( Page.Share.Data.emptyModel flags, Cmd.none )
+
+
+trackUpload : Model -> UploadState -> ( Model, Cmd Msg )
+trackUpload model state =
+ let
+ ( next, progress ) =
+ Data.UploadDict.trackUpload model.uploads state
+
+ progressCmd p =
+ case p of
+ Data.UploadDict.FileProgress index perc ->
+ [ ( "file-progress-" ++ String.fromInt index
+ , perc
+ )
+ ]
+
+ Data.UploadDict.AllProgress perc ->
+ [ ( "all-progress", perc )
+ ]
+
+ infoMsg =
+ case state.state of
+ Data.UploadState.Failed em ->
+ BasicResult False em
+
+ _ ->
+ model.formState
+ in
+ ( { model
+ | uploads = next
+ , uploadPaused = False
+ , formState = infoMsg
+ }
+ , Ports.setProgress (List.concatMap progressCmd progress)
+ )
diff --git a/modules/webapp/src/main/elm/Page/Share/View.elm b/modules/webapp/src/main/elm/Page/Share/View.elm
new file mode 100644
index 00000000..0693a00a
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Share/View.elm
@@ -0,0 +1,241 @@
+module Page.Share.View exposing (view)
+
+import Comp.Dropzone2
+import Comp.IntField
+import Comp.MarkdownInput
+import Comp.PasswordInput
+import Comp.ValidityField
+import Data.Flags exposing (Flags)
+import Data.UploadDict exposing (countDone)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Page exposing (Page(..))
+import Page.Share.Data exposing (Model, Msg(..))
+
+
+view : Flags -> Model -> Html Msg
+view flags model =
+ let
+ counts =
+ countDone model.uploads
+
+ allDone =
+ model.shareId
+ /= Nothing
+ && Tuple.first counts
+ + Tuple.second counts
+ == List.length model.uploads.selectedFiles
+ in
+ div []
+ [ div [ class "ui container" ]
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui share alternate icon" ] []
+ , text "Create a Share"
+ ]
+ ]
+ , div [ class "ui container" ]
+ [ p [] []
+ , div
+ [ classList
+ [ ( "ui small form", True )
+ , ( "error", not model.formState.success || Tuple.second counts > 0 )
+ , ( "success", Tuple.second counts == 0 )
+ ]
+ ]
+ [ if allDone then
+ doneMessageBox counts model
+
+ else
+ controls model
+ , Data.Flags.limitsMessage flags
+ [ class "ui info message" ]
+ , div [ class "ui error message" ]
+ [ text model.formState.message
+ ]
+ , div [ class "ui accordion" ]
+ [ div
+ [ classList
+ [ ( "ui header title", True )
+ , ( "active", model.showDetails )
+ ]
+ , onClick ToggleDetails
+ ]
+ [ i [ class "dropdown icon" ] []
+ , text "Details"
+ ]
+ , div
+ [ classList
+ [ ( "content", True )
+ , ( "active", model.showDetails )
+ ]
+ ]
+ [ div [ class "field" ]
+ [ label [] [ text "Description" ]
+ , Html.map DescMsg
+ (Comp.MarkdownInput.view
+ model.descField
+ model.descModel
+ )
+ ]
+ , div [ class "two fields" ]
+ [ div [ class "field" ]
+ [ label [] [ text "Name" ]
+ , input
+ [ type_ "text"
+ , placeholder "Optional Name"
+ , onInput SetName
+ ]
+ []
+ ]
+ , div [ class "required field" ]
+ [ label [] [ text "Validity" ]
+ , Html.map ValidityMsg
+ (Comp.ValidityField.view
+ model.validityField
+ model.validityModel
+ )
+ ]
+ ]
+ , div [ class "two fields" ]
+ [ Html.map MaxViewMsg
+ (Comp.IntField.view
+ model.maxViewField
+ model.maxViewModel
+ )
+ , div [ class "field" ]
+ [ label [] [ text "Password" ]
+ , Html.map PasswordMsg
+ (Comp.PasswordInput.view model.passwordField
+ model.passwordModel
+ )
+ ]
+ ]
+ ]
+ , div [ class "active ui header title" ]
+ [ i [ class "dropdown icon" ] []
+ , text "Files"
+ ]
+ , Html.map DropzoneMsg
+ (Comp.Dropzone2.view
+ (mkViewSettings model)
+ model.dropzoneModel
+ )
+ ]
+ ]
+ ]
+ ]
+
+
+doneMessageBox : ( Int, Int ) -> Model -> Html Msg
+doneMessageBox ( _, err ) model =
+ let
+ buttons =
+ div [ class "" ]
+ [ a
+ [ class "ui primary button"
+ , href "#"
+ , onClick ResetForm
+ ]
+ [ text "New Share"
+ ]
+ , a
+ [ class "ui secondary button"
+ , Page.href (DetailPage <| Maybe.withDefault "" model.shareId)
+ ]
+ [ text "Goto Share"
+ ]
+ ]
+
+ success =
+ div [ class "ui success icon message" ]
+ [ i [ class "ui check icon" ] []
+ , div [ class "content" ]
+ [ div [ class "ui header" ]
+ [ text "All files uploaded"
+ ]
+ , div [ class "ui divider" ] []
+ , buttons
+ ]
+ ]
+
+ error =
+ div [ class "ui error icon message" ]
+ [ i [ class "ui meh icon" ] []
+ , div [ class "content" ]
+ [ div [ class "header" ]
+ [ text "Some files failed"
+ ]
+ , p []
+ [ text "Some files failed to upload…. "
+ , text "You can go to the share and try uploading them again."
+ ]
+ , div [ class "ui divider" ] []
+ , buttons
+ ]
+ ]
+ in
+ if err > 0 then
+ error
+
+ else
+ success
+
+
+controls : Model -> Html Msg
+controls model =
+ div
+ [ class "field"
+ ]
+ [ a
+ [ classList
+ [ ( "ui primary button", True )
+ , ( "disabled", model.uploading )
+ ]
+ , href "#"
+ , onClick Submit
+ ]
+ [ i [ class "upload icon" ] []
+ , text "Submit"
+ ]
+ , a
+ [ onClick ClearFiles
+ , href "#"
+ , classList
+ [ ( "ui basic button", True )
+ , ( "disabled", model.uploading )
+ ]
+ ]
+ [ i [ class "undo icon" ] []
+ , text "Clear Files"
+ ]
+ , a
+ [ classList
+ [ ( "ui right floated basic button", True )
+ , ( "disabled", not model.uploading )
+ ]
+ , href "#"
+ , onClick StartStopUpload
+ ]
+ [ i
+ [ class
+ (if model.uploadPaused then
+ "play icon"
+
+ else
+ "pause icon"
+ )
+ ]
+ []
+ , if model.uploadPaused then
+ text "Resume"
+
+ else
+ text "Pause"
+ ]
+ ]
+
+
+mkViewSettings : Model -> Comp.Dropzone2.ViewSettings
+mkViewSettings model =
+ Comp.Dropzone2.mkViewSettings (not model.uploading) model.uploads
diff --git a/modules/webapp/src/main/elm/Page/Upload/Data.elm b/modules/webapp/src/main/elm/Page/Upload/Data.elm
new file mode 100644
index 00000000..13c47b90
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Upload/Data.elm
@@ -0,0 +1,30 @@
+module Page.Upload.Data exposing (Model, Msg(..), emptyModel)
+
+import Api.Model.ShareList exposing (ShareList)
+import Api.Model.ShareListItem exposing (ShareListItem)
+import Comp.ShareTable
+import Http
+
+
+type alias Model =
+ { selected : Maybe ShareListItem
+ , searchResult : List ShareListItem
+ , query : String
+ , tableModel : Comp.ShareTable.Model
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { selected = Nothing
+ , searchResult = []
+ , query = ""
+ , tableModel = Comp.ShareTable.init
+ }
+
+
+type Msg
+ = ShareTableMsg Comp.ShareTable.Msg
+ | SetQuery String
+ | SearchResp (Result Http.Error ShareList)
+ | Init
diff --git a/modules/webapp/src/main/elm/Page/Upload/Update.elm b/modules/webapp/src/main/elm/Page/Upload/Update.elm
new file mode 100644
index 00000000..d6cc3ab2
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Upload/Update.elm
@@ -0,0 +1,45 @@
+module Page.Upload.Update exposing (update)
+
+import Api
+import Browser.Navigation as Nav
+import Comp.ShareTable
+import Data.Flags exposing (Flags)
+import Page exposing (Page(..))
+import Page.Upload.Data exposing (Model, Msg(..))
+
+
+update : Nav.Key -> Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update key flags msg model =
+ case msg of
+ Init ->
+ ( model, Api.findShares flags model.query SearchResp )
+
+ SetQuery str ->
+ ( { model | query = str }
+ , Api.findShares flags str SearchResp
+ )
+
+ ShareTableMsg lmsg ->
+ let
+ ( lm, selected ) =
+ Comp.ShareTable.update lmsg model.tableModel
+
+ cmd =
+ case selected of
+ Just id ->
+ Page.set key (DetailPage id.id)
+
+ Nothing ->
+ Cmd.none
+ in
+ ( { model | tableModel = lm, selected = selected }
+ , cmd
+ )
+
+ SearchResp (Ok list) ->
+ ( { model | searchResult = list.items }
+ , Cmd.none
+ )
+
+ SearchResp (Err err) ->
+ ( model, Cmd.none )
diff --git a/modules/webapp/src/main/elm/Page/Upload/View.elm b/modules/webapp/src/main/elm/Page/Upload/View.elm
new file mode 100644
index 00000000..ee2c102d
--- /dev/null
+++ b/modules/webapp/src/main/elm/Page/Upload/View.elm
@@ -0,0 +1,57 @@
+module Page.Upload.View exposing (view)
+
+import Comp.ShareTable
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Page exposing (Page(..))
+import Page.Upload.Data exposing (Model, Msg(..))
+
+
+view : Model -> Html Msg
+view model =
+ div
+ [ classList
+ [ ( "ui container upload-page", True )
+ ]
+ ]
+ (viewList model)
+
+
+viewList : Model -> List (Html Msg)
+viewList model =
+ [ h1 [ class "ui dividing header" ]
+ [ i [ class "ui share alternate icon" ] []
+ , text "Your Shares"
+ ]
+ , searchArea model
+ , Html.map ShareTableMsg (Comp.ShareTable.view model.searchResult model.tableModel)
+ ]
+
+
+searchArea : Model -> Html Msg
+searchArea model =
+ div [ class "ui secondary menu" ]
+ [ div [ class "ui container" ]
+ [ div [ class "fitted-item" ]
+ [ div [ class "ui icon input" ]
+ [ input
+ [ type_ "text"
+ , onInput SetQuery
+ , placeholder "Search…"
+ ]
+ []
+ , i [ class "ui search icon" ]
+ []
+ ]
+ ]
+ , div [ class "right menu" ]
+ [ a
+ [ class "ui primary button"
+ , Page.href SharePage
+ ]
+ [ text "New Share"
+ ]
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/PageLocation.elm b/modules/webapp/src/main/elm/PageLocation.elm
deleted file mode 100644
index 8af074e9..00000000
--- a/modules/webapp/src/main/elm/PageLocation.elm
+++ /dev/null
@@ -1,172 +0,0 @@
-module PageLocation exposing (..)
-
-import Navigation
-import Http
-import Data exposing (UploadId(..), isUnauthorized)
-
--- index page
-
-indexPageHref: String
-indexPageHref = "#"
-
-indexPage: Cmd msg
-indexPage =
- Navigation.newUrl indexPageHref
-
-
--- login page
-
-loginPageHref: String
-loginPageHref = "#login"
-
-loginPage: Navigation.Location -> Cmd msg
-loginPage location =
- let
- url =
- if (String.length location.hash > 1) then
- (loginPageHref ++ "&redirect=" ++ location.hash)
- else
- loginPageHref
- in
- Navigation.newUrl url
-
-loginPageRedirect: Navigation.Location -> Cmd msg
-loginPageRedirect loc =
- let
- prefix = loginPageHref ++ "&redirect=#"
- url = if String.startsWith prefix loc.hash then
- String.dropLeft ((String.length prefix) - 1) loc.hash
- else
- "#"
- in
- if url /= timeoutPageHref && url /= loginPageHref then
- Navigation.newUrl url
- else
- indexPage
-
--- uploads page
-
-uploadsPageHref: String
-uploadsPageHref = "#uploads"
-
-uploadsPage: Cmd msg
-uploadsPage =
- Navigation.newUrl uploadsPageHref
-
-
--- download page
-
-downloadPageHref: UploadId -> String
-downloadPageHref uploadId =
- case uploadId of
- Pid id -> "#id=" ++ id
- Uid id -> "#uid=" ++ id
-
-downloadPage: UploadId -> Cmd msgs
-downloadPage id =
- Navigation.newUrl (downloadPageHref id)
-
-
-downloadPageId: String -> Maybe UploadId
-downloadPageId hash =
- if String.startsWith "#id=" hash then
- Pid (String.dropLeft 4 hash) |> Just
- else if String.startsWith "#uid=" hash then
- Uid (String.dropLeft 5 hash) |> Just
- else
- Nothing
-
--- account edit page
-
-accountEditPageHref: String
-accountEditPageHref = "#account-edit"
-
-accountEditPage: Cmd msg
-accountEditPage =
- Navigation.newUrl accountEditPageHref
-
-
--- new share page
-
-newSharePageHref: String
-newSharePageHref = "#new-share"
-
-newSharePage: Cmd msg
-newSharePage =
- Navigation.newUrl newSharePageHref
-
-
--- update account page
-
-profilePageHref: String
-profilePageHref = "#profile"
-
-profilePage: Cmd msg
-profilePage =
- Navigation.newUrl profilePageHref
-
-
--- manage alias pages
-
-aliasListPageHref: String
-aliasListPageHref = "#aliases"
-
-aliasListPage: Cmd msg
-aliasListPage =
- Navigation.newUrl aliasListPageHref
-
-
--- alias uploadFormModel
-
-aliasUploadPageHref: String -> String
-aliasUploadPageHref id =
- "#a=" ++ id
-
-aliasUploadPageId: String -> Maybe String
-aliasUploadPageId hash =
- if String.startsWith "#a=" hash then
- String.dropLeft 3 hash |> Just
- else
- Nothing
-
-aliasUploadPage: String -> Cmd msg
-aliasUploadPage id =
- Navigation.newUrl (aliasUploadPageHref id)
-
-
--- timeout page
-
-timeoutPageHref: String
-timeoutPageHref = "#timeout"
-
-timeoutPage: Cmd msg
-timeoutPage =
- Navigation.newUrl timeoutPageHref
-
-timeoutCmd: Http.Error -> Cmd msg
-timeoutCmd err =
- if Data.isUnauthorized err then
- timeoutPage
- else
- Cmd.none
-
--- manual page
-
-manualPageHref: String -> String
-manualPageHref name =
- "#manual/" ++ name
-
-manualPageName: String -> Maybe String
-manualPageName hash =
- if String.startsWith "#manual/" hash then
- String.dropLeft 8 hash |> Just
- else
- Nothing
-
--- error page
-errorPageHref: String
-errorPageHref = "#error"
-
-errorPage: Cmd msg
-errorPage =
- Navigation.newUrl errorPageHref
diff --git a/modules/webapp/src/main/elm/Pages/AccountEdit/Model.elm b/modules/webapp/src/main/elm/Pages/AccountEdit/Model.elm
deleted file mode 100644
index d9f91c84..00000000
--- a/modules/webapp/src/main/elm/Pages/AccountEdit/Model.elm
+++ /dev/null
@@ -1,23 +0,0 @@
-module Pages.AccountEdit.Model exposing(..)
-
-import Http
-import Data exposing (Account, RemoteUrls)
-
-import Widgets.AccountForm as AccountForm
-import Widgets.LoginSearch as LoginSearch
-
-type alias Model =
- { search: LoginSearch.Model
- , accountForm: Maybe AccountForm.Model
- , errorMsg: String
- , urls: RemoteUrls
- }
-
-emptyModel: RemoteUrls -> Model
-emptyModel urls =
- Model (LoginSearch.initModel urls) Nothing "" urls
-
-type Msg
- = NewAccount
- | AccountFormMsg AccountForm.Msg
- | LoginSearchMsg LoginSearch.Msg
diff --git a/modules/webapp/src/main/elm/Pages/AccountEdit/Update.elm b/modules/webapp/src/main/elm/Pages/AccountEdit/Update.elm
deleted file mode 100644
index 09ecb947..00000000
--- a/modules/webapp/src/main/elm/Pages/AccountEdit/Update.elm
+++ /dev/null
@@ -1,51 +0,0 @@
-module Pages.AccountEdit.Update exposing (..)
-
-import Data exposing (Account)
-import Pages.AccountEdit.Model exposing (..)
-
-import Widgets.AccountForm as AccountForm
-import Widgets.LoginSearch as LoginSearch
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let
- search = model.search
- in
- case msg of
- NewAccount ->
- let
- acc = Data.emptyAccount
- in
- ({model
- | accountForm = Just (AccountForm.createAccount model.urls model.search.login)
- , search = LoginSearch.initModel model.urls
- , errorMsg = ""
- }
- , Cmd.none)
-
- LoginSearchMsg msg ->
- let
- (val, cmd, acc) = LoginSearch.update msg model.search
- in
- case acc of
- Just a ->
- ({model
- | search = val
- , accountForm = Just (AccountForm.modifyAccount model.urls a)
- }
- , Cmd.map LoginSearchMsg cmd)
-
- Nothing ->
- ({model | search = val}, Cmd.map LoginSearchMsg cmd)
-
- AccountFormMsg msg ->
- case model.accountForm of
- Just m ->
- let
- (val, cmd) = AccountForm.update msg m
- in
- ({model | accountForm = Just val}
- ,Cmd.map AccountFormMsg cmd)
-
- Nothing ->
- (model, Cmd.none)
diff --git a/modules/webapp/src/main/elm/Pages/AccountEdit/View.elm b/modules/webapp/src/main/elm/Pages/AccountEdit/View.elm
deleted file mode 100644
index c55bf646..00000000
--- a/modules/webapp/src/main/elm/Pages/AccountEdit/View.elm
+++ /dev/null
@@ -1,103 +0,0 @@
-module Pages.AccountEdit.View exposing (..)
-
-import List
-import Html exposing (Html, div, text, span, i, input, a, p, h2)
-import Html.Attributes exposing (class, classList)
-import Html.Events exposing (onClick)
-
-import Data exposing (Account)
-
-import Widgets.AccountForm as AccountForm
-import Widgets.LoginSearch as LoginSearch
-
-import Pages.AccountEdit.Model exposing (..)
-import Pages.AccountEdit.Update exposing (..)
-
-view: Model -> Html Msg
-view model =
- let
- accForm =
- case model.accountForm of
- Just acc -> Html.map AccountFormMsg (AccountForm.view acc)
- Nothing -> div[][]
- in
- div [class "main ui grid container"]
- [
- div [class "ui two column centered row"]
- [
- div [class "column"]
- (viewSearchAndButton model)
- ]
- ,div [class "row"]
- [
- div [class "ten wide column"]
- [accForm]
- ,div [class "six wide column"]
- (infoMessage model)
- ]
- ]
-
-infoMessage: Model -> List (Html a)
-infoMessage model =
- case model.accountForm of
- Just account ->
- [
- h2 [class "ui horizontal divider header"]
- [
- text (toString account.update)
- ]
- , (infoText account)
- ]
- Nothing ->
- []
-
-infoText: AccountForm.Model -> Html a
-infoText model =
- case model.update of
- AccountForm.Modify ->
- modifyHint
- AccountForm.Create ->
- createHint
-
-modifyHint: Html a
-modifyHint =
- Data.markdownHtml """
-This will update the account as follows:
-
-* leave `password` empty to not change it
-* `Email` is optional, used to send a new passwords and notifications on received files
-"""
-
-createHint: Html a
-createHint =
- Data.markdownHtml """
-Create a new account:
-
-* supply `Password` for internal accounts
-* do not set `Password` for external accounts
-* login names must be alphanumeric and start with a letter
-* `Email` is optional, used to send a new passwords and notifications on received files
-"""
-
-viewSearchAndButton: Model -> List (Html Msg)
-viewSearchAndButton model =
- [
- div [class "ui raised brown segment"]
- [
- (Html.map LoginSearchMsg (LoginSearch.view model.search))
- ,div [class "ui horizontal divider"][text "Or"]
- ,a [class "ui brown labeled icon button", onClick NewAccount]
- [
- text "Create New Account"
- ,i [class "add icon"][]
- ]
- ,div [classList
- [ ("row ui error message", True)
- , ("hidden", model.errorMsg == "")
- , ("visible", (String.length model.errorMsg > 0))
- ]
- ]
- [ span [] [ text model.errorMsg ]
- ]
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/AliasList/Model.elm b/modules/webapp/src/main/elm/Pages/AliasList/Model.elm
deleted file mode 100644
index 78f1666d..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasList/Model.elm
+++ /dev/null
@@ -1,19 +0,0 @@
-module Pages.AliasList.Model exposing (..)
-
-import Data exposing (Alias, RemoteConfig, RemoteUrls)
-import Widgets.AliasList as AliasList
-
-type alias Model =
- {aliasList: AliasList.Model
- ,urls: RemoteUrls
- }
-
-emptyModel: RemoteConfig -> Model
-emptyModel cfg =
- Model (AliasList.emptyModel cfg) cfg.urls
-
-makeModel: RemoteConfig -> List Alias -> Model
-makeModel cfg alia =
- Model (AliasList.makeModel cfg alia) cfg.urls
-
-type Msg = AliasListMsg AliasList.Msg
diff --git a/modules/webapp/src/main/elm/Pages/AliasList/Update.elm b/modules/webapp/src/main/elm/Pages/AliasList/Update.elm
deleted file mode 100644
index 5442befe..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasList/Update.elm
+++ /dev/null
@@ -1,14 +0,0 @@
-module Pages.AliasList.Update exposing (..)
-
-import Data exposing (defer)
-import Pages.AliasList.Model exposing (..)
-import Widgets.AliasList as AliasList
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- AliasListMsg msg ->
- let
- (m, c) = AliasList.update msg model.aliasList
- in
- {model | aliasList = m} ! [Cmd.map AliasListMsg c]
diff --git a/modules/webapp/src/main/elm/Pages/AliasList/View.elm b/modules/webapp/src/main/elm/Pages/AliasList/View.elm
deleted file mode 100644
index 108d9d3c..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasList/View.elm
+++ /dev/null
@@ -1,19 +0,0 @@
-module Pages.AliasList.View exposing (..)
-
-import Html exposing (Html, div, text, h1, p)
-import Html.Attributes exposing (class)
-
-import Widgets.AliasList as AliasList
-import Pages.AliasList.Model exposing (..)
-
-view: Model -> Html Msg
-view model =
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- h1 [class "header"] [text "Aliases"]
- ,p[][text "Aliases are pages where other people can upload files for you."]
- ,(Html.map AliasListMsg (AliasList.view model.aliasList))
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/AliasUpload/Model.elm b/modules/webapp/src/main/elm/Pages/AliasUpload/Model.elm
deleted file mode 100644
index 924d9e7d..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasUpload/Model.elm
+++ /dev/null
@@ -1,94 +0,0 @@
-module Pages.AliasUpload.Model exposing (..)
-
-import Http
-
-import Resumable
-import Data exposing (Account, Alias, RemoteConfig)
-import Widgets.AliasUploadForm as AliasUploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownHelp as MarkdownHelp
-import Widgets.MarkdownEditor as MarkdownEditor
-
-type Mode
- = Form
- | Upload
- | Done
-
-type alias Model =
- {cfg: RemoteConfig
- ,alia: Maybe Alias
- ,uploadForm: AliasUploadForm.Model
- ,uploadProgress: UploadProgress.Model
- ,mode: Mode
- ,errorMessage: String
- ,account: Maybe Account
- ,markdownEditorModel: Maybe MarkdownEditor.Model
- ,showMarkdownHelp: Bool
- }
-
-emptyModel: RemoteConfig -> Maybe Account -> Model
-emptyModel cfg acc =
- Model cfg Nothing (AliasUploadForm.emptyModel cfg) UploadProgress.emptyModel Form "" acc Nothing False
-
-makeModel: RemoteConfig -> Maybe Account -> Alias -> Model
-makeModel cfg acc alia =
- let
- empty = emptyModel cfg acc
- in
- {empty | alia = Just alia}
-
-clearModel: Model -> Model
-clearModel model =
- { cfg = model.cfg
- , alia = model.alia
- , uploadForm = AliasUploadForm.clearModel model.uploadForm
- , uploadProgress = UploadProgress.emptyModel
- , mode = Form
- , errorMessage = ""
- , account = model.account
- , markdownEditorModel = Nothing
- , showMarkdownHelp = False
- }
-
-isAliasUser: Model -> Bool
-isAliasUser model =
- case (model.account, model.alia) of
- (Just ac, Just al) ->
- ac.login == al.login
- _ ->
- False
-
-isValidAlias: Model -> Bool
-isValidAlias model =
- model.alia
- |> Maybe.map .enable
- |> Maybe.withDefault False
-
-
-hasError: Model -> Bool
-hasError model =
- not <| String.isEmpty model.errorMessage
-
-clearError: Model -> Model
-clearError model =
- {model | errorMessage = ""}
-
-
-type Msg
- = AliasUploadFormMsg AliasUploadForm.Msg
- | UploadProgressMsg UploadProgress.Msg
- | InitUpload
- | UploadCreated (Result Http.Error ())
- | CancelUpload
- | ResetForm
- | UploadDeleted (Result Http.Error Int)
- | NotifyResult (Result Http.Error ())
- | ToggleMarkdownEditor
- | MarkdownEditorMsg MarkdownEditor.Msg
- | ToggleMarkdownHelp
-
-makeResumableMsg: Resumable.Msg -> List Msg
-makeResumableMsg rmsg =
- [AliasUploadFormMsg (AliasUploadForm.ResumableMsg rmsg)
- ,UploadProgressMsg (UploadProgress.ResumableMsg rmsg)
- ]
diff --git a/modules/webapp/src/main/elm/Pages/AliasUpload/Update.elm b/modules/webapp/src/main/elm/Pages/AliasUpload/Update.elm
deleted file mode 100644
index de7aea85..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasUpload/Update.elm
+++ /dev/null
@@ -1,184 +0,0 @@
-module Pages.AliasUpload.Update exposing (..)
-
-import Http
-import Json.Decode as Decode
-import Json.Encode as Encode
-
-import Resumable
-import Ports
-import Data exposing (defer)
-import PageLocation as PL
-import Pages.AliasUpload.Model exposing (..)
-import Widgets.AliasUploadForm as AliasUploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownEditor as MarkdownEditor
-
-update: Msg -> Model -> (Model, Cmd Msg, Cmd Msg)
-update msg model =
- case msg of
- AliasUploadFormMsg msg ->
- let
- (um, ucmd, ucmdd) = AliasUploadForm.update msg model.uploadForm
- in
- {model | uploadForm = um} ! [Cmd.map AliasUploadFormMsg ucmd] |> defer (Cmd.map AliasUploadFormMsg ucmdd)
-
- UploadProgressMsg msg ->
- let
- (um, ucmd) = UploadProgress.update msg model.uploadProgress
- model_ = {model | uploadProgress = um}
- in
- model_ ! [Cmd.map UploadProgressMsg ucmd, httpNotifyWhenDone model_] |> defer Cmd.none
-
- InitUpload ->
- model ! [httpInitUpload model] |> defer Cmd.none
-
- UploadCreated (Ok ()) ->
- let
- ufm = model.uploadForm
- um = {ufm | errorMessage = Nothing}
- handle = Maybe.withDefault "" model.uploadForm.resumableModel.handle
- (cmd1, cmd2) =
- if AliasUploadForm.hasFiles model.uploadForm then
- (Ports.resumableStart handle, Cmd.none)
- else
- (Cmd.none, Ports.resumableSetComplete (handle, "."++UploadProgress.progressClass))
- in
- {model | mode = Upload, uploadForm = um} ! [cmd1] |> defer cmd2
-
- UploadCreated (Err error) ->
- let
- ufm = model.uploadForm
- um = {ufm | errorMessage = Just (Data.errorMessage error)}
- in
- {model | uploadForm = um} ! [PL.timeoutCmd error] |> defer Cmd.none
-
- ResetForm ->
- clearModel model ! [Ports.reloadPage ()] |> defer Cmd.none
-
- CancelUpload ->
- let
- handle = Maybe.withDefault "" model.uploadForm.resumableModel.handle
- in
- model ! [Ports.resumableCancel handle, httpDeleteUpload model] |> defer Cmd.none
-
- UploadDeleted (Ok n) ->
- -- its a little hacky: going back means to rebind the resumable handlers
- let
- handle = Maybe.withDefault "" model.uploadForm.resumableModel.handle
- cmd = Ports.resumableRebind handle
- in
- clearModel model ! [] |> defer cmd
-
- UploadDeleted (Err error) ->
- let
- m = clearModel model
- in
- {m | errorMessage = Data.errorMessage error} ! [PL.timeoutCmd error] |> defer Cmd.none
-
- NotifyResult res ->
- model ! [] |> defer Cmd.none
-
- MarkdownEditorMsg memsg ->
- case model.markdownEditorModel of
- Just mem ->
- let
- (mem_, cmd) = MarkdownEditor.update memsg mem
- in
- {model | markdownEditorModel = Just mem_} ! [Cmd.map MarkdownEditorMsg cmd] |> defer Cmd.none
- Nothing ->
- model ! [] |> defer Cmd.none
-
- ToggleMarkdownEditor ->
- case model.markdownEditorModel of
- Just mem ->
- let
- ufm = model.uploadForm
- ufm_ = {ufm | description = mem.text}
- -- its a little hacky: going back means to rebind the resumable handlers
- handle = Maybe.withDefault "" model.uploadForm.resumableModel.handle
- cmd = Ports.resumableRebind handle
- in
- {model | markdownEditorModel = Nothing, uploadForm = ufm_} ! [] |> defer cmd
- Nothing ->
- let
- mem = MarkdownEditor.initModel model.uploadForm.description
- in
- {model | markdownEditorModel = Just mem} ! [] |> defer Cmd.none
-
- ToggleMarkdownHelp ->
- {model | showMarkdownHelp = not model.showMarkdownHelp} ! [] |> defer Cmd.none
-
-
-modelEncoder: Model -> Encode.Value
-modelEncoder model =
- let
- up = model.uploadForm
- in
- Encode.object
- [ ("id", Encode.string (Maybe.withDefault "" up.resumableModel.handle))
- , ("description", Encode.string up.description)
- , ("validity", Encode.string "1h") -- dummy values follow
- , ("maxdownloads", Encode.int 30)
- , ("password", Encode.string "")
- ]
-
-
-httpInitUpload: Model -> Cmd Msg
-httpInitUpload model =
- case model.alia of
- Just a ->
- let
- header = Http.header model.cfg.aliasHeaderName a.id
- url = model.cfg.urls.uploads
- in
- httpPost url header (Http.jsonBody (modelEncoder model)) (Decode.succeed ())
- |> Http.send UploadCreated
- Nothing ->
- Cmd.none
-
-
-httpDeleteUpload: Model -> Cmd Msg
-httpDeleteUpload model =
- case (model.uploadForm.resumableModel.handle, model.alia) of
- (Just h, Just a) ->
- let
- header = Http.header model.cfg.aliasHeaderName a.id
- url = model.cfg.urls.uploads ++"/"++ h
- in
- httpDelete url header Http.emptyBody (Decode.field "filesRemoved" Decode.int)
- |> Http.send UploadDeleted
- _ ->
- Cmd.none
-
-httpNotifyWhenDone: Model -> Cmd Msg
-httpNotifyWhenDone model =
- if UploadProgress.isComplete model.uploadProgress then
- let
- header = Http.header model.cfg.aliasHeaderName (model.alia |> Maybe.map .id |> Maybe.withDefault "")
- handle = Maybe.withDefault "" model.uploadForm.resumableModel.handle
- url = model.cfg.urls.uploadNotify ++"/"++ handle
- in
- httpPost url header Http.emptyBody (Decode.succeed ())
- |> Http.send NotifyResult
- else
- Cmd.none
-
-
-httpPost: String -> Http.Header -> Http.Body -> Decode.Decoder a -> (Http.Request a)
-httpPost = httpMethod "POST"
-
-httpDelete: String -> Http.Header -> Http.Body -> Decode.Decoder a -> (Http.Request a)
-httpDelete = httpMethod "DELETE"
-
-
-httpMethod: String -> String -> Http.Header -> Http.Body -> Decode.Decoder a -> (Http.Request a)
-httpMethod method url header body dec =
- Http.request
- { method = method
- , headers = [header]
- , url = url
- , body = body
- , expect = Http.expectJson dec
- , timeout = Nothing
- , withCredentials = False
- }
diff --git a/modules/webapp/src/main/elm/Pages/AliasUpload/View.elm b/modules/webapp/src/main/elm/Pages/AliasUpload/View.elm
deleted file mode 100644
index 98f9287b..00000000
--- a/modules/webapp/src/main/elm/Pages/AliasUpload/View.elm
+++ /dev/null
@@ -1,222 +0,0 @@
-module Pages.AliasUpload.View exposing (..)
-
-import Html exposing (Html, a, div, text, h1, h2, h3, button, i)
-import Html.Attributes exposing (class, classList)
-import Html.Events exposing (onClick)
-
-import Data
-import Pages.AliasUpload.Model exposing (..)
-import Widgets.AliasUploadForm as AliasUploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownEditor as MarkdownEditor
-import Widgets.MarkdownHelp as MarkdownHelp
-
-view: Model -> Html Msg
-view model =
- case model.markdownEditorModel of
- Just mem ->
- div []
- [
- div [class "main ui grid container"]
- [
- div [class "row"]
- [button [class "ui primary button", onClick ToggleMarkdownEditor][text "Back"]
- ,button [class "ui button", onClick ToggleMarkdownHelp][text "Help"]
- ]
- ,div [class "row"]
- [
- div [class "ui"]
- [text "Write Markdown in the left input below and a preview is displayed "
- ,text "at the right as you type. Click Help button to show syntax help."
- ]
- ]
- ]
- ,if model.showMarkdownHelp then
- markdownHelp
- else
- Html.map MarkdownEditorMsg (MarkdownEditor.view mem)
- ]
-
- Nothing ->
- div [class "main ui grid container"]
- [(mainView model)]
-
-
-mainView: Model -> Html Msg
-mainView model =
- div [class "main ui grid container"]
- [
- (userDimmer model)
- ,(emptyAliasDimmer model)
- ,div [class "sixteen wide column"]
- [h1 [class "ui header"][text "Upload your files here"]
- ,(renderError model)
- ]
- ,div [class "sixteen wide column"]
- (stepView model)
- ]
-
-markdownHelp: Html Msg
-markdownHelp =
- div [onClick ToggleMarkdownHelp]
- [h3 [class "ui horizontal clearing divider header"]
- [i [class "help icon"][]
- ,text "Markdown Help"
- ]
- ,div [class "ui center aligned segment"]
- [text "Click somewhere on the help text to close it."]
- ,MarkdownHelp.helpTextHtml
- ]
-
-stepView: Model -> List (Html Msg)
-stepView model =
- case model.mode of
- Form ->
- [
- button [class "ui basic button", onClick ToggleMarkdownEditor][text "Description Editor"]
- ,(Html.map AliasUploadFormMsg (AliasUploadForm.view model.uploadForm))
- ,(uploadButton model)
- ,(cancelButton model)
- ]
-
- Upload ->
- [
- Html.map UploadProgressMsg (UploadProgress.view model.uploadProgress)
- ,(doneMessage model)
- ,(cancelButton model)
- ,(moreButton model)
- ]
-
- Done ->
- [div [][text "Everything uploaded."]]
-
-userDimmer: Model -> Html Msg
-userDimmer model =
- div [classList [("ui inverted dimmer", True)
- ,("active", isAliasUser model)]]
- [
- div [class "content"]
- [
- div [class "ui center aligned grid"]
- [
- div [class "sixteen wide column"]
- [
- h2 [class "ui icon header"]
- [
- i [class "info icon"][]
- ,text "Let me explain…"
- ]
- ]
- ,div [class "eight wide column"]
- [
- div [class "ui info message"]
- [Data.markdownHtml
- """This page is not intended for
- you. Rather give the URL away to
- other, _anonymous_, users to allow
- them sending files to you. You
- receive all files uploaded through
- this page in _My Uploads_."""
-
- ]
- ]
- ]
- ]
- ]
-
-emptyAliasDimmer: Model -> Html Msg
-emptyAliasDimmer model =
- div [classList [("ui dimmer", True)
- ,("active", not (isValidAlias model))]]
- [div [class "content"]
- [div [class "ui center aligned grid"]
- [div [class "sixteen wide column"]
- [h2 [class "ui inverted icon header"]
- [i [class "warning sign icon"][]
- ,text "Not Found"
- ]
- ]
- ,div [class "eight wide column"]
- [div [class "ui inverted error message"]
- [text "The alias was not found"]
- ]
- ]
- ]
- ]
-
-doneMessage: Model -> Html Msg
-doneMessage model =
- if nextStepDisabled Done model || UploadProgress.hasErrors model.uploadProgress then
- div[][]
- else
- div [class "ui success message"]
- [
- div [class "header"][text "All done."]
- ,div [class "content"]
- [Data.markdownHtml
- """Your files have been uploaded. If you changed
- your mind, you can remove them by clicking the
- _Delete_ button. To upload more, simply refresh
- the page or click the _More…_ button."""
-
- ]
- ]
-
-moreButton: Model -> Html Msg
-moreButton model =
- if nextStepDisabled Done model then
- div[][]
- else
- button [class "ui primary button", onClick ResetForm]
- [
- i [class "add icon"][]
- ,text "More …"
- ]
-
-uploadButton: Model -> Html Msg
-uploadButton model =
- button [classList [("ui primary button", True)
- ,("disabled", nextStepDisabled Upload model)
- ]
- , onClick InitUpload
- ]
- [i [class "upload icon"][]
- ,text "Upload"
- ]
-
-cancelButton: Model -> Html Msg
-cancelButton model =
- let
- action = if model.mode == Upload then CancelUpload else ResetForm
- btntext = if model.mode == Form then
- "Reset"
- else if UploadProgress.isComplete model.uploadProgress then
- "Delete"
- else
- "Cancel"
- in
- a [class "ui labeled basic icon button", onClick action]
- [
- i [class "cancel icon"][]
- ,text btntext
- ]
-
-renderError: Model -> Html Msg
-renderError model =
- if hasError model then
- div [class "ui error message"]
- [text model.errorMessage]
- else
- div [][]
-
-nextStepDisabled: Mode -> Model -> Bool
-nextStepDisabled mode model =
- case (model.mode, mode) of
- (Form, Upload) ->
- not (AliasUploadForm.isReady model.uploadForm)
-
- (Upload, Done) ->
- not (UploadProgress.isComplete model.uploadProgress)
-
- _ ->
- True
diff --git a/modules/webapp/src/main/elm/Pages/Download/Model.elm b/modules/webapp/src/main/elm/Pages/Download/Model.elm
deleted file mode 100644
index dcdeadc9..00000000
--- a/modules/webapp/src/main/elm/Pages/Download/Model.elm
+++ /dev/null
@@ -1,19 +0,0 @@
-module Pages.Download.Model exposing (..)
-
-import Data exposing (Account, UploadInfo, RemoteConfig)
-import Widgets.DownloadView as DownloadView
-
-type alias Model =
- {uploadViewModel: Maybe DownloadView.Model
- }
-
-emptyModel: Model
-emptyModel = Model Nothing
-
-makeModel: UploadInfo -> RemoteConfig -> Maybe Account -> Model
-makeModel um cfg acc =
- Model (Just (DownloadView.makeModel um cfg acc))
-
-
-type Msg
- = DownloadViewMsg DownloadView.Msg
diff --git a/modules/webapp/src/main/elm/Pages/Download/Update.elm b/modules/webapp/src/main/elm/Pages/Download/Update.elm
deleted file mode 100644
index 65868485..00000000
--- a/modules/webapp/src/main/elm/Pages/Download/Update.elm
+++ /dev/null
@@ -1,18 +0,0 @@
-module Pages.Download.Update exposing (..)
-
-import Pages.Download.Model exposing (..)
-import Widgets.DownloadView as DownloadView
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- DownloadViewMsg msg ->
- let
- result = model.uploadViewModel
- |> Maybe.map (DownloadView.update msg)
- in
- case result of
- Just (m, c) ->
- {model | uploadViewModel = Just m} ! [Cmd.map DownloadViewMsg c]
- Nothing ->
- model ! []
diff --git a/modules/webapp/src/main/elm/Pages/Download/View.elm b/modules/webapp/src/main/elm/Pages/Download/View.elm
deleted file mode 100644
index 36ec5b04..00000000
--- a/modules/webapp/src/main/elm/Pages/Download/View.elm
+++ /dev/null
@@ -1,17 +0,0 @@
-module Pages.Download.View exposing (..)
-
-import Html exposing (Html, div, text)
-import Html.Attributes exposing (class)
-
-import Widgets.DownloadView as DownloadView
-import Pages.Download.Model exposing (..)
-
-view: Model -> Html Msg
-view model =
- case model.uploadViewModel of
- Just m ->
- div [class "main ui grid container"]
- (List.map (Html.map DownloadViewMsg) (DownloadView.view m))
-
- Nothing ->
- div[][text "You must specify the download id"]
diff --git a/modules/webapp/src/main/elm/Pages/Error/Model.elm b/modules/webapp/src/main/elm/Pages/Error/Model.elm
deleted file mode 100644
index f12947c0..00000000
--- a/modules/webapp/src/main/elm/Pages/Error/Model.elm
+++ /dev/null
@@ -1,12 +0,0 @@
-module Pages.Error.Model exposing (..)
-
-type alias Model =
- { message: String
- }
-
-initModel: String -> Model
-initModel msg =
- Model msg
-
-emptyModel: Model
-emptyModel = Model ""
diff --git a/modules/webapp/src/main/elm/Pages/Error/View.elm b/modules/webapp/src/main/elm/Pages/Error/View.elm
deleted file mode 100644
index 2c9fe1fb..00000000
--- a/modules/webapp/src/main/elm/Pages/Error/View.elm
+++ /dev/null
@@ -1,23 +0,0 @@
-module Pages.Error.View exposing (..)
-
-import Html exposing(Html, h1, div, text, a)
-import Html.Attributes exposing (class, href)
-
-import Pages.Error.Model exposing (..)
-
-import PageLocation as PL
-
-view: Model -> Html msg
-view model =
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- h1 [class "ui header"]
- [text "Error"]
- ,div [class "ui message"]
- [
- text model.message
- ]
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/Login/Commands.elm b/modules/webapp/src/main/elm/Pages/Login/Commands.elm
deleted file mode 100644
index 51008100..00000000
--- a/modules/webapp/src/main/elm/Pages/Login/Commands.elm
+++ /dev/null
@@ -1,24 +0,0 @@
-module Pages.Login.Commands exposing (..)
-
-import Http
-import Json.Encode as Encode
-import Data exposing (Account, accountDecoder)
-import Pages.Login.Model exposing (Model)
-import Pages.Login.Data as LoginData exposing (Msg)
-
-authenticate: Model -> Cmd Msg
-authenticate model =
- Http.post (authUrl model) (Http.jsonBody (userPassJson model)) accountDecoder
- |> Http.send LoginData.AuthResult
-
-authUrl: Model -> String
-authUrl model =
- model.loginUrl
-
-
-userPassJson: Model -> Encode.Value
-userPassJson model =
- Encode.object
- [ ("login", Encode.string model.login)
- , ("pass", Encode.string model.password)
- ]
diff --git a/modules/webapp/src/main/elm/Pages/Login/Data.elm b/modules/webapp/src/main/elm/Pages/Login/Data.elm
deleted file mode 100644
index dc73dcaa..00000000
--- a/modules/webapp/src/main/elm/Pages/Login/Data.elm
+++ /dev/null
@@ -1,10 +0,0 @@
-module Pages.Login.Data exposing (..)
-
-import Http
-import Data exposing (Account)
-
-type Msg
- = Login String
- | Password String
- | TryLogin
- | AuthResult (Result Http.Error Account)
diff --git a/modules/webapp/src/main/elm/Pages/Login/Model.elm b/modules/webapp/src/main/elm/Pages/Login/Model.elm
deleted file mode 100644
index 2c1e11f2..00000000
--- a/modules/webapp/src/main/elm/Pages/Login/Model.elm
+++ /dev/null
@@ -1,23 +0,0 @@
-module Pages.Login.Model exposing (..)
-
-import Data exposing (RemoteUrls)
-
-type alias Model =
- { login: String
- , password: String
- , error: String
- , loginUrl: String
- , welcomeMessage: String
- }
-
-emptyModel: Model
-emptyModel =
- Model "" "" "" "" ""
-
-sharryModel: RemoteUrls -> String -> Model
-sharryModel urls =
- Model "sharry" "sharry" "" urls.authLogin
-
-fromUrls: RemoteUrls -> String -> Model
-fromUrls urls =
- Model "" "" "" urls.authLogin
diff --git a/modules/webapp/src/main/elm/Pages/Login/Update.elm b/modules/webapp/src/main/elm/Pages/Login/Update.elm
deleted file mode 100644
index b7f87e27..00000000
--- a/modules/webapp/src/main/elm/Pages/Login/Update.elm
+++ /dev/null
@@ -1,33 +0,0 @@
-module Pages.Login.Update exposing(..)
-
-import String
-import Http
-import Json.Decode as Decode exposing(field)
-import Data exposing (Account)
-import Pages.Login.Model exposing(Model, emptyModel)
-import Pages.Login.Commands as Commands
-import Pages.Login.Data as Data exposing (..)
-
-
-update: Msg -> Model -> (Model, Cmd Msg, Maybe Account)
-update msg model =
- case msg of
- Login name ->
- ({ model | login = name }, Cmd.none, Nothing)
-
- Password pw ->
- ({ model | password = pw }, Cmd.none, Nothing)
-
- TryLogin ->
- if String.isEmpty model.login then
- ({model|error = "login is empty"}, Cmd.none, Nothing)
- else
- let c = Commands.authenticate model
- in
- ({ model | password = "" }, c, Nothing)
-
- AuthResult (Ok acc) ->
- (emptyModel, Cmd.none, Just acc)
-
- AuthResult (Err error) ->
- ({model | error = Data.errorMessage error}, Cmd.none, Nothing)
diff --git a/modules/webapp/src/main/elm/Pages/Login/View.elm b/modules/webapp/src/main/elm/Pages/Login/View.elm
deleted file mode 100644
index 517621c9..00000000
--- a/modules/webapp/src/main/elm/Pages/Login/View.elm
+++ /dev/null
@@ -1,82 +0,0 @@
-module Pages.Login.View exposing (..)
-
-import String
-import Html exposing (Html, button, div, text, h2, form, i, input, img, a, span, br)
-import Html.Attributes exposing (class, classList, type_, placeholder, src, href)
-import Html.Events exposing (onClick, onInput, onSubmit)
-import Pages.Login.Model exposing (Model)
-import Pages.Login.Data exposing (..)
-import PageLocation as PL
-import Data
-
-view: Model -> Html Msg
-view model =
- div [ class "ui middle aligned center aligned grid" ]
- [
- div [ class "column login-page-column" ]
- [
- img [class "ui fluid image login-page-image" , src "static/sharry-webapp/logo.png"] []
- , h2 [ class "ui brown image header"]
- [
- div [class "content"]
- [ text "Login, please" ]
- ]
- , form [ onSubmit TryLogin, class "ui large form" ]
- [
- div [class "ui segment"]
- [
- div [ class "field" ]
- [
- div [class "ui left icon input"]
- [
- i [class "user icon"] []
- , input [type_ "text", placeholder "Login", onInput Login] []
- ]
- ]
- , div [ class "field" ]
- [
- div [class "ui left icon input"]
- [
- i [class "lock icon"] []
- , input [type_ "password", placeholder "Password", onInput Password] []
- ]
- ]
- , button [class "ui fluid large brown submit button"] [ text "Login" ]
- ]
- , div [classList
- [ ("ui error message", True)
- , ("visible", (String.length model.error > 0))
- ]
- ]
- [ span [] [ text model.error ]
- ]
- ]
- , br [][]
- , div [class "ui mini horizontal divided list"]
- [
- div [class "item"]
- [
- a [href "https://github.com/eikek/sharry"]
- [
- i [class "github icon"] []
- , text "Github"
- ]
- ]
- , div [class "item"]
- [
- a [href (PL.manualPageHref "index.md")]
- [
- i [class "question circle outline icon"][]
- ,text "Manual"
- ]
- ]
- ]
- , (welcomeMessage model)
- ]
- ]
-
-welcomeMessage: Model -> Html msg
-welcomeMessage model =
- if String.isEmpty model.welcomeMessage then span [][]
- else div [class "ui basic segment"]
- [ Data.markdownHtml model.welcomeMessage ]
diff --git a/modules/webapp/src/main/elm/Pages/Manual/Model.elm b/modules/webapp/src/main/elm/Pages/Manual/Model.elm
deleted file mode 100644
index b67652b6..00000000
--- a/modules/webapp/src/main/elm/Pages/Manual/Model.elm
+++ /dev/null
@@ -1,22 +0,0 @@
-module Pages.Manual.Model exposing (..)
-
-type alias Model =
- { manualPage: String
- }
-
-initialModel: Model
-initialModel = Model "index.md"
-
-makeModel: String -> Model
-makeModel page =
- Model page
-
-type Msg
- = Content String
-
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Content str ->
- {model | manualPage = str} ! []
diff --git a/modules/webapp/src/main/elm/Pages/Manual/View.elm b/modules/webapp/src/main/elm/Pages/Manual/View.elm
deleted file mode 100644
index b0e99661..00000000
--- a/modules/webapp/src/main/elm/Pages/Manual/View.elm
+++ /dev/null
@@ -1,25 +0,0 @@
-module Pages.Manual.View exposing (..)
-
-import Html exposing (Html, div)
-import Html.Attributes exposing (class)
-import Markdown
-
-import Pages.Manual.Model exposing (..)
-
-markdownHtml: String -> Html msg
-markdownHtml str =
- let
- defaultOpts = Markdown.defaultOptions
- markedOptions = {defaultOpts | sanitize = False, smartypants = True, githubFlavored = Just { tables = True, breaks = False}}
- in
- Markdown.toHtmlWith markedOptions [class "sharry-manual"] str
-
-
-view: Model -> Html msg
-view model =
- div [class "main ui text container"]
- [
- div [class "sixteen wide column"]
- [ markdownHtml model.manualPage
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/Profile/Model.elm b/modules/webapp/src/main/elm/Pages/Profile/Model.elm
deleted file mode 100644
index 9b355b62..00000000
--- a/modules/webapp/src/main/elm/Pages/Profile/Model.elm
+++ /dev/null
@@ -1,20 +0,0 @@
-module Pages.Profile.Model exposing (..)
-
-import Data exposing (Account, RemoteUrls)
-import Widgets.UpdateEmailForm as UpdateEmailForm
-import Widgets.UpdatePasswordForm as UpdatePasswordForm
-
-type alias Model =
- {updateEmail: UpdateEmailForm.Model
- ,updatePassword: UpdatePasswordForm.Model
- ,name: String
- }
-
-makeModel: RemoteUrls -> Account -> Model
-makeModel urls acc =
- Model (UpdateEmailForm.makeModel acc urls) (UpdatePasswordForm.makeModel acc urls) acc.login
-
-
-type Msg
- = UpdateEmailFormMsg UpdateEmailForm.Msg
- | UpdatePasswordFormMsg UpdatePasswordForm.Msg
diff --git a/modules/webapp/src/main/elm/Pages/Profile/Update.elm b/modules/webapp/src/main/elm/Pages/Profile/Update.elm
deleted file mode 100644
index dce760b9..00000000
--- a/modules/webapp/src/main/elm/Pages/Profile/Update.elm
+++ /dev/null
@@ -1,20 +0,0 @@
-module Pages.Profile.Update exposing (..)
-
-import Pages.Profile.Model exposing (..)
-import Widgets.UpdateEmailForm as UpdateEmailForm
-import Widgets.UpdatePasswordForm as UpdatePasswordForm
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- UpdateEmailFormMsg msg ->
- let
- (m, c) = UpdateEmailForm.update msg model.updateEmail
- in
- {model | updateEmail = m} ! [Cmd.map UpdateEmailFormMsg c]
-
- UpdatePasswordFormMsg msg ->
- let
- (m, c) = UpdatePasswordForm.update msg model.updatePassword
- in
- {model | updatePassword = m} ! [Cmd.map UpdatePasswordFormMsg c]
diff --git a/modules/webapp/src/main/elm/Pages/Profile/View.elm b/modules/webapp/src/main/elm/Pages/Profile/View.elm
deleted file mode 100644
index 31cd523d..00000000
--- a/modules/webapp/src/main/elm/Pages/Profile/View.elm
+++ /dev/null
@@ -1,33 +0,0 @@
-module Pages.Profile.View exposing (..)
-
-import Html exposing (Html, div, h1, text, i)
-import Html.Attributes exposing (class)
-
-import Pages.Profile.Model exposing (..)
-import Widgets.UpdateEmailForm as UpdateEmailForm
-import Widgets.UpdatePasswordForm as UpdatePasswordForm
-
-view: Model -> Html Msg
-view model =
- div []
- [
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- h1 [class "ui header"]
- [
- i [class "user icon"][]
- ,text (model.name ++ "'s Profile")
- ]
- ]
- ,div [class "sixteen wide column"]
- [
- (Html.map UpdateEmailFormMsg (UpdateEmailForm.view model.updateEmail))
- ]
- ,div [class "sixteen wide column"]
- [
- (Html.map UpdatePasswordFormMsg (UpdatePasswordForm.view model.updatePassword))
- ]
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/Timeout/View.elm b/modules/webapp/src/main/elm/Pages/Timeout/View.elm
deleted file mode 100644
index 0137d0ec..00000000
--- a/modules/webapp/src/main/elm/Pages/Timeout/View.elm
+++ /dev/null
@@ -1,23 +0,0 @@
-module Pages.Timeout.View exposing (..)
-
-import Html exposing(Html, h1, div, text, a)
-import Html.Attributes exposing (class, href)
-
-import PageLocation as PL
-
-view: Html msg
-view =
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- h1 [class "ui header"]
- [text "Session Timeout"]
- ,div [class "ui message"]
- [
- text "The session has timed out. Please "
- ,a [href PL.loginPageHref][text "login"]
- ,text " again."
- ]
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Pages/Upload/Model.elm b/modules/webapp/src/main/elm/Pages/Upload/Model.elm
deleted file mode 100644
index 4fdfd0ec..00000000
--- a/modules/webapp/src/main/elm/Pages/Upload/Model.elm
+++ /dev/null
@@ -1,72 +0,0 @@
-module Pages.Upload.Model exposing(..)
-
-import Http
-import Data exposing (Account, RemoteConfig, UploadInfo)
-import Resumable
-import Widgets.UploadForm as UploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownEditor as MarkdownEditor
-
-type Mode
- = Settings
- | Upload
- | Publish
-
-type alias Model =
- { uploadFormModel: UploadForm.Model
- , uploadProgressModel: UploadProgress.Model
- , mode: Mode
- , serverConfig: RemoteConfig
- , errorMessage: String
- , markdownEditorModel: Maybe MarkdownEditor.Model
- , showMarkdownHelp: Bool
- }
-
-emptyModel: RemoteConfig -> Model
-emptyModel cfg =
- Model (UploadForm.emptyModel cfg) UploadProgress.emptyModel Settings cfg "" Nothing False
-
-clearModel: Model -> Model
-clearModel model =
- { uploadFormModel = UploadForm.clearModel model.uploadFormModel
- , uploadProgressModel = UploadProgress.emptyModel
- , mode = Settings
- , serverConfig = model.serverConfig
- , errorMessage = ""
- , markdownEditorModel = Nothing
- , showMarkdownHelp = False
- }
-
-hasError: Model -> Bool
-hasError model =
- not <| String.isEmpty model.errorMessage
-
-clearError: Model -> Model
-clearError model =
- {model | errorMessage = ""}
-
-
-type Msg
- = UploadFormMsg UploadForm.Msg
- | UploadProgressMsg UploadProgress.Msg
- | MoveToUpload
- | UploadCreated (Result Http.Error ())
- | MoveToPublish
- | ResetForm
- | CancelUpload
- | UploadDeleted (Result Http.Error Int)
- | UploadPublished (Result Http.Error UploadInfo)
- | ToggleMarkdownEditor
- | MarkdownEditorMsg MarkdownEditor.Msg
- | ToggleMarkdownHelp
-
-
-resumableMsg: Resumable.Msg -> List Msg
-resumableMsg rmsg =
- [UploadFormMsg (UploadForm.ResumableMsg rmsg)
- ,UploadProgressMsg (UploadProgress.ResumableMsg rmsg)
- ]
-
-randomPasswordMsg: String -> Msg
-randomPasswordMsg s =
- UploadFormMsg (UploadForm.RandomPassword s)
diff --git a/modules/webapp/src/main/elm/Pages/Upload/Update.elm b/modules/webapp/src/main/elm/Pages/Upload/Update.elm
deleted file mode 100644
index 00fc4443..00000000
--- a/modules/webapp/src/main/elm/Pages/Upload/Update.elm
+++ /dev/null
@@ -1,165 +0,0 @@
-module Pages.Upload.Update exposing (..)
-
-import Http
-import Json.Encode as Encode
-import Json.Decode as Decode
-import Navigation
-
-import Ports
-import Data exposing (Account, UploadId(..), defer)
-import PageLocation as PL
-import Resumable
-import Resumable.Update as ResumableUpdate
-import Widgets.UploadForm as UploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownEditor as MarkdownEditor
-import Pages.Upload.Model exposing (..)
-
-update: Msg -> Model -> (Model, Cmd Msg, Cmd Msg)
-update msg model =
- case msg of
- UploadFormMsg msg ->
- let
- (um, ucmd, ucmdd) = UploadForm.update msg model.uploadFormModel
- in
- {model | uploadFormModel = um} ! [Cmd.map UploadFormMsg ucmd] |> defer (Cmd.map UploadFormMsg ucmdd)
-
- UploadProgressMsg msg ->
- let
- (um, ucmd) = UploadProgress.update msg model.uploadProgressModel
- in
- {model | uploadProgressModel = um} ! [Cmd.map UploadProgressMsg ucmd] |> defer Cmd.none
-
- ResetForm ->
- let
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- in
- clearModel model ! [Ports.resumableCancel handle] |> defer Cmd.none
-
- CancelUpload ->
- let
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- in
- model ! [Ports.resumableCancel handle, httpDeleteUpload model] |> defer Cmd.none
-
- UploadDeleted (Ok n) ->
- -- its a little hacky: going back means to rebind the resumable handlers
- let
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- cmd = Ports.resumableRebind handle
- in
- clearModel model ! [] |> defer cmd
-
- UploadDeleted (Err error) ->
- let
- x = Debug.log "Error deleting upload" (Data.errorMessage error)
- in
- clearModel model ! [PL.timeoutCmd error] |> defer Cmd.none
-
- MoveToUpload ->
- if model.mode == Settings then
- model ! [httpInitUpload model] |> defer Cmd.none
- else
- (model, Cmd.none) |> defer Cmd.none
-
- UploadCreated (Ok ()) ->
- let
- ufm = model.uploadFormModel
- um = {ufm | errorMessage = Nothing}
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- (cmd1, cmd2) =
- if UploadForm.hasFiles model.uploadFormModel then
- (Ports.resumableStart handle, Cmd.none)
- else
- (Cmd.none, Ports.resumableSetComplete (handle, "."++UploadProgress.progressClass))
- in
- {model | mode = Upload, uploadFormModel = um} ! [cmd1] |> defer cmd2
-
- UploadCreated (Err error) ->
- let
- ufm = model.uploadFormModel
- um = {ufm | errorMessage = Just (Data.errorMessage error)}
- in
- {model | uploadFormModel = um} ! [PL.timeoutCmd error] |> defer Cmd.none
-
- MoveToPublish ->
- model ! [httpPublishUpload model] |> defer Cmd.none
-
- UploadPublished (Ok info) ->
- let
- model_ = clearModel model
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- in
- model_ ! [PL.downloadPage (Uid info.upload.id), Ports.resetResumable handle] |> defer Cmd.none
-
- UploadPublished (Err error) ->
- {model | errorMessage = Data.errorMessage error} ! [PL.timeoutCmd error] |> defer Cmd.none
-
- MarkdownEditorMsg memsg ->
- case model.markdownEditorModel of
- Just mem ->
- let
- (mem_, cmd) = MarkdownEditor.update memsg mem
- in
- {model | markdownEditorModel = Just mem_} ! [Cmd.map MarkdownEditorMsg cmd] |> defer Cmd.none
- Nothing ->
- model ! [] |> defer Cmd.none
-
- ToggleMarkdownEditor ->
- case model.markdownEditorModel of
- Just mem ->
- let
- ufm = model.uploadFormModel
- ufm_ = {ufm | description = mem.text}
- -- its a little hacky: going back means to rebind the resumable handlers
- handle = Maybe.withDefault "" model.uploadFormModel.resumableModel.handle
- cmd = Ports.resumableRebind handle
- in
- {model | markdownEditorModel = Nothing, uploadFormModel = ufm_} ! [] |> defer cmd
- Nothing ->
- let
- mem = MarkdownEditor.initModel model.uploadFormModel.description
- in
- {model | markdownEditorModel = Just mem} ! [] |> defer Cmd.none
-
- ToggleMarkdownHelp ->
- {model | showMarkdownHelp = not model.showMarkdownHelp} ! [] |> defer Cmd.none
-
-
-modelEncoder: Model -> Encode.Value
-modelEncoder model =
- let
- up = model.uploadFormModel
- in
- Encode.object
- [ ("id", Encode.string (Maybe.withDefault "" up.resumableModel.handle))
- , ("description", Encode.string up.description)
- , ("validity", Encode.string ((toString up.validityNum) ++ up.validityUnit))
- , ("maxdownloads", Encode.int up.maxDownloads)
- , ("password", Encode.string up.password)
- ]
-
-
-httpInitUpload: Model -> Cmd Msg
-httpInitUpload model =
- Http.post model.serverConfig.urls.uploads (Http.jsonBody (modelEncoder model)) (Decode.succeed ())
- |> Http.send UploadCreated
-
-httpDeleteUpload: Model -> Cmd Msg
-httpDeleteUpload model =
- case model.uploadFormModel.resumableModel.handle of
- Just h ->
- Data.httpDelete (model.serverConfig.urls.uploads ++ "/" ++ h) Http.emptyBody (Decode.field "filesRemoved" Decode.int)
- |> Http.send UploadDeleted
-
- Nothing ->
- Cmd.none
-
-httpPublishUpload: Model -> Cmd Msg
-httpPublishUpload model =
- case model.uploadFormModel.resumableModel.handle of
- Just h ->
- Http.post (model.serverConfig.urls.uploadPublish ++ "/" ++ h) Http.emptyBody Data.decodeUploadInfo
- |> Http.send UploadPublished
- Nothing ->
- Cmd.none
diff --git a/modules/webapp/src/main/elm/Pages/Upload/View.elm b/modules/webapp/src/main/elm/Pages/Upload/View.elm
deleted file mode 100644
index 4b8a91c6..00000000
--- a/modules/webapp/src/main/elm/Pages/Upload/View.elm
+++ /dev/null
@@ -1,161 +0,0 @@
-module Pages.Upload.View exposing (..)
-
-import List
-import Html exposing (Html, button, form, h1, div, label, text, textarea, select, option, i, input, a, p, h3)
-import Html.Attributes exposing (class, name, type_, href, classList, rows, placeholder, value, selected)
-import Html.Events exposing (onInput, onClick)
-
-import Resumable
-import Data exposing (Account, RemoteConfig, bytesReadable)
-import Widgets.UploadForm as UploadForm
-import Widgets.UploadProgress as UploadProgress
-import Widgets.MarkdownEditor as MarkdownEditor
-import Widgets.MarkdownHelp as MarkdownHelp
-import Pages.Upload.Model exposing (..)
-import Pages.Upload.Update exposing (..)
-
-view: Model -> Html Msg
-view model =
- case model.markdownEditorModel of
- Just mem ->
- div []
- [
- div [class "main ui grid container"]
- [
- div [class "row"]
- [button [class "ui primary button", onClick ToggleMarkdownEditor][text "Back"]
- ,button [class "ui button", onClick ToggleMarkdownHelp][text "Help"]
- ]
- ,div [class "row"]
- [
- div [class "ui"]
- [text "Write Markdown in the left input below and a preview is displayed "
- ,text "at the right as you type. Click Help button to show syntax help."
- ]
- ]
- ]
- ,if model.showMarkdownHelp then
- markdownHelp
- else
- Html.map MarkdownEditorMsg (MarkdownEditor.view mem)
- ]
-
- Nothing ->
- div [class "main ui grid container"]
- (mainView model)
-
-
-mainView: Model -> List (Html Msg)
-mainView model =
- [
- div [class "sixteen wide column"]
- [h1 [class "ui header"][text "New Share"]
- ]
- ,div [class "sixteen wide column"]
- [(steps model)
- ,(renderError model)
- ]
- ,div [class "sixteen wide column"]
- (stepView model)
- ]
-
-markdownHelp: Html Msg
-markdownHelp =
- div [onClick ToggleMarkdownHelp]
- [h3 [class "ui horizontal clearing divider header"]
- [i [class "help icon"][]
- ,text "Markdown Help"
- ]
- ,div [class "ui center aligned segment"]
- [text "Click somewhere on the help text to close it."]
- ,MarkdownHelp.helpTextHtml
- ]
-
-renderError: Model -> Html Msg
-renderError model =
- if hasError model then
- div [class "ui error message"]
- [text model.errorMessage]
- else
- div [][]
-
-cancelButton: Model -> Html Msg
-cancelButton model =
- let
- action = if model.mode == Upload then CancelUpload else ResetForm
- in
- a [class "ui labeled right floated basic icon button", onClick action]
- [
- i [class "cancel icon"][]
- ,text (if model.mode == Settings then "Reset" else "Cancel")
- ]
-
-stepView: Model -> List (Html Msg)
-stepView model =
- case model.mode of
- Settings ->
- [
- (cancelButton model)
- ,button [class "ui basic button", onClick ToggleMarkdownEditor][text "Description Editor"]
- ,Html.map UploadFormMsg (UploadForm.view model.uploadFormModel)
- ]
-
- Upload ->
- [
- Html.map UploadProgressMsg (UploadProgress.view model.uploadProgressModel)
- ,(cancelButton model)
- ]
- Publish ->
- [div [][text "Oopps, this is an error."]]
-
-nextStepDisabled: Mode -> Model -> Bool
-nextStepDisabled mode model =
- case (model.mode, mode) of
- (Settings, Upload) ->
- not (UploadForm.isReady model.uploadFormModel)
-
- (Upload, Publish) ->
- not (UploadProgress.isComplete model.uploadProgressModel)
-
- _ ->
- True
-
-stepClasses: Mode -> Model -> Html.Attribute msg
-stepClasses mode model =
- classList [("active", model.mode == mode)
- ,("disabled", model.mode /= mode && (nextStepDisabled mode model))
- ,("step", True)
- ]
-
-stepIcon: Mode -> String
-stepIcon mode =
- case mode of
- Settings -> "ui settings icon"
- Upload -> "ui upload icon"
- Publish -> "ui share icon"
-
-renderStep: Mode -> Maybe Msg -> Model -> Html Msg
-renderStep mode msg model =
- let
- handler = (Maybe.withDefault [] (Maybe.map (\m -> [onClick m]) msg))
- parent = \cs -> if mode == model.mode then
- div [(stepClasses mode model)] cs
- else
- a ([(stepClasses mode model)] ++ handler) cs
- in
- parent [ i [class (stepIcon mode)][]
- , div [class "content"]
- [
- div [class "title"]
- [text (toString mode)]
- ]
- ]
-
-steps: Model -> Html Msg
-steps model =
- div [class "ui three mini steps"]
- [
- (renderStep Settings Nothing model)
- ,(renderStep Upload (Just MoveToUpload) model)
- ,(renderStep Publish (Just MoveToPublish) model)
- ]
diff --git a/modules/webapp/src/main/elm/Pages/UploadList/Model.elm b/modules/webapp/src/main/elm/Pages/UploadList/Model.elm
deleted file mode 100644
index f92c8ed1..00000000
--- a/modules/webapp/src/main/elm/Pages/UploadList/Model.elm
+++ /dev/null
@@ -1,18 +0,0 @@
-module Pages.UploadList.Model exposing (..)
-
-import Data exposing (Upload, RemoteUrls)
-import Widgets.UploadList as UploadList
-
-type alias Model =
- { uploadList: UploadList.Model
- }
-
-emptyModel: RemoteUrls -> Model
-emptyModel urls =
- Model (UploadList.emptyModel urls)
-
-makeModel: RemoteUrls -> List Upload -> Model
-makeModel urls up =
- Model (UploadList.makeModel urls up)
-
-type Msg = UploadListMsg UploadList.Msg
diff --git a/modules/webapp/src/main/elm/Pages/UploadList/Update.elm b/modules/webapp/src/main/elm/Pages/UploadList/Update.elm
deleted file mode 100644
index c0f17472..00000000
--- a/modules/webapp/src/main/elm/Pages/UploadList/Update.elm
+++ /dev/null
@@ -1,13 +0,0 @@
-module Pages.UploadList.Update exposing (..)
-
-import Pages.UploadList.Model exposing (..)
-import Widgets.UploadList as UploadList
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- UploadListMsg msg ->
- let
- (m, c) = UploadList.update msg model.uploadList
- in
- {model | uploadList = m} ! [Cmd.map UploadListMsg c]
diff --git a/modules/webapp/src/main/elm/Pages/UploadList/View.elm b/modules/webapp/src/main/elm/Pages/UploadList/View.elm
deleted file mode 100644
index 3ab93e9d..00000000
--- a/modules/webapp/src/main/elm/Pages/UploadList/View.elm
+++ /dev/null
@@ -1,18 +0,0 @@
-module Pages.UploadList.View exposing (..)
-
-import Html exposing (Html, div, h1, text)
-import Html.Attributes exposing (class)
-
-import Pages.UploadList.Model exposing (..)
-import Widgets.UploadList as UploadList
-
-view: Model -> Html Msg
-view model =
- div [class "main ui grid container"]
- [
- div [class "sixteen wide column"]
- [
- h1 [class "header"] [text "Uploads"]
- ,(Html.map UploadListMsg (UploadList.view model.uploadList))
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm
index 00544463..05ae3545 100644
--- a/modules/webapp/src/main/elm/Ports.elm
+++ b/modules/webapp/src/main/elm/Ports.elm
@@ -1,42 +1,55 @@
port module Ports exposing (..)
-import Data exposing (..)
-import Resumable
+import Api.Model.AuthResult exposing (AuthResult)
+import Json.Decode as D
--- Ports
-port setAccount : Account -> Cmd msg
-port removeAccount : Account -> Cmd msg
+port setAccount : AuthResult -> Cmd msg
-port makeRandomString: String -> Cmd msg
-port randomString: (String -> msg) -> Sub msg
-port setProgress: (String, Float, Bool) -> Cmd msg
+port removeAccount : () -> Cmd msg
-port initAccordionAndTabs: () -> Cmd msg
-port initEmbeds: () -> Cmd msg
-port makeResumable: Resumable.Config -> Cmd msg
-port resetResumable: Resumable.Handle -> Cmd msg
-port resumableHandle: ((String, Resumable.Handle) -> msg) -> Sub msg
+port submitFiles : D.Value -> Cmd msg
-port resumableRebind: Resumable.Handle -> Cmd msg
-port resumableStart: Resumable.Handle -> Cmd msg
-port resumablePause: Resumable.Handle -> Cmd msg
-port resumableCancel: Resumable.Handle -> Cmd msg
-port resumableRetry: (Resumable.Handle, List String) -> Cmd msg
-port resumableSetComplete: (Resumable.Handle, String) -> Cmd msg
+{-| Information from JS about an upload that is currently in progress
+or completed.
-port resumableFileAdded: ((String, Resumable.File) -> msg) -> Sub msg
-port resumableFileSuccess: ((String, Resumable.File) -> msg) -> Sub msg
-port resumableStarted: (String -> msg) -> Sub msg
-port resumablePaused: (String -> msg) -> Sub msg
-port resumableProgress: ((String, Float) -> msg) -> Sub msg
-port resumableComplete: (String -> msg) -> Sub msg
-port resumableError: ((String, String, Resumable.File) -> msg) -> Sub msg
+The JSON data is read into a [UploadState](#Data.UploadState) data
+type.
-port resumableMaxFileSizeError: ((String, Resumable.File) -> msg) -> Sub msg
-port resumableMaxFilesError: ((String, Resumable.File) -> msg) -> Sub msg
+-}
+port uploadState : (D.Value -> msg) -> Sub msg
-port reloadPage: () -> Cmd msg
+
+{-| Run JS code to set the progress of a Semantic-UI progress div to
+some value.
+
+The string in the tuple is the element id, the second part the value
+in percent from 0 to 100.
+
+-}
+port setProgress : List ( String, Int ) -> Cmd msg
+
+
+{-| Requests to stop the current upload.
+-}
+port stopUpload : String -> Cmd msg
+
+
+port startUpload : String -> Cmd msg
+
+
+{-| Callback from the JS side to tell when a call to `stopUpload` has
+completed.
+-}
+port uploadStopped : (Maybe String -> msg) -> Sub msg
+
+
+{-| Scroll to the top
+-}
+port scrollTop : () -> Cmd msg
+
+
+port scrollToElem : String -> Cmd msg
diff --git a/modules/webapp/src/main/elm/Resumable.elm b/modules/webapp/src/main/elm/Resumable.elm
deleted file mode 100644
index ff3a0017..00000000
--- a/modules/webapp/src/main/elm/Resumable.elm
+++ /dev/null
@@ -1,115 +0,0 @@
-module Resumable exposing (..)
-
-import Json.Encode as Json
-import Data exposing (RemoteConfig)
-
-type alias Handle = String
-
-type alias Config =
- { target: String
- , testTarget: String
- , chunkSize: Int
- , forceChunkSize: Bool
- , simultaneousUploads: Int
- , testChunks: Bool
- , maxFiles: Int
- , maxFileSize: Int
- , withCredentials: Bool
- , handle: Maybe Handle
- , dropClass: String
- , browseClass: String
- , page: String
- , headers: Json.Value
- }
-
-browseCssClass: String
-browseCssClass = "sharry-add-files"
-
-dropCssClass: String
-dropCssClass = "sharry-dropzone"
-
-makeStandardConfig: RemoteConfig -> Config
-makeStandardConfig cfg =
- { target = cfg.urls.uploadData
- , testTarget = cfg.urls.uploadData
- , chunkSize = cfg.chunkSize
- , simultaneousUploads = cfg.simultaneousUploads
- , maxFiles = cfg.maxFiles
- , maxFileSize = cfg.maxFileSize
- , forceChunkSize = True
- , testChunks = True
- , withCredentials = True
- , handle = Nothing
- , dropClass = "."++dropCssClass
- , browseClass = "."++browseCssClass
- , page = ""
- , headers = Json.object []
- }
-
-makeAliasConfig: RemoteConfig -> String -> Config
-makeAliasConfig cfg aliasId =
- let
- default = makeStandardConfig cfg
- in
- {default| headers = Json.object [(cfg.aliasHeaderName, Json.string aliasId)]}
-
-type alias File =
- { fileName: String
- , size: Int
- , uniqueIdentifier: String
- , progress: Float
- , completed: Bool
- , uploading: Bool
- }
-
-type State
- = Initial
- | Uploading
- | Paused
- | Cancelled
- | Completed
-
-type alias Model =
- { handle: Maybe Handle
- , files: List File
- , progress: Float
- , errorFiles: List (File, String)
- , state: State
- }
-
-emptyModel: Model
-emptyModel =
- Model Nothing [] -1 [] Initial
-
-makeErrorList: Model -> List String
-makeErrorList model =
- model.errorFiles
- |> List.map (\(f, msg) -> f.fileName ++": "++ msg)
-
-
-{-| Clears everything but the handle to reuse a resumable instance.
--}
-clearModel: Model -> Model
-clearModel model =
- {emptyModel | handle = model.handle}
-
-isInitialized: Model -> Bool
-isInitialized model =
- case model.handle of
- Just h -> True
- Nothing -> False
-
-hasErrors: Model -> Bool
-hasErrors model =
- not <| List.isEmpty model.errorFiles
-
-type Msg
- = Initialize Config
- | SetHandle Handle
- | FileAdded File
- | FileError File String
- | FileSuccess File
- | Progress Float
- | UploadStarted
- | UploadPaused
- | UploadComplete
diff --git a/modules/webapp/src/main/elm/Resumable/Update.elm b/modules/webapp/src/main/elm/Resumable/Update.elm
deleted file mode 100644
index a1a25442..00000000
--- a/modules/webapp/src/main/elm/Resumable/Update.elm
+++ /dev/null
@@ -1,37 +0,0 @@
-module Resumable.Update exposing (..)
-
-import Resumable exposing (..)
-import Ports
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Initialize cfg ->
- model ! [ Ports.makeResumable {cfg | handle = model.handle} ]
-
- SetHandle handle ->
- {model | handle = Just handle} ! []
-
- FileAdded file ->
- {model | files = file :: model.files, errorFiles = []} ! []
-
- FileError file msg ->
- {model | errorFiles = (file, msg) :: model.errorFiles} ! []
-
- FileSuccess file ->
- let
- notFile = \f -> f.uniqueIdentifier /= file.uniqueIdentifier
- in
- {model | errorFiles = List.filter (Tuple.first >> notFile) model.errorFiles} ! []
-
- Progress percent ->
- {model | progress = percent, state = Uploading} ! []
-
- UploadComplete ->
- {model | state = Completed, errorFiles = []} ! []
-
- UploadStarted ->
- {model | state = Uploading, errorFiles = []} ! []
-
- UploadPaused ->
- {model | state = Paused} ! []
diff --git a/modules/webapp/src/main/elm/Util/Duration.elm b/modules/webapp/src/main/elm/Util/Duration.elm
new file mode 100644
index 00000000..dae1894c
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Duration.elm
@@ -0,0 +1,66 @@
+module Util.Duration exposing (Duration, toHuman)
+
+-- 486ms -> 12s -> 1:05 -> 59:45 -> 1:02:12
+
+
+type alias Duration =
+ Int
+
+
+toHuman : Duration -> String
+toHuman dur =
+ fromMillis dur
+
+
+
+-- implementation
+
+
+fromMillis : Int -> String
+fromMillis ms =
+ case ms // 1000 of
+ 0 ->
+ String.fromInt ms ++ "ms"
+
+ n ->
+ fromSeconds n
+
+
+fromSeconds : Int -> String
+fromSeconds sec =
+ case sec // 60 of
+ 0 ->
+ String.fromInt sec ++ "s"
+
+ n ->
+ let
+ s =
+ sec - (n * 60)
+ in
+ fromMinutes n ++ ":" ++ num s
+
+
+fromMinutes : Int -> String
+fromMinutes min =
+ case min // 60 of
+ 0 ->
+ num min
+
+ n ->
+ let
+ m =
+ min - (n * 60)
+ in
+ num n ++ ":" ++ num m
+
+
+num : Int -> String
+num n =
+ String.fromInt n
+ |> (++)
+ (if n < 10 then
+ "0"
+
+ else
+ ""
+ )
diff --git a/modules/webapp/src/main/elm/Util/Html.elm b/modules/webapp/src/main/elm/Util/Html.elm
new file mode 100644
index 00000000..d23bd9d3
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Html.elm
@@ -0,0 +1,57 @@
+module Util.Html exposing
+ ( checkbox
+ , checkboxChecked
+ , checkboxUnchecked
+ , noElement
+ , resultMsg
+ , resultMsgMaybe
+ )
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+
+
+checkboxChecked : Html msg
+checkboxChecked =
+ i [ class "ui check square outline icon" ] []
+
+
+checkboxUnchecked : Html msg
+checkboxUnchecked =
+ i [ class "ui square outline icon" ] []
+
+
+checkbox : Bool -> Html msg
+checkbox flag =
+ if flag then
+ checkboxChecked
+
+ else
+ checkboxUnchecked
+
+
+noElement : Html msg
+noElement =
+ span [ class "invisible" ] []
+
+
+resultMsg : BasicResult -> Html msg
+resultMsg result =
+ resultMsgMaybe (Just result)
+
+
+resultMsgMaybe : Maybe BasicResult -> Html msg
+resultMsgMaybe mres =
+ div
+ [ classList
+ [ ( "ui message", True )
+ , ( "invisible hidden", mres == Nothing )
+ , ( "error", Maybe.map .success mres == Just False )
+ , ( "success", Maybe.map .success mres == Just True )
+ ]
+ ]
+ [ Maybe.map .message mres
+ |> Maybe.withDefault ""
+ |> text
+ ]
diff --git a/modules/webapp/src/main/elm/Util/Http.elm b/modules/webapp/src/main/elm/Util/Http.elm
new file mode 100644
index 00000000..35417f8b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Http.elm
@@ -0,0 +1,281 @@
+module Util.Http exposing (..)
+
+import Api.Model.AuthResult exposing (AuthResult)
+import Http
+import Json.Decode as D
+import Process
+import Task exposing (Task)
+
+
+
+-- Authenticated Requests
+
+
+authReq :
+ { url : String
+ , account : AuthResult
+ , method : String
+ , headers : List Http.Header
+ , body : Http.Body
+ , expect : Http.Expect msg
+ , tracker : Maybe String
+ }
+ -> Cmd msg
+authReq req =
+ Http.request
+ { url = req.url
+ , method = req.method
+ , headers = Http.header "Sharry-Auth" (Maybe.withDefault "" req.account.token) :: req.headers
+ , expect = req.expect
+ , body = req.body
+ , timeout = Nothing
+ , tracker = req.tracker
+ }
+
+
+aliasReq :
+ { url : String
+ , aliasId : String
+ , method : String
+ , headers : List Http.Header
+ , body : Http.Body
+ , expect : Http.Expect msg
+ , tracker : Maybe String
+ }
+ -> Cmd msg
+aliasReq req =
+ Http.request
+ { url = req.url
+ , method = req.method
+ , headers = Http.header "Sharry-Alias" req.aliasId :: req.headers
+ , expect = req.expect
+ , body = req.body
+ , timeout = Nothing
+ , tracker = req.tracker
+ }
+
+
+authPost :
+ { url : String
+ , account : AuthResult
+ , body : Http.Body
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+authPost req =
+ authReq
+ { url = req.url
+ , account = req.account
+ , body = req.body
+ , expect = req.expect
+ , method = "POST"
+ , headers = []
+ , tracker = Nothing
+ }
+
+
+aliasPost :
+ { url : String
+ , aliasId : String
+ , body : Http.Body
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+aliasPost req =
+ aliasReq
+ { url = req.url
+ , aliasId = req.aliasId
+ , body = req.body
+ , expect = req.expect
+ , method = "POST"
+ , headers = []
+ , tracker = Nothing
+ }
+
+
+authPostTrack :
+ { url : String
+ , account : AuthResult
+ , body : Http.Body
+ , expect : Http.Expect msg
+ , tracker : String
+ }
+ -> Cmd msg
+authPostTrack req =
+ authReq
+ { url = req.url
+ , account = req.account
+ , body = req.body
+ , expect = req.expect
+ , method = "POST"
+ , headers = []
+ , tracker = Just req.tracker
+ }
+
+
+authPut :
+ { url : String
+ , account : AuthResult
+ , body : Http.Body
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+authPut req =
+ authReq
+ { url = req.url
+ , account = req.account
+ , body = req.body
+ , expect = req.expect
+ , method = "PUT"
+ , headers = []
+ , tracker = Nothing
+ }
+
+
+authGet :
+ { url : String
+ , account : AuthResult
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+authGet req =
+ authReq
+ { url = req.url
+ , account = req.account
+ , body = Http.emptyBody
+ , expect = req.expect
+ , method = "GET"
+ , headers = []
+ , tracker = Nothing
+ }
+
+
+getH :
+ { url : String
+ , headers : List Http.Header
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+getH req =
+ Http.request
+ { url = req.url
+ , method = "GET"
+ , headers = req.headers
+ , expect = req.expect
+ , body = Http.emptyBody
+ , timeout = Nothing
+ , tracker = Nothing
+ }
+
+
+authDelete :
+ { url : String
+ , account : AuthResult
+ , expect : Http.Expect msg
+ }
+ -> Cmd msg
+authDelete req =
+ authReq
+ { url = req.url
+ , account = req.account
+ , body = Http.emptyBody
+ , expect = req.expect
+ , method = "DELETE"
+ , headers = []
+ , tracker = Nothing
+ }
+
+
+
+-- Error Utilities
+
+
+errorToStringStatus : Http.Error -> (Int -> String) -> String
+errorToStringStatus error statusString =
+ case error of
+ Http.BadUrl url ->
+ "There is something wrong with this url: " ++ url
+
+ Http.Timeout ->
+ "There was a network timeout."
+
+ Http.NetworkError ->
+ "There was a network error."
+
+ Http.BadStatus status ->
+ statusString status
+
+ Http.BadBody str ->
+ "There was an error decoding the response: " ++ str
+
+
+errorToString : Http.Error -> String
+errorToString error =
+ let
+ f sc =
+ case sc of
+ 404 ->
+ "The requested resource doesn't exist."
+
+ _ ->
+ "There was an invalid response status: " ++ String.fromInt sc
+ in
+ errorToStringStatus error f
+
+
+
+-- Http.Task Utilities
+
+
+jsonResolver : D.Decoder a -> Http.Resolver Http.Error a
+jsonResolver decoder =
+ Http.stringResolver <|
+ \response ->
+ case response of
+ Http.BadUrl_ url ->
+ Err (Http.BadUrl url)
+
+ Http.Timeout_ ->
+ Err Http.Timeout
+
+ Http.NetworkError_ ->
+ Err Http.NetworkError
+
+ Http.BadStatus_ metadata _ ->
+ Err (Http.BadStatus metadata.statusCode)
+
+ Http.GoodStatus_ _ body ->
+ case D.decodeString decoder body of
+ Ok value ->
+ Ok value
+
+ Err err ->
+ Err (Http.BadBody (D.errorToString err))
+
+
+executeIn : Float -> (Result Http.Error a -> msg) -> Task Http.Error a -> Cmd msg
+executeIn delay receive task =
+ Process.sleep delay
+ |> Task.andThen (\_ -> task)
+ |> Task.attempt receive
+
+
+authTask :
+ { method : String
+ , headers : List Http.Header
+ , account : AuthResult
+ , url : String
+ , body : Http.Body
+ , resolver : Http.Resolver x a
+ , timeout : Maybe Float
+ }
+ -> Task x a
+authTask req =
+ Http.task
+ { method = req.method
+ , headers = Http.header "Sharry-Auth" (Maybe.withDefault "" req.account.token) :: req.headers
+ , url = req.url
+ , body = req.body
+ , resolver = req.resolver
+ , timeout = req.timeout
+ }
diff --git a/modules/webapp/src/main/elm/Util/List.elm b/modules/webapp/src/main/elm/Util/List.elm
new file mode 100644
index 00000000..513aedae
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/List.elm
@@ -0,0 +1,96 @@
+module Util.List exposing
+ ( distinct
+ , find
+ , findIndexed
+ , findNext
+ , findPrev
+ , get
+ , nonEmpty
+ , remove
+ )
+
+
+remove : Int -> List a -> List a
+remove index list =
+ List.indexedMap Tuple.pair list
+ |> List.filter (\t -> index /= Tuple.first t)
+ |> List.map Tuple.second
+
+
+nonEmpty : List a -> Bool
+nonEmpty list =
+ not (List.isEmpty list)
+
+
+get : List a -> Int -> Maybe a
+get list index =
+ if index < 0 then
+ Nothing
+
+ else
+ case list of
+ [] ->
+ Nothing
+
+ x :: xs ->
+ if index == 0 then
+ Just x
+
+ else
+ get xs (index - 1)
+
+
+find : (a -> Bool) -> List a -> Maybe a
+find pred list =
+ findIndexed pred list |> Maybe.map Tuple.first
+
+
+findIndexed : (a -> Bool) -> List a -> Maybe ( a, Int )
+findIndexed pred list =
+ findIndexed1 pred list 0
+
+
+findIndexed1 : (a -> Bool) -> List a -> Int -> Maybe ( a, Int )
+findIndexed1 pred list index =
+ case list of
+ [] ->
+ Nothing
+
+ x :: xs ->
+ if pred x then
+ Just ( x, index )
+
+ else
+ findIndexed1 pred xs (index + 1)
+
+
+distinct : List a -> List a
+distinct list =
+ List.reverse <|
+ List.foldl
+ (\a ->
+ \r ->
+ if List.member a r then
+ r
+
+ else
+ a :: r
+ )
+ []
+ list
+
+
+findPrev : (a -> Bool) -> List a -> Maybe a
+findPrev pred list =
+ findIndexed pred list
+ |> Maybe.map Tuple.second
+ |> Maybe.map (\i -> i - 1)
+ |> Maybe.andThen (get list)
+
+
+findNext : (a -> Bool) -> List a -> Maybe a
+findNext pred list =
+ findIndexed pred list
+ |> Maybe.map Tuple.second
+ |> Maybe.map (\i -> i + 1)
+ |> Maybe.andThen (get list)
diff --git a/modules/webapp/src/main/elm/Util/Maybe.elm b/modules/webapp/src/main/elm/Util/Maybe.elm
new file mode 100644
index 00000000..c3b549e5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Maybe.elm
@@ -0,0 +1,57 @@
+module Util.Maybe exposing
+ ( filter
+ , fromString
+ , isEmpty
+ , nonEmpty
+ , or
+ )
+
+
+nonEmpty : Maybe a -> Bool
+nonEmpty ma =
+ ma /= Nothing
+
+
+isEmpty : Maybe a -> Bool
+isEmpty ma =
+ ma == Nothing
+
+
+or : List (Maybe a) -> Maybe a
+or listma =
+ case listma of
+ [] ->
+ Nothing
+
+ (Just el) :: _ ->
+ Just el
+
+ Nothing :: els ->
+ or els
+
+
+filter : (a -> Bool) -> Maybe a -> Maybe a
+filter pred ma =
+ case ma of
+ Just v ->
+ if pred v then
+ ma
+
+ else
+ Nothing
+
+ Nothing ->
+ Nothing
+
+
+fromString : String -> Maybe String
+fromString str =
+ let
+ s =
+ String.trim str
+ in
+ if s == "" then
+ Nothing
+
+ else
+ Just str
diff --git a/modules/webapp/src/main/elm/Util/Share.elm b/modules/webapp/src/main/elm/Util/Share.elm
new file mode 100644
index 00000000..fac00ea2
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Share.elm
@@ -0,0 +1,59 @@
+module Util.Share exposing (splitDescription, validate)
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.ShareDetail exposing (ShareDetail)
+import Data.Flags exposing (Flags)
+import Data.UploadDict exposing (UploadDict)
+
+
+splitDescription : ShareDetail -> ( String, String )
+splitDescription share =
+ let
+ fallback =
+ Maybe.map (\n -> "# " ++ n) share.name
+ |> Maybe.withDefault "# Your Share"
+
+ desc =
+ Maybe.map String.trim share.description
+ |> Maybe.withDefault ""
+
+ lines =
+ String.lines desc
+ in
+ case lines of
+ [] ->
+ ( fallback, desc )
+
+ first :: rest ->
+ if String.startsWith "#" (String.trim first) then
+ ( first, String.join "\n" rest )
+
+ else
+ ( fallback, desc )
+
+
+validate :
+ Flags
+ -> Maybe ShareDetail
+ -> { m | descField : String, uploads : UploadDict }
+ -> BasicResult
+validate flags mshare model =
+ if model.descField == "" && model.uploads.selectedFiles == [] then
+ BasicResult False "Either some files or a description must be provided."
+
+ else
+ let
+ nsz =
+ Data.UploadDict.size model.uploads
+
+ esz =
+ Maybe.map .files mshare
+ |> Maybe.withDefault []
+ |> List.map .size
+ |> List.sum
+ in
+ if (nsz + esz) > flags.config.maxSize then
+ BasicResult False "Upload is too large."
+
+ else
+ BasicResult True ""
diff --git a/modules/webapp/src/main/elm/Util/Size.elm b/modules/webapp/src/main/elm/Util/Size.elm
new file mode 100644
index 00000000..92a838c5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Size.elm
@@ -0,0 +1,57 @@
+module Util.Size exposing (SizeUnit(..), bytesReadable)
+
+
+type SizeUnit
+ = G
+ | M
+ | K
+ | B
+
+
+prettyNumber : Float -> String
+prettyNumber n =
+ let
+ parts =
+ String.split "." (String.fromFloat n)
+ in
+ case parts of
+ n0 :: d :: [] ->
+ n0 ++ "." ++ String.left 2 d
+
+ _ ->
+ String.join "." parts
+
+
+bytesReadable : SizeUnit -> Float -> String
+bytesReadable unit n =
+ let
+ k =
+ n / 1024
+
+ num =
+ prettyNumber n
+ in
+ case unit of
+ G ->
+ num ++ "G"
+
+ M ->
+ if k > 1 then
+ bytesReadable G k
+
+ else
+ num ++ "M"
+
+ K ->
+ if k > 1 then
+ bytesReadable M k
+
+ else
+ num ++ "K"
+
+ B ->
+ if k > 1 then
+ bytesReadable K k
+
+ else
+ num ++ "B"
diff --git a/modules/webapp/src/main/elm/Util/String.elm b/modules/webapp/src/main/elm/Util/String.elm
new file mode 100644
index 00000000..f4129781
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/String.elm
@@ -0,0 +1,16 @@
+module Util.String exposing (shorten)
+
+
+shorten : Int -> String -> String
+shorten max str =
+ let
+ len =
+ max // 2
+
+ pref =
+ String.left len str
+
+ suff =
+ String.right len str
+ in
+ pref ++ "…" ++ suff
diff --git a/modules/webapp/src/main/elm/Util/Time.elm b/modules/webapp/src/main/elm/Util/Time.elm
new file mode 100644
index 00000000..16945406
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Time.elm
@@ -0,0 +1,102 @@
+module Util.Time exposing (..)
+
+import DateFormat
+import Time exposing (Posix, Zone, utc)
+
+
+dateFormatter : Zone -> Posix -> String
+dateFormatter =
+ DateFormat.format
+ [ DateFormat.dayOfWeekNameAbbreviated
+ , DateFormat.text ", "
+ , DateFormat.monthNameFull
+ , DateFormat.text " "
+ , DateFormat.dayOfMonthSuffix
+ , DateFormat.text ", "
+ , DateFormat.yearNumber
+ ]
+
+
+dateFormatterShort : Zone -> Posix -> String
+dateFormatterShort =
+ DateFormat.format
+ [ DateFormat.yearNumber
+ , DateFormat.text "/"
+ , DateFormat.monthFixed
+ , DateFormat.text "/"
+ , DateFormat.dayOfMonthFixed
+ ]
+
+
+timeFormatter : Zone -> Posix -> String
+timeFormatter =
+ DateFormat.format
+ [ DateFormat.hourMilitaryNumber
+ , DateFormat.text ":"
+ , DateFormat.minuteFixed
+ ]
+
+
+isoDateTimeFormatter : Zone -> Posix -> String
+isoDateTimeFormatter =
+ DateFormat.format
+ [ DateFormat.yearNumber
+ , DateFormat.text "-"
+ , DateFormat.monthFixed
+ , DateFormat.text "-"
+ , DateFormat.dayOfMonthFixed
+ , DateFormat.text "T"
+ , DateFormat.hourMilitaryNumber
+ , DateFormat.text ":"
+ , DateFormat.minuteFixed
+ , DateFormat.text ":"
+ , DateFormat.secondFixed
+ ]
+
+
+timeZone : Zone
+timeZone =
+ utc
+
+
+
+{- Format millis into "Wed, 10. Jan 2018, 18:57" -}
+
+
+formatDateTime : Int -> String
+formatDateTime millis =
+ formatDate millis ++ ", " ++ formatTime millis
+
+
+formatIsoDateTime : Int -> String
+formatIsoDateTime millis =
+ Time.millisToPosix millis
+ |> isoDateTimeFormatter timeZone
+
+
+
+{- Format millis into "18:57". The current time (not the duration of
+ the millis).
+-}
+
+
+formatTime : Int -> String
+formatTime millis =
+ Time.millisToPosix millis
+ |> timeFormatter timeZone
+
+
+
+{- Format millis into "Wed, 10. Jan 2018" -}
+
+
+formatDate : Int -> String
+formatDate millis =
+ Time.millisToPosix millis
+ |> dateFormatter timeZone
+
+
+formatDateShort : Int -> String
+formatDateShort millis =
+ Time.millisToPosix millis
+ |> dateFormatterShort timeZone
diff --git a/modules/webapp/src/main/elm/Util/Update.elm b/modules/webapp/src/main/elm/Util/Update.elm
new file mode 100644
index 00000000..dee7dfe4
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Update.elm
@@ -0,0 +1,18 @@
+module Util.Update exposing (andThen1)
+
+
+andThen1 : List (a -> ( a, Cmd b )) -> a -> ( a, Cmd b )
+andThen1 fs a =
+ let
+ init =
+ ( a, [] )
+
+ update el tuple =
+ let
+ ( a2, c2 ) =
+ el (Tuple.first tuple)
+ in
+ ( a2, c2 :: Tuple.second tuple )
+ in
+ List.foldl update init fs
+ |> Tuple.mapSecond Cmd.batch
diff --git a/modules/webapp/src/main/elm/Util/Url.elm b/modules/webapp/src/main/elm/Util/Url.elm
new file mode 100644
index 00000000..59d5bade
--- /dev/null
+++ b/modules/webapp/src/main/elm/Util/Url.elm
@@ -0,0 +1,13 @@
+module Util.Url exposing (emptyHttp, emptyHttps)
+
+import Url exposing (Url)
+
+
+emptyHttp : Url
+emptyHttp =
+ Url Url.Http "" Nothing "" Nothing Nothing
+
+
+emptyHttps : Url
+emptyHttps =
+ Url Url.Https "" Nothing "" Nothing Nothing
diff --git a/modules/webapp/src/main/elm/Widgets/AccountForm.elm b/modules/webapp/src/main/elm/Widgets/AccountForm.elm
deleted file mode 100644
index ba4e504f..00000000
--- a/modules/webapp/src/main/elm/Widgets/AccountForm.elm
+++ /dev/null
@@ -1,209 +0,0 @@
-module Widgets.AccountForm exposing (..)
-
-import Http
-import Html exposing (Html, form, div, h2, button, label, input, text, i, ul, li)
-import Html.Attributes exposing (class, classList, type_, value, name, placeholder, checked)
-import Html.Events exposing (..)
-import Data exposing (Account, accountEncoder, accountDecoder, httpPut, RemoteUrls)
-import PageLocation as PL
-
-{- the model -}
-type Update
- = Create
- | Modify
-
-type alias Model =
- { account: Account
- , update: Update
- , errors: List String
- , success: Maybe String
- , showPass: Bool
- , url: String
- }
-
-createAccount: RemoteUrls -> String -> Model
-createAccount urls login =
- Model (Data.fromLogin login) Create [] Nothing False urls.accounts
-
-modifyAccount: RemoteUrls -> Account -> Model
-modifyAccount urls acc =
- Model {acc|password = Nothing} Modify [] Nothing False urls.accounts
-
-makeModify: Model -> Model
-makeModify model =
- let
- acc = model.account
- in
- { model
- | update = Modify
- , errors = []
- , account = {acc | password = Nothing}}
-
-hasError: Model -> Bool
-hasError model =
- if List.isEmpty model.errors then False else True
-
-hasSuccess: Model -> Bool
-hasSuccess model =
- Data.isPresent model.success
-
-updateAccount: (Account -> Account) -> Model -> Model
-updateAccount update model =
- {model | account = update model.account, errors = [], success = Nothing}
-
-type Msg
- = AccountSetPassword String
- | AccountSetEmail String
- | AccountSetEnabled Bool
- | AccountSetAdmin Bool
- | AccountSetExtern Bool
- | SubmitAccount
- | ToggleShowPassword
- | CreateAccountResult (Result Http.Error Account)
-
-
-{- commands -}
-
-httpCreateAccount: Model -> Cmd Msg
-httpCreateAccount model =
- httpPut model.url (Http.jsonBody (accountEncoder model.account)) accountDecoder
- |> Http.send CreateAccountResult
-
-httpModifyAccount: Model -> Cmd Msg
-httpModifyAccount model =
- Http.post model.url (Http.jsonBody (accountEncoder model.account)) accountDecoder
- |> Http.send CreateAccountResult
-
-
-{- update -}
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- AccountSetPassword pass ->
- updateAccount (\acc -> {acc | password = Just pass}) model ! []
-
- AccountSetEmail email ->
- updateAccount (\acc -> {acc | email = Just email}) model ! []
-
- AccountSetEnabled flag ->
- updateAccount (\acc -> {acc | enabled = flag}) model ! []
-
- AccountSetAdmin flag ->
- updateAccount (\acc -> {acc | admin = flag}) model ! []
-
- AccountSetExtern flag ->
- updateAccount (\acc -> {acc | extern = flag}) model ! []
-
- CreateAccountResult (Ok acc) ->
- let
- verb = if model.update == Modify then "updated" else "created"
- newmodel = {model | account = acc, success = Just ("The account has been "++ verb ++".")}
- in
- (makeModify newmodel, Cmd.none)
-
- CreateAccountResult (Err error) ->
- let
- msg = Data.errorMessage error
- in
- {model | errors = msg :: model.errors, success = Nothing} ! [PL.timeoutCmd error]
-
- ToggleShowPassword ->
- ({model | showPass = (not model.showPass)}, Cmd.none)
-
- SubmitAccount ->
- case model.update of
- Create ->
- ({model| errors = [], success = Nothing}, httpCreateAccount model)
- Modify ->
- ({model | errors = [], success = Nothing}, httpModifyAccount model)
-
-
-{- view -}
-
-view: Model -> Html Msg
-view model =
- let
- acc = model.account
- in
- form [classList [("ui form", True)
- ,("error", hasError model)
- ,("success", hasSuccess model)]
- , onSubmit SubmitAccount
- ]
- [
- h2 [class "ui horizontal divider header"]
- [
- text acc.login
- ]
- , div [classList
- [ ("ui error message", True)
- , ("visible", (Data.nonEmpty model.errors))
- ]
- ]
- [Data.messagesToHtml model.errors]
- ,div [class "ui success message"]
- [model.success |> Maybe.withDefault "" |> text
- ]
- ,div [class "fields"]
- [
- div [class "fourteen wide field"]
- [
- label [] [text "Password"]
- ,input [type_ (if model.showPass then "text" else "password")
- , onInput AccountSetPassword
- , value (Maybe.withDefault "" acc.password)
- , name "password"
- , placeholder "password"][]
- ]
- ,div [class "two wide field"]
- [
- label [] [text "Show"]
- ,button [type_ "button"
- , class "ui button"
- , onClick ToggleShowPassword
- ]
- [text (if model.showPass then "Hide" else "Show")]
- ]
- ]
- ,div [class "field"]
- [
- label [] [text "Email"]
- ,input [type_ "text"
- , onInput AccountSetEmail
- , value (Maybe.withDefault "" acc.email)
- , name "email"
- , placeholder "optional email address"][]
- ]
- ,div [class "field"]
- [
- div [class "ui checkbox"]
- [
- input [type_ "checkbox"
- , checked acc.enabled
- , onCheck AccountSetEnabled][]
- ,label [] [text "Enabled"]
- ]
- ]
- ,div [class "field"]
- [
- div [class "ui checkbox"]
- [
- input [type_ "checkbox"
- , checked acc.admin
- , onCheck AccountSetAdmin][]
- ,label [] [text "Admin"]
- ]
- ]
- ,div [class "field"]
- [
- div [class "ui checkbox"]
- [
- input [type_ "checkbox"
- , checked acc.extern
- , onCheck AccountSetExtern][]
- ,label [] [text "Extern"]
- ]
- ]
- ,button [class "ui button", type_ "submit"] [text "Submit"]
- ]
diff --git a/modules/webapp/src/main/elm/Widgets/AliasEdit.elm b/modules/webapp/src/main/elm/Widgets/AliasEdit.elm
deleted file mode 100644
index ee59c9ed..00000000
--- a/modules/webapp/src/main/elm/Widgets/AliasEdit.elm
+++ /dev/null
@@ -1,169 +0,0 @@
-module Widgets.AliasEdit exposing (..)
-
-import Http
-import Json.Decode as Decode
-import Html exposing (Html, div, form, input, select, option, h2, text, label, a, p)
-import Html.Attributes exposing (class, classList, selected, value, placeholder, type_, name, checked)
-import Html.Events exposing (onInput, onCheck, onClick)
-
-import Data exposing (Alias, RemoteUrls)
-import PageLocation as PL
-
-type alias Model =
- {current: Alias
- ,currentId: String
- ,urls: RemoteUrls
- ,validityUnit: String
- ,validityNum: String
- ,errorMessage: Maybe String
- ,infoMessage: Maybe String
- }
-
-makeModel: Alias -> RemoteUrls -> Model
-makeModel alia urls =
- case Data.parseDuration alia.validity of
- Just (n, unit) ->
- Model alia alia.id urls unit (toString n) Nothing Nothing
- Nothing ->
- Model alia alia.id urls "" "" Nothing Nothing
-
-hasError: Model -> Bool
-hasError model = Data.isPresent model.errorMessage
-
-hasInfo: Model -> Bool
-hasInfo model = Data.isPresent model.infoMessage
-
-type Msg
- = SetName String
- | SetValidityNum String
- | SetValidityUnit String
- | SetEnabled Bool
- | SetId String
- | TrySubmit
- | SubmitAliasResult (Result Http.Error Alias)
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- SetId id ->
- let
- a = model.current
- na = {a | id = id}
- in
- {model | current = na, errorMessage = Nothing} ! []
-
- SetName name ->
- let
- a = model.current
- na = {a | name = name}
- in
- {model | current = na, errorMessage = Nothing} ! []
-
- SetValidityUnit unit ->
- {model | validityUnit = unit, errorMessage = Nothing} ! []
-
- SetValidityNum num ->
- if num /= "" then
- case (String.toInt num) of
- Ok n ->
- {model | validityNum = num, errorMessage = Nothing} ! []
- Err msg ->
- {model | validityNum = num, errorMessage = Just msg} ! []
- else
- {model | validityNum = num} ! []
-
- SetEnabled flag ->
- let
- a = model.current
- na = {a | enable = flag}
- in
- {model | current = na, errorMessage = Nothing} ! []
-
- TrySubmit ->
- case String.toInt model.validityNum of
- Ok n ->
- let
- validity = model.validityNum ++ model.validityUnit
- thisAlias = model.current
- newAlias = {thisAlias | validity = validity}
- model_ = {model | current = newAlias, errorMessage = Nothing}
- in
- model_ ! [httpSubmitAlias model_ model.currentId]
- Err msg ->
- {model | errorMessage = Just ("Error parsing validity number: "++ msg)} ! []
-
- SubmitAliasResult (Ok na) ->
- {model| currentId = na.id, current = na, infoMessage = Just "Alias has been updated."} ! []
-
- SubmitAliasResult (Err error) ->
- {model | errorMessage = Just (Data.errorMessage error), infoMessage = Nothing} ! [PL.timeoutCmd error]
-
-
-
-view: Model -> Html Msg
-view model =
- form [class "ui form"]
- [h2 [class "header"][text "Change Alias"]
- ,form [classList [("ui form", True)
- ,("error", hasError model)
- ,("success", hasInfo model)
- ]]
- [
- div [class "ui error message"]
- [model.errorMessage |> Maybe.withDefault "" |> text]
- ,div [class "ui success message"]
- [model.infoMessage |> Maybe.withDefault "" |> text]
- ,div [class "field"]
- [
- label [][text "Id"]
- ,input [onInput SetId, placeholder "Id", value model.current.id][]
- ,div [class "ui info message"]
- [div [class "header"][text "Note on changing the id:"]
- ,p[][text "The id must be globally unique and is used to authorize your public upload site. It therefore changes the URL to the alias page. If it is easy to guess, it may be abused to send spam to you. If unsure, leave the default value."]
- ]
- ]
- ,div [class "field"]
- [
- label [][text "Name"]
- ,input [onInput SetName, placeholder "Name", value model.current.name][]
- ]
- ,div [class "field"]
- [
- label [][text "Validity"]
- ,div [class "two fields"]
- [
- div [class "field"]
- [
- input [class "ui input"
- ,onInput SetValidityNum
- ,type_ "text"
- ,placeholder "Number"
- ,value model.validityNum][]
- ]
- ,div [class "field"]
- [
- select [onInput SetValidityUnit]
- (List.map
- (\n -> case n of
- (val, unit) -> option [value val, selected <| model.validityUnit == val][text unit])
- [("h", "Hours"), ("d", "Days")])
- ]
- ]
- ]
- ,div [class "inline ui checkbox field"]
- [
- input [type_ "checkbox"
- ,checked model.current.enable
- ,onCheck SetEnabled
- ][]
- ,label [][text "Enable"]
- ]
- ,div [class "ui divider"][]
- ,a [class "ui primary button", onClick TrySubmit][text "Submit"]
- ]
- ]
-
-httpSubmitAlias: Model -> String -> Cmd Msg
-httpSubmitAlias model id =
- Http.post (model.urls.aliases ++"/"++ id) (Http.jsonBody (Data.encodeAlias model.current)) (Data.decodeAlias)
- |> Http.send SubmitAliasResult
diff --git a/modules/webapp/src/main/elm/Widgets/AliasList.elm b/modules/webapp/src/main/elm/Widgets/AliasList.elm
deleted file mode 100644
index 3c0eb947..00000000
--- a/modules/webapp/src/main/elm/Widgets/AliasList.elm
+++ /dev/null
@@ -1,229 +0,0 @@
-module Widgets.AliasList exposing (..)
-
-import Http
-import Html exposing (Html, div, table, th, tr, thead, td, tbody, a, i, text, button, h2)
-import Html.Attributes exposing (class, href)
-import Html.Events exposing (onClick)
-import Json.Decode as Decode
-
-import Ports
-import Data exposing (Alias, RemoteConfig, RemoteUrls, defer)
-import Widgets.AliasEdit as AliasEdit
-import Widgets.MailForm as MailForm
-import PageLocation as PL
-
-type Selected
- = EditDetail AliasEdit.Model
- | MailDetail MailForm.Model
- | Table
-
-type alias Model =
- {aliases: List Alias
- ,cfg: RemoteConfig
- ,selected: Selected
- }
-
-makeModel: RemoteConfig -> List Alias -> Model
-makeModel cfg aliases =
- Model aliases cfg Table
-
-emptyModel: RemoteConfig -> Model
-emptyModel cfg =
- Model [] cfg Table
-
-type Msg
- = DeleteAlias String
- | DeleteAliasResult (Result Http.Error ())
- | AliasListResult (Result Http.Error (List Alias))
- | AddNewAlias
- | EditAlias Alias
- | NewAliasResult (Result Http.Error Alias)
- | AliasEditMsg AliasEdit.Msg
- | BackToTable
- | OpenMailForm Alias
- | MailFormMsg MailForm.Msg
-
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- DeleteAlias id ->
- model ! [httpDeleteAlias model id]
-
- DeleteAliasResult (Ok _) ->
- model ! [httpGetAliases model]
-
- DeleteAliasResult (Err error) ->
- let
- x = Debug.log "Error deleting upload" (Data.errorMessage error)
- in
- model ! [PL.timeoutCmd error]
-
- AliasListResult (Ok list) ->
- {model | aliases = list} ! []
-
- AliasListResult (Err error) ->
- let
- x = Debug.log "Error getting upload list" (Data.errorMessage error)
- in
- model ! [PL.timeoutCmd error]
-
- AddNewAlias ->
- model ! [httpAddAlias model]
-
- NewAliasResult (Ok alia) ->
- model ! [httpGetAliases model]
-
- NewAliasResult (Err error) ->
- model ! [PL.timeoutCmd error]
-
- AliasEditMsg msg ->
- case model.selected of
- EditDetail am ->
- let
- (m, c) = AliasEdit.update msg am
- in
- {model | selected = EditDetail m} ! [Cmd.map AliasEditMsg c]
- _ ->
- model ! []
-
- EditAlias alia ->
- {model | selected = EditDetail (AliasEdit.makeModel alia model.cfg.urls)} ! []
-
- BackToTable ->
- case model.selected of
- EditDetail m ->
- {model | selected = Table} ! [httpGetAliases model]
- _ ->
- {model | selected = Table} ! []
-
- OpenMailForm alia ->
- {model | selected = MailDetail (MailForm.makeModel model.cfg.urls)} ! [httpGetTemplate model alia]
-
- MailFormMsg msg ->
- case model.selected of
- MailDetail m ->
- let
- (m_, c) = MailForm.update msg m
- in
- {model | selected = MailDetail m_} ! [Cmd.map MailFormMsg c]
- _ ->
- model ! []
-
-view: Model -> Html Msg
-view model =
- case model.selected of
- EditDetail alia ->
- div []
- [
- button [class "ui button", onClick BackToTable][text "Back"]
- ,div [class "ui divider"][]
- ,createAliasEdit alia
- ]
- MailDetail mf ->
- div [class "sixteen wide column"]
- [
- a [class "ui button", onClick BackToTable][text "Back"]
- ,div [class "ui divider"][]
- ,div [class "sixteen wide column"]
- [h2 [class "ui header"][text "Send an email"]
- ,(Html.map MailFormMsg (MailForm.view mf))
- ]
- ]
-
- Table ->
- div[]
- [
- button [class "ui right floated primary button", onClick AddNewAlias]
- [
- i [class "add icon"][]
- ,text "New Alias"
- ]
- ,table [class "ui selectable celled table"]
- [
- thead []
- [
- tr []
- [
- th[][text "Link"]
- ,th[][text "Created"]
- ,th[][text "Validity"]
- ,th[][text "Enabled"]
- ,th[][text ""]
- ]
- ]
- ,tbody[]
- (List.map (createRow model) model.aliases)
- ]
- ]
-
-createAliasEdit: AliasEdit.Model -> Html Msg
-createAliasEdit aliasModel =
- Html.map AliasEditMsg (AliasEdit.view aliasModel)
-
-createRow: Model -> Alias -> Html Msg
-createRow model alia =
- let
- no = "brown minus square outline icon"
- yes = "brown checkmark box icon"
- in
- tr[]
- [
- td []
- [a [href (PL.aliasUploadPageHref alia.id)][text alia.name]]
- ,td [class "center aligned"][alia.created |> Data.formatDate |> text]
- ,td [class "center aligned"]
- [
- text (Data.formatDuration alia.validity)
- ]
- ,td [class "center aligned"]
- [
- i [class (if alia.enable then yes else no)][]
- ]
- ,td [class "right aligned"]
- [
- a [class "mini ui basic primary button", onClick (EditAlias alia)]
- [
- i [class "edit icon"][]
- ,text "Edit"
- ]
- ,a [class "mini ui basic negative button", onClick (DeleteAlias alia.id)]
- [
- i [class "remove icon"][]
- ,text "Delete"
- ]
- ,if model.cfg.mailEnabled then
- a[class "mini ui basic button", onClick (OpenMailForm alia)]
- [
- i [class "mail icon"][]
- ,text "Email"
- ]
- else
- div[][]
- ]
- ]
-
-httpAddAlias: Model -> Cmd Msg
-httpAddAlias model =
- Http.post model.cfg.urls.aliases Http.emptyBody (Data.decodeAlias)
- |> Http.send NewAliasResult
-
-httpDeleteAlias: Model -> String -> Cmd Msg
-httpDeleteAlias model id =
- Data.httpDelete (model.cfg.urls.aliases ++"/"++ id) Http.emptyBody (Decode.succeed ())
- |> Http.send DeleteAliasResult
-
-httpGetAliases: Model -> Cmd Msg
-httpGetAliases model =
- Http.get model.cfg.urls.aliases (Decode.list Data.decodeAlias)
- |> Http.send AliasListResult
-
-httpGetTemplate: Model -> Alias -> Cmd Msg
-httpGetTemplate model alia =
- let
- href = PL.aliasUploadPageHref alia.id
- url = model.cfg.urls.baseUrl ++ href
- cmd = Http.get (model.cfg.urls.mailAliasTemplate ++ "?url=" ++ (Http.encodeUri url)) MailForm.decodeTemplate
- |> Http.send MailForm.TemplateResult
- in
- Cmd.map MailFormMsg cmd
diff --git a/modules/webapp/src/main/elm/Widgets/AliasUploadForm.elm b/modules/webapp/src/main/elm/Widgets/AliasUploadForm.elm
deleted file mode 100644
index 9a6d891e..00000000
--- a/modules/webapp/src/main/elm/Widgets/AliasUploadForm.elm
+++ /dev/null
@@ -1,149 +0,0 @@
-module Widgets.AliasUploadForm exposing (..)
-
-import Html exposing (Html, button, form, h3, div, label, text, textarea, select, option, i, input, a, p)
-import Html.Attributes exposing (class, name, type_, href, classList, rows, placeholder, value, selected)
-import Html.Events exposing (onInput, onClick)
-
-import Ports
-import Resumable
-import Resumable.Update as ResumableUpdate
-import Data exposing (RemoteConfig, defer, bytesReadable)
-import Widgets.MarkdownHelp as MarkdownHelp
-
-type alias Limits =
- { maxFileSize: Int
- , maxFiles: Int
- }
-
-type alias Model =
- { errorMessage: Maybe String
- , showMarkdownHelp: Bool
- , description: String
- , limits: Limits
- , resumableModel: Resumable.Model
- }
-
-emptyModel: RemoteConfig -> Model
-emptyModel cfg =
- Model Nothing False "" (Limits cfg.maxFileSize cfg.maxFiles) Resumable.emptyModel
-
-clearModel: Model -> Model
-clearModel model =
- Model Nothing False "" model.limits (Resumable.clearModel model.resumableModel)
-
-hasError: Model -> Bool
-hasError model =
- Data.isPresent model.errorMessage || Data.nonEmpty model.resumableModel.errorFiles
-
-hasFiles: Model -> Bool
-hasFiles model =
- (List.length model.resumableModel.files) > 0
-
-isReady: Model -> Bool
-isReady model =
- (not <| Data.isPresent model.errorMessage) &&
- ((hasFiles model) || (not <| String.isEmpty model.description))
-
-errorMessage: Model -> List String
-errorMessage model =
- let
- resumableErrors = Resumable.makeErrorList model.resumableModel
- in
- model.errorMessage
- |> Maybe.map List.singleton
- |> Maybe.map ((++) resumableErrors)
- |> Maybe.withDefault resumableErrors
-
-
-type Msg
- = SetDescription String
- | ResumableMsg Resumable.Msg
- | ToggleMarkdownHelp
-
-update: Msg -> Model -> (Model, Cmd Msg, Cmd Msg)
-update msg model =
- case msg of
- SetDescription desc ->
- ({model | description = desc, errorMessage = Nothing}, Cmd.none) |> defer Cmd.none
-
- ResumableMsg msg ->
- let
- (rmodel, cmd) = ResumableUpdate.update msg model.resumableModel
- in
- {model | resumableModel = rmodel} ! [] |> defer (Cmd.map ResumableMsg cmd)
-
- ToggleMarkdownHelp ->
- {model | showMarkdownHelp = not model.showMarkdownHelp} ! [] |> defer Cmd.none
-
-
-view: Model -> Html Msg
-view model =
- if model.showMarkdownHelp then markdownHelp
- else
- form [classList [("ui form", True)
- ,("error", hasError model)
- ]
- ]
- [
- infoView model.limits
- ,div [class "ui error message"]
- [errorMessage model |> Data.messagesToHtml]
- ,div [class "field"]
- [
- label [][text "Description (supports "
- ,a[onClick ToggleMarkdownHelp, class "ui link"][text "Markdown"]
- ,text ")"
- ]
- , textarea [name "description"
- , rows 5
- , onInput SetDescription
- , placeholder "Optional description"
- , value model.description
- ][]
- ]
- ,div[]
- [
- a [class ("ui button " ++ Resumable.browseCssClass)][text "Add files"]
- ]
- ,div [class ("ui center aligned container " ++ Resumable.dropCssClass)]
- [
- p []
- [
- text "Drop files here or use the “Add files” button to select files to upload."
- ]
- ,makeFilesView model.resumableModel.files
- ]
- ]
-
-
-makeFilesView: List Resumable.File -> Html Msg
-makeFilesView files =
- let
- size = List.sum (List.map (\m -> m.size) files)
- bytes = bytesReadable Data.B (toFloat size)
- message = "Selected " ++ (toString (List.length files)) ++ " files, " ++ bytes
- in
- h3 [class "header"][text message]
-
-infoView: Limits -> Html Msg
-infoView cfg =
- p []
- [text ("You can select up to " ++
- (toString cfg.maxFiles) ++
- " files with a total of " ++
- (bytesReadable Data.B (toFloat cfg.maxFileSize)) ++
- ".")
- ,text " The »Upload« button is enabled when a description is present and/or files are selected."
- ]
-
-markdownHelp:Html Msg
-markdownHelp =
- div [onClick ToggleMarkdownHelp]
- [h3 [class "ui horizontal clearing divider header"]
- [i [class "help icon"][]
- ,text "Markdown Help"
- ]
- ,div [class "ui center aligned segment"]
- [text "Click somewhere on the help text to close it."]
- ,MarkdownHelp.helpTextHtml
- ]
diff --git a/modules/webapp/src/main/elm/Widgets/DownloadView.elm b/modules/webapp/src/main/elm/Widgets/DownloadView.elm
deleted file mode 100644
index aa8a1628..00000000
--- a/modules/webapp/src/main/elm/Widgets/DownloadView.elm
+++ /dev/null
@@ -1,611 +0,0 @@
-module Widgets.DownloadView exposing (..)
-
-import Http
-import Html exposing (Html, div, text, h2, h3)
-import Html.Attributes as HA
-import Html.Events as HE
-import Json.Decode as Decode exposing (field, at)
-import Json.Encode as Encode
-import Data exposing (Account, UploadInfo, File, RemoteUrls, RemoteConfig, UploadId(..), htmlList)
-import PageLocation as PL
-import Widgets.MailForm as MailForm
-
-type alias Model =
- {info: UploadInfo
- ,cfg: RemoteConfig
- ,login: Maybe String
- ,password: Maybe String
- ,validPassword: Bool
- ,errorMessage: List String
- ,mailForm: Maybe MailForm.Model
- ,editName: Maybe String
- }
-
-type Msg
- = SetPassword String
- | PasswordAttempt
- | PasswordCheck (Result Http.Error (List String))
- | DeleteDownload
- | DeleteDownloadResult (Result Http.Error Int)
- | PublishDownload
- | UnpublishDownload
- | PublishDownloadResult (Result Http.Error UploadInfo)
- | OpenMailForm
- | MailFormCancel
- | MailFormMsg MailForm.Msg
- | EditName
- | CancelEditName
- | SaveEditName
- | SetName String
- | UploadUpdateResult (Result Http.Error ())
-
-makeModel: UploadInfo -> RemoteConfig -> Maybe Account -> Model
-makeModel info cfg account =
- Model info cfg (Maybe.map (\a -> a.login) account) Nothing False [] Nothing Nothing
-
-
-isOwner: Model -> Bool
-isOwner model =
- Maybe.map (\s -> s == model.info.upload.login) model.login
- |> Maybe.withDefault False
-
-isAskPassword: Model -> Bool
-isAskPassword model =
- model.info.upload.requiresPassword && (not model.validPassword)
-
-isValid: Model -> Bool
-isValid model =
- Data.isValidUpload model.info.upload
-
-hasPasswordErrors: Model -> Bool
-hasPasswordErrors model =
- not (List.isEmpty model.errorMessage)
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- SetPassword p ->
- {model | password = Just p, errorMessage = []} ! []
-
- PasswordAttempt ->
- model ! [httpCheckPassword model]
-
- PasswordCheck (Ok list) ->
- {model| validPassword = List.isEmpty list, errorMessage = list} ! []
-
- PasswordCheck (Err error) ->
- {model | errorMessage = [Data.errorMessage error]} ! [PL.timeoutCmd error]
-
- DeleteDownload ->
- model ! [httpDeleteDownload model]
-
- DeleteDownloadResult (Ok n) ->
- model ! [PL.uploadsPage]
-
- DeleteDownloadResult (Err error) ->
- {model | errorMessage = Debug.log "Error deleting download" [(Data.errorMessage error)]} ! [PL.timeoutCmd error]
-
- PublishDownload ->
- model ! [httpPublishDownload model]
-
- PublishDownloadResult (Ok info) ->
- {model | info = info} ! []
-
- PublishDownloadResult (Err error) ->
- {model | errorMessage = Debug.log "Error un-/publishing download" [(Data.errorMessage error)]} ! [PL.timeoutCmd error]
-
- UnpublishDownload ->
- model ! [httpUnpublishDownload model]
-
- OpenMailForm ->
- {model | mailForm = Just (MailForm.makeModel model.cfg.urls)} ! [httpGetTemplate model]
-
- MailFormCancel ->
- {model | mailForm = Nothing} ! []
-
- MailFormMsg msg ->
- case model.mailForm of
- Just m ->
- let
- (m_, c) = MailForm.update msg m
- in
- {model | mailForm = Just m_} ! [Cmd.map MailFormMsg c]
- Nothing ->
- model ! []
-
- EditName ->
- ({model|editName = Data.maybeOrElse model.info.upload.name (Just "")}, Cmd.none)
- CancelEditName ->
- ({model|editName = Nothing}, Cmd.none)
- SaveEditName ->
- case model.editName of
- Just name ->
- (model, httpSetName model name)
- Nothing ->
- ({model|editName = Nothing}, Cmd.none)
- SetName name ->
- ({model|editName = Just name}, Cmd.none)
- UploadUpdateResult (Ok _) ->
- let
- info = model.info
- up = info.upload
- nup = {up|name = model.editName}
- ninfo = {info|upload = nup}
- in
- ({model|editName = Nothing, info = ninfo}, Cmd.none)
- UploadUpdateResult (Err _) ->
- (model, Cmd.none)
-
-view: Model -> List (Html Msg)
-view model =
- case model.mailForm of
- Just m ->
- viewMailForm m
- Nothing ->
- viewPage model
-
-viewMailForm: MailForm.Model -> List (Html Msg)
-viewMailForm model =
- [div [HA.class "sixteen wide column"]
- [
- Html.a [HA.class "ui button", HE.onClick MailFormCancel][text "Back"]
- ,div [HA.class "ui divider"][]
- ]
- ,div [HA.class "sixteen wide column"]
- [h2 [HA.class "ui header"][text "Send an email"]
- ,(Html.map MailFormMsg (MailForm.view model))
- ]
- ]
-
-viewPage: Model -> List (Html Msg)
-viewPage model =
- let
- msg = Maybe.withDefault (defaultDescription model) model.info.upload.description
- in
- htmlList [
- (not (isValid model || isOwner model),
- renderDimmer model)
- ,(not (isOwner model) && isValid model && isAskPassword model,
- passwordForm model)
- ,(True, div [HA.class "sixteen wide column"]
- [(Data.markdownHtml msg)])
- ,(True, div [HA.class "eight wide column"]
- (uploadInfoItems model))
- ,(True, div [HA.class "six wide column"]
- (downloadInfoItems model))
- ,(True, div [HA.class "two wide column"]
- [actionButtons model])
- ,(True, setNameInput model)
- ,(isValid model && isOwner model, infoMessage model)
- ,(model.info.upload.requiresPassword && isOwner model,
- div [HA.class "sixteen wide column"]
- (passwordHint model))
- ,(not (isValid model) && isOwner model,
- div [HA.class "sixteen wide column"]
- (validatedHint model))
- ,(True, div [HA.class "sixteen wide column"]
- [
- h2 [HA.class "ui header"][
- text "Files"
- ,div [HA.class "sub header"]
- [text (fileSummary model)]
- ]
- ,div [HA.class "ui fluid accordion"]
- (if List.isEmpty model.info.files then
- [text "No files attached."]
- else
- (List.map (renderFile model) model.info.files))
- ])
- ]
-
-setNameInput: Model -> Html Msg
-setNameInput model =
- case model.editName of
- Just name ->
- div [HA.class "ui right aligned container"]
- [Html.div [HA.class "ui action input"]
- [Html.input [HA.type_ "text"
- ,HA.value name
- ,HE.onInput SetName
- ][]
- ,Html.button [HA.class "ui primary button", HE.onClick SaveEditName]
- [text "Save"
- ]
- ,Html.button [HA.class "ui secodary button", HE.onClick CancelEditName]
- [text "Cancel"
- ]
- ]
- ]
- Nothing ->
- Html.span [][]
-
-infoMessage: Model -> Html msg
-infoMessage model =
- case model.info.upload.publishId of
- Just id ->
- let
- href = PL.downloadPageHref (Pid id)
- url = model.cfg.urls.baseUrl ++ href
- in
- div []
- [text "You can share this page with others by sending the following link:"
- ,Html.br[][]
- ,Html.a[HA.href href][text url]
- ]
- Nothing ->
- div[][]
-
-
-defaultDescription: Model -> String
-defaultDescription model =
- if (isOwner model) then "# Your Upload"
- else
- """# Your Files
-
-Someone provided the following files. Download is available for the given time period."""
-
-
-uploadInfoItems: Model -> List (Html msg)
-uploadInfoItems model =
- [
- div [HA.class "ui list"]
- [div [HA.class "item"]
- [Html.i [HA.class "comment outline icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Name"]
- ,div [HA.class "content"]
- [text (Maybe.withDefault "-" model.info.upload.name)]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "calendar outline icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Uploaded at"]
- ,div [HA.class "content"]
- [text (Data.formatDate model.info.upload.created)]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "hashtag icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Publish Id"]
- ,div [HA.class "content"]
- [case model.info.upload.publishId of
- Just id ->
- Html.a[HA.href (PL.downloadPageHref (Pid id))][text id]
- Nothing ->
- text "-"
- ]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "calendar icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Published on"]
- ,div [HA.class "content"]
- [Maybe.map Data.formatDate model.info.upload.publishDate |> Maybe.withDefault "-" |> text]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "download icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Max. downloads"]
- ,div [HA.class "content"]
- [toString model.info.upload.maxDownloads |> text]
- ]
- ]
- ]
- ]
-
-downloadInfoItems: Model -> List (Html msg)
-downloadInfoItems model =
- [
- div [HA.class "ui list"]
- [
- div [HA.class "item"]
- [Html.i [HA.class "download icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Downloads"]
- ,div [HA.class "content"]
- [toString model.info.upload.downloads |> text]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "download icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Last download at"]
- ,div [HA.class "content"]
- [Maybe.map Data.formatDate model.info.upload.lastDownload |> Maybe.withDefault "-" |> text]
- ]
- ]
-
- ,div [HA.class "item"]
- [Html.i [HA.class "protect icon"][]
- ,div [HA.class "content"]
- [
- div [HA.class "header"]
- [text "Valid until"]
- ,div [HA.class "content"]
- [Maybe.map Data.formatDate model.info.upload.validUntil |> Maybe.withDefault "-" |> text]
- ]
- ]
- ]
- ]
-
-passwordHint: Model -> List (Html msg)
-passwordHint model =
- if (model.info.upload.requiresPassword && isOwner model) then
- div [HA.class "eight wide column"]
- [
- div [HA.class "ui info message"]
- [
- div []
- [text "This download requires a password!"]
- ]
- ] |> List.singleton
- else
- []
-
-validatedHint: Model -> List (Html msg)
-validatedHint model =
- if isValid model && not (isOwner model) then []
- else div [HA.class "eight wide column"]
- [
- div [HA.class "ui info message"]
- [
- div [HA.class "header"]
- [text "This is not a valid public download!"]
- ,Html.ul [HA.class "list"]
- (List.map (\s -> Html.li[][text s]) model.info.upload.validated)
- ]
- ] |> List.singleton
-
-renderDimmer: Model -> Html msg
-renderDimmer model =
- div [HA.class "ui active dimmer"]
- [
- div [HA.class "content"]
- [
- div [HA.class "center"]
- [
- h2 [HA.class "ui inverted icon header"]
- [
- Html.i [HA.class "meh icon"][]
- ,text "This download is not available anymore!"
- ]
- ,div []
- (List.map (\s -> Html.p[][text s]) model.info.upload.validated)
- ]
- ]
- ]
-
-renderFile: Model -> File -> Html msg
-renderFile model file =
- let
- downloadUrl = if isOwner model then
- model.cfg.urls.download ++ "/" ++ file.id
- else if isValid model && not (isAskPassword model) then
- model.cfg.urls.downloadPublished ++ "/" ++ file.id
- else
- "#"
- downloadUrlAbs = if String.startsWith "/" downloadUrl then
- model.cfg.urls.baseUrl ++ String.dropLeft 1 downloadUrl
- else
- model.cfg.urls.baseUrl ++ downloadUrl
- mimecss = case Data.parseMime file.mimetype of
- ("application", "zip") -> "file archive outline"
- ("application", "pdf") -> "file pdf outline"
- ("text", _) -> "file text outline"
- ("image", _) -> "file image outline"
- ("audio", _) -> "file audio outline"
- ("video", _) -> "file video outline"
- ("application", "vnd.openxmlformats-officedocument.wordprocessingml.document") ->
- "file word outline"
- ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet") ->
- "file excel outline"
- ("application", "vnd.openxmlformats-officedocument.presentationml.presentation") ->
- "file powerpoint outline"
- _ -> "file outline"
- niceEmbed = case Data.parseMime file.mimetype of
- ("application", "pdf") -> Just "circle arrow right"
- ("image", _) -> Just "circle arrow right"
- ("video", _) -> Just "video play outline"
- _ -> Nothing
- in
- div []
- [div [HA.class "title"]
- [Html.i [HA.class ("large dropdown middle aligned icon")][]
- ,Html.i [HA.class ("large " ++ mimecss ++ " middle aligned icon")][]
- ,Html.a [HA.class "header", HA.href downloadUrl]
- [text file.filename
- ]
- ,text " ("
- ,Data.bytesReadable Data.B (toFloat file.length) |> text
- ,text ")"
- ]
- ,div [HA.class "content"]
- [div [HA.class "ui pointing secondary tabular menu"]
- [Html.a [HA.class "active item", HA.attribute "data-tab" ("preview-"++file.id)][text "Preview"]
- ,Html.a [HA.class "item", HA.attribute "data-tab" ("embed-"++file.id)][text "Embed"]
- ]
- ,div [HA.class "ui bottom active tab", HA.attribute "data-tab" ("preview-"++file.id)]
- [
- case niceEmbed of
- Just icon ->
- div [HA.class "ui embed"
- ,HA.attribute "data-url" downloadUrl
- ,HA.attribute "data-icon" icon
- ,HA.attribute "data-placeholder" "static/sharry-webapp/placeholder.png"]
- []
- Nothing ->
- Html.embed [HA.type_ file.mimetype
- ,HA.src downloadUrl
- ,HA.attribute "width" "100%"
- ,HA.attribute "allowFullscreen" ""]
- []
- ]
- ,div [HA.class "ui bottom attached tab", HA.attribute "data-tab" ("embed-"++file.id)]
- [
- Html.pre [HA.style [("margin-left", "1em")]]
- [Html.code [HA.class "lang-html"]
- [text ("")
- ]
- ]
- ]
- ]
- ]
-
-passwordForm: Model -> Html Msg
-passwordForm model =
- div [HA.class "ui active dimmer"]
- [
- div [HA.class "content"]
- [
- div [HA.class "ui center aligned grid"]
- [
- div [HA.class "sixteen wide column"]
- [
- h2 [HA.class "ui inverted icon header"]
- [
- Html.i [HA.class "lock icon"][]
- ,text "This download requires a password."
- ]
- ]
- ,div [HA.class "eight wide column"]
- [
- Html.form [HE.onSubmit PasswordAttempt, HA.classList [("error", hasPasswordErrors model)]]
- [
- div [HA.class "ui right action left icon input"]
- [
- Html.i [HA.class "lock icon"] []
- ,Html.input [HA.type_ "password", HA.placeholder "Password", HA.size 30, HE.onInput SetPassword] []
- ,Html.button [HA.class "ui basic floating brown submit button"] [ text "Submit" ]
- ]
- ,case model.errorMessage of
- [] ->
- div [HA.class "ui basic segment"][text ""]
- a :: [] ->
- div [HA.class "ui basic red segment"][text a]
- _ ->
- div [HA.class "ui basic red segment"]
- [Html.ul []
- (List.map (\t -> Html.li[][text t]) model.errorMessage)
- ]
- ]
- ]
- ]
-
- ]
- ]
-
-
-actionButtons: Model -> Html Msg
-actionButtons model =
- div [HA.class "ui vertical buttons"]
- <| Data.htmlList
- [(isOwner model && not (Data.isPublishedUpload model.info.upload),
- Html.button [HA.class "ui button", HE.onClick PublishDownload][text "Publish"])
- ,(isOwner model && Data.isPublishedUpload model.info.upload,
- Html.button [HA.class "ui button", HE.onClick UnpublishDownload][text "Unpublish"])
- ,(isOwner model,
- Html.button [HA.class "negative ui button", HE.onClick DeleteDownload][text "Delete"])
- ,(isOwner model && isValid model && model.cfg.mailEnabled,
- Html.button [HA.class "ui button", HE.onClick OpenMailForm][text "Send email"])
- ,(True
- , case model.editName of
- Just name ->
- Html.span [][]
- Nothing ->
- Html.button [HA.class "ui button", HE.onClick EditName][text "Edit Name"])
- ]
-
-fileSummary: Model -> String
-fileSummary model =
- if (List.length model.info.files) > 1 then
- (toString (List.length model.info.files)) ++ ", " ++ (sumFileSize model)
- else
- ""
-
-sumFileSize: Model -> String
-sumFileSize model =
- model.info.files
- |> List.map .length
- |> List.sum
- |> toFloat
- |> Data.bytesReadable Data.B
-
-httpCheckPassword: Model -> Cmd Msg
-httpCheckPassword model =
- let
- url id = model.cfg.urls.checkPassword ++ "/" ++ id
- decoder = Decode.list Decode.string
- encoded pass = Encode.object [("password", Encode.string pass)]
- makeCmd pass id =
- Http.post (url id) (Http.jsonBody (encoded pass)) decoder
- |> Http.send PasswordCheck
- in
- Maybe.map2 makeCmd model.password model.info.upload.publishId
- |> Maybe.withDefault Cmd.none
-
-httpDeleteDownload: Model -> Cmd Msg
-httpDeleteDownload model =
- Data.httpDelete (model.cfg.urls.uploads ++ "/" ++ model.info.upload.id) Http.emptyBody (Decode.field "filesRemoved" Decode.int)
- |> Http.send DeleteDownloadResult
-
-
-httpPublishDownload: Model -> Cmd Msg
-httpPublishDownload model =
- Http.post (model.cfg.urls.uploadPublish ++ "/" ++ model.info.upload.id) Http.emptyBody Data.decodeUploadInfo
- |> Http.send PublishDownloadResult
-
-httpUnpublishDownload: Model -> Cmd Msg
-httpUnpublishDownload model =
- Http.post (model.cfg.urls.uploadUnpublish ++ "/" ++ model.info.upload.id) Http.emptyBody Data.decodeUploadInfo
- |> Http.send PublishDownloadResult
-
-httpGetTemplate: Model -> Cmd Msg
-httpGetTemplate model =
- case model.info.upload.publishId of
- Just id ->
- let
- href = PL.downloadPageHref (Pid id)
- url = model.cfg.urls.baseUrl ++ href
- templateUrl = model.cfg.urls.mailDownloadTemplate
- ++ "?url=" ++ (Http.encodeUri url)
- ++ "&pass="++ (toString model.info.upload.requiresPassword)
- cmd = Http.get templateUrl MailForm.decodeTemplate
- |> Http.send MailForm.TemplateResult
- in
- Cmd.map MailFormMsg cmd
- Nothing ->
- Cmd.none
-
-httpSetName: Model -> String -> Cmd Msg
-httpSetName model name =
- Http.post
- (model.cfg.urls.uploads ++ "/" ++ model.info.upload.id)
- (Http.jsonBody (Data.uploadUpdateEncoder (Data.UploadUpdate name)))
- (Decode.succeed ()) |> Http.send UploadUpdateResult
-
diff --git a/modules/webapp/src/main/elm/Widgets/LoginSearch.elm b/modules/webapp/src/main/elm/Widgets/LoginSearch.elm
deleted file mode 100644
index 801080fb..00000000
--- a/modules/webapp/src/main/elm/Widgets/LoginSearch.elm
+++ /dev/null
@@ -1,138 +0,0 @@
-module Widgets.LoginSearch exposing (..)
-
-import Http
-import Html exposing (Html, div, text, a, i, input)
-import Html.Attributes exposing (class, classList, value, placeholder, type_)
-import Html.Events exposing (onInput, onClick)
-import Json.Decode as Decode exposing(field)
-import Json.Encode as Encode
-import Data exposing (Account, RemoteUrls, accountDecoder)
-import PageLocation as PL
-
-type State
- = Init
- | Searching
- | SearchDone
- | Selected
-
-type alias Model =
- { login: String
- , results: List String
- , active: String
- , state: State
- , errorMsg: String
- , url: String
- }
-
-type Msg
- = SetSearch String
- | SelectLogin String
- | SearchResult (Result Http.Error (List String))
- | GetAccountResult (Result Http.Error Account)
-{- | SetActiveResult (Maybe String) -}
-
-initModel: RemoteUrls -> Model
-initModel urls =
- Model "" [] "" Init "" urls.accounts
-
-errorModel: Model -> String -> Model
-errorModel model msg =
- {model | errorMsg = msg, results = [], active = "", state = Init}
-
-
-{-- Commands --}
-
-
-searchLogins: Model -> Cmd Msg
-searchLogins model =
- let
- url = model.url ++ "?q=" ++ (Http.encodeUri model.login)
- in
- Http.get url decodeLoginList
- |> Http.send SearchResult
-
-decodeLoginList: Decode.Decoder (List String)
-decodeLoginList =
- Decode.list Decode.string
-
-
-fetchAccount: Model -> Cmd Msg
-fetchAccount model =
- Http.get (model.url ++ "/" ++ (Http.encodeUri model.login)) accountDecoder
- |> Http.send GetAccountResult
-
-
-{-- update --}
-
-update: Msg -> Model -> (Model, Cmd Msg, Maybe Account)
-update msg model =
- case msg of
- SetSearch login ->
- let
- new = {model | login = login, state = Searching}
- in
- (new, searchLogins new, Nothing)
-
- SearchResult (Ok logins) ->
- ({model
- | results = logins
- , state = SearchDone
- }
- , Cmd.none, Nothing)
-
- SearchResult (Err error) ->
- ({model
- | state = Init
- , errorMsg = (Data.errorMessage error)
- }
- , PL.timeoutCmd error, Nothing)
-
- SelectLogin login ->
- let
- new = { model
- | login = login
- , results = []
- , errorMsg = ""
- , state = Selected
- }
- in
- (new, fetchAccount new, Nothing)
-
- GetAccountResult (Ok acc) ->
- ({model | errorMsg = ""}, Cmd.none, Just acc)
-
- GetAccountResult (Err error) ->
- (errorModel model (Data.errorMessage error), PL.timeoutCmd error, Nothing)
-
-{-- view --}
-
-
-view: Model -> Html Msg
-view model =
- div [classList [("ui search focus", True)
- ,("loading", model.state == Searching)]
- ]
- [
- div [class "ui icon input fluid"]
- [
- input [class "prompt", type_ "text", placeholder "Logins…", value model.login, onInput SetSearch] []
- , i [class "search icon"] []
- ]
- ,div [classList [("results", True)
- ,("transition visible", Data.nonEmpty model.results)
- ,("transition hidden", List.isEmpty model.results)
- ]
- ]
- (List.map (menuItem model) model.results)
- ]
-
-menuItem: Model -> String -> Html Msg
-menuItem model login =
- a [classList [("result", True), ("active", login == model.active)], onClick (SelectLogin login)]
- [
- div [class "content"]
- [
- div [class "title"]
- [text login]
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Widgets/MailForm.elm b/modules/webapp/src/main/elm/Widgets/MailForm.elm
deleted file mode 100644
index b60f4789..00000000
--- a/modules/webapp/src/main/elm/Widgets/MailForm.elm
+++ /dev/null
@@ -1,189 +0,0 @@
-module Widgets.MailForm exposing (..)
-
-import Http
-import Regex
-import Html exposing (Html, div, text, a, form, input, textarea, h3, label)
-import Html.Attributes exposing (class, classList, type_, rows, placeholder, value, name)
-import Html.Events exposing (onClick, onInput)
-import Json.Decode as Decode
-import Json.Encode as Encode
-
-import Data exposing (RemoteUrls)
-import PageLocation as PL
-
-type alias Model =
- {urls: RemoteUrls
- ,text: String
- ,subject: String
- ,recipients: String
- ,tos: List String
- ,sending: Bool
- ,errorMessage: List String
- ,successMessage: List String
- }
-
-type alias Template =
- {subject: String
- ,text: String
- }
-
-decodeTemplate: Decode.Decoder Template
-decodeTemplate =
- Decode.map2 Template
- (Decode.field "subject" Decode.string)
- (Decode.field "text" Decode.string)
-
-makeModel: RemoteUrls -> Model
-makeModel urls =
- Model urls "" "" "" [] False [] []
-
-isSuccessfulSend: Model -> Bool
-isSuccessfulSend model =
- List.isEmpty model.errorMessage && List.isEmpty model.successMessage |> not
-
-type alias SendResult =
- {message: String
- ,success: List (String)
- ,failed: List (String)
- }
-
-decodeResult: Decode.Decoder SendResult
-decodeResult =
- Decode.map3 SendResult
- (Decode.field "message" Decode.string)
- (Decode.field "success" (Decode.list Decode.string))
- (Decode.field "failed" (Decode.list Decode.string))
-
-encodeMail: Model -> Encode.Value
-encodeMail model =
- Encode.object
- [("to", Encode.list (List.map Encode.string model.tos))
- ,("subject", Encode.string model.subject)
- ,("text", Encode.string model.text)]
-
-
-type Msg
- = TemplateResult (Result Http.Error Template)
- | SetRecipient String
- | SetText String
- | SetSubject String
- | SendMail
- | MailSendResult (Result Http.Error SendResult)
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- TemplateResult (Ok t) ->
- {model
- | subject = t.subject
- , text = t.text
- } ! []
-
- TemplateResult (Err error) ->
- {model | errorMessage = [Data.errorMessage error]} ! [PL.timeoutCmd error]
-
- SetText text ->
- let
- m = {model | text = text}
- in
- {m | errorMessage = validate m, successMessage = []} ! []
-
- SetSubject text ->
- let
- m = {model | subject = text}
- in
- {m | errorMessage = validate m, successMessage = []} ! []
-
- SetRecipient text ->
- let
- m = {model | recipients = text, tos = splitRecipients text}
- in
- {m | errorMessage = validate m, successMessage = []} ! []
-
- MailSendResult (Ok result) ->
- let
- errors = if List.isEmpty result.success then
- result.message :: result.failed
- else
- result.failed
- success = if List.isEmpty result.success then
- []
- else
- result.message :: result.success
- in
- {model | sending = False, errorMessage = errors, successMessage = success} ! []
-
- MailSendResult (Err error) ->
- {model | sending = False, errorMessage = ["Error sending mails: " ++ (Data.errorMessage error)]} ! [PL.timeoutCmd error]
-
- SendMail ->
- let
- errors = validate model
- in
- if List.isEmpty errors then
- {model | sending = True} ! [httpSendMail model]
- else
- {model | errorMessage = errors} ! []
-
-view: Model -> Html Msg
-view model =
- div []
- [
- div [classList [("ui inverted dimmer", True)
- ,("active", model.sending)
- ]]
- [
- div [class "ui text loader"][text "Sending ..."]
- ]
- ,form [classList [("ui form", True)
- ,("error", Data.nonEmpty model.errorMessage)
- ,("success", Data.nonEmpty model.successMessage)
- ]]
- [
- div [class "ui success message"]
- [Data.messagesToHtml model.successMessage]
- ,div [class "ui error message"]
- [Data.messagesToHtml model.errorMessage]
- ,if Data.nonEmpty model.successMessage then
- div[][]
- else
- div[]
- [
- div [class "ten wide field"]
- [
- label [][text "Recipients (separated by comma)"]
- ,input [name "recipients", type_ "text", value model.recipients, onInput SetRecipient][]
- ]
- ,div [class "ten wide field"]
- [
- label [][text "Subject"]
- ,input [name "subject", type_ "text", value model.subject, onInput SetSubject][]
- ]
- ,div [class "ten wide field"]
- [
- label [][text "Text"]
- ,textarea [name "text", rows 8, value model.text, onInput SetText][]
- ]
- ,a [class "ui primary button", onClick SendMail][text "Send"]
- ]
- ]
- ]
-
-splitRecipients: String -> List String
-splitRecipients line =
- String.split "," line
- |> List.map String.trim
-
-
-validate: Model -> List String
-validate model =
- List.filter (String.isEmpty >> not)
- [if List.isEmpty model.tos then "No recipients set" else ""
- ,if String.isEmpty model.subject then "No subject given" else ""
- ,if String.isEmpty model.text then "No mail text" else ""]
-
-
-httpSendMail: Model -> Cmd Msg
-httpSendMail model =
- Http.post model.urls.mailSend (Http.jsonBody (encodeMail model)) decodeResult
- |> Http.send MailSendResult
diff --git a/modules/webapp/src/main/elm/Widgets/MarkdownEditor.elm b/modules/webapp/src/main/elm/Widgets/MarkdownEditor.elm
deleted file mode 100644
index 4d0427e2..00000000
--- a/modules/webapp/src/main/elm/Widgets/MarkdownEditor.elm
+++ /dev/null
@@ -1,40 +0,0 @@
-module Widgets.MarkdownEditor exposing (..)
-
-import Html exposing (Html, div, textarea)
-import Html.Attributes exposing (class, value, style)
-import Html.Events exposing (onInput)
-
-import Data
-
-type alias Model =
- {text: String
- }
-
-emptyModel: Model
-emptyModel = Model ""
-
-initModel: String -> Model
-initModel str =
- Model str
-
-type Msg
- = SetText String
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- SetText str ->
- {model | text = str} ! []
-
-
-view: Model -> Html Msg
-view model =
- div [class "ui stackable two column grid"]
- [
- div [class "column"]
- [textarea [onInput SetText, class "sharry-md-edit", value model.text][]
- ]
- ,div [class "column"]
- [Data.markdownHtml model.text
- ]
- ]
diff --git a/modules/webapp/src/main/elm/Widgets/MarkdownHelp.elm b/modules/webapp/src/main/elm/Widgets/MarkdownHelp.elm
deleted file mode 100644
index cae6b8e2..00000000
--- a/modules/webapp/src/main/elm/Widgets/MarkdownHelp.elm
+++ /dev/null
@@ -1,333 +0,0 @@
-module Widgets.MarkdownHelp exposing (..)
-
-import Html exposing (Html)
-import Markdown
-
--- based on https://gist.github.com/jonschlinkert/5854601
-
-helpText: String
-helpText = """# Typography
-
-## Headings
-
-Headings from 1 to 6 are constructed with a `#` for each level:
-
-```markdown
-# heading 1
-## heading 2
-### heading 3
-#### heading 4
-##### heading 5
-###### heading 6
-```
-
-renders to
-
-# heading 1
-## heading 2
-### heading 3
-#### heading 4
-##### heading 5
-###### heading 6
-
-
-## Horizontal Rules
-
-A "thematic break" between paragraph-level elements can be created
-with any of the following:
-
-* `___`: three consecutive underscores
-* `---`: three consecutive dashes
-* `***`: three consecutive asterisks
-
-renders to:
-
-___
-
----
-
-***
-
-
-## Emphasis
-
-### Bold
-
-For emphasizing a snippet of text with a heavier font-weight.
-
-The following snippet of text is **rendered as bold text**.
-
-``` markdown
-**rendered as bold text**
-```
-renders to:
-
-**rendered as bold text**
-
-
-### Italics
-For emphasizing a snippet of text with italics.
-
-The following snippet of text is _rendered as italicized text_.
-
-``` markdown
-_rendered as italicized text_
-```
-
-renders to:
-
-_rendered as italicized text_
-
-### strikethrough
-In GFM you can do strickthroughs.
-
-``` markdown
-~~Strike through this text.~~
-```
-Which renders to:
-
-~~Strike through this text.~~
-
-
-## Blockquotes
-
-For quoting blocks of content from another source within your
-document. Add `>` before any text you want to quote.
-
-```markdown
-> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.
-```
-
-Renders to:
-
-> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.
-
-
-Blockquotes can also be nested:
-
-``` markdown
-> Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue.
-Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi.
->> Sed adipiscing elit vitae augue consectetur a gravida nunc vehicula. Donec auctor
-odio non est accumsan facilisis. Aliquam id turpis in dolor tincidunt mollis ac eu diam.
->>> Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue.
-Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi.
-```
-
-Renders to:
-
-> Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue.
-Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi.
->> Sed adipiscing elit vitae augue consectetur a gravida nunc vehicula. Donec auctor
-odio non est accumsan facilisis. Aliquam id turpis in dolor tincidunt mollis ac eu diam.
->>> Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue.
-Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi.
-
-
-## Lists
-
-### Unordered
-
-A list of items in which the order of the items does not explicitly
-matter.
-
-You may use any of the following symbols to denote bullets for each
-list item:
-
-```markdown
-* valid bullet
-- valid bullet
-+ valid bullet
-```
-
-For example
-
-``` markdown
-+ Lorem ipsum dolor sit amet
-+ Consectetur adipiscing elit
-+ Integer molestie lorem at massa
-+ Facilisis in pretium nisl aliquet
-+ Nulla volutpat aliquam velit
- - Phasellus iaculis neque
- - Purus sodales ultricies
- - Vestibulum laoreet porttitor sem
- - Ac tristique libero volutpat at
-+ Faucibus porta lacus fringilla vel
-+ Aenean sit amet erat nunc
-+ Eget porttitor lorem
-```
-Renders to:
-
-+ Lorem ipsum dolor sit amet
-+ Consectetur adipiscing elit
-+ Integer molestie lorem at massa
-+ Facilisis in pretium nisl aliquet
-+ Nulla volutpat aliquam velit
- - Phasellus iaculis neque
- - Purus sodales ultricies
- - Vestibulum laoreet porttitor sem
- - Ac tristique libero volutpat at
-+ Faucibus porta lacus fringilla vel
-+ Aenean sit amet erat nunc
-+ Eget porttitor lorem
-
-
-### Ordered
-
-A list of items in which the order of items does explicitly matter.
-
-``` markdown
-1. Lorem ipsum dolor sit amet
-2. Consectetur adipiscing elit
-3. Integer molestie lorem at massa
-4. Facilisis in pretium nisl aliquet
-5. Nulla volutpat aliquam velit
-6. Faucibus porta lacus fringilla vel
-7. Aenean sit amet erat nunc
-8. Eget porttitor lorem
-```
-Renders to:
-
-1. Lorem ipsum dolor sit amet
-2. Consectetur adipiscing elit
-3. Integer molestie lorem at massa
-4. Facilisis in pretium nisl aliquet
-5. Nulla volutpat aliquam velit
-6. Faucibus porta lacus fringilla vel
-7. Aenean sit amet erat nunc
-8. Eget porttitor lorem
-
-
-**TIP**: If you just use `1.` for each number, it will automatically
- number each item. For example:
-
-``` markdown
-1. Lorem ipsum dolor sit amet
-1. Consectetur adipiscing elit
-1. Integer molestie lorem at massa
-1. Facilisis in pretium nisl aliquet
-1. Nulla volutpat aliquam velit
-1. Faucibus porta lacus fringilla vel
-1. Aenean sit amet erat nunc
-1. Eget porttitor lorem
-```
-
-Renders to:
-
-1. Lorem ipsum dolor sit amet
-2. Consectetur adipiscing elit
-3. Integer molestie lorem at massa
-4. Facilisis in pretium nisl aliquet
-5. Nulla volutpat aliquam velit
-6. Faucibus porta lacus fringilla vel
-7. Aenean sit amet erat nunc
-8. Eget porttitor lorem
-
-
-## Code
-
-### Inline code
-
-Wrap inline snippets of code with `` ` ``.
-
-``` html
-For example, `` should be wrapped as "inline".
-```
-
-renders to
-
-For example, `` should be wrapped as "inline".
-
-
-### Indented code
-
-Or indent several lines of code by at least four spaces, as in:
-
-``` js
- // Some comments
- line 1 of code
- line 2 of code
- line 3 of code
-```
-
- // Some comments
- line 1 of code
- line 2 of code
- line 3 of code
-
-
-### Block code "fences"
-
-Use "fences" ```` ``` ```` to block in multiple lines of code.
-
-
-```
-Sample text here...
-```
-
-
-
-```
-Sample text here...
-```
-
-
-## Links
-
-### Basic link
-
-``` markdown
-[Sharry](https://github.com/eikek/sharry)
-```
-
-Renders to (hover over the link, there is no tooltip):
-
-[Sharry](https://github.com/eikek/sharry)
-
-
-### Add a title
-
-``` markdown
-[Sharry](https://github.com/eikek/sharry/ "Visit Sharry!")
-```
-
-Renders to (hover over the link, there should be a tooltip):
-
-[Sharry](https://github.com/eikek/sharry/ "Visit Sharry!")
-
-
-## Images
-
-Images have a similar syntax to links but include a preceding
-exclamation point.
-
-``` markdown
-![Minion](http://octodex.github.com/images/minion.png)
-```
-![Minion](http://octodex.github.com/images/minion.png)
-
-or
-
-``` markdown
-![Alt text](http://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
-```
-![Alt text](http://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
-
-Like links, Images also have a footnote style syntax
-
-``` markdown
-![Alt text][id]
-```
-![Alt text][id]
-
-With a reference later in the document defining the URL location:
-
-[id]: http://octodex.github.com/images/dojocat.jpg "The Dojocat"
-
-
- [id]: http://octodex.github.com/images/dojocat.jpg "The Dojocat"
-"""
-
-helpTextHtml: Html msg
-helpTextHtml =
- Markdown.toHtml [] helpText
diff --git a/modules/webapp/src/main/elm/Widgets/UpdateEmailForm.elm b/modules/webapp/src/main/elm/Widgets/UpdateEmailForm.elm
deleted file mode 100644
index 72f0d27a..00000000
--- a/modules/webapp/src/main/elm/Widgets/UpdateEmailForm.elm
+++ /dev/null
@@ -1,96 +0,0 @@
-module Widgets.UpdateEmailForm exposing (..)
-
-import Html exposing (Html, div, i, h4, text, input, button)
-import Html.Attributes exposing (class, classList, type_, placeholder, value)
-import Html.Events exposing (onInput, onClick)
-import Http
-
-import Data exposing (Account, RemoteUrls)
-import PageLocation as PL
-
-type alias Model =
- {account: Account
- ,urls: RemoteUrls
- ,email: Maybe String
- ,infoMessage: Maybe String
- ,errorMessage: Maybe String
- }
-
-makeModel: Account -> RemoteUrls -> Model
-makeModel acc urls =
- Model acc urls acc.email Nothing Nothing
-
-hasInfo: Model -> Bool
-hasInfo model =
- Data.isPresent model.infoMessage
-
-hasError: Model -> Bool
-hasError model =
- Data.isPresent model.errorMessage
-
-type Msg
- = SetEmail String
- | UpdateEmail
- | UpdateEmailResult (Result Http.Error Account)
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- SetEmail em ->
- {model | email = Data.nonEmptyStr em, infoMessage = Nothing, errorMessage = Nothing} ! []
-
- UpdateEmailResult (Ok acc) ->
- {model | account = acc, email = acc.email, infoMessage = Just "Email was updated."} ! []
-
- UpdateEmailResult (Err error) ->
- {model | errorMessage = Data.errorMessage error |> Just} ! [PL.timeoutCmd error]
-
- UpdateEmail ->
- let
- change acc = {acc | email = model.email}
- m = {model | account = change model.account}
- in
- m ! [httpUpdateEmail m]
-
-
-view: Model -> Html Msg
-view model =
- let
- address = Maybe.withDefault "" model.email
- in
- div []
- [
- h4 [class "ui dividing header"][text "Change Email"]
- ,div [class "ui large right action left icon input"]
- [
- i [class "at icon"] []
- ,input [onInput SetEmail, type_ "text", placeholder "Email", value address] []
- ,button [class "ui floating primary submit button", onClick UpdateEmail] [ text "Submit" ]
- ]
- ,div [classList [("hidden", not (hasInfo model))
- ,("ui icon success message", True)]]
- [
- i [class "smile icon"][]
- ,div [class "content"]
- [model.infoMessage |> Maybe.withDefault "" |> text]
- ]
- ,div [classList [("hidden", not (hasError model))
- ,("ui icon error message", True)]]
- [
- i [class "frown icon"][]
- ,div [class "content"]
- [model.errorMessage |> Maybe.withDefault "" |> text]
- ]
- ,div [classList [("hidden", hasInfo model || hasError model)
- ,("ui icon info message", True)]]
- [
- i [class "info icon"][]
- ,div [class "content"]
- [text "Submitting an empty email field will delete it from your profile."]
- ]
- ]
-
-httpUpdateEmail: Model -> Cmd Msg
-httpUpdateEmail model =
- Http.post model.urls.profileEmail (Http.jsonBody (Data.accountEncoder model.account)) Data.accountDecoder
- |> Http.send UpdateEmailResult
diff --git a/modules/webapp/src/main/elm/Widgets/UpdatePasswordForm.elm b/modules/webapp/src/main/elm/Widgets/UpdatePasswordForm.elm
deleted file mode 100644
index 3fb492ef..00000000
--- a/modules/webapp/src/main/elm/Widgets/UpdatePasswordForm.elm
+++ /dev/null
@@ -1,130 +0,0 @@
-module Widgets.UpdatePasswordForm exposing (..)
-
-import Html exposing (Html, div, i, h4, text, input, button, form)
-import Html.Attributes exposing (class, classList, type_, placeholder, value)
-import Html.Events exposing (onInput, onSubmit)
-import Http
-
-import Data exposing (Account, RemoteUrls)
-import PageLocation as PL
-
-type alias Model =
- {account: Account
- ,urls: RemoteUrls
- ,password: Maybe String
- ,passwordConfirm: Maybe String
- ,infoMessage: Maybe String
- ,errorMessage: Maybe String
- }
-
-makeModel: Account -> RemoteUrls -> Model
-makeModel acc urls =
- Model acc urls Nothing Nothing Nothing Nothing
-
-hasInfo: Model -> Bool
-hasInfo model =
- Data.isPresent model.infoMessage
-
-hasError: Model -> Bool
-hasError model =
- Data.isPresent model.errorMessage
-
-type Msg
- = SetPassword String
- | SetPasswordConfirm String
- | UpdatePassword
- | UpdatePasswordResult (Result Http.Error Account)
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- SetPassword pw ->
- {model | password = Data.nonEmptyStr pw, infoMessage = Nothing, errorMessage = Nothing} ! []
-
- SetPasswordConfirm pw ->
- {model | passwordConfirm = Data.nonEmptyStr pw, infoMessage = Nothing, errorMessage = Nothing} ! []
-
- UpdatePasswordResult (Ok acc) ->
- {model | account = acc, infoMessage = Just "Password was updated."} ! []
-
- UpdatePasswordResult (Err error) ->
- {model | errorMessage = Data.errorMessage error |> Just} ! [PL.timeoutCmd error]
-
- UpdatePassword ->
- let
- same = model.password == model.passwordConfirm
- change acc = {acc | password = model.password}
- m = {model | account = change model.account}
- in
- if model.account.extern then
- {model | errorMessage = Just "Password cannot be changed for external accounts"} ! []
- else if same then
- m ! [httpUpdatePassword m]
- else
- {model | errorMessage = Just "Passwords are not equal."} ! []
-
-view: Model -> Html Msg
-view model =
- div []
- [
- h4 [class "ui dividing header"][text "Change Password"]
- ,form [classList [("ui form", True)
- ,("error", hasError model)
- ,("success", hasInfo model)]
- ,onSubmit UpdatePassword
- ]
- [
- div [classList [("ui inverted dimmer", True)
- ,("active", model.account.extern)]]
- [
- div [class "content"]
- [
- div [class "center"]
- [
- h4 [class "ui icon header"]
- [
- i [class "announcement icon"][]
- ,text "Passwords cannot be changed for external accounts."
- ]
- ]
- ]
- ]
- ,div [class "eight wide field"]
- [
- div [class "ui large left icon input"]
- [
- i [class "lock icon"] []
- ,input [onInput SetPassword, type_ "password", placeholder "Password"] []
- ]
- ]
- ,div [class "eight wide field"]
- [
- div [class "ui large left icon input"]
- [
- i [class "lock icon"][]
- ,input [onInput SetPasswordConfirm, type_ "password", placeholder "Confirm"][]
- ]
- ]
- ,button [class "ui primary submit button", type_ "sumit"]
- [text "Submit"]
- ,div [classList [("hidden", False)
- ,("ui icon success message", True)]]
- [
- i [class "smile icon"][]
- ,div [class "content"]
- [model.infoMessage |> Maybe.withDefault "" |> text]
- ]
- ,div [classList [("hidden", False)
- ,("ui icon error message", True)]]
- [
- i [class "frown icon"][]
- ,div [class "content"]
- [model.errorMessage |> Maybe.withDefault "" |> text]
- ]
- ]
- ]
-
-httpUpdatePassword: Model -> Cmd Msg
-httpUpdatePassword model =
- Http.post model.urls.profilePassword (Http.jsonBody (Data.accountEncoder model.account)) Data.accountDecoder
- |> Http.send UpdatePasswordResult
diff --git a/modules/webapp/src/main/elm/Widgets/UploadForm.elm b/modules/webapp/src/main/elm/Widgets/UploadForm.elm
deleted file mode 100644
index 0b370a54..00000000
--- a/modules/webapp/src/main/elm/Widgets/UploadForm.elm
+++ /dev/null
@@ -1,295 +0,0 @@
-module Widgets.UploadForm exposing (..)
-
-import Html exposing (Html, button, form, h1, h3, div, label, text, textarea, select, option, i, input, a, p)
-import Html.Attributes exposing (class, name, type_, href, classList, rows, placeholder, value, selected)
-import Html.Events exposing (onInput, onClick)
-
-import Ports
-import Resumable
-import Resumable.Update as ResumableUpdate
-import Data exposing (RemoteConfig, defer, bytesReadable)
-import Widgets.MarkdownHelp as MarkdownHelp
-
-type alias Limits =
- { maxFileSize: Int
- , maxFiles: Int
- , maxValidity: String
- }
-
-type alias Model =
- { errorMessage: Maybe String
- , showMarkdownHelp: Bool
- , description: String
- , validityNum: Int
- , validityNumStr: String
- , validityUnit: String
- , maxDownloads: Int
- , maxDownloadsStr: String
- , password: String
- , showPassword: Bool
- , limits: Limits
- , resumableModel: Resumable.Model
- }
-
-emptyModel: RemoteConfig -> Model
-emptyModel cfg =
- {errorMessage = Nothing
- ,showMarkdownHelp = False
- ,description = ""
- ,validityNum = 5
- ,validityNumStr = "5"
- ,validityUnit = "d"
- ,maxDownloads = 30
- ,maxDownloadsStr = "30"
- ,password = ""
- ,showPassword = False
- ,limits = Limits cfg.maxFileSize cfg.maxFiles cfg.maxValidity
- ,resumableModel = Resumable.emptyModel
- }
-
-clearModel: Model -> Model
-clearModel model =
- {errorMessage = Nothing
- ,showMarkdownHelp = False
- ,description = ""
- ,validityNum = 5
- ,validityNumStr = "5"
- ,validityUnit = "d"
- ,maxDownloads = 30
- ,maxDownloadsStr = "30"
- ,password = ""
- ,showPassword = False
- ,limits = model.limits
- ,resumableModel = Resumable.clearModel model.resumableModel
- }
-
-hasError: Model -> Bool
-hasError model =
- Data.isPresent model.errorMessage || Data.nonEmpty model.resumableModel.errorFiles
-
-hasFiles: Model -> Bool
-hasFiles model =
- (List.length model.resumableModel.files) > 0
-
-isReady: Model -> Bool
-isReady model =
- (not <| Data.isPresent model.errorMessage) &&
- ((hasFiles model) || (not <| String.isEmpty model.description))
-
-errorMessage: Model -> List String
-errorMessage model =
- let
- resumableErrors = Resumable.makeErrorList model.resumableModel
- in
- model.errorMessage
- |> Maybe.map List.singleton
- |> Maybe.map ((++) resumableErrors)
- |> Maybe.withDefault resumableErrors
-
-
-
-type Msg
- = SetValidityNum String
- | SetValidityUnit String
- | SetMaxDownloads String
- | SetDescription String
- | SetPassword String
- | GeneratePassword
- | RandomPassword String
- | TogglePasswordVisible
- | ResumableMsg Resumable.Msg
- | ToggleMarkdownHelp
-
-updateNumber: String -> Model -> (Int -> Model -> Model) -> Model
-updateNumber str model apply =
- case (String.toInt str) of
- Ok n ->
- if n > 0 then
- let
- model_ = apply n model
- in
- {model_| errorMessage = Nothing}
- else
- {model | errorMessage = Just "It must be a positive number!"}
- Err msg ->
- if str == "" then
- {model | errorMessage = Just "A number is requred"}
- else
- {model | errorMessage = Just ("Error converting number: "++msg)}
-
-
-update: Msg -> Model -> (Model, Cmd Msg, Cmd Msg)
-update msg model =
- case msg of
- SetValidityNum str ->
- let
- model_ = {model | validityNumStr = str}
- apply n m = {m | validityNum = n}
- in
- updateNumber str model_ apply ! [] |> defer Cmd.none
-
- SetValidityUnit unit ->
- ({model | validityUnit = unit, errorMessage = Nothing}, Cmd.none) |> defer Cmd.none
-
- SetMaxDownloads str ->
- let
- model_ = {model | maxDownloadsStr = str}
- apply n m = {m | maxDownloads = n}
- in
- updateNumber str model_ apply ! [] |> defer Cmd.none
-
- SetDescription desc ->
- ({model | description = desc, errorMessage = Nothing}, Cmd.none) |> defer Cmd.none
-
- SetPassword pw ->
- ({model | password = pw, errorMessage = Nothing}, Cmd.none) |> defer Cmd.none
-
- GeneratePassword ->
- (model, Ports.makeRandomString "") |> defer Cmd.none
-
- RandomPassword s ->
- {model | password = s} ! [] |> defer Cmd.none
-
- TogglePasswordVisible ->
- {model | showPassword = not model.showPassword, errorMessage = Nothing} ! [] |> defer Cmd.none
-
- ResumableMsg msg ->
- let
- (rmodel, cmd) = ResumableUpdate.update msg model.resumableModel
- in
- {model | resumableModel = rmodel} ! [] |> defer (Cmd.map ResumableMsg cmd)
-
- ToggleMarkdownHelp ->
- {model | showMarkdownHelp = Debug.log "have it " not model.showMarkdownHelp} ! [] |> defer Cmd.none
-
-
-view: Model -> Html Msg
-view model =
- if model.showMarkdownHelp then markdownHelp model
- else
- form [classList [("ui form", True)
- ,("error", hasError model)
- ]
- ]
- [
- infoView model.limits
- ,div [class "ui error message"]
- [errorMessage model |> Data.messagesToHtml]
- ,div [class "field"]
- [
- label [][text "Description (supports "
- ,a[onClick ToggleMarkdownHelp, class "ui link"][text "Markdown"]
- ,text ")"
- ]
- , textarea [name "description"
- , rows 5
- , onInput SetDescription
- , placeholder "Optional description"
- , value model.description
- ][]
- ]
- ,div [class "two fields"]
- [
- div [class "field"]
- [
- label [][text "Validity"]
- ,input [class "ui input"
- ,onInput SetValidityNum
- ,type_ "text"
- ,placeholder "Number"
- ,value model.validityNumStr][]
- ]
- ,div [class "field"]
- [
- label [][text "Unit"]
- ,select [onInput SetValidityUnit]
- (List.map
- (\n -> case n of
- (val, unit) -> option [value val, selected <| model.validityUnit == val][text unit])
- [("h", "Hours"), ("d", "Days")])
- ]
- ]
- ,div [class "field"]
- [
- label [][text "Max. Downloads"]
- ,input [ class "ui input"
- , type_ "text"
- , name "maxdownloads"
- , onInput SetMaxDownloads
- , placeholder "Maximum number of downloads"
- , value model.maxDownloadsStr][]
- ]
- ,div [class "field"]
- [
- label [][text "Password"]
- ,div [class "two fields"]
- [
- div [class "field"]
- [
- input [ class "ui input"
- , type_ (if model.showPassword then "text" else "password")
- , onInput SetPassword
- , placeholder "Optional password"
- , value model.password][]
- ]
- ,div [class "field"]
- [
- a [class "ui button"
- , onClick TogglePasswordVisible
- ]
- [text (if model.showPassword then "Hide" else "Show")]
- ,a [class "ui button"
- , onClick GeneratePassword
- ]
- [text "Generate"]
- ]
- ]
- ]
- ,div[]
- [
- a [class ("ui button " ++ Resumable.browseCssClass)][text "Add files"]
- ]
- ,div [class ("ui center aligned container " ++ Resumable.dropCssClass)]
- [
- p []
- [
- text "Drop files here or use the “Add files” button to select files to upload."
- ]
- ,makeFilesView model.resumableModel.files
- ]
- ]
-
-
-makeFilesView: List Resumable.File -> Html Msg
-makeFilesView files =
- let
- size = List.sum (List.map (\m -> m.size) files)
- bytes = bytesReadable Data.B (toFloat size)
- message = "Selected " ++ (toString (List.length files)) ++ " files, " ++ bytes
- in
- h3 [class "header"][text message]
-
-infoView: Limits -> Html Msg
-infoView cfg =
- p []
- [text ("You can select up to " ++
- (toString cfg.maxFiles) ++
- " files with a total of " ++
- (bytesReadable Data.B (toFloat cfg.maxFileSize)) ++
- ". The maximum validity is " ++
- (Data.formatDuration cfg.maxValidity) ++
- ".")
- ,text " The »Upload« button is enabled when a description is present and/or files are selected."
- ]
-
-markdownHelp: Model -> Html Msg
-markdownHelp model =
- div [onClick ToggleMarkdownHelp]
- [h3 [class "ui horizontal clearing divider header"]
- [i [class "help icon"][]
- ,text "Markdown Help"
- ]
- ,div [class "ui center aligned segment"]
- [text "Click somewhere on the help text to close it."]
- ,MarkdownHelp.helpTextHtml
- ]
diff --git a/modules/webapp/src/main/elm/Widgets/UploadList.elm b/modules/webapp/src/main/elm/Widgets/UploadList.elm
deleted file mode 100644
index 67461732..00000000
--- a/modules/webapp/src/main/elm/Widgets/UploadList.elm
+++ /dev/null
@@ -1,161 +0,0 @@
-module Widgets.UploadList exposing (..)
-
-import Http
-import Html exposing (Html, div, table, th, tr, thead, td, tbody, a, i, text, select, option, input, button)
-import Html.Attributes exposing (class, href, colspan, value, type_)
-import Html.Events exposing (onClick, onInput)
-import Json.Decode as Decode
-
-import Data exposing (Upload, RemoteUrls, UploadId(..))
-import PageLocation as PL
-
-type alias Model =
- {uploads: List Upload
- ,urls: RemoteUrls
- ,filter: String
- }
-
-makeModel: RemoteUrls -> List Upload -> Model
-makeModel urls uploads =
- Model uploads urls "all"
-
-emptyModel: RemoteUrls -> Model
-emptyModel urls =
- Model [] urls "all"
-
-hasAlias: Upload -> Bool
-hasAlias upload =
- Data.isPresent upload.alia
-
-type Msg
- = DeleteUpload String
- | DeleteUploadResult (Result Http.Error Int)
- | UploadData (Result Http.Error (List Upload))
- | SetFilter String
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- DeleteUpload id ->
- model ! [httpDeleteUpload model id]
-
- DeleteUploadResult (Ok n) ->
- model ! [httpGetUploads model]
-
- DeleteUploadResult (Err error) ->
- let
- x = Debug.log "Error deleting upload" (Data.errorMessage error)
- in
- model ! []
-
- UploadData (Ok list) ->
- {model | uploads = list} ! []
-
- UploadData (Err error) ->
- let
- x = Debug.log "Error getting upload list" (Data.errorMessage error)
- in
- model ! []
-
- SetFilter f ->
- {model | filter = f} ! []
-
-
-view: Model -> Html Msg
-view model =
- table [class "ui selectable celled table"]
- [
- thead []
- [
- tr []
- [th [colspan 7]
- [
- div [class "ui right aligned container"]
- [
- select [class "ui dropdown", onInput SetFilter]
- [
- option [value "all"][text "All"]
- ,option [value "incoming"][text "Incoming"]
- ,option [value "outgoing"][text "Outgoing"]
- ]
- ]
- ]
- ]
- ,tr []
- [th[][text "Upload"]
- ,th[][text "Created"]
- ,th[][text "Password"]
- ,th[][text "Published"]
- ,th[][text "Valid"]
- ,th[][text "Alias"]
- ,th[][text ""]
- ]
- ]
- ,tbody[]
- (model.uploads
- |> List.filter (makeFilter model)
- |> List.map (createRow model))
- ]
-
-makeFilter: Model -> Upload -> Bool
-makeFilter model upload =
- let
- present = hasAlias upload
- in
- case model.filter of
- "incoming" -> present
- "outgoing" -> not present
- _ -> True
-
-
-createRow: Model -> Upload -> Html Msg
-createRow model upload =
- let
- no = "brown minus square outline icon"
- yes = "brown checkmark box icon"
- in
- tr[]
- [td []
- [a [href (PL.downloadPageHref (Uid upload.id))]
- [Maybe.withDefault upload.id upload.name |> text]
- ]
- ,td [class "center aligned collapsing"][Data.formatDate upload.created |> text]
- ,td [class "center aligned collapsing"]
- [
- i [class (if upload.requiresPassword then yes else no)][]
- ]
- ,td [class "center aligned collapsing"]
- [
- case upload.publishId of
- Just _ -> i [class yes][]
- Nothing -> i [class no][]
- ]
- ,td [class "center aligned collapsing"]
- [
- i [class (if Data.isValidUpload upload then yes else no)][]
- ]
- ,td []
- [upload.aliasName |> Maybe.withDefault "" |> text]
- ,td [class "center aligned collapsing"]
- [
- a [class "mini ui basic button", onClick (DeleteUpload upload.id)]
- [
- i [class "remove icon"][]
- ,text "Delete"
- ]
- ]
- ]
-
-httpDeleteUpload: Model -> String -> Cmd Msg
-httpDeleteUpload model id =
- Data.httpDelete (model.urls.uploads ++ "/" ++ id) Http.emptyBody (Decode.field "filesRemoved" Decode.int)
- |> Http.send DeleteUploadResult
-
-httpGetUploads: Model -> Cmd Msg
-httpGetUploads model =
- Http.get model.urls.uploads (Decode.list Data.decodeUpload)
- |> Http.send UploadData
-
-httpSetName: Model -> String -> String -> Cmd Msg
-httpSetName model id name =
- Cmd.none
diff --git a/modules/webapp/src/main/elm/Widgets/UploadProgress.elm b/modules/webapp/src/main/elm/Widgets/UploadProgress.elm
deleted file mode 100644
index 7f96e933..00000000
--- a/modules/webapp/src/main/elm/Widgets/UploadProgress.elm
+++ /dev/null
@@ -1,122 +0,0 @@
-module Widgets.UploadProgress exposing (..)
-
-import Html exposing (Html, div, text, i, a)
-import Html.Attributes exposing (class, classList)
-import Html.Events exposing (onClick)
-
-import Data
-import Ports
-import Resumable
-import Resumable.Update as ResumableUpdate
-
-type alias Model =
- { resumableModel: Resumable.Model
- }
-
-progressClass: String
-progressClass = "sharry-upload-progress"
-
-emptyModel: Model
-emptyModel =
- Model Resumable.emptyModel
-
-isComplete: Model -> Bool
-isComplete model =
- model.resumableModel.state == Resumable.Completed
-
-hasErrors: Model -> Bool
-hasErrors model =
- Resumable.hasErrors model.resumableModel
-
-type Msg
- = ResumableMsg Resumable.Msg
- | PauseUpload
- | StartUpload
- | RetryUpload
-
-update: Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let
- handle = Maybe.withDefault "" model.resumableModel.handle
- in
- case msg of
- ResumableMsg msg ->
- let
- (um, ucmd) = ResumableUpdate.update msg model.resumableModel
- progressCmd = \p -> Ports.setProgress ("."++progressClass, p, hasErrors model)
- in
- case msg of
- Resumable.Initialize cfg ->
- if isComplete model then
- model ! [progressCmd 1.0]
- else
- model ! []
-
- Resumable.Progress percent ->
- {model | resumableModel = um} ! [Cmd.map ResumableMsg ucmd, progressCmd percent]
- _ ->
- {model | resumableModel = um} ! [Cmd.map ResumableMsg ucmd]
-
- PauseUpload ->
- (model, Ports.resumablePause handle)
-
- StartUpload ->
- (model, Ports.resumableStart handle)
-
- RetryUpload ->
- let
- rm = model.resumableModel
- selectIdent = Tuple.first >> (\f -> f.uniqueIdentifier)
- in
- {model | resumableModel = {rm | errorFiles = []}} ! [Ports.resumableRetry (handle, List.map selectIdent rm.errorFiles)]
-
-toggleButton: Model -> Html Msg
-toggleButton model =
- case model.resumableModel.state of
- Resumable.Uploading ->
- a [class "ui labeled basic icon button", onClick PauseUpload]
- [
- i [class "pause icon"][]
- ,text "Pause"
- ]
- Resumable.Paused ->
- a [class "ui labeled basic icon button", onClick StartUpload]
- [
- i [class "play icon"][]
- ,text "Start"
- ]
- _ -> div[][]
-
-retryButton: Model -> Html Msg
-retryButton model =
- if (isComplete model && hasErrors model) then
- a [class "ui labeled basic icon button", onClick RetryUpload]
- [
- i [class "retweet icon"][]
- ,text "Retry"
- ]
- else
- div[][]
-
-view: Model -> Html Msg
-view model =
- let
- message = if isComplete model then
- if hasErrors model then "There were errors uploading some of your files." else "Done."
- else
- "Uploading Files"
- in
- div []
- [
- div [classList [("ui indicating progress " ++ progressClass, True)
- ,("error", hasErrors model)]]
- [
- div [class "bar"]
- [
- div [class "progress"][text "{percent}"]
- ]
- ,div [class "label"][text message]
- ]
- ,(toggleButton model)
- ,(retryButton model)
- ]
diff --git a/modules/webapp/src/main/html/index.html b/modules/webapp/src/main/html/index.html
deleted file mode 100644
index d1481aba..00000000
--- a/modules/webapp/src/main/html/index.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
- Sharry
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/modules/webapp/src/main/html/placeholder.png b/modules/webapp/src/main/html/placeholder.png
deleted file mode 100644
index cc29bd82..00000000
Binary files a/modules/webapp/src/main/html/placeholder.png and /dev/null differ
diff --git a/modules/webapp/src/main/js/sharry.js b/modules/webapp/src/main/js/sharry.js
deleted file mode 100644
index c9f8a3bc..00000000
--- a/modules/webapp/src/main/js/sharry.js
+++ /dev/null
@@ -1,234 +0,0 @@
-elmApp.ports.setAccount.subscribe(function(state) {
- localStorage.setItem('account', JSON.stringify(state));
-});
-
-elmApp.ports.removeAccount.subscribe(function() {
- localStorage.removeItem('account');
-});
-
-elmApp.ports.reloadPage.subscribe(function() {
- location.reload();
-});
-
-// semantic interop
-
-elmApp.ports.setProgress.subscribe(function(selectorPercentError) {
- var selector = selectorPercentError[0];
- var percent = selectorPercentError[1];
- var error = selectorPercentError[2];
- percent = Math.round(percent * 100);
- $(selector).progress('set percent', percent);
- if (error) {
- $(selector).progress( "set error");
- }
-});
-
-elmApp.ports.initAccordionAndTabs.subscribe(function() {
- //https://github.com/Semantic-Org/Semantic-UI/issues/5421
- $('.ui.accordion').accordion({animateChildren: false});
- $('.tabular.menu .item').tab({history: false});
-});
-
-elmApp.ports.initEmbeds.subscribe(function() {
- $('.ui.embed').embed({
- onDisplay: function () {
- // For images an img tag is rendered without a width
- // attribue resulting in displaying the image in original
- // size. This adds a widh attribute, so it is resized to
- // see the full image
- $(this).find("iframe").bind("load", function() {
- $(this).contents().find("img").attr("width", "100%");
- });
- }
- });
-});
-
-
-// very nice, found here: https://gist.github.com/gordonbrander/2230317
-var genId = function (prefix) {
- // Math.random should be unique because of its seeding algorithm.
- // Convert it to base 36 (numbers + letters), and grab the first x characters
- // after the decimal.
- var gen = function() {
- return Math.random().toString(36).substr(2);
- };
-
- var p = prefix || "";
- return p + gen() + gen();
-};
-
-elmApp.ports.makeRandomString.subscribe(function(prefix) {
- var s = genId(prefix);
- elmApp.ports.randomString.send(s);
-});
-
-// resumable interop
-
-var sharryResumables = {};
-
-elmApp.ports.resumableSetComplete.subscribe(function(handleSelector) {
- var handle = handleSelector[0];
- var r = sharryResumables[handle];
- if (r) {
- var page = r.opts.page;
- $(handleSelector[1]).progress('set percent', 100);
- elmApp.ports.resumableComplete.send(page);
- }
-});
-
-
-var registerCallbacks = function(r, browseClass, dropClass) {
- var nodes0 = document.querySelectorAll(browseClass);
- if (nodes0.length == 1) {
- r.assignBrowse(nodes0[0]);
- } else {
- console.warn("No elements to bind browseButton");
- }
- var nodes1 = document.querySelectorAll(dropClass);
- if (nodes1.length == 1) {
- r.assignDrop(nodes1[0]);
- } else {
- console.warn("No elements to bind dropZone");
- }
- return nodes0.length + nodes1.length;
-};
-
-elmApp.ports.resumableRebind.subscribe(function(handle) {
- var r = sharryResumables[handle];
- if (r) {
- registerCallbacks(r, r.opts.browseClass, r.opts.dropClass);
- }
-});
-
-elmApp.ports.resetResumable.subscribe(function(handle) {
- var r = sharryResumables[handle];
- if (r) {
- r.cancel();
- delete sharryResumables[handle];
- var cfg = r.opts;
- var id = genId("u");
- elmApp.ports.resumableHandle.send([cfg.page, id]);
- }
-});
-
-elmApp.ports.resumableStart.subscribe(function(handle) {
- var r = sharryResumables[handle];
- if (r) {
- r.upload();
- }
-});
-
-elmApp.ports.resumablePause.subscribe(function(handle) {
- var r = sharryResumables[handle];
- if (r) {
- r.pause();
- }
-});
-
-elmApp.ports.resumableCancel.subscribe(function(handle) {
- var r = sharryResumables[handle];
- if (r) {
- r.cancel();
- }
-});
-
-elmApp.ports.resumableRetry.subscribe(function(handleAndIds) {
- var handle = handleAndIds[0];
- var ids = handleAndIds[1];
- var r = sharryResumables[handle];
- if (r) {
- ids.forEach(function(id) {
- var file = r.getFromUniqueIdentifier(id);
- if (file) {
- file.retry();
- }
- });
- }
-});
-
-elmApp.ports.makeResumable.subscribe(function(cfg) {
- var id = cfg.handle || genId("u");
- var page = cfg.page;
- var browseClass = cfg.browseClass;
- var dropClass = cfg.dropClass;
-
- var makeFile = function(file) {
- var progress = 0;
- if (file.hasOwnProperty("process")) {
- progress = file.progress();
- }
- var completed = false;
- if (file.hasOwnProperty("isComplete")) {
- completed = file.isComplete();
- }
- var uploading = false;
- if (file.hasOwnProperty("isUploading")) {
- uploading = file.isUploading();
- }
- return {
- fileName: file.fileName || file.name,
- size: file.size,
- uniqueIdentifier: file.uniqueIdentifier || "",
- progress: progress,
- completed: completed,
- uploading: uploading
- };
- };
- if (!sharryResumables[id]) {
- if (cfg.maxFiles <= 0) {
- cfg.maxFiles = undefined;
- }
- if (cfg.maxFileSize <= 0) {
- cfg.maxFileSize = undefined;
- }
- cfg.chunkRetryInterval = 800;
- cfg.typeParameterName = "";
- cfg.method = "octet";
- cfg.query = { token: id };
- cfg.maxFileSizeErrorCallback = function(file, count) {
- if (file instanceof FileList && file.length > 0) {
- elmApp.ports.resumableMaxFileSizeError.send([page, makeFile(file.item(0))]);
- } else {
- elmApp.ports.resumableMaxFileSizeError.send([page, makeFile(file)]);
- }
- };
- cfg.maxFilesErrorCallback = function(file, count) {
- if (file instanceof FileList && file.length > 0) {
- elmApp.ports.resumableMaxFilesError.send([page, makeFile(file.item(0)), count]);
- } else {
- elmApp.ports.resumableMaxFilesError.send([page, makeFile(file), count]);
- }
- };
- var r = new Resumable(cfg);
- var n = registerCallbacks(r, browseClass, dropClass);
- if (n > 0) {
- sharryResumables[id] = r;
-
- r.on('uploadStart', function() {
- elmApp.ports.resumableStarted.send(page);
- });
- r.on('fileAdded', function(file, event) {
- elmApp.ports.resumableFileAdded.send([page, makeFile(file)]);
- });
- r.on('fileSuccess', function(file, message) {
- elmApp.ports.resumableFileSuccess.send([page, makeFile(file)]);
- });
- r.on('progress', function() {
- elmApp.ports.resumableProgress.send([page, r.progress()]);
- });
- r.on('complete', function() {
- elmApp.ports.resumableComplete.send(page);
- });
- r.on('pause', function() {
- elmApp.ports.resumablePaused.send(page);
- });
- r.on('error', function(message, file) {
- elmApp.ports.resumableError.send([page, message, makeFile(file)]);
- });
-
- elmApp.ports.resumableHandle.send([page, id]);
- }
- } else {
- registerCallbacks(sharryResumables[id], browseClass, dropClass);
- }
-});
diff --git a/modules/webapp/src/main/scala/sharry/webapp/route/Url.scala b/modules/webapp/src/main/scala/sharry/webapp/route/Url.scala
deleted file mode 100644
index 7b0484a0..00000000
--- a/modules/webapp/src/main/scala/sharry/webapp/route/Url.scala
+++ /dev/null
@@ -1,56 +0,0 @@
-package sharry.webapp.route
-
-import java.net.URL
-import java.nio.file.{Path, Paths}
-import cats.syntax.either._
-import fs2.{io, Stream}
-import cats.effect.IO
-
-case class Url(jurl: URL) {
- require(jurl != null, "url argument must not be null")
-
- val asString = jurl.toString
-
- def host = jurl.getHost
- def protocol = jurl.getProtocol
- def path: Option[Path] = Option(jurl.getPath).filter(_.nonEmpty).map(p => Paths.get(p))
-
- def fileName: Option[String] =
- path.map(_.getFileName.toString)
-
- def readAll(chunkSize: Int): Stream[IO, Byte] =
- io.readInputStream(IO(jurl.openStream), chunkSize)
-
- def toJava = jurl
-}
-
-object Url {
- def apply(url: String): Url =
- try {
- Url(new URL(url))
- } catch {
- case e: java.net.MalformedURLException =>
- val e2 = new java.net.MalformedURLException(e.getMessage +" ("+ url +")")
- e2.setStackTrace(e.getStackTrace)
- throw e2
- }
-
- def tryApply(url: String): Either[Throwable, Url] =
- Either.catchNonFatal(apply(url))
-
- def file(p: Path): Url = Url(s"file://${p.normalize.toAbsolutePath}")
-
- def resource(name: String): Option[Url] =
- Option(getClass.getClassLoader.getResource(name)).map(Url(_))
-
- object Parts {
- def unapply(url: Url): Option[(String, String, Option[Path])] =
- Some((url.protocol, url.host, url.path))
- }
-
- object Protocol {
- def unapply(url: Url): Option[String] =
- Some(url.protocol)
- }
-
-}
diff --git a/modules/webapp/src/main/scala/sharry/webapp/route/webjar.scala b/modules/webapp/src/main/scala/sharry/webapp/route/webjar.scala
deleted file mode 100644
index c2945065..00000000
--- a/modules/webapp/src/main/scala/sharry/webapp/route/webjar.scala
+++ /dev/null
@@ -1,204 +0,0 @@
-package sharry.webapp.route
-
-import java.time.{Instant, ZoneId}
-import io.circe.generic.auto._, io.circe.parser._, io.circe.syntax._
-import fs2.{text, Stream}
-import cats.effect.IO
-import shapeless.{HNil, ::}
-import scodec.bits.BitVector
-import spinoco.fs2.http.routing._
-import spinoco.fs2.http.HttpResponse
-import spinoco.protocol.mime._
-import spinoco.protocol.http.{HttpResponseHeader, HttpStatusCode}
-import spinoco.protocol.http.header._
-import spinoco.protocol.http.header.value._
-import yamusca.implicits._
-import yamusca.imports._
-
-import sharry.common.data._
-
-object webjar {
- val webjarToc: Webjars.Toc = readWebjarToc.unsafeRunSync
-
- private def readWebjarToc: IO[Webjars.Toc] = {
- def parseToc(json: String): Webjars.Toc =
- decode[Webjars.Toc](json) match {
- case Right(toc) => toc
- case Left(ex) => throw ex
- }
-
- val tocUrl = Url(getClass.getResource("toc.json"))
- tocUrl.readAll(32 * 1024).
- through(text.utf8Decode).
- fold1(_ + _).
- map(parseToc).
- compile.last.
- map(_.get)
- }
-
- def endpoint(config: RemoteConfig): Route[IO] =
- choice(resourceGet, index(config))
-
-
- def ifModifiedSince: Matcher[IO, Option[Instant]] =
- header[`If-Modified-Since`].? map { _.map {
- v => v.value.atZone(ZoneId.of("UTC")).toInstant
- }}
-
- def ifNoneMatch: Matcher[IO, Option[String]] =
- header[`If-None-Match`].? map {
- case Some(`If-None-Match`(EntityTagRange.Range(List(EntityTag(tag, false))))) => Some(tag)
- case _ => None
- }
-
- def restPath: Matcher[IO, Seq[String]] =
- path.map(p => p.segments)
-
-
- def resourceGet: Route[IO] =
- Get >> ifModifiedSince :: ifNoneMatch :: "static" / as[String] :/: restPath map {
- case modSince :: noneMatch :: name :: rest :: HNil =>
- Stream.emit {
- resource.lookup(name, rest, modSince, noneMatch) match {
- case res @ Find.Found((_, url)) =>
- makeResponse(res, rest).copy(body = url.readAll(8192))
- case res =>
- makeResponse(res, rest)
- }
- }
- }
-
-
- def index(config: RemoteConfig): Route[IO] = {
- val indexHtml = html.render(config).compile.toVector.unsafeRunSync
- Get >> choice(empty, "index.html") >> ifModifiedSince :: ifNoneMatch map {
- case modSince :: noneMatch :: HNil =>
- val index = Seq("index.html")
- Stream[HttpResponse[IO]] {
- resource.lookup("sharry-webapp", index, modSince, noneMatch) match {
- case res @ Find.Found((wj, _)) =>
- makeResponse(res, index, Some(indexHtml.size.toLong)).copy(body = Stream.emits(indexHtml))
-
- case res =>
- makeResponse(res, index, Some(indexHtml.size.toLong))
- }
- }
- }
- }
-
- private def makeResponse(find: Find[(Webjars.ModuleId, Url)], path: Seq[String], len: Option[Long] = None): HttpResponse[IO] = {
- def parseContentType(s: String): ContentType =
- ContentType.codec.decodeValue(BitVector.view(s.getBytes("UTF-8"))).require
-
- def make(wj: Webjars.ModuleId, status: HttpStatusCode): HttpResponse[IO] = {
- val p = path.mkString("/")
- HttpResponse(
- HttpResponseHeader(
- status = status,
- reason = "",
- headers = List(
- Some(ETag(EntityTag(wj.hash, false))),
- Some(`Last-Modified`(Webjars.lastModified.atZone(ZoneId.of("UTC")).toLocalDateTime)),
- webjarToc.get(wj.hash).flatMap(_.get(p)).map(fi => `Content-Type`(parseContentType(fi.contentType))),
- len.orElse(webjarToc.get(wj.hash).flatMap(_.get(p)).map(_.length)).map(`Content-Length`.apply)
- ).collect({case Some(v) => v })),
- Stream.empty
- )
- }
- find match {
- case Find.Found((wj, _)) =>
- make(wj, HttpStatusCode.Ok)
- case Find.NotModified((wj, _)) =>
- make(wj, HttpStatusCode.NotModified)
- case Find.NotFound =>
- HttpResponse(HttpResponseHeader(HttpStatusCode.NotFound, ""), Stream.empty)
- }
- }
-
- sealed trait Find[+A] {
- def map[B](f: A => B): Find[B]
- def getOrElse[B >: A](a: => B): B
- }
- object Find {
- case class Found[+A](value: A) extends Find[A] {
- def map[B](f: A => B) = Found(f(value))
- def getOrElse[B>:A](a: => B): B = value
- }
- case class NotModified[+A](value: A) extends Find[A] {
- def map[B](f: A => B) = NotModified(f(value))
- def getOrElse[B>:A](a: => B): B = value
- }
- case object NotFound extends Find[Nothing] {
- def map[B](f: Nothing => B) = this
- def getOrElse[B>:Nothing](a: => B): B = a
- }
- }
-
- object resource {
-
- def lookup(name: String, path: Seq[String], modSince: Option[Instant] = None, noneMatch: Option[String] = None): Find[(Webjars.ModuleId, Url)] =
- find(name, path) match {
- case Some((wj, url)) if isMatch(wj, noneMatch) || isUnmodified(modSince) =>
- Find.NotModified((wj, url))
- case Some((wj, url)) =>
- Find.Found((wj, url))
- case None =>
- Find.NotFound
- }
-
-
- private def find(name: String, path: Seq[String]): Option[(Webjars.ModuleId, Url)] =
- for {
- wj <- Webjars.modules.find(_.artifactId equalsIgnoreCase name)
- url <- wj.localUrl(path.mkString("/"))
- } yield (wj, url)
-
- def isMatch(wj: Webjars.ModuleId, noneMatch: Option[String]): Boolean =
- Some(wj.hash) == noneMatch
-
- def isUnmodified(modSince: Option[Instant]): Boolean =
- modSince match {
- case Some(since) => Webjars.lastModified.isBefore(since)
- case _ => false
- }
-
- private implicit class WebjarOps(wj: Webjars.ModuleId) {
-
- def localUrl(path: String): Option[Url] = {
- val resource = s"${wj.resourcePrefix}/$path"
- Option(getClass.getResource(resource)).map(Url.apply)
- }
-
- def cdnUrl(path: String, protocol: String = "http"): Url = {
- val base = s"$protocol://cdn.jsdelivr.net/webjars/org.webjars/${wj.artifactId}/${wj.version}/$path"
- Url(base)
- }
- }
- }
-
- object html {
-
- case class Data(config: String, highlightjsTheme: String)
- object Data {
- implicit val dataConverter: ValueConverter[Data] =
- ValueConverter.deriveConverter[Data]
- }
-
- def render(config: RemoteConfig): Stream[IO, Byte] = {
- resource.lookup("sharry-webapp", Seq("index.html")) match {
- case Find.Found((wj, url)) =>
- val data = Data(config.asJson.spaces4, config.highlightjsTheme)
- url.readAll(8192).
- through(text.utf8Decode).
- fold1(_ + _).
- map(mustache.parse).
- map(_.left.map(err => new Exception(s"${err._2} at ${err._1.pos}"))).
- rethrow.
- map(data.render).
- through(text.utf8Encode)
-
- case _ => sys.error("index.html not found")
- }
- }
- }
-}
diff --git a/modules/webapp/src/main/html/favicon/android-chrome-192x192.png b/modules/webapp/src/main/webjar/favicon/android-chrome-192x192.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/android-chrome-192x192.png
rename to modules/webapp/src/main/webjar/favicon/android-chrome-192x192.png
diff --git a/modules/webapp/src/main/html/favicon/android-chrome-512x512.png b/modules/webapp/src/main/webjar/favicon/android-chrome-512x512.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/android-chrome-512x512.png
rename to modules/webapp/src/main/webjar/favicon/android-chrome-512x512.png
diff --git a/modules/webapp/src/main/html/favicon/apple-touch-icon.png b/modules/webapp/src/main/webjar/favicon/apple-touch-icon.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/apple-touch-icon.png
rename to modules/webapp/src/main/webjar/favicon/apple-touch-icon.png
diff --git a/modules/webapp/src/main/html/favicon/browserconfig.xml b/modules/webapp/src/main/webjar/favicon/browserconfig.xml
similarity index 100%
rename from modules/webapp/src/main/html/favicon/browserconfig.xml
rename to modules/webapp/src/main/webjar/favicon/browserconfig.xml
diff --git a/modules/webapp/src/main/html/favicon/favicon-16x16.png b/modules/webapp/src/main/webjar/favicon/favicon-16x16.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/favicon-16x16.png
rename to modules/webapp/src/main/webjar/favicon/favicon-16x16.png
diff --git a/modules/webapp/src/main/html/favicon/favicon-32x32.png b/modules/webapp/src/main/webjar/favicon/favicon-32x32.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/favicon-32x32.png
rename to modules/webapp/src/main/webjar/favicon/favicon-32x32.png
diff --git a/modules/webapp/src/main/html/favicon/favicon.ico b/modules/webapp/src/main/webjar/favicon/favicon.ico
similarity index 100%
rename from modules/webapp/src/main/html/favicon/favicon.ico
rename to modules/webapp/src/main/webjar/favicon/favicon.ico
diff --git a/modules/webapp/src/main/html/favicon/manifest.json b/modules/webapp/src/main/webjar/favicon/manifest.json
similarity index 100%
rename from modules/webapp/src/main/html/favicon/manifest.json
rename to modules/webapp/src/main/webjar/favicon/manifest.json
diff --git a/modules/webapp/src/main/html/favicon/mstile-150x150.png b/modules/webapp/src/main/webjar/favicon/mstile-150x150.png
similarity index 100%
rename from modules/webapp/src/main/html/favicon/mstile-150x150.png
rename to modules/webapp/src/main/webjar/favicon/mstile-150x150.png
diff --git a/modules/webapp/src/main/html/favicon/safari-pinned-tab.svg b/modules/webapp/src/main/webjar/favicon/safari-pinned-tab.svg
similarity index 100%
rename from modules/webapp/src/main/html/favicon/safari-pinned-tab.svg
rename to modules/webapp/src/main/webjar/favicon/safari-pinned-tab.svg
diff --git a/modules/webapp/src/main/webjar/img/icon.svg b/modules/webapp/src/main/webjar/img/icon.svg
new file mode 120000
index 00000000..c72880d8
--- /dev/null
+++ b/modules/webapp/src/main/webjar/img/icon.svg
@@ -0,0 +1 @@
+../../../../../../artwork/icon.svg
\ No newline at end of file
diff --git a/modules/webapp/src/main/webjar/img/logo.png b/modules/webapp/src/main/webjar/img/logo.png
new file mode 120000
index 00000000..028b5082
--- /dev/null
+++ b/modules/webapp/src/main/webjar/img/logo.png
@@ -0,0 +1 @@
+../../../../../../artwork/logo.png
\ No newline at end of file
diff --git a/modules/webapp/src/main/webjar/img/logo.svg b/modules/webapp/src/main/webjar/img/logo.svg
new file mode 120000
index 00000000..c08d1be8
--- /dev/null
+++ b/modules/webapp/src/main/webjar/img/logo.svg
@@ -0,0 +1 @@
+../../../../../../artwork/logo.svg
\ No newline at end of file
diff --git a/modules/webapp/src/main/webjar/sharry.css b/modules/webapp/src/main/webjar/sharry.css
new file mode 100644
index 00000000..f308a4d2
--- /dev/null
+++ b/modules/webapp/src/main/webjar/sharry.css
@@ -0,0 +1,114 @@
+/* Sharry CSS */
+
+.default-layout {
+ background: #fff;
+ height: 100vh;
+}
+
+.default-layout .main-content {
+ margin-top: 45px;
+}
+
+.default-layout .main-content h1 {
+ padding-top: 15px;
+}
+
+.default-layout a.header.item > img.ui.image.logo-icon,
+.default-layout a.icon.item > img.image.icon.logo-icon {
+ height: 14px;
+ margin-right: 16px;
+}
+
+.default-layout .home-page > .segment {
+ margin-top: 110px;
+}
+
+.default-layout .share-description {
+ font-size: 1.14285714rem;
+ line-height: 1.5;
+}
+
+.default-layout .preview-image {
+ max-height: 300px;
+ width: 290px;
+}
+
+.default-layout embed.full-embed {
+ width: 100%;
+ height: 90%;
+}
+.default-layout img.full-width {
+ width: 100%;
+ height: auto;
+}
+
+.default-layout .full-height {
+ height: 100%;
+}
+
+.login-layout, .register-layout, .newinvite-layout {
+ background: #aaa;
+ height: 101vh;
+}
+
+.login-layout .login-view, .register-layout .register-view {
+ background: #fff;
+ position: relative;
+ top: 2vh;
+}
+
+label.ui.button input[type="file"] {
+ display: none;
+}
+
+.ui.form .markdown-preview {
+ overflow: auto;
+ max-height: 300px;
+}
+.ui.form .markdown-split > textarea.markdown-editor {
+ height: 100%;
+ max-height: 300px;
+}
+.ui.form textarea.markdown-editor {
+ max-height: 300px;
+}
+.ui.form .mini.menu a.help-link {
+ color: #4183c4
+}
+
+label span.muted {
+ font-size: smaller;
+ color: rgba(0,0,0,0.6);
+ margin-left: 0.5em;
+}
+
+.ui > pre.url {
+ font-size: smaller;
+ margin: 5px 5px;
+}
+
+.ui.header>img.logo.image {
+ width: 220px;
+}
+
+.ui.dropdown.open {
+ z-index: 20;
+}
+
+.invisible {
+ display: none !important;
+}
+
+table.selectable > tbody > tr {
+ cursor: pointer;
+}
+
+@media (min-height: 320px) {
+ .ui.footer {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ text-align: center;
+ font-size: x-small;
+ }
+}
diff --git a/modules/webapp/src/main/webjar/sharry.js b/modules/webapp/src/main/webjar/sharry.js
new file mode 100644
index 00000000..d7e4b755
--- /dev/null
+++ b/modules/webapp/src/main/webjar/sharry.js
@@ -0,0 +1,151 @@
+/* Sharry JS */
+
+var elmApp = Elm.Main.init({
+ node: document.getElementById("sharry-app"),
+ flags: elmFlags
+});
+
+elmApp.ports.setAccount.subscribe(function(authResult) {
+ console.log("Add account from local storage");
+ localStorage.setItem("account", JSON.stringify(authResult));
+});
+
+elmApp.ports.removeAccount.subscribe(function() {
+ console.log("Remove account from local storage");
+ localStorage.removeItem("account");
+});
+
+elmApp.ports.setProgress.subscribe(function(data) {
+ for (var i = 0; i < data.length; i++) {
+ var id = data[i][0];
+ var perc = data[i][1];
+ if (perc < 0) {
+ perc = 0;
+ }
+ if (perc > 100) {
+ perc = 100;
+ }
+ $("#" + id).progress({
+ percent: perc
+ });
+ }
+});
+
+elmApp.ports.scrollTop.subscribe(function(data) {
+ window.scrollTo(0, 0);
+});
+
+elmApp.ports.scrollToElem.subscribe(function(id) {
+ if (id && id != "") {
+ window.setTimeout(function() {
+ var el = document.getElementById(id);
+ if (el) {
+ if (el["scrollIntoViewIfNeeded"]) {
+ el.scrollIntoViewIfNeeded();
+ } else {
+ el.scrollIntoView();
+ }
+ }
+ }, 0);
+ }
+});
+
+var sharry_uploads = {};
+
+elmApp.ports.submitFiles.subscribe(function(data) {
+ var url = data.url;
+ var files = data.files;
+ var myHeaders = {};
+ if (data.aliasId) {
+ myHeaders["Sharry-Alias"] = data.aliasId;
+ }
+
+ var doUpload = function (index, file) {
+ var upload = new tus.Upload(
+ file,
+ { endpoint: url,
+ chunkSize: sharryFlags.chunkSize,
+ retryDelays: sharryFlags.retryDelays,
+ removeFingerprintOnSuccess: true,
+ headers: $.extend(myHeaders, {
+ "Sharry-File-Name": encodeURIComponent(file.name),
+ "Sharry-File-Length": file.size,
+ "Sharry-File-Type": file.type
+ }),
+ onError: function(error) {
+ console.log("XX: " + error);
+ elmApp.ports.uploadState.send({
+ id: data.id,
+ file: index,
+ progress: {
+ state: "failed",
+ error: (error || "").toString()
+ }
+ });
+ },
+ onProgress: function(bytesUploaded, bytesTotal) {
+ elmApp.ports.uploadState.send({
+ id: data.id,
+ file: index,
+ progress: {
+ state: "progress",
+ uploaded: bytesUploaded,
+ total: bytesTotal
+ }
+ });
+ },
+ onChunkComplete: function(chunkSize, bytesUploaded, bytesTotal) {
+ elmApp.ports.uploadState.send({
+ id: data.id,
+ file: index,
+ progress: {
+ state: "progress",
+ uploaded: bytesUploaded,
+ total: bytesTotal
+ }
+ });
+ },
+ onSuccess: function() {
+ elmApp.ports.uploadState.send({
+ id: data.id,
+ file: index,
+ progress: {
+ state: "complete"
+ }
+ });
+ var next = index + 1;
+ if (next < files.length) {
+ doUpload(next, files[next]);
+ } else {
+ delete sharry_uploads[data.id];
+ }
+ }
+ });
+ sharry_uploads[data.id] = upload;
+ upload.start();
+ };
+
+ if (url && files && files.length > 0) {
+ doUpload(0, files[0]);
+ } else {
+ console.log("No files to upload");
+ }
+});
+
+elmApp.ports.stopUpload.subscribe(function(id) {
+ var upload = sharry_uploads[id];
+ if (upload) {
+ upload.abort(false, function(error) {
+ elmApp.ports.uploadStopped.send(error);
+ });
+ // The callback is not called by tus-js-client …
+ elmApp.ports.uploadStopped.send(null);
+ }
+});
+
+elmApp.ports.startUpload.subscribe(function(id) {
+ var upload = sharry_uploads[id];
+ if (upload) {
+ upload.start();
+ }
+});
diff --git a/modules/webapp/src/test/elm/DataTest.elm b/modules/webapp/src/test/elm/DataTest.elm
deleted file mode 100644
index 5e369544..00000000
--- a/modules/webapp/src/test/elm/DataTest.elm
+++ /dev/null
@@ -1,43 +0,0 @@
-module DataTest exposing (..)
-
-import Expect
-import Test exposing (Test, describe, test, fuzz)
-import Fuzz exposing (string)
-
-import Data
-
-formatDate: Test
-formatDate =
- describe "format date times"
- [ test "prepend zeros" <|
- \() ->
- "2017-05-21T12:03:03Z"
- |> Data.formatDate
- |> Expect.equal "Sun, 21. May 2017, 14:03"
- ]
-
-
-parseDuration: Test
-parseDuration =
- describe "parse java.time.Durations"
- [ test "hours" <|
- \() ->
- "PT96H"
- |> Data.parseDuration
- |> Expect.equal (Just (96, "h"))
- ]
-
-formatDuration: Test
-formatDuration =
- describe "format durations"
- [ test "hours" <|
- \() ->
- "PT12H"
- |> Data.formatDuration
- |> Expect.equal "12h"
- , test "days" <|
- \() ->
- "PT96H"
- |> Data.formatDuration
- |> Expect.equal "4d"
- ]
diff --git a/nixos-sbt b/nixos-sbt
deleted file mode 100755
index f247903b..00000000
--- a/nixos-sbt
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env bash
-#
-# TL;DR: use this script ot start sbt if you're on NixOS >
-# 18.03. Otherwise running just "sbt" should work.
-#
-# Sharry using Elm 0.18.0 and newer NixOSes have 0.19.0 installed.
-#
-# The build.nix creates a FHS environment that is also necessary to
-# create the debian package.
-#
-# The LD_LIBRARY_PATH is set to empty, because there were strange
-# errors when running elm-make:
-#
-# relocation error: /usr/lib/libc.so.6: symbol
-# _dl_exception_create, version GLIBC_PRIVATE not defined in file
-# ld-linux-x86-64.so.2 with link time reference
-#
-# See here: https://github.com/NixOS/nixpkgs/issues/48780
-
-nix-build build.nix && ./result/bin/sharry-sbt
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
new file mode 100644
index 00000000..7147094e
--- /dev/null
+++ b/project/Dependencies.scala
@@ -0,0 +1,140 @@
+import sbt._
+
+object Dependencies {
+
+ val BcryptVersion = "0.4"
+ val BetterMonadicForVersion = "0.3.1"
+ val BitpeaceVersion = "0.4.1"
+ val CirceVersion = "0.12.3"
+ val DoobieVersion = "0.8.7"
+ val EmilVersion = "0.1.1"
+ val FastparseVersion = "2.1.3"
+ val FlywayVersion = "6.1.3"
+ val Fs2Version = "2.1.0"
+ val H2Version = "1.4.200"
+ val Http4sVersion = "0.21.0-M6"
+ val JQueryVersion = "3.4.1"
+ val KindProjectorVersion = "0.10.3"
+ val Log4sVersion = "1.8.2"
+ val LogbackVersion = "1.2.3"
+ val MariaDbVersion = "2.5.2"
+ val MiniTestVersion = "2.7.0"
+ val PostgresVersion = "42.2.9"
+ val PureConfigVersion = "0.12.2"
+ val SemanticUIVersion = "2.4.1"
+ val SqliteVersion = "3.28.0"
+ val SwaggerVersion = "3.24.3"
+ val TikaVersion = "1.23"
+ val TusClientVersion = "1.8.0-1"
+ val YamuscaVersion = "0.6.1"
+
+ val fs2 = Seq(
+ "co.fs2" %% "fs2-core" % Fs2Version
+ )
+ val fs2io = Seq(
+ "co.fs2" %% "fs2-io" % Fs2Version
+ )
+
+ val tika = Seq(
+ "org.apache.tika" % "tika-core" % TikaVersion
+ )
+
+ val http4s = Seq(
+ "org.http4s" %% "http4s-blaze-server" % Http4sVersion,
+ "org.http4s" %% "http4s-circe" % Http4sVersion,
+ "org.http4s" %% "http4s-dsl" % Http4sVersion
+ )
+
+ val http4sclient = Seq(
+ "org.http4s" %% "http4s-dsl" % Http4sVersion,
+ "org.http4s" %% "http4s-blaze-client" % Http4sVersion
+ )
+
+ val circe = Seq(
+ "io.circe" %% "circe-generic" % CirceVersion,
+ "io.circe" %% "circe-parser" % CirceVersion
+ )
+
+ // https://github.com/Log4s/log4s;ASL 2.0
+ val loggingApi = Seq(
+ "org.log4s" %% "log4s" % Log4sVersion
+ )
+
+ val logging = Seq(
+ "ch.qos.logback" % "logback-classic" % LogbackVersion % Runtime
+ )
+
+ // https://github.com/melrief/pureconfig
+ // MPL 2.0
+ val pureconfig = Seq(
+ "com.github.pureconfig" %% "pureconfig" % PureConfigVersion
+ )
+
+ val fastparse = Seq(
+ "com.lihaoyi" %% "fastparse" % FastparseVersion
+ )
+
+ // https://github.com/h2database/h2database
+ // MPL 2.0 or EPL 1.0
+ val h2 = Seq(
+ "com.h2database" % "h2" % H2Version
+ )
+ val mariadb = Seq(
+ "org.mariadb.jdbc" % "mariadb-java-client" % MariaDbVersion
+ )
+ val postgres = Seq(
+ "org.postgresql" % "postgresql" % PostgresVersion
+ )
+ val sqlite = Seq(
+ "org.xerial" % "sqlite-jdbc" % SqliteVersion
+ )
+ val databases = h2 ++ mariadb ++ postgres ++ sqlite
+
+ // https://github.com/tpolecat/doobie
+ // MIT
+ val doobie = Seq(
+ "org.tpolecat" %% "doobie-core" % DoobieVersion,
+ "org.tpolecat" %% "doobie-hikari" % DoobieVersion
+ )
+
+ val bitpeace = Seq(
+ "com.github.eikek" %% "bitpeace-core" % BitpeaceVersion
+ )
+
+ val emil = Seq(
+ "com.github.eikek" %% "emil-common" % EmilVersion,
+ "com.github.eikek" %% "emil-javamail" % EmilVersion
+ )
+
+ // https://github.com/flyway/flyway
+ // ASL 2.0
+ val flyway = Seq(
+ "org.flywaydb" % "flyway-core" % FlywayVersion
+ )
+
+ val yamusca = Seq(
+ "com.github.eikek" %% "yamusca-core" % YamuscaVersion
+ )
+
+ val bcrypt = Seq(
+ "org.mindrot" % "jbcrypt" % BcryptVersion
+ )
+
+ val miniTest = Seq(
+ // https://github.com/monix/minitest
+ // Apache 2.0
+ "io.monix" %% "minitest" % MiniTestVersion,
+ "io.monix" %% "minitest-laws" % MiniTestVersion
+ ).map(_ % Test)
+
+ val kindProjectorPlugin = "org.typelevel" %% "kind-projector" % KindProjectorVersion
+ val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % BetterMonadicForVersion
+
+ val webjars = Seq(
+ "org.webjars" % "swagger-ui" % SwaggerVersion,
+ "org.webjars" % "Semantic-UI" % SemanticUIVersion,
+ "org.webjars" % "jquery" % JQueryVersion,
+ "org.webjars.npm" % "tus-js-client" % TusClientVersion
+ )
+
+}
diff --git a/project/ElmPlugin.scala b/project/ElmPlugin.scala
deleted file mode 100644
index 54c82cc0..00000000
--- a/project/ElmPlugin.scala
+++ /dev/null
@@ -1,189 +0,0 @@
-package sharry.build
-
-import sbt._
-import sbt.Keys._
-import java.nio.file._
-import scala.util.{Failure, Success, Try}
-import scala.sys.process._
-import com.google.javascript.jscomp.CommandLineRunner
-
-object ElmPlugin extends AutoPlugin {
-
- object autoImport {
- val elmMakeExecuteable = settingKey[String]("The executable `elm-make'")
- val elmTestExecuteable = settingKey[String]("The executable `elm-test'")
- val elmMakeOutputPath = settingKey[File]("The directory to store elm-make output")
- val elmDependencies = settingKey[Seq[(String, String)]]("Elm package dependencies")
- val elmSources = settingKey[File]("Directory to scan for elm files")
- val elmVersion = settingKey[String]("The version (range) for elm language")
- val elmDebug = settingKey[Boolean]("Whether to use --debug with `elm-make'")
- val elmWd = settingKey[File]("Working directory for elm-make")
- val elmGithubRepo = settingKey[String]("Github url to elm package (required in elm-package.json)")
- val elmReactorExecuteable = settingKey[String]("Executeable for `elm-reactor'")
- val elmReactorPort = settingKey[Int]("The port for elm-reactor")
- val elmMakeCompilationLevel = settingKey[String]("The compilation level passed to google closure compiler. One of WHITESPACE_ONLY, SIMPLE or ADVANCED")
- val elmMinify = settingKey[Boolean]("Whether to run minifier after compilation")
- val elmProject = taskKey[Seq[File]]("Create elm-package.json")
- val elmMake = taskKey[Seq[File]]("Compile elm files")
- val elmTest = taskKey[Seq[File]]("Run elm tests using elm-test")
- val elmReactor = taskKey[Unit]("Run `elm-reactor'")
- }
-
- import autoImport._
-
- lazy val elmSettings = Seq(
- elmMakeExecuteable := "elm-make",
- elmTestExecuteable := "elm-test",
- elmReactorExecuteable := "elm-reactor",
- elmReactorPort := 8000,
- elmMakeOutputPath := (resourceManaged in Compile).value/"META-INF"/"resources"/"webjars"/(name in Compile).value/(version in Compile).value,
- elmSources := (sourceDirectory in Compile).value/"elm",
- elmSources in Test := (sourceDirectory in Test).value/"elm",
- elmDebug := false,
- elmMinify := false,
- elmGithubRepo := (homepage.value match {
- case Some(url) if url.toString.startsWith("https://github.com") => url.toString
- case _ => "https://github.com/user/repo.git"
- }),
- elmDependencies := Seq.empty,
- elmDependencies in Test := (elmDependencies in Compile).value,
- elmWd := (target in Compile).value/"elm-make",
- elmMakeCompilationLevel := "SIMPLE",
- elmProject := {
- val wd = elmWd.value
- val logger = streams.value.log
- IO.createDirectories(Seq(wd))
- if (!Files.exists((wd/elmSources.value.getName).toPath) && Files.exists(elmSources.value.toPath)) {
- Files.createSymbolicLink((wd/elmSources.value.getName).toPath, elmSources.value.toPath)
- }
- val pkgJson = wd/"elm-package.json"
- val content = packageJson(elmDependencies, false).value
- if (!pkgJson.exists || Hash.toHex(Hash(pkgJson)) != Hash.toHex(Hash(content))) {
- logger.info("Generating elm-package.json")
- IO.write(pkgJson, content)
- }
-
- val testPkgJson = wd/"tests"/"elm-package.json"
- val testContent = packageJson(elmDependencies in Test, true).value
- if (!testPkgJson.exists || Hash.toHex(Hash(testPkgJson)) != Hash.toHex(Hash(testContent))) {
- logger.info("Generating tests/elm-package.json")
- IO.write(testPkgJson, testContent)
- }
- if (!Files.exists((wd/"tests"/(elmSources in Test).value.getName).toPath) && Files.exists((elmSources in Test).value.toPath)) {
- Files.createSymbolicLink((wd/"tests"/(elmSources in Test).value.getName).toPath, (elmSources in Test).value.toPath)
- }
-
- Seq(pkgJson, testPkgJson)
- },
- elmMake := {
- val logger = streams.value.log
- val pkg = elmProject.value
- val wd = elmWd.value
- // need to check all files whether to decide for recompile
- val allFiles: Seq[File] = sbt.Path.allSubpaths(elmSources.value).
- map(_._1).
- filter(_.getName.endsWith(".elm")).
- toSeq
- val filesToCompile = IO.listFiles(elmSources.value, GlobFilter("*.elm")).
- map(f => elmSources.value.getName + java.io.File.separator + f.getName)
- if (allFiles.isEmpty) {
- logger.info("No elm source files found.")
- Seq.empty
- } else {
- val newest = allFiles.sortBy(-_.lastModified).head
- val out = elmMakeOutputPath.value/"elm-main.js"
- val minified = elmMakeOutputPath.value/"elm-main.min.js"
- if (!out.exists || newest.lastModified >= out.lastModified) {
- logger.info(s"Compiling ${filesToCompile.size} elm files …")
- IO.delete(Seq(out, minified))
- val opts: Seq[String] = if (elmDebug.value) Seq("--debug", "--yes") else Seq("--yes")
- val proc = Process(elmMakeExecuteable.value +: (filesToCompile ++ opts ++ Seq("--output", out.toString)), Some(wd))
- runCmd(proc, logger,
- "Elm files compiled successfully",
- "Error compiling elm files")
- if (elmMinify.value) {
- logger.info("Running Closure compiler…")
- val clrun = new Minify("--compilation_level", elmMakeCompilationLevel.value, "--js", out.toString, "--js_output_file", minified.toString)
- clrun.compile()
- IO.move(minified, out)
- }
- } else {
- logger.info("Elm files are up to date")
- }
- Seq(out)
- }
- },
- elmTest := {
- val wd = elmWd.value
- val proc = Process(elmTestExecuteable.value, Some(wd))
- runCmd(proc, streams.value.log,
- "Elm tests successful",
- "Elm tests failed")
- val out = wd/"elm-stuff"/"generated-code"/"elm-community"/"elm-test"/"elmTestOutput.js"
- if (out.exists) Seq(out) else Seq.empty[File]
- },
- elmReactor := {
- val pkg = elmProject.value
- val wd = elmWd.value
- val proc = Process(Seq(elmReactorExecuteable.value, "-p", elmReactorPort.value.toString), Some(wd))
- runCmd(proc, streams.value.log,
- "Started elm-reactor",
- "Unable to start elm-reactor")
- }
- )
-
- override def projectSettings =
- inConfig(Compile)(elmSettings) ++ Seq(
- watchSources ++= ((elmSources in Compile).value ** ("*.elm")).get ++ ((elmSources in Test).value ** ("*.elm")).get
- )
-
-
- def packageJson(deps: SettingKey[Seq[(String, String)]], test: Boolean) = Def.task {
- val sources =
- if (!test) Seq((elmSources in Compile).value.getName)
- else Seq((elmSources in Test).value.getName, "../"+ (elmSources in Compile).value.getName)
-
- s"""{
- | "version" : "${version.value.replaceAll("[^0-9\\.]", "")}",
- | "summary" : "${description.value}",
- | "repository" : "${elmGithubRepo.value}",
- | "license" : "",
- | "source-directories" : [
- | ${sources.mkString("\"", "\", \"", "\"")}
- | ],
- | "exposed-modules" : [],
- | "dependencies" : {
- | ${deps.value.map({case (k,v) => "\""+k+"\" : \""+v+"\"" }).mkString(",\n ")}
- | },
- | "elm-version" : "${elmVersion.value}"
- |}""".stripMargin
- }
-
-
- def runCmd(proc: ProcessBuilder, log: Logger, success: String, error: String): Unit = {
- val logger = new ProcLogger(log)
- Try({
- val rc = proc ! logger
- if (rc != 0) sys.error("Non-zero return value")
- }) match {
- case Success(_) =>
- log.info(success)
- case Failure(ex) =>
- throw new Exception(s"$error: ${ex.getMessage}", ex)
- }
- }
-
- final class ProcLogger(log: Logger) extends ProcessLogger {
- def buffer[T](f: => T): T = f
- def err(s: => String): Unit = {
- log.error(s)
- }
- def out(s: => String): Unit = {
- log.info(s)
- }
- }
-
- final class Minify(args: String*) extends CommandLineRunner(args.toArray) {
- def compile() = doRun()
- }
-}
diff --git a/project/WebjarPlugin.scala b/project/WebjarPlugin.scala
deleted file mode 100644
index c9b4132d..00000000
--- a/project/WebjarPlugin.scala
+++ /dev/null
@@ -1,165 +0,0 @@
-package sharry.build
-
-import sbt._
-import sbt.Keys._
-import scala.collection.JavaConverters._
-import scala.util.Try
-import org.apache.tika.Tika
-import java.io.FileInputStream
-import java.util.zip.ZipInputStream
-import java.net.{URI, URLEncoder}
-import java.util.{HashMap => JMap}
-import java.time.Instant
-import _root_.io.circe._, _root_.io.circe.generic.auto._, _root_.io.circe.syntax._
-
-object WebjarPlugin extends AutoPlugin {
-
- object autoImport {
- val webjarSource = taskKey[Seq[File]]("Creates a scala source file listing the webjars")
- val webjarContents = taskKey[Seq[File]]("Creates a json file containing webjar toc")
- val webjarFile = settingKey[String]("The source file name")
- val webjarPackage = settingKey[String]("The package name")
- val webjarPrefix = settingKey[String]("The path name into the jar file")
- val webjarWebPackages = settingKey[Seq[Task[WebPackage]]]("More resources to add")
- val webjarWebPackageResources = taskKey[Seq[File]]("Copy web package files to resource location")
-
- case class WebPackage(groupId: String, artifactId: String, version: String, files: Seq[(File, String)]) {
- lazy val hash = Hash.toHex(Hash(files.map(f => Hash(f._1)).mkString + s"$groupId:$artifactId:$version"))
- lazy val moduleID: ModuleID = groupId % artifactId % version
- private[WebjarPlugin] lazy val toWebjar = Webjar(moduleID, hash, file(""))
- private[WebjarPlugin] lazy val entries: Map[String, FileInfo] = files.
- map({ case (f, name) =>
- name -> FileInfo(Webjar.detect(f.getName), f.length)
- }).
- toMap
- }
- }
-
- import autoImport._
-
- val webjarSettings = Seq(
- webjarFile in webjarSource := "Webjars.scala",
- webjarPackage in webjarSource := "webjars",
- webjarWebPackages := Seq.empty,
- webjarSource := {
- val logger = streams.value.log
- val entry = packageToFile((webjarPackage in webjarSource).value)
- val target = (sourceManaged in Compile).value/entry/(webjarFile in webjarSource).value
- val webjars: Seq[ModuleID] = (libraryDependencies in Compile).value.filter(_.organization startsWith "org.webjars")
- val files: Seq[Webjar] = Attributed.data((dependencyClasspath in Compile).value).collect(findWebjarFile(webjars)) ++
- internalResources.value.map(_.toWebjar)
- val code = s"""package ${(webjarPackage in webjarSource).value}
- |object ${(webjarFile in webjarSource).value.dropRight(6)} {
- | case class ModuleId(groupId: String, artifactId: String, version: String, hash: String) {
- | val resourcePrefix = s"/META-INF/resources/webjars/$${artifactId}/$${version}"
- | }
- | case class FileInfo(contentType: String, length: Long)
- | type Hash = String
- | type Path = String
- | type Toc = Map[Hash, Map[Path, FileInfo]]
- | val lastModified = java.time.Instant.parse("${Instant.now.toString}")
- | val modules = ${files.map({ wj => "ModuleId(\""+wj.module.organization+"\", \""+wj.module.name+"\", \""+ wj.module.revision +"\", \""+wj.hash+"\")"})}
- |}""".stripMargin
- if (!target.exists || Hash.toHex(Hash(target)) != Hash.toHex(Hash(code))) {
- logger.info(s"Generating ${(webjarFile in webjarSource).value}")
- IO.createDirectories(Seq(target.getParentFile))
- IO.write(target, code)
- }
- Seq(target)
- },
- webjarContents := {
- streams.value.log.info("Generating webjar toc file")
- val entry = packageToFile((webjarPackage in webjarSource).value)
- val target = (resourceManaged in Compile).value/entry/"toc.json"
- val webjars: Seq[ModuleID] = (libraryDependencies in Compile).value.filter(_.organization startsWith "org.webjars")
- val files: Seq[Webjar] = Attributed.data((dependencyClasspath in Compile).value).collect(findWebjarFile(webjars))
- val libMap = files.map(wj => wj.hash -> wj.listEntries).toMap
- val intMap = internalResources.value.map(r => r.hash -> r.entries)
- IO.createDirectories(Seq(target.getParentFile))
- IO.write(target, (libMap ++ intMap).asJson.spaces2)
- Seq(target)
- },
- webjarWebPackageResources := {
- val base = resourceManaged.value/"META-INF"/"resources"/"webjars"
- val pkgs: Seq[WebPackage] = internalResources.value
- pkgs.flatMap { wp =>
- wp.files.map { case (f, name) =>
- val target = base/wp.artifactId/wp.version/name
- IO.copy(Seq(f -> target))
- target
- }
- }
- }
- )
-
- override def projectSettings =
- inConfig(Compile)(webjarSettings)
-
-
- private def packageToFile(pkg: String) =
- pkg.replace(".", java.io.File.separator)
-
- lazy val internalResources = Def.taskDyn {
- evalWebPackage(webjarWebPackages.value)
- }
-
- def evalWebPackage(ts: Seq[Task[WebPackage]]): Def.Initialize[Task[List[WebPackage]]] = Def.taskDyn {
- ts.headOption match {
- case None => Def.task[List[WebPackage]](Nil)
- case Some(r) => Def.taskDyn {
- val head = r.value
- Def.task[List[WebPackage]](head :: evalWebPackage(ts.drop(1)).value)
- }
- }
- }
-
- case class FileInfo(contentType: String, length: Long)
-
- case class Webjar(module: ModuleID, hash: String, file: File) {
- val resourcePrefix = s"/META-INF/resources/webjars/${module.name}/${module.revision}"
-
- def listEntries: Map[String, FileInfo] = {
- if (!file.exists) sys.error(file.toString)
- def loop(in: ZipInputStream, entries: List[(String, FileInfo)]): List[(String, FileInfo)] =
- Option(in.getNextEntry) match {
- case Some(e) if e.getName.startsWith(resourcePrefix.substring(1)) && !e.getName.endsWith("/") =>
- loop(in, (e.getName.substring(resourcePrefix.length), FileInfo(Webjar.detect(e.getName), e.getSize)) :: entries)
- case Some(_) =>
- loop(in, entries)
- case _ =>
- entries
- }
-
- closing(new ZipInputStream(new FileInputStream(file))) { zin =>
- loop(zin, Nil).toMap
- }
- }
- }
-
- object Webjar {
- private val tika = new Tika()
- def detect(f: File): String = tika.detect(f)
- def detect(name: String): String = tika.detect(name)
- }
-
- def findModuleID(webjars: Seq[ModuleID], file: File): Option[ModuleID] = {
- val s = file.toPath.normalize.toAbsolutePath.asScala.mkString(".")
- webjars.find(m => s.contains(m.organization+ "." + m.name))
- }
-
- def isWebjarFile(webjars: Seq[ModuleID], file: File): Boolean =
- findModuleID(webjars, file).isDefined
-
- def findWebjarFile(webjars: Seq[ModuleID]): PartialFunction[File, Webjar] = {
- case f if isWebjarFile(webjars, f) =>
- Webjar(findModuleID(webjars, f).get, Hash.toHex(Hash(f)), f)
- }
-
- private def closing[A <: AutoCloseable, B](in: A)(body: A => B): B = {
- try {
- body(in)
- } finally {
- in.close()
- }
- }
-}
diff --git a/project/build.nix b/project/build.nix
new file mode 100644
index 00000000..d3a9dd8f
--- /dev/null
+++ b/project/build.nix
@@ -0,0 +1,14 @@
+with import { };
+let
+ initScript = writeScript "sharry-build-init" ''
+ export LD_LIBRARY_PATH=
+ sbt "$@"
+ '';
+in
+buildFHSUserEnv {
+ name = "sharry-sbt";
+ targetPkgs = pkgs: with pkgs; [
+ netcat jdk8 wget which zsh dpkg sbt git ncurses mc jekyll fakeroot elmPackages.elm
+ ];
+ runScript = initScript;
+}
diff --git a/project/build.properties b/project/build.properties
index cc041cd4..5a9ed925 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1 +1 @@
-sbt.version=1.2.1
\ No newline at end of file
+sbt.version=1.3.4
diff --git a/project/build.sbt b/project/build.sbt
deleted file mode 100644
index 164707bf..00000000
--- a/project/build.sbt
+++ /dev/null
@@ -1,9 +0,0 @@
-libraryDependencies ++= Seq(
- // elm plugin: minify elm js file
- "com.google.javascript" % "closure-compiler" % "v20190415",
-
- // webjar plugin
- "org.apache.tika" % "tika-core" % "1.20",
- "io.circe" %% "circe-core" % "0.9.3",
- "io.circe" %% "circe-generic" % "0.9.3"
-)
diff --git a/project/libs.scala b/project/libs.scala
deleted file mode 100644
index deecdcef..00000000
--- a/project/libs.scala
+++ /dev/null
@@ -1,123 +0,0 @@
-import sbt._
-
-object libs {
-
- val `scala-version` = "2.12.8"
-
- def webjar(name: String, version: String): ModuleID =
- "org.webjars" % name % version
-
- // https://github.com/melrief/pureconfig
- // MPL 2.0
- val pureconfig = "com.github.pureconfig" %% "pureconfig" % "0.9.2"
-
- // https://github.com/typelevel/cats
- // MIT http://opensource.org/licenses/mit-license.php
- val `cats-core` = "org.typelevel" %% "cats-core" % "1.1.0"
-
- // https://github.com/functional-streams-for-scala/fs2
- // MIT
- val `fs2-core` = "co.fs2" %% "fs2-core" % "0.10.7"
- val `fs2-io` = "co.fs2" %% "fs2-io" % "0.10.7"
-
- // https://github.com/Spinoco/fs2-http
- // MIT
- val `fs2-http` = "com.spinoco" %% "fs2-http" % "0.3.0"
-
- // https://github.com/scalatest/scalatest
- // ASL 2.0
- val scalatest = "org.scalatest" %% "scalatest" % "3.0.5"
-
- // https://github.com/rickynils/scalacheck
- // unmodified 3-clause BSD
- // val scalacheck = "org.scalacheck" %% "scalacheck" % "1.13.5"
-
- // https://github.com/scodec/scodec-bits
- // 3-clause BSD
- val `scodec-bits` = "org.scodec" %% "scodec-bits" % "1.1.10"
-
- // https://github.com/tpolecat/doobie
- // MIT
- val `doobie-core` = "org.tpolecat" %% "doobie-core" % "0.5.4"
- val `doobie-hikari` = "org.tpolecat" %% "doobie-hikari" % "0.5.4"
-
- // https://jdbc.postgresql.org/
- // BSD
- val postgres = "org.postgresql" % "postgresql" % "42.2.5"
-
- // https://github.com/h2database/h2database
- // MPL 2.0 or EPL 1.0
- val h2 = "com.h2database" % "h2" % "1.4.199"
-
- // https://github.com/circe/circe
- // ASL 2.0
- val `circe-core` = "io.circe" %% "circe-core" % "0.9.3"
- val `circe-generic` = "io.circe" %% "circe-generic" % "0.9.3"
- val `circe-parser` = "io.circe" %% "circe-parser" % "0.9.3"
-
- // http://tika.apache.org
- // ASL 2.0
- val tika = "org.apache.tika" % "tika-core" % "1.20"
-
- // https://github.com/Log4s/log4s
- // ASL 2.0
- val log4s = "org.log4s" %% "log4s" % "1.8.2"
-
- // http://logback.qos.ch/
- // EPL1.0 or LGPL 2.1
- val `logback-classic` = "ch.qos.logback" % "logback-classic" % "1.2.3"
-
- // https://github.com/t3hnar/scala-bcrypt
- // ASL 2.0
- // using:
- // - jbcrypt: ISC/BSD
- val `scala-bcrypt` = "com.github.t3hnar" %% "scala-bcrypt" % "3.1"
-
- // https://github.com/Semantic-Org/Semantic-UI
- // MIT
- val `semantic-ui` = webjar("Semantic-UI", "2.4.1")
-
- // https://github.com/23/resumable.js
- // MIT
- val resumablejs = webjar("resumable.js", "1.0.2")
-
- // https://github.com/jquery/jquery
- // MIT
- val jquery = webjar("jquery", "3.3.1")
-
- // https://highlightjs.org/
- // BSD
- val highlightjs = "org.webjars.bower" % "highlightjs" % "9.12.0"
-
- // https://java.net/projects/javamail/pages/Home
- // CDDL 1.0, GPL 2.0
- val `javax-mail-api` = "javax.mail" % "javax.mail-api" % "1.6.2"
- val `javax-mail` = "com.sun.mail" % "javax.mail" % "1.6.2"
-
- // http://dnsjava.org/
- // BSD
- val dnsjava = "dnsjava" % "dnsjava" % "2.1.9" intransitive()
-
- // https://github.com/eikek/yamusca
- // MIT
- val yamusca = "com.github.eikek" %% "yamusca-core" % "0.5.1"
-
- // https://github.com/eikek/bitpeace
- // MIT
- val `bitpeace-core` = "com.github.eikek" %% "bitpeace-core" % "0.2.1"
-
- // https://github.com/scopt/scopt
- // MIT
- val scopt = "com.github.scopt" %% "scopt" % "3.7.1"
-
- // https://github.com/vsch/flexmark-java
- // BSD 2-Clause
- val `flexmark-core` = "com.vladsch.flexmark" % "flexmark" % "0.32.20"
- val `flexmark-gfm-tables` = "com.vladsch.flexmark" % "flexmark-ext-gfm-tables" % "0.32.20"
- val `flexmark-gfm-strikethrough` = "com.vladsch.flexmark" % "flexmark-ext-gfm-strikethrough" % "0.32.20"
- val `flexmark-formatter` = "com.vladsch.flexmark" % "flexmark-formatter" % "0.32.20"
-
- // https://github.com/jhy/jsoup
- // MIT
- val jsoup = "org.jsoup" % "jsoup" % "1.11.3"
-}
diff --git a/project/plugins.sbt b/project/plugins.sbt
index e4585e76..0635754d 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,5 +1,11 @@
-addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.1.0-M1")
-addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
+addSbtPlugin("com.47deg" % "sbt-microsites" % "1.0.2")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0")
+addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "0.5.0")
+addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.12")
+addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.1-M3")
addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0")
+addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.4.1")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
+addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.0.3")
+addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.2.0")
+addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.8.1")
diff --git a/project/project/build.properties b/project/project/build.properties
deleted file mode 100644
index 66fe5117..00000000
--- a/project/project/build.properties
+++ /dev/null
@@ -1 +0,0 @@
-sbt.version=1.1.0
\ No newline at end of file
diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt
deleted file mode 100644
index e7863983..00000000
--- a/project/project/plugins.sbt
+++ /dev/null
@@ -1 +0,0 @@
-addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.1.0-M1")
diff --git a/version.sbt b/version.sbt
index 54faed50..6dc05889 100644
--- a/version.sbt
+++ b/version.sbt
@@ -1 +1 @@
- version in ThisBuild := "0.7.0-SNAPSHOT"
+version in ThisBuild := "1.0.0-SNAPSHOT"