diff options
author | Gabriel Arakaki Giovanini <mail@gabrielgio.me> | 2023-02-26 19:54:48 +0100 |
---|---|---|
committer | Gabriel Arakaki Giovanini <mail@gabrielgio.me> | 2023-06-18 16:30:36 +0200 |
commit | c8e1328164e9ffbd681c3c0e449f1e6b9856b896 (patch) | |
tree | faee639a4c55c5dc3bfc59a5400026822c40221d | |
download | lens-c8e1328164e9ffbd681c3c0e449f1e6b9856b896.tar.gz lens-c8e1328164e9ffbd681c3c0e449f1e6b9856b896.tar.bz2 lens-c8e1328164e9ffbd681c3c0e449f1e6b9856b896.zip |
feat: Inicial commit
It contains rough template for the server and runners.
It contains rough template for the server and runners.
54 files changed, 3721 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b06c8ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor/ +static/ +bin/ +media_cache/ + +.env +*.db diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e76d9ef --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "scss/bulma"] + path = scss/bulma + url = https://github.com/jgthms/bulma.git @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<http://www.gnu.org/licenses/>. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9d82dd9 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +BIN=img +OUT=./bin/$(BIN) +SERVER=./cmd/server/main.go + +GO_TEST=go test -v -timeout 100ms -shuffle on -parallel `nproc` +GO_BUILD=go build -v +GO_RUN=go run -v + +all: build sass + +build: + $(GO_BUILD) -o $(OUT) $(SERVER) + +run: sass + $(GO_RUN) $(SERVER) + +sass: + @mkdir -p static + sassc \ + -I scss scss/main.scss static/main.css \ + --style compressed + +test: test.unit test.integration + +test.all: gci test.unit test.integration lint + +test.integration: + $(GO_TEST) -tags=integration ./... + +test.unit: + $(GO_TEST) -tags=unit ./... + +gen: + go run -v \ + ./cmd/ggen/... + +cover.%: + $(GO_TEST) \ + -tags=$* \ + -coverprofile=bin/cover \ + ./... + go tool cover \ + -html=bin/cover \ + -o bin/cover.html + echo "open bin/cover.html" + +lint: + golangci-lint run \ + --fix \ + --config golangci.yml \ + --verbose \ + ./... + +gci: + find . \ + -type f \ + -name "*.go" \ + -not -path "./vendor/*" \ + -exec gci write -s standard -s default -s "prefix(git.sr.ht/~gabrielgio/img)" {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..055853a --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# IMG + +A read only file explorer with media (mainly photos) capabilities. + +## Task + +* File Scanner (up to the user) +* EXIF Extractor (every minute recomended) +* Thumbnailer (up to the user) diff --git a/cmd/ggen/main.go b/cmd/ggen/main.go new file mode 100644 index 0000000..b519739 --- /dev/null +++ b/cmd/ggen/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gen" + "gorm.io/gorm" + + "git.sr.ht/~gabrielgio/img/pkg/database/sql" + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +func main() { + cfg := gen.Config{ + OutPath: "./pkg/database/sql/query", + WithUnitTest: true, + Mode: gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface, // generate mode + } + + g := gen.NewGenerator(cfg) + + logger := logrus.New() + logger.SetLevel(logrus.TraceLevel) + d := sqlite.Open("test.db") + db, err := gorm.Open(d, &gorm.Config{ + Logger: ext.Wraplog(logger.WithField("context", "sql")), + }) + if err != nil { + panic("failed to gen database: " + err.Error()) + } + + g.UseDB(db) + + for _, m := range []any{ + &sql.User{}, + &sql.Settings{}, + &sql.Media{}, + } { + g.ApplyBasic(m) + } + + g.Execute() +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..375a26c --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/hex" + "os" + "os/signal" + + "github.com/fasthttp/router" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "git.sr.ht/~gabrielgio/img/pkg/components/auth" + "git.sr.ht/~gabrielgio/img/pkg/components/filesystem" + "git.sr.ht/~gabrielgio/img/pkg/components/media" + "git.sr.ht/~gabrielgio/img/pkg/database/localfs" + "git.sr.ht/~gabrielgio/img/pkg/database/sql" + "git.sr.ht/~gabrielgio/img/pkg/ext" + "git.sr.ht/~gabrielgio/img/pkg/view" + "git.sr.ht/~gabrielgio/img/pkg/worker" +) + +const root = "/home/gabrielgio" + +func main() { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + d := sqlite.Open("test.db") + db, err := gorm.Open(d, &gorm.Config{ + Logger: ext.Wraplog(logger.WithField("context", "sql")), + }) + if err != nil { + panic("failed to connect database: " + err.Error()) + } + + if err = sql.Migrate(db); err != nil { + panic("failed to migrate database: " + err.Error()) + } + + // TODO: properly set this up + key, _ := hex.DecodeString("6368616e676520746869732070617373") + r := router.New() + r.ServeFiles("/static/{filepath:*}", "./static") + r.NotFound = ext.NotFoundHTML + + authMiddleware := ext.NewAuthMiddleware(key, logger.WithField("context", "auth")) + logMiddleware := ext.NewLogMiddleare(logger.WithField("context", "http")) + + extRouter := ext.NewRouter(r) + extRouter.AddMiddleware(logMiddleware.HTTP) + extRouter.AddMiddleware(authMiddleware.LoggedIn) + extRouter.AddMiddleware(ext.HTML) + + scheduler := worker.NewScheduler(10) + + // repository + var ( + userRepository = sql.NewUserRepository(db) + settingsRepository = sql.NewSettingsRespository(db) + fileSystemRepository = localfs.NewFileSystemRepository(root) + mediaRepository = sql.NewMediaRepository(db) + ) + + //TODO: remove later + userRepository.EnsureAdmin(context.Background()) + + // controller + var ( + userController = auth.NewController(userRepository, key) + fileSystemController = filesystem.NewController(fileSystemRepository) + ) + + // view + for _, v := range []view.View{ + view.NewAuthView(userController), + view.NewFileSystemView(*fileSystemController, settingsRepository), + view.NewSettingsView(settingsRepository), + view.NewMediaView(mediaRepository), + } { + v.SetMyselfIn(extRouter) + } + + // worker + var ( + serverWorker = worker.NewServerWorker(&fasthttp.Server{Handler: r.Handler}) + fileScanner = worker.NewFileScanner(root, mediaRepository) + exifScanner = worker.NewEXIFScanner(root, mediaRepository) + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + pool := worker.NewWorkerPool() + pool.AddWorker("http server", serverWorker) + pool.AddWorker("exif scanner", worker.NewWorkerFromListProcessor[*media.Media](exifScanner, scheduler)) + pool.AddWorker("file scanner", worker.NewWorkerFromChanProcessor[string](fileScanner, scheduler)) + + pool.Start(ctx) + pool.Wait() +} @@ -0,0 +1,42 @@ +module git.sr.ht/~gabrielgio/img + +go 1.19 + +require ( + github.com/barasher/go-exiftool v1.10.0 + github.com/fasthttp/router v1.4.19 + github.com/gabriel-vasile/mimetype v1.4.2 + github.com/google/go-cmp v0.5.9 + github.com/samber/lo v1.38.1 + github.com/sirupsen/logrus v1.9.2 + github.com/valyala/fasthttp v1.47.0 + golang.org/x/crypto v0.8.0 + gorm.io/driver/postgres v1.5.2 + gorm.io/driver/sqlite v1.5.0 + gorm.io/gen v0.3.22 + gorm.io/gorm v1.25.1 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.3.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.16.5 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/tools v0.9.3 // indirect + gorm.io/datatypes v1.2.0 // indirect + gorm.io/driver/mysql v1.5.1 // indirect + gorm.io/hints v1.1.2 // indirect + gorm.io/plugin/dbresolver v1.4.1 // indirect +) @@ -0,0 +1,93 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp/router v1.4.19 h1:RLE539IU/S4kfb4MP56zgP0TIBU9kEg0ID9GpWO0vqk= +github.com/fasthttp/router v1.4.19/go.mod h1:+Fh3YOd8x1+he6ZS+d2iUDBH9MGGZ1xQFUor0DE9rKE= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= +github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= +github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= +github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= +golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= +gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= +gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= +gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= +gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c= +gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I= +gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/gen v0.3.22 h1:K7u5tCyaZfe1cbQFD8N2xrTqUuqximNFSRl7zOFPq+M= +gorm.io/gen v0.3.22/go.mod h1:dQcELeF/7Kf82M6AQF+O/rKT5r1sjv49TlGz0cerPn4= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64= +gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o= +gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg= +gorm.io/plugin/dbresolver v1.4.1 h1:Ug4LcoPhrvqq71UhxtF346f+skTYoCa/nEsdjvHwEzk= +gorm.io/plugin/dbresolver v1.4.1/go.mod h1:CTbCtMWhsjXSiJqiW2R8POvJ2cq18RVOl4WGyT5nhNc= diff --git a/golangci.yml b/golangci.yml new file mode 100644 index 0000000..25b47fd --- /dev/null +++ b/golangci.yml @@ -0,0 +1,38 @@ +output: + sort-results: true + +linters: + enable: + - revive + - whitespace + - unconvert + - promlinter + - nilerr + - gofmt + - unparam + - gci + - bodyclose + - makezero + +linters-settings: + gci: + sections: + - standard + - default + - prefix(git.sr.ht/~gabrielgio/img) + skip-generated: true + custom-order: true + errcheck: + ignore: Close + revive: + rules: + - name: unused-parameter + severity: error + disabled: false + - name: package-comments + disabled: true + +issues: + exclude-use-default: false + + timeout: 2m Binary files differdiff --git a/pkg/components/auth/controller.go b/pkg/components/auth/controller.go new file mode 100644 index 0000000..4da6071 --- /dev/null +++ b/pkg/components/auth/controller.go @@ -0,0 +1,57 @@ +package auth + +import ( + "context" + + "golang.org/x/crypto/bcrypt" + + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +type Controller struct { + repository Repository + key []byte +} + +func NewController(repository Repository, key []byte) *Controller { + return &Controller{ + repository: repository, + key: key, + } +} + +func (c *Controller) Login(ctx context.Context, username, password []byte) ([]byte, error) { + id, err := c.repository.GetIDByUsername(ctx, string(username)) + if err != nil { + return nil, err + } + + hashedPassword, err := c.repository.GetPassword(ctx, id) + if err != nil { + return nil, err + } + + if err := bcrypt.CompareHashAndPassword(hashedPassword, password); err != nil { + return nil, err + } + + token := &ext.Token{ + UserID: id, + Username: string(username), + } + return ext.WriteToken(token, c.key) +} + +func (c *Controller) Register(ctx context.Context, username, password []byte) error { + hash, err := bcrypt.GenerateFromPassword(password, bcrypt.MinCost) + if err != nil { + return err + } + + _, err = c.repository.Create(ctx, &CreateUser{ + Username: string(username), + Password: hash, + }) + + return err +} diff --git a/pkg/components/auth/controller_test.go b/pkg/components/auth/controller_test.go new file mode 100644 index 0000000..33aa901 --- /dev/null +++ b/pkg/components/auth/controller_test.go @@ -0,0 +1,190 @@ +//go:build unit + +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + + "git.sr.ht/~gabrielgio/img/pkg/ext" + "git.sr.ht/~gabrielgio/img/pkg/testkit" +) + +type ( + scene struct { + ctx context.Context + mockRepository *MockUserRepository + controller Controller + } + + mockUser struct { + id uint + username string + password []byte + } + + MockUserRepository struct { + index uint + users []*mockUser + err error + } +) + +var ( + _ Repository = &MockUserRepository{} + key = []byte("6368616e676520746869732070617373") +) + +func setUp() *scene { + mockUserRepository := &MockUserRepository{} + return &scene{ + ctx: context.Background(), + mockRepository: mockUserRepository, + controller: *NewController(mockUserRepository, key), + } +} + +func TestRegisterAndLogin(t *testing.T) { + testCases := []struct { + name string + username string + password []byte + }{ + { + name: "Normal register", + username: "username", + password: []byte("password"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + scene := setUp() + + err := scene.controller.Register(scene.ctx, []byte(tc.username), tc.password) + testkit.TestFatalError(t, "Register", err) + + userID := scene.mockRepository.GetLastId() + + user, err := scene.mockRepository.Get(scene.ctx, userID) + testkit.TestFatalError(t, "Get", err) + testkit.TestValue(t, "Register", tc.username, user.Username) + + auth, err := scene.controller.Login(scene.ctx, []byte(tc.username), tc.password) + testkit.TestFatalError(t, "Login", err) + + token, err := ext.ReadToken(auth, key) + testkit.TestFatalError(t, "Login", err) + + testkit.TestValue(t, "Login", tc.username, token.Username) + testkit.TestValue(t, "Login", userID, token.UserID) + }) + } +} + +func toUser(m *mockUser, _ int) *User { + return &User{ + ID: m.id, + Username: m.username, + } +} + +func (m *MockUserRepository) GetLastId() uint { + return m.index +} + +func (m *MockUserRepository) List(ctx context.Context) ([]*User, error) { + if m.err != nil { + return nil, m.err + } + + return lo.Map(m.users, toUser), nil +} + +func (m *MockUserRepository) Get(ctx context.Context, id uint) (*User, error) { + if m.err != nil { + return nil, m.err + } + + for _, m := range m.users { + if m.id == id { + return toUser(m, 0), nil + } + } + return nil, errors.New("Item not found") +} + +func (m *MockUserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) { + if m.err != nil { + return 0, m.err + } + + for _, m := range m.users { + if m.username == username { + return m.id, nil + } + } + return 0, errors.New("Item not found") +} + +func (m *MockUserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + + for _, m := range m.users { + if m.id == id { + return m.password, nil + } + } + return nil, errors.New("Item not found") +} + +func (m *MockUserRepository) Create(ctx context.Context, createUser *CreateUser) (uint, error) { + if m.err != nil { + return 0, m.err + } + + m.index++ + + m.users = append(m.users, &mockUser{ + id: m.index, + username: createUser.Username, + password: createUser.Password, + }) + + return m.index, nil +} + +func (m *MockUserRepository) Update(ctx context.Context, id uint, update *UpdateUser) error { + if m.err != nil { + return m.err + } + + for _, m := range m.users { + if m.id == id { + m.username = update.Username + } + } + return nil +} + +func remove[T any](slice []T, s int) []T { + return append(slice[:s], slice[s+1:]...) +} + +func (r *MockUserRepository) Delete(ctx context.Context, id uint) error { + if r.err != nil { + return r.err + } + + for i, m := range r.users { + if m.id == id { + r.users = remove(r.users, i) + } + } + return nil +} diff --git a/pkg/components/auth/model.go b/pkg/components/auth/model.go new file mode 100644 index 0000000..e46ef49 --- /dev/null +++ b/pkg/components/auth/model.go @@ -0,0 +1,32 @@ +package auth + +import "context" + +type ( + // TODO: move to user later + User struct { + ID uint + Username string + Name string + } + + // TODO: move to user later + UpdateUser struct { + Username string + Name string + } + + // TODO: move to user later + CreateUser struct { + Username string + Name string + Password []byte + } + + Repository interface { + GetIDByUsername(ctx context.Context, username string) (uint, error) + GetPassword(ctx context.Context, id uint) ([]byte, error) + // TODO: move to user later + Create(ctx context.Context, createUser *CreateUser) (uint, error) + } +) diff --git a/pkg/components/filesystem/controller.go b/pkg/components/filesystem/controller.go new file mode 100644 index 0000000..6b478a5 --- /dev/null +++ b/pkg/components/filesystem/controller.go @@ -0,0 +1,89 @@ +package filesystem + +import ( + "io/fs" + "net/url" + "path" + "strings" +) + +type ( + Controller struct { + repository Repository + } + + DirectoryParam struct { + Name string + UrlEncodedPath string + } + + FileParam struct { + UrlEncodedPath string + Info fs.FileInfo + } + + Page struct { + History []*DirectoryParam + Files []*FileParam + } +) + +func NewController(repository Repository) *Controller { + return &Controller{ + repository: repository, + } +} + +func getHistory(filepath string) []*DirectoryParam { + var ( + paths = strings.Split(filepath, "/") + result = make([]*DirectoryParam, 0, len(paths)) + acc = "" + ) + + // add root folder + result = append(result, &DirectoryParam{ + Name: "...", + UrlEncodedPath: "", + }) + + if len(paths) == 1 && paths[0] == "" { + return result + } + + for _, p := range paths { + acc = path.Join(acc, p) + result = append(result, &DirectoryParam{ + Name: p, + UrlEncodedPath: url.QueryEscape(acc), + }) + } + return result +} + +func (self *Controller) GetPage(filepath string) (*Page, error) { + decodedPath, err := url.QueryUnescape(filepath) + if err != nil { + return nil, err + } + + files, err := self.repository.List(decodedPath) + if err != nil { + return nil, err + } + + params := make([]*FileParam, 0, len(files)) + for _, info := range files { + fullPath := path.Join(decodedPath, info.Name()) + scapedFullPath := url.QueryEscape(fullPath) + params = append(params, &FileParam{ + Info: info, + UrlEncodedPath: scapedFullPath, + }) + } + + return &Page{ + Files: params, + History: getHistory(decodedPath), + }, nil +} diff --git a/pkg/components/filesystem/model.go b/pkg/components/filesystem/model.go new file mode 100644 index 0000000..2caed82 --- /dev/null +++ b/pkg/components/filesystem/model.go @@ -0,0 +1,10 @@ +package filesystem + +import "io/fs" + +type ( + Repository interface { + List(path string) ([]fs.FileInfo, error) + Stat(path string) (fs.FileInfo, error) + } +) diff --git a/pkg/components/media/model.go b/pkg/components/media/model.go new file mode 100644 index 0000000..f5c9ff6 --- /dev/null +++ b/pkg/components/media/model.go @@ -0,0 +1,57 @@ +package media + +import ( + "context" + "time" +) + +type ( + Media struct { + ID uint + Name string + Path string + PathHash string + MIMEType string + } + + MediaEXIF struct { + Description *string + Camera *string + Maker *string + Lens *string + DateShot *time.Time + Exposure *float64 + Aperture *float64 + Iso *int64 + FocalLength *float64 + Flash *int64 + Orientation *int64 + ExposureProgram *int64 + GPSLatitude *float64 + GPSLongitude *float64 + } + + Pagination struct { + Page int + Size int + } + + CreateMedia struct { + Name string + Path string + PathHash string + MIMEType string + } + + Repository interface { + Create(context.Context, *CreateMedia) error + Exists(context.Context, string) (bool, error) + List(context.Context, *Pagination) ([]*Media, error) + Get(context.Context, string) (*Media, error) + GetPath(context.Context, string) (string, error) + + GetEmptyEXIF(context.Context, *Pagination) ([]*Media, error) + GetEXIF(context.Context, uint) (*MediaEXIF, error) + CreateEXIF(context.Context, uint, *MediaEXIF) error + } +) diff --git a/pkg/components/settings/model.go b/pkg/components/settings/model.go new file mode 100644 index 0000000..da07f2c --- /dev/null +++ b/pkg/components/settings/model.go @@ -0,0 +1,15 @@ +package settings + +import "context" + +type ( + Settings struct { + ShowMode bool + ShowOwner bool + } + + Repository interface { + Save(context.Context, *Settings) error + Load(context.Context) (*Settings, error) + } +) diff --git a/pkg/database/localfs/filesystem.go b/pkg/database/localfs/filesystem.go new file mode 100644 index 0000000..c7c6458 --- /dev/null +++ b/pkg/database/localfs/filesystem.go @@ -0,0 +1,49 @@ +package localfs + +import ( + "io/fs" + "os" + "path" + "strings" +) + +type FileSystemRepository struct { + root string +} + +func NewFileSystemRepository(root string) *FileSystemRepository { + return &FileSystemRepository{ + root: root, + } +} + +func (self *FileSystemRepository) getFilesFromPath(filepath string) ([]fs.FileInfo, error) { + dirs, err := os.ReadDir(filepath) + if err != nil { + return nil, err + } + + infos := make([]fs.FileInfo, 0, len(dirs)) + for _, dir := range dirs { + if strings.HasPrefix(dir.Name(), ".") { + continue + } + info, err := dir.Info() + if err != nil { + return nil, err + } + infos = append(infos, info) + } + + return infos, nil +} + +func (self *FileSystemRepository) List(filepath string) ([]fs.FileInfo, error) { + workingPath := path.Join(self.root, filepath) + return self.getFilesFromPath(workingPath) +} + +func (self *FileSystemRepository) Stat(filepath string) (fs.FileInfo, error) { + workingPath := path.Join(self.root, filepath) + return os.Stat(workingPath) +} diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go new file mode 100644 index 0000000..835e262 --- /dev/null +++ b/pkg/database/sql/media.go @@ -0,0 +1,238 @@ +package sql + +import ( + "context" + "time" + + "gorm.io/gorm" + + "git.sr.ht/~gabrielgio/img/pkg/components/media" + "git.sr.ht/~gabrielgio/img/pkg/list" +) + +type ( + Media struct { + gorm.Model + Name string `gorm:"not null"` + Path string `gorm:"not null;unique"` + PathHash string `gorm:"not null;unique"` + MIMEType string `gorm:"not null"` + } + + MediaEXIF struct { + gorm.Model + MediaID uint + Media Media + Description *string + Camera *string + Maker *string + Lens *string + DateShot *time.Time + Exposure *float64 + Aperture *float64 + Iso *int64 + FocalLength *float64 + Flash *int64 + Orientation *int64 + ExposureProgram *int64 + GPSLatitude *float64 + GPSLongitude *float64 + } + + MediaRepository struct { + db *gorm.DB + } +) + +var _ media.Repository = &MediaRepository{} + +func (self *Media) ToModel() *media.Media { + return &media.Media{ + ID: self.ID, + Path: self.Path, + PathHash: self.PathHash, + Name: self.Name, + MIMEType: self.MIMEType, + } +} + +func (m *MediaEXIF) ToModel() *media.MediaEXIF { + return &media.MediaEXIF{ + Description: m.Description, + Camera: m.Camera, + Maker: m.Maker, + Lens: m.Lens, + DateShot: m.DateShot, + Exposure: m.Exposure, + Aperture: m.Aperture, + Iso: m.Iso, + FocalLength: m.FocalLength, + Flash: m.Flash, + Orientation: m.Orientation, + ExposureProgram: m.ExposureProgram, + GPSLatitude: m.GPSLatitude, + GPSLongitude: m.GPSLongitude, + } +} + +func NewMediaRepository(db *gorm.DB) *MediaRepository { + return &MediaRepository{ + db: db, + } +} + +func (self *MediaRepository) Create(ctx context.Context, createMedia *media.CreateMedia) error { + media := &Media{ + Name: createMedia.Name, + Path: createMedia.Path, + PathHash: createMedia.PathHash, + MIMEType: createMedia.MIMEType, + } + + result := self.db. + WithContext(ctx). + Create(media) + if result.Error != nil { + return result.Error + } + + return nil +} + +func (self *MediaRepository) Exists(ctx context.Context, path string) (bool, error) { + var exists bool + result := self.db. + WithContext(ctx). + Model(&Media{}). + Select("count(id) > 0"). + Where("path_hash = ?", path). + Find(&exists) + + if result.Error != nil { + return false, result.Error + } + + return exists, nil +} + +func (self *MediaRepository) List(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) { + medias := make([]*Media, 0) + result := self.db. + WithContext(ctx). + Model(&Media{}). + Offset(pagination.Page * pagination.Size). + Limit(pagination.Size). + Order("created_at DESC"). + Find(&medias) + + if result.Error != nil { + return nil, result.Error + } + + m := list.Map(medias, func(s *Media) *media.Media { + return s.ToModel() + }) + + return m, nil +} + +func (self *MediaRepository) Get(ctx context.Context, pathHash string) (*media.Media, error) { + m := &Media{} + result := self.db. + WithContext(ctx). + Model(&Media{}). + Where("path_hash = ?", pathHash). + Limit(1). + Take(m) + + if result.Error != nil { + return nil, result.Error + } + + return m.ToModel(), nil +} + +func (self *MediaRepository) GetPath(ctx context.Context, pathHash string) (string, error) { + var path string + result := self.db. + WithContext(ctx). + Model(&Media{}). + Select("path"). + Where("path_hash = ?", pathHash). + Limit(1). + Find(&path) + + if result.Error != nil { + return "", result.Error + } + + return path, nil +} + +func (m *MediaRepository) GetEXIF(ctx context.Context, mediaID uint) (*media.MediaEXIF, error) { + exif := &MediaEXIF{} + result := m.db. + WithContext(ctx). + Model(&Media{}). + Where("media_id = ?", mediaID). + Limit(1). + Take(m) + + if result.Error != nil { + return nil, result.Error + } + + return exif.ToModel(), nil +} + +func (s *MediaRepository) CreateEXIF(ctx context.Context, id uint, info *media.MediaEXIF) error { + media := &MediaEXIF{ + MediaID: id, + Description: info.Description, + Camera: info.Camera, + Maker: info.Maker, + Lens: info.Lens, + DateShot: info.DateShot, + Exposure: info.Exposure, + Aperture: info.Aperture, + Iso: info.Iso, + FocalLength: info.FocalLength, + Flash: info.Flash, + Orientation: info.Orientation, + ExposureProgram: info.ExposureProgram, + GPSLatitude: info.GPSLatitude, + GPSLongitude: info.GPSLongitude, + } + + result := s.db. + WithContext(ctx). + Create(media) + if result.Error != nil { + return result.Error + } + + return nil +} + +func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) { + medias := make([]*Media, 0) + result := r.db. + WithContext(ctx). + Model(&Media{}). + Joins("left join media_exifs on media.id = media_exifs.media_id"). + Where("media_exifs.media_id IS NULL"). + Offset(pagination.Page * pagination.Size). + Limit(pagination.Size). + Order("created_at DESC"). + Find(&medias) + + if result.Error != nil { + return nil, result.Error + } + + m := list.Map(medias, func(s *Media) *media.Media { + return s.ToModel() + }) + + return m, nil +} diff --git a/pkg/database/sql/migration.go b/pkg/database/sql/migration.go new file mode 100644 index 0000000..019eb91 --- /dev/null +++ b/pkg/database/sql/migration.go @@ -0,0 +1,17 @@ +package sql + +import "gorm.io/gorm" + +func Migrate(db *gorm.DB) error { + for _, m := range []any{ + &User{}, + &Settings{}, + &Media{}, + &MediaEXIF{}, + } { + if err := db.AutoMigrate(m); err != nil { + return err + } + } + return nil +} diff --git a/pkg/database/sql/settings.go b/pkg/database/sql/settings.go new file mode 100644 index 0000000..7ad718b --- /dev/null +++ b/pkg/database/sql/settings.go @@ -0,0 +1,69 @@ +package sql + +import ( + "context" + + "gorm.io/gorm" + + "git.sr.ht/~gabrielgio/img/pkg/components/settings" +) + +type ( + Settings struct { + gorm.Model + ShowMode bool + ShowOwner bool + } + + SettingsRepository struct { + db *gorm.DB + } +) + +var _ settings.Repository = &SettingsRepository{} + +func NewSettingsRespository(db *gorm.DB) *SettingsRepository { + return &SettingsRepository{ + db: db, + } +} + +func (self *SettingsRepository) ensureSettings(ctx context.Context) (*Settings, error) { + var ( + db = self.db.WithContext(ctx) + s = &Settings{} + ) + result := db.Limit(1).Find(s) + if result.Error != nil { + return nil, result.Error + } + + return s, nil +} + +func (self *SettingsRepository) Save(ctx context.Context, toSaveSettings *settings.Settings) error { + db := self.db.WithContext(ctx) + + s, err := self.ensureSettings(ctx) + if err != nil { + return err + } + + s.ShowMode = toSaveSettings.ShowMode + s.ShowOwner = toSaveSettings.ShowOwner + + result := db.Save(s) + return result.Error +} + +func (self *SettingsRepository) Load(ctx context.Context) (*settings.Settings, error) { + s, err := self.ensureSettings(ctx) + if err != nil { + return nil, err + } + + return &settings.Settings{ + ShowMode: s.ShowMode, + ShowOwner: s.ShowOwner, + }, nil +} diff --git a/pkg/database/sql/user.go b/pkg/database/sql/user.go new file mode 100644 index 0000000..d449b05 --- /dev/null +++ b/pkg/database/sql/user.go @@ -0,0 +1,182 @@ +package sql + +import ( + "context" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "git.sr.ht/~gabrielgio/img/pkg/components/auth" + user "git.sr.ht/~gabrielgio/img/pkg/components/auth" +) + +type ( + User struct { + gorm.Model + Username string + Name string + Password string + } + + Users []*User + + UserRepository struct { + db *gorm.DB + } +) + +var _ auth.Repository = &UserRepository{} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{ + db: db, + } +} + +func (self *User) ToModel() *user.User { + return &user.User{ + ID: self.Model.ID, + Name: self.Name, + Username: self.Username, + } +} + +func (self Users) ToModel() (users []*user.User) { + for _, user := range self { + users = append(users, user.ToModel()) + } + return +} + +// Testing function, will remove later +// TODO: remove later +func (self *UserRepository) EnsureAdmin(ctx context.Context) { + var exists bool + self.db. + WithContext(ctx). + Model(&User{}). + Select("count(*) > 0"). + Where("username = ?", "admin"). + Find(&exists) + + if !exists { + hash, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.MinCost) + self.db.Save(&User{ + Username: "admin", + Password: string(hash), + }) + } +} + +func (self *UserRepository) List(ctx context.Context) ([]*user.User, error) { + users := Users{} + result := self.db. + WithContext(ctx). + Find(&users) + + if result.Error != nil { + return nil, result.Error + } + + return users.ToModel(), nil +} + +func (self *UserRepository) Get(ctx context.Context, id uint) (*user.User, error) { + var user = &user.User{ID: id} + result := self.db. + WithContext(ctx). + First(user) + + if result.Error != nil { + return nil, result.Error + } + + return user, nil +} + +func (self *UserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) { + userID := struct { + ID uint + }{} + + result := self.db. + WithContext(ctx). + Model(&User{}). + Where("username = ?", username). + First(&userID) + + if result.Error != nil { + return 0, result.Error + } + + return userID.ID, nil +} + +func (self *UserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) { + userPassword := struct { + Password []byte + }{} + + result := self.db. + WithContext(ctx). + Model(&User{}). + Where("id = ?", id). + First(&userPassword) + + if result.Error != nil { + return nil, result.Error + } + + return userPassword.Password, nil +} + +func (self *UserRepository) Create(ctx context.Context, createUser *user.CreateUser) (uint, error) { + user := &User{ + Username: createUser.Username, + Name: createUser.Name, + Password: string(createUser.Password), + } + + result := self.db. + WithContext(ctx). + Create(user) + if result.Error != nil { + return 0, result.Error + } + + return user.Model.ID, nil +} + +func (self *UserRepository) Update(ctx context.Context, id uint, update *user.UpdateUser) error { + user := &User{ + Model: gorm.Model{ + ID: id, + }, + Username: update.Username, + Name: update.Name, + } + + result := self.db. + WithContext(ctx). + Save(user) + if result.Error != nil { + return result.Error + } + + return nil +} + +func (self *UserRepository) Delete(ctx context.Context, id uint) error { + userID := struct { + ID uint + }{ + ID: id, + } + result := self.db. + WithContext(ctx). + Delete(userID) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/pkg/database/sql/user_test.go b/pkg/database/sql/user_test.go new file mode 100644 index 0000000..875b8e6 --- /dev/null +++ b/pkg/database/sql/user_test.go @@ -0,0 +1,110 @@ +//go:build integration + +package sql + +import ( + "context" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "git.sr.ht/~gabrielgio/img/pkg/components/auth" +) + +func setup(t *testing.T) (*gorm.DB, func()) { + t.Helper() + + file, err := os.CreateTemp("", "img_user_*.db") + if err != nil { + t.Fatalf("Error creating tmp error: %s", err.Error()) + } + + db, err := gorm.Open(sqlite.Open(file.Name()), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + t.Fatalf("Error openning db, error %s", err.Error()) + } + + err = Migrate(db) + if err != nil { + t.Fatalf("Error migrating db, error %s", err.Error()) + } + + return db, func() { + //nolint:errcheck + os.Remove(file.Name()) + } +} + +func TestCreate(t *testing.T) { + t.Parallel() + db, tearDown := setup(t) + defer tearDown() + + repository := NewUserRepository(db) + + id, err := repository.Create(context.Background(), &auth.CreateUser{ + Username: "new_username", + Name: "new_name", + }) + if err != nil { + t.Fatalf("Error creating: %s", err.Error()) + } + + got, err := repository.Get(context.Background(), id) + if err != nil { + t.Fatalf("Error getting: %s", err.Error()) + } + want := &auth.User{ + ID: id, + Username: "new_username", + Name: "new_name", + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff) + } +} + +func TestUpdate(t *testing.T) { + t.Parallel() + db, tearDown := setup(t) + defer tearDown() + + repository := NewUserRepository(db) + + id, err := repository.Create(context.Background(), &auth.CreateUser{ + Username: "username", + Name: "name", + }) + if err != nil { + t.Fatalf("Error creating user: %s", err.Error()) + } + + err = repository.Update(context.Background(), id, &auth.UpdateUser{ + Username: "new_username", + Name: "new_name", + }) + if err != nil { + t.Fatalf("Error update user: %s", err.Error()) + } + + got, err := repository.Get(context.Background(), id) + if err != nil { + t.Fatalf("Error getting user: %s", err.Error()) + } + want := &auth.User{ + ID: id, + Username: "new_username", + Name: "new_name", + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff) + } +} diff --git a/pkg/ext/auth.go b/pkg/ext/auth.go new file mode 100644 index 0000000..d9fbfba --- /dev/null +++ b/pkg/ext/auth.go @@ -0,0 +1,72 @@ +package ext + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/gob" + "fmt" + "io" +) + +type Token struct { + UserID uint + Username string +} + +var nonce []byte + +func init() { + nonce = make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + fmt.Println("Erro while generating nonce " + err.Error()) + panic(1) + } +} + +func ReadToken(data []byte, key []byte) (*Token, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + panic(err.Error()) + } + + plaintext, err := aesgcm.Open(nil, nonce, data, nil) + if err != nil { + return nil, err + } + + r := bytes.NewReader(plaintext) + var token Token + dec := gob.NewDecoder(r) + if err = dec.Decode(&token); err != nil { + return nil, err + } + return &token, nil +} + +func WriteToken(token *Token, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + var buffer bytes.Buffer + enc := gob.NewEncoder(&buffer) + if err := enc.Encode(token); err != nil { + return nil, err + } + + ciphertext := aesgcm.Seal(nil, nonce, buffer.Bytes(), nil) + return ciphertext, nil +} diff --git a/pkg/ext/auth_test.go b/pkg/ext/auth_test.go new file mode 100644 index 0000000..dc72a0c --- /dev/null +++ b/pkg/ext/auth_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package ext + +import ( + "testing" + + "git.sr.ht/~gabrielgio/img/pkg/testkit" +) + +func TestReadWriteToken(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + key []byte + token *Token + }{ + { + name: "Normal write", + key: []byte("AES256Key-32Characters1234567890"), + token: &Token{ + UserID: 3, + Username: "username", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + data, err := WriteToken(tc.token, tc.key) + testkit.TestFatalError(t, "WriteToken", err) + + token, err := ReadToken(data, tc.key) + testkit.TestFatalError(t, "ReadToken", err) + + testkit.TestValue(t, "ReadWriteToken", token, tc.token) + }) + } +} diff --git a/pkg/ext/gorm_logger.go b/pkg/ext/gorm_logger.go new file mode 100644 index 0000000..bfb26d2 --- /dev/null +++ b/pkg/ext/gorm_logger.go @@ -0,0 +1,58 @@ +package ext + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + "gorm.io/gorm/logger" + "gorm.io/gorm/utils" +) + +type Log struct { + logrus *logrus.Entry +} + +func getFullMsg(msg string, data ...interface{}) string { + return fmt.Sprintf(msg, append([]interface{}{utils.FileWithLineNum()}, data...)...) +} + +func (self *Log) LogMode(log logger.LogLevel) logger.Interface { + return self +} + +func (self *Log) Info(ctx context.Context, msg string, data ...interface{}) { + fullMsg := getFullMsg(msg, data) + self.logrus. + WithContext(ctx). + Info(fullMsg) +} + +func (self *Log) Warn(ctx context.Context, msg string, data ...interface{}) { + fullMsg := getFullMsg(msg, data) + self.logrus. + WithContext(ctx). + Warn(fullMsg) +} +func (self *Log) Error(ctx context.Context, msg string, data ...interface{}) { + fullMsg := getFullMsg(msg, data) + self.logrus. + WithContext(ctx). + Error(fullMsg) +} + +func (self *Log) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + elapsed := time.Since(begin) + sql, _ := fc() + self.logrus. + WithContext(ctx). + WithField("time", elapsed). + Printf(sql) +} + +func Wraplog(log *logrus.Entry) *Log { + return &Log{ + logrus: log, + } +} diff --git a/pkg/ext/middleware.go b/pkg/ext/middleware.go new file mode 100644 index 0000000..771c0ac --- /dev/null +++ b/pkg/ext/middleware.go @@ -0,0 +1,89 @@ +package ext + +import ( + "encoding/base64" + "time" + + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" +) + +func HTML(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + ctx.Response.Header.SetContentType("text/html") + next(ctx) + } +} + +type LogMiddleware struct { + entry *logrus.Entry +} + +func NewLogMiddleare(log *logrus.Entry) *LogMiddleware { + return &LogMiddleware{ + entry: log, + } +} + +func (l *LogMiddleware) HTTP(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + start := time.Now() + next(ctx) + elapsed := time.Since(start) + l.entry. + WithField("time", elapsed). + WithField("code", ctx.Response.StatusCode()). + WithField("path", string(ctx.Path())). + WithField("bytes", len(ctx.Response.Body())). + Info(string(ctx.Request.Header.Method())) + } +} + +type AuthMiddleware struct { + key []byte + entry *logrus.Entry +} + +func NewAuthMiddleware(key []byte, log *logrus.Entry) *AuthMiddleware { + return &AuthMiddleware{ + key: key, + entry: log.WithField("context", "auth"), + } +} + +func (a *AuthMiddleware) LoggedIn(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + path := string(ctx.Path()) + if path == "/login" { + next(ctx) + return + } + + redirectLogin := "/login?redirect=" + path + authBase64 := ctx.Request.Header.Cookie("auth") + if authBase64 == nil { + a.entry.Info("No auth provided") + ctx.Redirect(redirectLogin, 307) + return + } + + auth, err := base64.StdEncoding.DecodeString(string(authBase64)) + if err != nil { + a.entry.Error(err) + return + } + + token, err := ReadToken(auth, a.key) + if err != nil { + a.entry.Error(err) + ctx.Redirect(redirectLogin, 307) + return + } + ctx.SetUserValue("token", token) + a.entry. + WithField("userID", token.UserID). + WithField("username", token.Username). + Info("user recognized") + next(ctx) + } +} diff --git a/pkg/ext/responses.go b/pkg/ext/responses.go new file mode 100644 index 0000000..7354395 --- /dev/null +++ b/pkg/ext/responses.go @@ -0,0 +1,50 @@ +package ext + +import ( + "bytes" + "fmt" + + "github.com/valyala/fasthttp" + + "git.sr.ht/~gabrielgio/img" +) + +var ( + ContentTypeJSON = []byte("application/json") + ContentTypeHTML = []byte("text/html") + ContentTypeMARKDOWN = []byte("text/markdown") + ContentTypeJPEG = []byte("image/jpeg") +) + +func NotFoundHTML(ctx *fasthttp.RequestCtx) { + ctx.Response.Header.SetContentType("text/html") + //nolint:errcheck + img.Render(ctx, "error.html", &img.HTMLView[string]{ + Data: "NotFound", + }) +} + +func NotFound(ctx *fasthttp.RequestCtx) { + ctx.Response.SetStatusCode(404) + ct := ctx.Response.Header.ContentType() + if bytes.Equal(ct, ContentTypeHTML) { + NotFoundHTML(ctx) + } +} + +func InternalServerError(ctx *fasthttp.RequestCtx, err error) { + ctx.Response.Header.SetContentType("text/html") + message := fmt.Sprintf("Internal Server Error:\n%+v", err) + //nolint:errcheck + respErr := img.Render(ctx, "error.html", &img.HTMLView[string]{ + Data: message, + }) + + if respErr != nil { + fmt.Println(respErr.Error()) + } +} + +func NoContent(ctx *fasthttp.RequestCtx) { + ctx.Response.SetStatusCode(204) +} diff --git a/pkg/ext/router.go b/pkg/ext/router.go new file mode 100644 index 0000000..74f0a95 --- /dev/null +++ b/pkg/ext/router.go @@ -0,0 +1,51 @@ +package ext + +import ( + "github.com/fasthttp/router" + "github.com/valyala/fasthttp" +) + +type ( + Router struct { + middlewares []Middleware + fastRouter *router.Router + } + Middleware func(next fasthttp.RequestHandler) fasthttp.RequestHandler + ErrorRequestHandler func(ctx *fasthttp.RequestCtx) error +) + +func NewRouter(nestedRouter *router.Router) *Router { + return &Router{ + fastRouter: nestedRouter, + } +} + +func (self *Router) AddMiddleware(middleware Middleware) { + self.middlewares = append(self.middlewares, middleware) +} + +func wrapError(next ErrorRequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + if err := next(ctx); err != nil { + ctx.Response.SetStatusCode(500) + InternalServerError(ctx, err) + } + } +} + +func (self *Router) run(next ErrorRequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + req := wrapError(next) + for _, r := range self.middlewares { + req = r(req) + } + req(ctx) + } +} + +func (self *Router) GET(path string, handler ErrorRequestHandler) { + self.fastRouter.GET(path, self.run(handler)) +} +func (self *Router) POST(path string, handler ErrorRequestHandler) { + self.fastRouter.POST(path, self.run(handler)) +} diff --git a/pkg/fileop/exif.go b/pkg/fileop/exif.go new file mode 100644 index 0000000..48e495c --- /dev/null +++ b/pkg/fileop/exif.go @@ -0,0 +1,165 @@ +package fileop + +import ( + "math" + "time" + + "git.sr.ht/~gabrielgio/img/pkg/components/media" + "github.com/barasher/go-exiftool" +) + +func ReadExif(path string) (*media.MediaEXIF, error) { + et, err := exiftool.NewExiftool() + if err != nil { + return nil, err + } + defer et.Close() + + newExif := &media.MediaEXIF{} + fileInfo := et.ExtractMetadata(path)[0] + + // Get description + description, err := fileInfo.GetString("ImageDescription") + if err == nil { + newExif.Description = &description + } + + // Get camera model + model, err := fileInfo.GetString("Model") + if err == nil { + newExif.Camera = &model + } + + // Get Camera make + make, err := fileInfo.GetString("Make") + if err == nil { + newExif.Maker = &make + } + + // Get lens + lens, err := fileInfo.GetString("LensModel") + if err == nil { + newExif.Lens = &lens + } + + //Get time of photo + createDateKeys := []string{ + "CreationDate", + "DateTimeOriginal", + "CreateDate", + "TrackCreateDate", + "MediaCreateDate", + "FileCreateDate", + "ModifyDate", + "TrackModifyDate", + "MediaModifyDate", + "FileModifyDate", + } + for _, createDateKey := range createDateKeys { + date, err := fileInfo.GetString(createDateKey) + if err == nil { + layout := "2006:01:02 15:04:05" + dateTime, err := time.Parse(layout, date) + if err == nil { + newExif.DateShot = &dateTime + } else { + layoutWithOffset := "2006:01:02 15:04:05+02:00" + dateTime, err = time.Parse(layoutWithOffset, date) + if err == nil { + newExif.DateShot = &dateTime + } + } + break + } + } + + // Get exposure time + exposureTime, err := fileInfo.GetFloat("ExposureTime") + if err == nil { + newExif.Exposure = &exposureTime + } + + // Get aperture + aperture, err := fileInfo.GetFloat("Aperture") + if err == nil { + newExif.Aperture = &aperture + } + + // Get ISO + iso, err := fileInfo.GetInt("ISO") + if err == nil { + newExif.Iso = &iso + } + + // Get focal length + focalLen, err := fileInfo.GetFloat("FocalLength") + if err == nil { + newExif.FocalLength = &focalLen + } + + // Get flash info + flash, err := fileInfo.GetInt("Flash") + if err == nil { + newExif.Flash = &flash + } + + // Get orientation + orientation, err := fileInfo.GetInt("Orientation") + if err == nil { + newExif.Orientation = &orientation + } + + // Get exposure program + expProgram, err := fileInfo.GetInt("ExposureProgram") + if err == nil { + newExif.ExposureProgram = &expProgram + } + + // GPS coordinates - longitude + longitudeRaw, err := fileInfo.GetFloat("GPSLongitude") + if err == nil { + newExif.GPSLongitude = &longitudeRaw + } + + // GPS coordinates - latitude + latitudeRaw, err := fileInfo.GetFloat("GPSLatitude") + if err == nil { + newExif.GPSLatitude = &latitudeRaw + } + + sanitizeEXIF(newExif) + + return newExif, nil +} + +// isFloatReal returns true when the float value represents a real number +// (different than +Inf, -Inf or NaN) +func isFloatReal(v float64) bool { + if math.IsInf(v, 1) { + return false + } else if math.IsInf(v, -1) { + return false + } else if math.IsNaN(v) { + return false + } + return true +} + +// sanitizeEXIF removes any EXIF float64 field that is not a real number (+Inf, +// -Inf or Nan) +func sanitizeEXIF(exif *media.MediaEXIF) { + if exif.Exposure != nil && !isFloatReal(*exif.Exposure) { + exif.Exposure = nil + } + if exif.Aperture != nil && !isFloatReal(*exif.Aperture) { + exif.Aperture = nil + } + if exif.FocalLength != nil && !isFloatReal(*exif.FocalLength) { + exif.FocalLength = nil + } + if (exif.GPSLatitude != nil && !isFloatReal(*exif.GPSLatitude)) || + (exif.GPSLongitude != nil && !isFloatReal(*exif.GPSLongitude)) { + exif.GPSLatitude = nil + exif.GPSLongitude = nil + } +} diff --git a/pkg/list/list.go b/pkg/list/list.go new file mode 100644 index 0000000..ff259f7 --- /dev/null +++ b/pkg/list/list.go @@ -0,0 +1,9 @@ +package list + +func Map[V any, T any](source []V, fun func(V) T) []T { + result := make([]T, 0, len(source)) + for _, s := range source { + result = append(result, fun(s)) + } + return result +} diff --git a/pkg/testkit/testkit.go b/pkg/testkit/testkit.go new file mode 100644 index 0000000..526e1b3 --- /dev/null +++ b/pkg/testkit/testkit.go @@ -0,0 +1,31 @@ +//go:build unit || integration + +package testkit + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestValue[T any](t *testing.T, method string, want, got T) { + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s() mismatch (-want +got):\n%s", method, diff) + } +} + +func TestFatalError(t *testing.T, method string, err error) { + if err != nil { + t.Fatalf("%s() fatal error : %+v", method, err) + } +} + +func TestError(t *testing.T, method string, want, got error) { + if !equalError(want, got) { + t.Errorf("%s() err mismatch want: %+v got %+v", method, want, got) + } +} + +func equalError(a, b error) bool { + return a == nil && b == nil || a != nil && b != nil && a.Error() == b.Error() +} diff --git a/pkg/view/auth.go b/pkg/view/auth.go new file mode 100644 index 0000000..5c83eba --- /dev/null +++ b/pkg/view/auth.go @@ -0,0 +1,97 @@ +package view + +import ( + "encoding/base64" + + "github.com/valyala/fasthttp" + + "git.sr.ht/~gabrielgio/img" + "git.sr.ht/~gabrielgio/img/pkg/components/auth" + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +type AuthView struct { + userController *auth.Controller +} + +func NewAuthView(userController *auth.Controller) *AuthView { + return &AuthView{ + userController: userController, + } +} + +func (v *AuthView) LoginView(ctx *fasthttp.RequestCtx) error { + return img.Render[interface{}](ctx, "login.html", nil) +} + +func (v *AuthView) Logout(ctx *fasthttp.RequestCtx) error { + cook := fasthttp.Cookie{} + cook.SetKey("auth") + cook.SetValue("") + cook.SetMaxAge(-1) + cook.SetHTTPOnly(true) + cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode) + ctx.Response.Header.SetCookie(&cook) + + ctx.Redirect("/", 307) + return nil +} + +func (v *AuthView) Login(ctx *fasthttp.RequestCtx) error { + username := ctx.FormValue("username") + password := ctx.FormValue("password") + + auth, err := v.userController.Login(ctx, username, password) + if err != nil { + return err + } + + base64Auth := base64.StdEncoding.EncodeToString(auth) + + cook := fasthttp.Cookie{} + cook.SetKey("auth") + cook.SetValue(base64Auth) + cook.SetHTTPOnly(true) + cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode) + ctx.Response.Header.SetCookie(&cook) + + redirect := string(ctx.FormValue("redirect")) + if redirect == "" { + ctx.Redirect("/", 307) + } else { + ctx.Redirect(redirect, 307) + } + return nil +} + +func (v *AuthView) RegisterView(ctx *fasthttp.RequestCtx) error { + return img.Render[interface{}](ctx, "register.html", nil) +} + +func (v *AuthView) Register(ctx *fasthttp.RequestCtx) error { + username := ctx.FormValue("username") + password := ctx.FormValue("password") + + err := v.userController.Register(ctx, username, password) + if err != nil { + return err + } + + ctx.Redirect("/login", 307) + return nil +} + +func Index(ctx *fasthttp.RequestCtx) { + ctx.Redirect("/login", 307) +} + +func (v *AuthView) SetMyselfIn(r *ext.Router) { + r.GET("/login", v.LoginView) + r.POST("/login", v.Login) + + r.GET("/register", v.RegisterView) + r.POST("/register", v.Register) + + r.GET("/logout", v.Logout) + r.POST("/logout", v.Logout) +} diff --git a/pkg/view/filesystem.go b/pkg/view/filesystem.go new file mode 100644 index 0000000..f10d788 --- /dev/null +++ b/pkg/view/filesystem.go @@ -0,0 +1,66 @@ +package view + +import ( + "github.com/valyala/fasthttp" + + "git.sr.ht/~gabrielgio/img" + "git.sr.ht/~gabrielgio/img/pkg/components/filesystem" + "git.sr.ht/~gabrielgio/img/pkg/components/settings" + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +type ( + FileSystemView struct { + controller filesystem.Controller + settings settings.Repository + } + FilePage struct { + Page *filesystem.Page + ShowMode bool + ShowOwner bool + } +) + +func NewFileSystemView( + controller filesystem.Controller, + settingsRepository settings.Repository, +) *FileSystemView { + return &FileSystemView{ + controller: controller, + settings: settingsRepository, + } +} + +func (self *FileSystemView) Index(ctx *fasthttp.RequestCtx) error { + pathValue := string(ctx.FormValue("path")) + + page, err := self.controller.GetPage(pathValue) + if err != nil { + return err + } + + settings, err := self.settings.Load(ctx) + if err != nil { + return err + } + + err = img.Render(ctx, "fs.html", &img.HTMLView[*FilePage]{ + Title: pathValue, + Data: &FilePage{ + Page: page, + ShowMode: settings.ShowMode, + ShowOwner: settings.ShowOwner, + }, + }) + if err != nil { + return err + } + return nil +} + +func (self *FileSystemView) SetMyselfIn(r *ext.Router) { + r.GET("/", self.Index) + r.POST("/", self.Index) + r.GET("/fs/", self.Index) + r.POST("/fs/", self.Index) +} diff --git a/pkg/view/media.go b/pkg/view/media.go new file mode 100644 index 0000000..22f950d --- /dev/null +++ b/pkg/view/media.go @@ -0,0 +1,101 @@ +package view + +import ( + "strconv" + + "github.com/valyala/fasthttp" + + "git.sr.ht/~gabrielgio/img" + "git.sr.ht/~gabrielgio/img/pkg/components/media" + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +type ( + MediaView struct { + mediaRepository media.Repository + } + + Page struct { + Medias []*media.Media + Next *media.Pagination + } +) + +func getPagination(ctx *fasthttp.RequestCtx) *media.Pagination { + var ( + size int + page int + sizeStr = string(ctx.FormValue("size")) + pageStr = string(ctx.FormValue("page")) + ) + + if sizeStr == "" { + size = 100 + } else if s, err := strconv.Atoi(sizeStr); err != nil { + size = 100 + } else { + size = s + } + + if pageStr == "" { + page = 0 + } else if p, err := strconv.Atoi(pageStr); err != nil { + page = 0 + } else { + page = p + } + + return &media.Pagination{ + Page: page, + Size: size, + } +} + +func NewMediaView(mediaRepository media.Repository) *MediaView { + return &MediaView{ + mediaRepository: mediaRepository, + } +} + +func (self *MediaView) Index(ctx *fasthttp.RequestCtx) error { + p := getPagination(ctx) + medias, err := self.mediaRepository.List(ctx, p) + if err != nil { + return err + } + + err = img.Render(ctx, "media.html", &img.HTMLView[*Page]{ + Title: "Media", + Data: &Page{ + Medias: medias, + Next: &media.Pagination{ + Size: p.Size, + Page: p.Page + 1, + }, + }, + }) + if err != nil { + return err + } + return nil +} + +func (self *MediaView) GetImage(ctx *fasthttp.RequestCtx) error { + pathHash := string(ctx.FormValue("path_hash")) + + media, err := self.mediaRepository.Get(ctx, pathHash) + if err != nil { + return err + } + + ctx.Response.Header.SetContentType(media.MIMEType) + ctx.SendFile(media.Path) + return nil +} + +func (self *MediaView) SetMyselfIn(r *ext.Router) { + r.GET("/media", self.Index) + r.POST("/media", self.Index) + + r.GET("/media/image", self.GetImage) +} diff --git a/pkg/view/settings.go b/pkg/view/settings.go new file mode 100644 index 0000000..746dee4 --- /dev/null +++ b/pkg/view/settings.go @@ -0,0 +1,53 @@ +package view + +import ( + "github.com/valyala/fasthttp" + + "git.sr.ht/~gabrielgio/img" + "git.sr.ht/~gabrielgio/img/pkg/components/settings" + "git.sr.ht/~gabrielgio/img/pkg/ext" +) + +type SettingsView struct { + // there is not need to create a controller for this + repository settings.Repository +} + +func NewSettingsView(respository settings.Repository) *SettingsView { + return &SettingsView{ + repository: respository, + } +} + +func (self *SettingsView) Index(ctx *fasthttp.RequestCtx) error { + s, err := self.repository.Load(ctx) + if err != nil { + return err + } + return img.Render(ctx, "settings.html", &img.HTMLView[*settings.Settings]{ + Title: "Settings", + Data: s, + }) +} + +func (self *SettingsView) Save(ctx *fasthttp.RequestCtx) error { + var ( + showMode = string(ctx.FormValue("showMode")) == "on" + showOwner = string(ctx.FormValue("showOwner")) == "on" + ) + + err := self.repository.Save(ctx, &settings.Settings{ + ShowMode: showMode, + ShowOwner: showOwner, + }) + if err != nil { + return err + } + + return self.Index(ctx) +} + +func (self *SettingsView) SetMyselfIn(r *ext.Router) { + r.GET("/settings/", self.Index) + r.POST("/settings/", self.Save) +} diff --git a/pkg/view/view.go b/pkg/view/view.go new file mode 100644 index 0000000..663738b --- /dev/null +++ b/pkg/view/view.go @@ -0,0 +1,7 @@ +package view + +import "git.sr.ht/~gabrielgio/img/pkg/ext" + +type View interface { + SetMyselfIn(r *ext.Router) +} diff --git a/pkg/worker/exif_scanner.go b/pkg/worker/exif_scanner.go new file mode 100644 index 0000000..66091cd --- /dev/null +++ b/pkg/worker/exif_scanner.go @@ -0,0 +1,43 @@ +package worker + +import ( + "context" + + "git.sr.ht/~gabrielgio/img/pkg/components/media" + "git.sr.ht/~gabrielgio/img/pkg/fileop" +) + +type ( + EXIFScanner struct { + repository media.Repository + } +) + +var _ ListProcessor[*media.Media] = &EXIFScanner{} + +func NewEXIFScanner(root string, repository media.Repository) *EXIFScanner { + return &EXIFScanner{ + repository: repository, + } +} + +func (e *EXIFScanner) Query(ctx context.Context) ([]*media.Media, error) { + medias, err := e.repository.GetEmptyEXIF(ctx, &media.Pagination{ + Page: 0, + Size: 100, + }) + if err != nil { + return nil, err + } + + return medias, nil +} + +func (e *EXIFScanner) Process(ctx context.Context, m *media.Media) error { + newExif, err := fileop.ReadExif(m.Path) + if err != nil { + return err + } + + return e.repository.CreateEXIF(ctx, m.ID, newExif) +} diff --git a/pkg/worker/file_scanner.go b/pkg/worker/file_scanner.go new file mode 100644 index 0000000..321fbca --- /dev/null +++ b/pkg/worker/file_scanner.go @@ -0,0 +1,81 @@ +package worker + +import ( + "context" + "crypto/md5" + "encoding/hex" + "io/fs" + "path/filepath" + + "github.com/gabriel-vasile/mimetype" + + "git.sr.ht/~gabrielgio/img/pkg/components/media" +) + +type ( + FileScanner struct { + root string + repository media.Repository + } +) + +var _ ChanProcessor[string] = &FileScanner{} + +func NewFileScanner(root string, repository media.Repository) *FileScanner { + return &FileScanner{ + root: root, + repository: repository, + } +} + +func (f *FileScanner) Query(ctx context.Context) (<-chan string, error) { + c := make(chan string) + go func() { + defer close(c) + _ = filepath.Walk(f.root, func(path string, info fs.FileInfo, err error) error { + if info.IsDir() && filepath.Base(info.Name())[0] == '.' { + return filepath.SkipDir + } + + if info.IsDir() { + return nil + } + + if filepath.Ext(info.Name()) != ".jpg" && + filepath.Ext(info.Name()) != ".jpeg" && + filepath.Ext(info.Name()) != ".png" { + return nil + } + c <- path + return nil + }) + }() + return c, nil +} + +func (f *FileScanner) Process(ctx context.Context, path string) error { + hash := md5.Sum([]byte(path)) + str := hex.EncodeToString(hash[:]) + name := filepath.Base(path) + + exists, errResp := f.repository.Exists(ctx, str) + if errResp != nil { + return errResp + } + + if exists { + return nil + } + + mime, errResp := mimetype.DetectFile(path) + if errResp != nil { + return errResp + } + + return f.repository.Create(ctx, &media.CreateMedia{ + Name: name, + Path: path, + PathHash: str, + MIMEType: mime.String(), + }) +} diff --git a/pkg/worker/httpserver.go b/pkg/worker/httpserver.go new file mode 100644 index 0000000..181cf73 --- /dev/null +++ b/pkg/worker/httpserver.go @@ -0,0 +1,31 @@ +package worker + +import ( + "context" + + "github.com/valyala/fasthttp" +) + +type ServerWorker struct { + server *fasthttp.Server +} + +func (self *ServerWorker) Start(ctx context.Context) error { + go func() { + // nolint: errcheck + self.server.ListenAndServe("0.0.0.0:8080") + }() + + <-ctx.Done() + return self.Shutdown() +} + +func (self *ServerWorker) Shutdown() error { + return self.server.Shutdown() +} + +func NewServerWorker(server *fasthttp.Server) *ServerWorker { + return &ServerWorker{ + server: server, + } +} diff --git a/pkg/worker/list_processor.go b/pkg/worker/list_processor.go new file mode 100644 index 0000000..d53b7ea --- /dev/null +++ b/pkg/worker/list_processor.go @@ -0,0 +1,102 @@ +package worker + +import ( + "context" +) + +type ( + + // A simple worker to deal with list. + ChanProcessor[T any] interface { + Query(context.Context) (<-chan T, error) + Process(context.Context, T) error + } + + ListProcessor[T any] interface { + Query(context.Context) ([]T, error) + Process(context.Context, T) error + } + + chanProcessorWorker[T any] struct { + chanProcessor ChanProcessor[T] + scheduler *Scheduler + } + + listProcessorWorker[T any] struct { + listProcessor ListProcessor[T] + scheduler *Scheduler + } +) + +func NewWorkerFromListProcessor[T any]( + listProcessor ListProcessor[T], + scheduler *Scheduler, +) Worker { + return &listProcessorWorker[T]{ + listProcessor: listProcessor, + scheduler: scheduler, + } +} + +func NewWorkerFromChanProcessor[T any]( + listProcessor ChanProcessor[T], + scheduler *Scheduler, +) Worker { + return &chanProcessorWorker[T]{ + chanProcessor: listProcessor, + scheduler: scheduler, + } +} + +func (l *listProcessorWorker[T]) Start(ctx context.Context) error { + for { + values, err := l.listProcessor.Query(ctx) + if err != nil { + return err + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if len(values) == 0 { + return nil + } + + for _, v := range values { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := l.listProcessor.Process(ctx, v); err != nil { + return err + } + } + } +} + +func (l *chanProcessorWorker[T]) Start(ctx context.Context) error { + c, err := l.chanProcessor.Query(ctx) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case v, ok := <-c: + if !ok { + return nil + } + + if err := l.chanProcessor.Process(ctx, v); err != nil { + return err + } + } + } +} diff --git a/pkg/worker/list_processor_test.go b/pkg/worker/list_processor_test.go new file mode 100644 index 0000000..b7373d1 --- /dev/null +++ b/pkg/worker/list_processor_test.go @@ -0,0 +1,90 @@ +// go:build unit + +package worker + +import ( + "context" + "errors" + "math/rand" + "sync" + "testing" + + "git.sr.ht/~gabrielgio/img/pkg/testkit" +) + +type ( + mockCounterListProcessor struct { + done bool + countTo int + counter int + } + + mockContextListProcessor struct { + } +) + +func TestListProcessorLimit(t *testing.T) { + mock := &mockCounterListProcessor{ + countTo: 10000, + } + worker := NewWorkerFromListProcessor[int](mock, nil) + + err := worker.Start(context.Background()) + testkit.TestFatalError(t, "Start", err) + + testkit.TestValue(t, "Start", mock.countTo, mock.counter) +} + +func TestListProcessorContextCancelQuery(t *testing.T) { + mock := &mockContextListProcessor{} + worker := NewWorkerFromListProcessor[int](mock, nil) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + err := worker.Start(ctx) + if errors.Is(err, context.Canceled) { + return + } + testkit.TestFatalError(t, "Start", err) + }() + + cancel() + // this rely on timeout to test + wg.Wait() +} + +func (m *mockCounterListProcessor) Query(_ context.Context) ([]int, error) { + if m.done { + return make([]int, 0), nil + } + values := make([]int, 0, m.countTo) + for i := 0; i < m.countTo; i++ { + values = append(values, rand.Int()) + } + + m.done = true + return values, nil +} + +func (m *mockCounterListProcessor) Process(_ context.Context, _ int) error { + m.counter++ + return nil +} + +func (m *mockContextListProcessor) Query(_ context.Context) ([]int, error) { + // keeps returning the query so it can run in infinity loop + values := make([]int, 0, 10) + for i := 0; i < 10; i++ { + values = append(values, rand.Int()) + } + return values, nil +} + +func (m *mockContextListProcessor) Process(_ context.Context, _ int) error { + // do nothing + return nil +} diff --git a/pkg/worker/scheduler.go b/pkg/worker/scheduler.go new file mode 100644 index 0000000..b410b33 --- /dev/null +++ b/pkg/worker/scheduler.go @@ -0,0 +1,29 @@ +package worker + +import ( + "fmt" + "sync/atomic" +) + +type Scheduler struct { + pool chan any + count atomic.Int64 +} + +func NewScheduler(count uint) *Scheduler { + return &Scheduler{ + pool: make(chan any, count), + } +} + +func (self *Scheduler) Take() { + self.pool <- nil + self.count.Add(1) + fmt.Printf("<- %d\n", self.count.Load()) +} + +func (self *Scheduler) Return() { + <-self.pool + self.count.Add(-1) + fmt.Printf("-> %d\n", self.count.Load()) +} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go new file mode 100644 index 0000000..c52f0be --- /dev/null +++ b/pkg/worker/worker.go @@ -0,0 +1,54 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "sync" +) + +type ( + // Worker should watch for context + Worker interface { + Start(context.Context) error + } + + Work struct { + Name string + Worker Worker + } + + WorkerPool struct { + workers []*Work + wg sync.WaitGroup + } +) + +func NewWorkerPool() *WorkerPool { + return &WorkerPool{} +} + +func (self *WorkerPool) AddWorker(name string, worker Worker) { + self.workers = append(self.workers, &Work{ + Name: name, + Worker: worker, + }) +} + +func (self *WorkerPool) Start(ctx context.Context) { + for _, w := range self.workers { + self.wg.Add(1) + go func(w *Work) { + defer self.wg.Done() + if err := w.Worker.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { + fmt.Println("Error ", w.Name, err.Error()) + } else { + fmt.Println(w.Name, "done") + } + }(w) + } +} + +func (self *WorkerPool) Wait() { + self.wg.Wait() +} diff --git a/scss/bulma b/scss/bulma new file mode 160000 +Subproject 3e00a8e6d0d0e566d507328f0185ef84854effb diff --git a/scss/main.scss b/scss/main.scss new file mode 100644 index 0000000..bf6b3d8 --- /dev/null +++ b/scss/main.scss @@ -0,0 +1,70 @@ +$breakpoint: 520px; + +$tablet: $breakpoint; +$body-font-size: 1.3rem; +$radius-rounded: 0; +$container-max-width: 920px; + +$navbar-breakpoint: $breakpoint; + +$panel-item-border: 1px solid hsl(0, 0%, 93%); +$panel-radius: 0; +$panel-shadow: 0; + +$card-shadow: 0; +$card-radius: 0; + +@import "bulma/sass/base/_all.sass"; +@import "bulma/sass/utilities/_all.sass"; +@import "bulma/sass/grid/_all.sass"; +@import "bulma/sass/components/_all.sass"; +@import "bulma/sass/form/_all.sass"; +@import "bulma/sass/helpers/_all.sass"; +@import "bulma/sass/layout/_all.sass"; +@import "bulma/sass/elements/_all.sass"; + +body { + font-family: $family-primary +} + +.input, .button{ + border-radius: 0; +} + +.file-row { + width: 100%; + font-family: monospace; +} + +nav { + border-bottom: 1px solid; + max-width: 1024px; + margin: auto; +} + +.container { + margin-top: 15px; + + @include until($breakpoint) { + margin-left: 15px; + margin-right: 15px; + } +} + +.card { + margin: 5px; +} + + +.image.is-fit { + height: auto; + width: 130px; + + @include until($breakpoint) { + width: auto; + } +} + +.img { + object-fit: cover; +} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..cbde400 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,5 @@ +{{template "layout.html" .}} +{{define "title"}} Not Found {{end}} +{{define "content"}} +{{.}} +{{end}} diff --git a/templates/fs.html b/templates/fs.html new file mode 100644 index 0000000..608289d --- /dev/null +++ b/templates/fs.html @@ -0,0 +1,29 @@ +{{template "layout.html" .}} +{{define "title"}} {{.Title}} {{end}} +{{define "content"}} +<div class="panel"> + <div class="panel-block"> + <div class="columns file-row is-gapless is-mobile"> + <div id="path" class="container-fluid"> + <small>{{range .Data.Page.History}}<a href="/fs?path={{.UrlEncodedPath}}" >{{.Name}}/</a>{{end}}</small> + </div> + </div> + </div> + {{range .Data.Page.Files}} + <div class="panel-block"> + <div class="columns file-row is-gapless is-mobile"> + <div class="column"> + {{if $.Data.ShowMode}}{{.Info.Mode}} {{end}} + {{if $.Data.ShowOwner}}{{.Info.Sys.Gid}}:{{.Info.Sys.Uid}} {{end}} + {{if .Info.IsDir}} + <a href="/?path={{.UrlEncodedPath}}">{{.Info.Name}}/</a> + {{else}} + {{.Info.Name}} + {{end}} + </div> + <div class="column has-text-right">{{.Info.Size}} B</div> + </div> + </div> + {{end}} +</div> +{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..56d02f8 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>img | {{block "title" .}} noop {{end}}</title> + <link rel="stylesheet" href="/static/main.css"> + <link rel="icon" href="static/square.svg" sizes="any" type="image/svg+xml"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + </head> + <body> + <nav class="navbar"> + <div class="navbar-start"> + <a href="/fs" class="navbar-item"> + files + </a> + <a href="/media" class="navbar-item"> + media + </a> + <a href="/settings" class="navbar-item"> + settings + </a> + </div> + </nav> + <div class="container is-max-desktop"> + {{block "content" .}}noop{{end}} + </div> + </body> + {{block "script" .}}{{end}} +</html> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..f71d9d3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,24 @@ +{{template "layout.html" .}} +{{define "title"}} Register {{end}} +{{define "content"}} +<form action="/login" method="post"> + <div class="field"> + <label class="label">Username</label> + <div class="control"> + <input class="input" name="username" type="text"> + </div> + </div> + <div class="field"> + <label class="label">Password</label> + <div class="control"> + <input class="input" name="password" type="password"> + </div> + </div> + <div class="field"> + <a href="#" class="is-pulled-left">forgot password?</a> + </div> + <div class="field"> + <input class="button is-pulled-right" value="login" type="submit"> + </div> +</form> +{{end}} diff --git a/templates/media.html b/templates/media.html new file mode 100644 index 0000000..478d8ae --- /dev/null +++ b/templates/media.html @@ -0,0 +1,18 @@ +{{template "layout.html" .}} +{{define "title"}} {{.Title}} {{end}} +{{define "content"}} +<div class="columns is-multiline"> +{{range .Data.Medias}} +<div class="card"> + <div class="card-image"> + <figure class="image is-fit"> + <img src="/media/image?path_hash={{.PathHash}}"> + </figure> + </div> +</div> +{{end}} +</div> +<div class="row"> + <a href="/media?page={{.Data.Next.Page}}" class="button is-pulled-right">next</a> +</div> +{{end}} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..f8423fc --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,25 @@ +{{template "layout.html" .}} +{{define "title"}} {{.Title}} {{end}} +{{define "content"}} +<form action="/settings/", method="post"> + <div class="field"> + <div class="control"> + <label class="checkbox"> + <input type="checkbox" id="showMode" name="showMode" {{if .Data.ShowMode}}checked{{end}}> + Show File Modes + </label> + </div> + </div> + <div class="field"> + <div class="control"> + <label class="checkbox"> + <input type="checkbox" id="showOwner" name="showOwner" {{if .Data.ShowOwner}}checked{{end}}> + Show File Owner + </label> + </div> + </div> + <div class="field"> + <input class="button" value="save" type="submit"> + </div> +</form> +{{end}} @@ -0,0 +1,29 @@ +package img + +import ( + "embed" + "fmt" + "html/template" + "io" +) + +//go:embed templates/*.html +var TemplateFS embed.FS + +var Template *template.Template + +type HTMLView[T any] struct { + Title string + Username string + Data T +} + +func Render[T any](w io.Writer, page string, view *HTMLView[T]) error { + pageFile := fmt.Sprintf("templates/%s", page) + tmpl, err := template.New("").ParseFS(TemplateFS, "templates/layout.html", pageFile) + if err != nil { + return err + } + + return tmpl.ExecuteTemplate(w, page, view) +} |