1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000000000000000000000000000000000000..b06c8abc37de3837d42f95e6a132cf7d0b810f1f
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,7 @@
7+vendor/
8+static/
9+bin/
10+media_cache/
11+
12+.env
13+*.db
14diff --git a/.gitmodules b/.gitmodules
15new file mode 100644
16index 0000000000000000000000000000000000000000..e76d9ef66b6ba19840ec335a7a82980b2ed1cd05
17--- /dev/null
18+++ b/.gitmodules
19@@ -0,0 +1,3 @@
20+[submodule "scss/bulma"]
21+ path = scss/bulma
22+ url = https://github.com/jgthms/bulma.git
23diff --git a/LICENSE b/LICENSE
24new file mode 100644
25index 0000000000000000000000000000000000000000..e4a541caef67a411af02919d19ae4b5324d506d4
26--- /dev/null
27+++ b/LICENSE
28@@ -0,0 +1,661 @@
29+ GNU AFFERO GENERAL PUBLIC LICENSE
30+ Version 3, 19 November 2007
31+
32+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
33+ Everyone is permitted to copy and distribute verbatim copies
34+ of this license document, but changing it is not allowed.
35+
36+ Preamble
37+
38+ The GNU Affero General Public License is a free, copyleft license for
39+software and other kinds of works, specifically designed to ensure
40+cooperation with the community in the case of network server software.
41+
42+ The licenses for most software and other practical works are designed
43+to take away your freedom to share and change the works. By contrast,
44+our General Public Licenses are intended to guarantee your freedom to
45+share and change all versions of a program--to make sure it remains free
46+software for all its users.
47+
48+ When we speak of free software, we are referring to freedom, not
49+price. Our General Public Licenses are designed to make sure that you
50+have the freedom to distribute copies of free software (and charge for
51+them if you wish), that you receive source code or can get it if you
52+want it, that you can change the software or use pieces of it in new
53+free programs, and that you know you can do these things.
54+
55+ Developers that use our General Public Licenses protect your rights
56+with two steps: (1) assert copyright on the software, and (2) offer
57+you this License which gives you legal permission to copy, distribute
58+and/or modify the software.
59+
60+ A secondary benefit of defending all users' freedom is that
61+improvements made in alternate versions of the program, if they
62+receive widespread use, become available for other developers to
63+incorporate. Many developers of free software are heartened and
64+encouraged by the resulting cooperation. However, in the case of
65+software used on network servers, this result may fail to come about.
66+The GNU General Public License permits making a modified version and
67+letting the public access it on a server without ever releasing its
68+source code to the public.
69+
70+ The GNU Affero General Public License is designed specifically to
71+ensure that, in such cases, the modified source code becomes available
72+to the community. It requires the operator of a network server to
73+provide the source code of the modified version running there to the
74+users of that server. Therefore, public use of a modified version, on
75+a publicly accessible server, gives the public access to the source
76+code of the modified version.
77+
78+ An older license, called the Affero General Public License and
79+published by Affero, was designed to accomplish similar goals. This is
80+a different license, not a version of the Affero GPL, but Affero has
81+released a new version of the Affero GPL which permits relicensing under
82+this license.
83+
84+ The precise terms and conditions for copying, distribution and
85+modification follow.
86+
87+ TERMS AND CONDITIONS
88+
89+ 0. Definitions.
90+
91+ "This License" refers to version 3 of the GNU Affero General Public License.
92+
93+ "Copyright" also means copyright-like laws that apply to other kinds of
94+works, such as semiconductor masks.
95+
96+ "The Program" refers to any copyrightable work licensed under this
97+License. Each licensee is addressed as "you". "Licensees" and
98+"recipients" may be individuals or organizations.
99+
100+ To "modify" a work means to copy from or adapt all or part of the work
101+in a fashion requiring copyright permission, other than the making of an
102+exact copy. The resulting work is called a "modified version" of the
103+earlier work or a work "based on" the earlier work.
104+
105+ A "covered work" means either the unmodified Program or a work based
106+on the Program.
107+
108+ To "propagate" a work means to do anything with it that, without
109+permission, would make you directly or secondarily liable for
110+infringement under applicable copyright law, except executing it on a
111+computer or modifying a private copy. Propagation includes copying,
112+distribution (with or without modification), making available to the
113+public, and in some countries other activities as well.
114+
115+ To "convey" a work means any kind of propagation that enables other
116+parties to make or receive copies. Mere interaction with a user through
117+a computer network, with no transfer of a copy, is not conveying.
118+
119+ An interactive user interface displays "Appropriate Legal Notices"
120+to the extent that it includes a convenient and prominently visible
121+feature that (1) displays an appropriate copyright notice, and (2)
122+tells the user that there is no warranty for the work (except to the
123+extent that warranties are provided), that licensees may convey the
124+work under this License, and how to view a copy of this License. If
125+the interface presents a list of user commands or options, such as a
126+menu, a prominent item in the list meets this criterion.
127+
128+ 1. Source Code.
129+
130+ The "source code" for a work means the preferred form of the work
131+for making modifications to it. "Object code" means any non-source
132+form of a work.
133+
134+ A "Standard Interface" means an interface that either is an official
135+standard defined by a recognized standards body, or, in the case of
136+interfaces specified for a particular programming language, one that
137+is widely used among developers working in that language.
138+
139+ The "System Libraries" of an executable work include anything, other
140+than the work as a whole, that (a) is included in the normal form of
141+packaging a Major Component, but which is not part of that Major
142+Component, and (b) serves only to enable use of the work with that
143+Major Component, or to implement a Standard Interface for which an
144+implementation is available to the public in source code form. A
145+"Major Component", in this context, means a major essential component
146+(kernel, window system, and so on) of the specific operating system
147+(if any) on which the executable work runs, or a compiler used to
148+produce the work, or an object code interpreter used to run it.
149+
150+ The "Corresponding Source" for a work in object code form means all
151+the source code needed to generate, install, and (for an executable
152+work) run the object code and to modify the work, including scripts to
153+control those activities. However, it does not include the work's
154+System Libraries, or general-purpose tools or generally available free
155+programs which are used unmodified in performing those activities but
156+which are not part of the work. For example, Corresponding Source
157+includes interface definition files associated with source files for
158+the work, and the source code for shared libraries and dynamically
159+linked subprograms that the work is specifically designed to require,
160+such as by intimate data communication or control flow between those
161+subprograms and other parts of the work.
162+
163+ The Corresponding Source need not include anything that users
164+can regenerate automatically from other parts of the Corresponding
165+Source.
166+
167+ The Corresponding Source for a work in source code form is that
168+same work.
169+
170+ 2. Basic Permissions.
171+
172+ All rights granted under this License are granted for the term of
173+copyright on the Program, and are irrevocable provided the stated
174+conditions are met. This License explicitly affirms your unlimited
175+permission to run the unmodified Program. The output from running a
176+covered work is covered by this License only if the output, given its
177+content, constitutes a covered work. This License acknowledges your
178+rights of fair use or other equivalent, as provided by copyright law.
179+
180+ You may make, run and propagate covered works that you do not
181+convey, without conditions so long as your license otherwise remains
182+in force. You may convey covered works to others for the sole purpose
183+of having them make modifications exclusively for you, or provide you
184+with facilities for running those works, provided that you comply with
185+the terms of this License in conveying all material for which you do
186+not control copyright. Those thus making or running the covered works
187+for you must do so exclusively on your behalf, under your direction
188+and control, on terms that prohibit them from making any copies of
189+your copyrighted material outside their relationship with you.
190+
191+ Conveying under any other circumstances is permitted solely under
192+the conditions stated below. Sublicensing is not allowed; section 10
193+makes it unnecessary.
194+
195+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
196+
197+ No covered work shall be deemed part of an effective technological
198+measure under any applicable law fulfilling obligations under article
199+11 of the WIPO copyright treaty adopted on 20 December 1996, or
200+similar laws prohibiting or restricting circumvention of such
201+measures.
202+
203+ When you convey a covered work, you waive any legal power to forbid
204+circumvention of technological measures to the extent such circumvention
205+is effected by exercising rights under this License with respect to
206+the covered work, and you disclaim any intention to limit operation or
207+modification of the work as a means of enforcing, against the work's
208+users, your or third parties' legal rights to forbid circumvention of
209+technological measures.
210+
211+ 4. Conveying Verbatim Copies.
212+
213+ You may convey verbatim copies of the Program's source code as you
214+receive it, in any medium, provided that you conspicuously and
215+appropriately publish on each copy an appropriate copyright notice;
216+keep intact all notices stating that this License and any
217+non-permissive terms added in accord with section 7 apply to the code;
218+keep intact all notices of the absence of any warranty; and give all
219+recipients a copy of this License along with the Program.
220+
221+ You may charge any price or no price for each copy that you convey,
222+and you may offer support or warranty protection for a fee.
223+
224+ 5. Conveying Modified Source Versions.
225+
226+ You may convey a work based on the Program, or the modifications to
227+produce it from the Program, in the form of source code under the
228+terms of section 4, provided that you also meet all of these conditions:
229+
230+ a) The work must carry prominent notices stating that you modified
231+ it, and giving a relevant date.
232+
233+ b) The work must carry prominent notices stating that it is
234+ released under this License and any conditions added under section
235+ 7. This requirement modifies the requirement in section 4 to
236+ "keep intact all notices".
237+
238+ c) You must license the entire work, as a whole, under this
239+ License to anyone who comes into possession of a copy. This
240+ License will therefore apply, along with any applicable section 7
241+ additional terms, to the whole of the work, and all its parts,
242+ regardless of how they are packaged. This License gives no
243+ permission to license the work in any other way, but it does not
244+ invalidate such permission if you have separately received it.
245+
246+ d) If the work has interactive user interfaces, each must display
247+ Appropriate Legal Notices; however, if the Program has interactive
248+ interfaces that do not display Appropriate Legal Notices, your
249+ work need not make them do so.
250+
251+ A compilation of a covered work with other separate and independent
252+works, which are not by their nature extensions of the covered work,
253+and which are not combined with it such as to form a larger program,
254+in or on a volume of a storage or distribution medium, is called an
255+"aggregate" if the compilation and its resulting copyright are not
256+used to limit the access or legal rights of the compilation's users
257+beyond what the individual works permit. Inclusion of a covered work
258+in an aggregate does not cause this License to apply to the other
259+parts of the aggregate.
260+
261+ 6. Conveying Non-Source Forms.
262+
263+ You may convey a covered work in object code form under the terms
264+of sections 4 and 5, provided that you also convey the
265+machine-readable Corresponding Source under the terms of this License,
266+in one of these ways:
267+
268+ a) Convey the object code in, or embodied in, a physical product
269+ (including a physical distribution medium), accompanied by the
270+ Corresponding Source fixed on a durable physical medium
271+ customarily used for software interchange.
272+
273+ b) Convey the object code in, or embodied in, a physical product
274+ (including a physical distribution medium), accompanied by a
275+ written offer, valid for at least three years and valid for as
276+ long as you offer spare parts or customer support for that product
277+ model, to give anyone who possesses the object code either (1) a
278+ copy of the Corresponding Source for all the software in the
279+ product that is covered by this License, on a durable physical
280+ medium customarily used for software interchange, for a price no
281+ more than your reasonable cost of physically performing this
282+ conveying of source, or (2) access to copy the
283+ Corresponding Source from a network server at no charge.
284+
285+ c) Convey individual copies of the object code with a copy of the
286+ written offer to provide the Corresponding Source. This
287+ alternative is allowed only occasionally and noncommercially, and
288+ only if you received the object code with such an offer, in accord
289+ with subsection 6b.
290+
291+ d) Convey the object code by offering access from a designated
292+ place (gratis or for a charge), and offer equivalent access to the
293+ Corresponding Source in the same way through the same place at no
294+ further charge. You need not require recipients to copy the
295+ Corresponding Source along with the object code. If the place to
296+ copy the object code is a network server, the Corresponding Source
297+ may be on a different server (operated by you or a third party)
298+ that supports equivalent copying facilities, provided you maintain
299+ clear directions next to the object code saying where to find the
300+ Corresponding Source. Regardless of what server hosts the
301+ Corresponding Source, you remain obligated to ensure that it is
302+ available for as long as needed to satisfy these requirements.
303+
304+ e) Convey the object code using peer-to-peer transmission, provided
305+ you inform other peers where the object code and Corresponding
306+ Source of the work are being offered to the general public at no
307+ charge under subsection 6d.
308+
309+ A separable portion of the object code, whose source code is excluded
310+from the Corresponding Source as a System Library, need not be
311+included in conveying the object code work.
312+
313+ A "User Product" is either (1) a "consumer product", which means any
314+tangible personal property which is normally used for personal, family,
315+or household purposes, or (2) anything designed or sold for incorporation
316+into a dwelling. In determining whether a product is a consumer product,
317+doubtful cases shall be resolved in favor of coverage. For a particular
318+product received by a particular user, "normally used" refers to a
319+typical or common use of that class of product, regardless of the status
320+of the particular user or of the way in which the particular user
321+actually uses, or expects or is expected to use, the product. A product
322+is a consumer product regardless of whether the product has substantial
323+commercial, industrial or non-consumer uses, unless such uses represent
324+the only significant mode of use of the product.
325+
326+ "Installation Information" for a User Product means any methods,
327+procedures, authorization keys, or other information required to install
328+and execute modified versions of a covered work in that User Product from
329+a modified version of its Corresponding Source. The information must
330+suffice to ensure that the continued functioning of the modified object
331+code is in no case prevented or interfered with solely because
332+modification has been made.
333+
334+ If you convey an object code work under this section in, or with, or
335+specifically for use in, a User Product, and the conveying occurs as
336+part of a transaction in which the right of possession and use of the
337+User Product is transferred to the recipient in perpetuity or for a
338+fixed term (regardless of how the transaction is characterized), the
339+Corresponding Source conveyed under this section must be accompanied
340+by the Installation Information. But this requirement does not apply
341+if neither you nor any third party retains the ability to install
342+modified object code on the User Product (for example, the work has
343+been installed in ROM).
344+
345+ The requirement to provide Installation Information does not include a
346+requirement to continue to provide support service, warranty, or updates
347+for a work that has been modified or installed by the recipient, or for
348+the User Product in which it has been modified or installed. Access to a
349+network may be denied when the modification itself materially and
350+adversely affects the operation of the network or violates the rules and
351+protocols for communication across the network.
352+
353+ Corresponding Source conveyed, and Installation Information provided,
354+in accord with this section must be in a format that is publicly
355+documented (and with an implementation available to the public in
356+source code form), and must require no special password or key for
357+unpacking, reading or copying.
358+
359+ 7. Additional Terms.
360+
361+ "Additional permissions" are terms that supplement the terms of this
362+License by making exceptions from one or more of its conditions.
363+Additional permissions that are applicable to the entire Program shall
364+be treated as though they were included in this License, to the extent
365+that they are valid under applicable law. If additional permissions
366+apply only to part of the Program, that part may be used separately
367+under those permissions, but the entire Program remains governed by
368+this License without regard to the additional permissions.
369+
370+ When you convey a copy of a covered work, you may at your option
371+remove any additional permissions from that copy, or from any part of
372+it. (Additional permissions may be written to require their own
373+removal in certain cases when you modify the work.) You may place
374+additional permissions on material, added by you to a covered work,
375+for which you have or can give appropriate copyright permission.
376+
377+ Notwithstanding any other provision of this License, for material you
378+add to a covered work, you may (if authorized by the copyright holders of
379+that material) supplement the terms of this License with terms:
380+
381+ a) Disclaiming warranty or limiting liability differently from the
382+ terms of sections 15 and 16 of this License; or
383+
384+ b) Requiring preservation of specified reasonable legal notices or
385+ author attributions in that material or in the Appropriate Legal
386+ Notices displayed by works containing it; or
387+
388+ c) Prohibiting misrepresentation of the origin of that material, or
389+ requiring that modified versions of such material be marked in
390+ reasonable ways as different from the original version; or
391+
392+ d) Limiting the use for publicity purposes of names of licensors or
393+ authors of the material; or
394+
395+ e) Declining to grant rights under trademark law for use of some
396+ trade names, trademarks, or service marks; or
397+
398+ f) Requiring indemnification of licensors and authors of that
399+ material by anyone who conveys the material (or modified versions of
400+ it) with contractual assumptions of liability to the recipient, for
401+ any liability that these contractual assumptions directly impose on
402+ those licensors and authors.
403+
404+ All other non-permissive additional terms are considered "further
405+restrictions" within the meaning of section 10. If the Program as you
406+received it, or any part of it, contains a notice stating that it is
407+governed by this License along with a term that is a further
408+restriction, you may remove that term. If a license document contains
409+a further restriction but permits relicensing or conveying under this
410+License, you may add to a covered work material governed by the terms
411+of that license document, provided that the further restriction does
412+not survive such relicensing or conveying.
413+
414+ If you add terms to a covered work in accord with this section, you
415+must place, in the relevant source files, a statement of the
416+additional terms that apply to those files, or a notice indicating
417+where to find the applicable terms.
418+
419+ Additional terms, permissive or non-permissive, may be stated in the
420+form of a separately written license, or stated as exceptions;
421+the above requirements apply either way.
422+
423+ 8. Termination.
424+
425+ You may not propagate or modify a covered work except as expressly
426+provided under this License. Any attempt otherwise to propagate or
427+modify it is void, and will automatically terminate your rights under
428+this License (including any patent licenses granted under the third
429+paragraph of section 11).
430+
431+ However, if you cease all violation of this License, then your
432+license from a particular copyright holder is reinstated (a)
433+provisionally, unless and until the copyright holder explicitly and
434+finally terminates your license, and (b) permanently, if the copyright
435+holder fails to notify you of the violation by some reasonable means
436+prior to 60 days after the cessation.
437+
438+ Moreover, your license from a particular copyright holder is
439+reinstated permanently if the copyright holder notifies you of the
440+violation by some reasonable means, this is the first time you have
441+received notice of violation of this License (for any work) from that
442+copyright holder, and you cure the violation prior to 30 days after
443+your receipt of the notice.
444+
445+ Termination of your rights under this section does not terminate the
446+licenses of parties who have received copies or rights from you under
447+this License. If your rights have been terminated and not permanently
448+reinstated, you do not qualify to receive new licenses for the same
449+material under section 10.
450+
451+ 9. Acceptance Not Required for Having Copies.
452+
453+ You are not required to accept this License in order to receive or
454+run a copy of the Program. Ancillary propagation of a covered work
455+occurring solely as a consequence of using peer-to-peer transmission
456+to receive a copy likewise does not require acceptance. However,
457+nothing other than this License grants you permission to propagate or
458+modify any covered work. These actions infringe copyright if you do
459+not accept this License. Therefore, by modifying or propagating a
460+covered work, you indicate your acceptance of this License to do so.
461+
462+ 10. Automatic Licensing of Downstream Recipients.
463+
464+ Each time you convey a covered work, the recipient automatically
465+receives a license from the original licensors, to run, modify and
466+propagate that work, subject to this License. You are not responsible
467+for enforcing compliance by third parties with this License.
468+
469+ An "entity transaction" is a transaction transferring control of an
470+organization, or substantially all assets of one, or subdividing an
471+organization, or merging organizations. If propagation of a covered
472+work results from an entity transaction, each party to that
473+transaction who receives a copy of the work also receives whatever
474+licenses to the work the party's predecessor in interest had or could
475+give under the previous paragraph, plus a right to possession of the
476+Corresponding Source of the work from the predecessor in interest, if
477+the predecessor has it or can get it with reasonable efforts.
478+
479+ You may not impose any further restrictions on the exercise of the
480+rights granted or affirmed under this License. For example, you may
481+not impose a license fee, royalty, or other charge for exercise of
482+rights granted under this License, and you may not initiate litigation
483+(including a cross-claim or counterclaim in a lawsuit) alleging that
484+any patent claim is infringed by making, using, selling, offering for
485+sale, or importing the Program or any portion of it.
486+
487+ 11. Patents.
488+
489+ A "contributor" is a copyright holder who authorizes use under this
490+License of the Program or a work on which the Program is based. The
491+work thus licensed is called the contributor's "contributor version".
492+
493+ A contributor's "essential patent claims" are all patent claims
494+owned or controlled by the contributor, whether already acquired or
495+hereafter acquired, that would be infringed by some manner, permitted
496+by this License, of making, using, or selling its contributor version,
497+but do not include claims that would be infringed only as a
498+consequence of further modification of the contributor version. For
499+purposes of this definition, "control" includes the right to grant
500+patent sublicenses in a manner consistent with the requirements of
501+this License.
502+
503+ Each contributor grants you a non-exclusive, worldwide, royalty-free
504+patent license under the contributor's essential patent claims, to
505+make, use, sell, offer for sale, import and otherwise run, modify and
506+propagate the contents of its contributor version.
507+
508+ In the following three paragraphs, a "patent license" is any express
509+agreement or commitment, however denominated, not to enforce a patent
510+(such as an express permission to practice a patent or covenant not to
511+sue for patent infringement). To "grant" such a patent license to a
512+party means to make such an agreement or commitment not to enforce a
513+patent against the party.
514+
515+ If you convey a covered work, knowingly relying on a patent license,
516+and the Corresponding Source of the work is not available for anyone
517+to copy, free of charge and under the terms of this License, through a
518+publicly available network server or other readily accessible means,
519+then you must either (1) cause the Corresponding Source to be so
520+available, or (2) arrange to deprive yourself of the benefit of the
521+patent license for this particular work, or (3) arrange, in a manner
522+consistent with the requirements of this License, to extend the patent
523+license to downstream recipients. "Knowingly relying" means you have
524+actual knowledge that, but for the patent license, your conveying the
525+covered work in a country, or your recipient's use of the covered work
526+in a country, would infringe one or more identifiable patents in that
527+country that you have reason to believe are valid.
528+
529+ If, pursuant to or in connection with a single transaction or
530+arrangement, you convey, or propagate by procuring conveyance of, a
531+covered work, and grant a patent license to some of the parties
532+receiving the covered work authorizing them to use, propagate, modify
533+or convey a specific copy of the covered work, then the patent license
534+you grant is automatically extended to all recipients of the covered
535+work and works based on it.
536+
537+ A patent license is "discriminatory" if it does not include within
538+the scope of its coverage, prohibits the exercise of, or is
539+conditioned on the non-exercise of one or more of the rights that are
540+specifically granted under this License. You may not convey a covered
541+work if you are a party to an arrangement with a third party that is
542+in the business of distributing software, under which you make payment
543+to the third party based on the extent of your activity of conveying
544+the work, and under which the third party grants, to any of the
545+parties who would receive the covered work from you, a discriminatory
546+patent license (a) in connection with copies of the covered work
547+conveyed by you (or copies made from those copies), or (b) primarily
548+for and in connection with specific products or compilations that
549+contain the covered work, unless you entered into that arrangement,
550+or that patent license was granted, prior to 28 March 2007.
551+
552+ Nothing in this License shall be construed as excluding or limiting
553+any implied license or other defenses to infringement that may
554+otherwise be available to you under applicable patent law.
555+
556+ 12. No Surrender of Others' Freedom.
557+
558+ If conditions are imposed on you (whether by court order, agreement or
559+otherwise) that contradict the conditions of this License, they do not
560+excuse you from the conditions of this License. If you cannot convey a
561+covered work so as to satisfy simultaneously your obligations under this
562+License and any other pertinent obligations, then as a consequence you may
563+not convey it at all. For example, if you agree to terms that obligate you
564+to collect a royalty for further conveying from those to whom you convey
565+the Program, the only way you could satisfy both those terms and this
566+License would be to refrain entirely from conveying the Program.
567+
568+ 13. Remote Network Interaction; Use with the GNU General Public License.
569+
570+ Notwithstanding any other provision of this License, if you modify the
571+Program, your modified version must prominently offer all users
572+interacting with it remotely through a computer network (if your version
573+supports such interaction) an opportunity to receive the Corresponding
574+Source of your version by providing access to the Corresponding Source
575+from a network server at no charge, through some standard or customary
576+means of facilitating copying of software. This Corresponding Source
577+shall include the Corresponding Source for any work covered by version 3
578+of the GNU General Public License that is incorporated pursuant to the
579+following paragraph.
580+
581+ Notwithstanding any other provision of this License, you have
582+permission to link or combine any covered work with a work licensed
583+under version 3 of the GNU General Public License into a single
584+combined work, and to convey the resulting work. The terms of this
585+License will continue to apply to the part which is the covered work,
586+but the work with which it is combined will remain governed by version
587+3 of the GNU General Public License.
588+
589+ 14. Revised Versions of this License.
590+
591+ The Free Software Foundation may publish revised and/or new versions of
592+the GNU Affero General Public License from time to time. Such new versions
593+will be similar in spirit to the present version, but may differ in detail to
594+address new problems or concerns.
595+
596+ Each version is given a distinguishing version number. If the
597+Program specifies that a certain numbered version of the GNU Affero General
598+Public License "or any later version" applies to it, you have the
599+option of following the terms and conditions either of that numbered
600+version or of any later version published by the Free Software
601+Foundation. If the Program does not specify a version number of the
602+GNU Affero General Public License, you may choose any version ever published
603+by the Free Software Foundation.
604+
605+ If the Program specifies that a proxy can decide which future
606+versions of the GNU Affero General Public License can be used, that proxy's
607+public statement of acceptance of a version permanently authorizes you
608+to choose that version for the Program.
609+
610+ Later license versions may give you additional or different
611+permissions. However, no additional obligations are imposed on any
612+author or copyright holder as a result of your choosing to follow a
613+later version.
614+
615+ 15. Disclaimer of Warranty.
616+
617+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
618+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
619+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
620+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
621+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
622+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
623+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
624+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
625+
626+ 16. Limitation of Liability.
627+
628+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
629+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
630+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
631+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
632+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
633+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
634+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
635+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
636+SUCH DAMAGES.
637+
638+ 17. Interpretation of Sections 15 and 16.
639+
640+ If the disclaimer of warranty and limitation of liability provided
641+above cannot be given local legal effect according to their terms,
642+reviewing courts shall apply local law that most closely approximates
643+an absolute waiver of all civil liability in connection with the
644+Program, unless a warranty or assumption of liability accompanies a
645+copy of the Program in return for a fee.
646+
647+ END OF TERMS AND CONDITIONS
648+
649+ How to Apply These Terms to Your New Programs
650+
651+ If you develop a new program, and you want it to be of the greatest
652+possible use to the public, the best way to achieve this is to make it
653+free software which everyone can redistribute and change under these terms.
654+
655+ To do so, attach the following notices to the program. It is safest
656+to attach them to the start of each source file to most effectively
657+state the exclusion of warranty; and each file should have at least
658+the "copyright" line and a pointer to where the full notice is found.
659+
660+ <one line to give the program's name and a brief idea of what it does.>
661+ Copyright (C) <year> <name of author>
662+
663+ This program is free software: you can redistribute it and/or modify
664+ it under the terms of the GNU Affero General Public License as published by
665+ the Free Software Foundation, either version 3 of the License, or
666+ (at your option) any later version.
667+
668+ This program is distributed in the hope that it will be useful,
669+ but WITHOUT ANY WARRANTY; without even the implied warranty of
670+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
671+ GNU Affero General Public License for more details.
672+
673+ You should have received a copy of the GNU Affero General Public License
674+ along with this program. If not, see <http://www.gnu.org/licenses/>.
675+
676+Also add information on how to contact you by electronic and paper mail.
677+
678+ If your software can interact with users remotely through a computer
679+network, you should also make sure that it provides a way for users to
680+get its source. For example, if your program is a web application, its
681+interface could display a "Source" link that leads users to an archive
682+of the code. There are many ways you could offer source, and different
683+solutions will be better for different programs; see section 13 for the
684+specific requirements.
685+
686+ You should also get your employer (if you work as a programmer) or school,
687+if any, to sign a "copyright disclaimer" for the program, if necessary.
688+For more information on this, and how to apply and follow the GNU AGPL, see
689+<http://www.gnu.org/licenses/>.
690diff --git a/Makefile b/Makefile
691new file mode 100644
692index 0000000000000000000000000000000000000000..9d82dd93b49920d80cc38fee9643687ecb0fc8ea
693--- /dev/null
694+++ b/Makefile
695@@ -0,0 +1,59 @@
696+BIN=img
697+OUT=./bin/$(BIN)
698+SERVER=./cmd/server/main.go
699+
700+GO_TEST=go test -v -timeout 100ms -shuffle on -parallel `nproc`
701+GO_BUILD=go build -v
702+GO_RUN=go run -v
703+
704+all: build sass
705+
706+build:
707+ $(GO_BUILD) -o $(OUT) $(SERVER)
708+
709+run: sass
710+ $(GO_RUN) $(SERVER)
711+
712+sass:
713+ @mkdir -p static
714+ sassc \
715+ -I scss scss/main.scss static/main.css \
716+ --style compressed
717+
718+test: test.unit test.integration
719+
720+test.all: gci test.unit test.integration lint
721+
722+test.integration:
723+ $(GO_TEST) -tags=integration ./...
724+
725+test.unit:
726+ $(GO_TEST) -tags=unit ./...
727+
728+gen:
729+ go run -v \
730+ ./cmd/ggen/...
731+
732+cover.%:
733+ $(GO_TEST) \
734+ -tags=$* \
735+ -coverprofile=bin/cover \
736+ ./...
737+ go tool cover \
738+ -html=bin/cover \
739+ -o bin/cover.html
740+ echo "open bin/cover.html"
741+
742+lint:
743+ golangci-lint run \
744+ --fix \
745+ --config golangci.yml \
746+ --verbose \
747+ ./...
748+
749+gci:
750+ find . \
751+ -type f \
752+ -name "*.go" \
753+ -not -path "./vendor/*" \
754+ -exec gci write -s standard -s default -s "prefix(git.sr.ht/~gabrielgio/img)" {} +
755diff --git a/README.md b/README.md
756new file mode 100644
757index 0000000000000000000000000000000000000000..055853af9f6b7fdec426d42cfeabe77d942b7390
758--- /dev/null
759+++ b/README.md
760@@ -0,0 +1,9 @@
761+# IMG
762+
763+A read only file explorer with media (mainly photos) capabilities.
764+
765+## Task
766+
767+* File Scanner (up to the user)
768+* EXIF Extractor (every minute recomended)
769+* Thumbnailer (up to the user)
770diff --git a/cmd/ggen/main.go b/cmd/ggen/main.go
771new file mode 100644
772index 0000000000000000000000000000000000000000..b5197390638a8fc7f4f82c75feb6efe9306ac5e7
773--- /dev/null
774+++ b/cmd/ggen/main.go
775@@ -0,0 +1,43 @@
776+package main
777+
778+import (
779+ "github.com/sirupsen/logrus"
780+ "gorm.io/driver/sqlite"
781+ "gorm.io/gen"
782+ "gorm.io/gorm"
783+
784+ "git.sr.ht/~gabrielgio/img/pkg/database/sql"
785+ "git.sr.ht/~gabrielgio/img/pkg/ext"
786+)
787+
788+func main() {
789+ cfg := gen.Config{
790+ OutPath: "./pkg/database/sql/query",
791+ WithUnitTest: true,
792+ Mode: gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface, // generate mode
793+ }
794+
795+ g := gen.NewGenerator(cfg)
796+
797+ logger := logrus.New()
798+ logger.SetLevel(logrus.TraceLevel)
799+ d := sqlite.Open("test.db")
800+ db, err := gorm.Open(d, &gorm.Config{
801+ Logger: ext.Wraplog(logger.WithField("context", "sql")),
802+ })
803+ if err != nil {
804+ panic("failed to gen database: " + err.Error())
805+ }
806+
807+ g.UseDB(db)
808+
809+ for _, m := range []any{
810+ &sql.User{},
811+ &sql.Settings{},
812+ &sql.Media{},
813+ } {
814+ g.ApplyBasic(m)
815+ }
816+
817+ g.Execute()
818+}
819diff --git a/cmd/server/main.go b/cmd/server/main.go
820new file mode 100644
821index 0000000000000000000000000000000000000000..375a26c407e5db424cef0e1a920f8386993dd8e0
822--- /dev/null
823+++ b/cmd/server/main.go
824@@ -0,0 +1,103 @@
825+package main
826+
827+import (
828+ "context"
829+ "encoding/hex"
830+ "os"
831+ "os/signal"
832+
833+ "github.com/fasthttp/router"
834+ "github.com/sirupsen/logrus"
835+ "github.com/valyala/fasthttp"
836+ "gorm.io/driver/sqlite"
837+ "gorm.io/gorm"
838+
839+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
840+ "git.sr.ht/~gabrielgio/img/pkg/components/filesystem"
841+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
842+ "git.sr.ht/~gabrielgio/img/pkg/database/localfs"
843+ "git.sr.ht/~gabrielgio/img/pkg/database/sql"
844+ "git.sr.ht/~gabrielgio/img/pkg/ext"
845+ "git.sr.ht/~gabrielgio/img/pkg/view"
846+ "git.sr.ht/~gabrielgio/img/pkg/worker"
847+)
848+
849+const root = "/home/gabrielgio"
850+
851+func main() {
852+ logger := logrus.New()
853+ logger.SetLevel(logrus.ErrorLevel)
854+
855+ d := sqlite.Open("test.db")
856+ db, err := gorm.Open(d, &gorm.Config{
857+ Logger: ext.Wraplog(logger.WithField("context", "sql")),
858+ })
859+ if err != nil {
860+ panic("failed to connect database: " + err.Error())
861+ }
862+
863+ if err = sql.Migrate(db); err != nil {
864+ panic("failed to migrate database: " + err.Error())
865+ }
866+
867+ // TODO: properly set this up
868+ key, _ := hex.DecodeString("6368616e676520746869732070617373")
869+ r := router.New()
870+ r.ServeFiles("/static/{filepath:*}", "./static")
871+ r.NotFound = ext.NotFoundHTML
872+
873+ authMiddleware := ext.NewAuthMiddleware(key, logger.WithField("context", "auth"))
874+ logMiddleware := ext.NewLogMiddleare(logger.WithField("context", "http"))
875+
876+ extRouter := ext.NewRouter(r)
877+ extRouter.AddMiddleware(logMiddleware.HTTP)
878+ extRouter.AddMiddleware(authMiddleware.LoggedIn)
879+ extRouter.AddMiddleware(ext.HTML)
880+
881+ scheduler := worker.NewScheduler(10)
882+
883+ // repository
884+ var (
885+ userRepository = sql.NewUserRepository(db)
886+ settingsRepository = sql.NewSettingsRespository(db)
887+ fileSystemRepository = localfs.NewFileSystemRepository(root)
888+ mediaRepository = sql.NewMediaRepository(db)
889+ )
890+
891+ //TODO: remove later
892+ userRepository.EnsureAdmin(context.Background())
893+
894+ // controller
895+ var (
896+ userController = auth.NewController(userRepository, key)
897+ fileSystemController = filesystem.NewController(fileSystemRepository)
898+ )
899+
900+ // view
901+ for _, v := range []view.View{
902+ view.NewAuthView(userController),
903+ view.NewFileSystemView(*fileSystemController, settingsRepository),
904+ view.NewSettingsView(settingsRepository),
905+ view.NewMediaView(mediaRepository),
906+ } {
907+ v.SetMyselfIn(extRouter)
908+ }
909+
910+ // worker
911+ var (
912+ serverWorker = worker.NewServerWorker(&fasthttp.Server{Handler: r.Handler})
913+ fileScanner = worker.NewFileScanner(root, mediaRepository)
914+ exifScanner = worker.NewEXIFScanner(root, mediaRepository)
915+ )
916+
917+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
918+ defer stop()
919+
920+ pool := worker.NewWorkerPool()
921+ pool.AddWorker("http server", serverWorker)
922+ pool.AddWorker("exif scanner", worker.NewWorkerFromListProcessor[*media.Media](exifScanner, scheduler))
923+ pool.AddWorker("file scanner", worker.NewWorkerFromChanProcessor[string](fileScanner, scheduler))
924+
925+ pool.Start(ctx)
926+ pool.Wait()
927+}
928diff --git a/go.mod b/go.mod
929new file mode 100644
930index 0000000000000000000000000000000000000000..1ad6b0dac656ed33ae2e81b085263a1a1745beac
931--- /dev/null
932+++ b/go.mod
933@@ -0,0 +1,42 @@
934+module git.sr.ht/~gabrielgio/img
935+
936+go 1.19
937+
938+require (
939+ github.com/barasher/go-exiftool v1.10.0
940+ github.com/fasthttp/router v1.4.19
941+ github.com/gabriel-vasile/mimetype v1.4.2
942+ github.com/google/go-cmp v0.5.9
943+ github.com/samber/lo v1.38.1
944+ github.com/sirupsen/logrus v1.9.2
945+ github.com/valyala/fasthttp v1.47.0
946+ golang.org/x/crypto v0.8.0
947+ gorm.io/driver/postgres v1.5.2
948+ gorm.io/driver/sqlite v1.5.0
949+ gorm.io/gen v0.3.22
950+ gorm.io/gorm v1.25.1
951+)
952+
953+require (
954+ github.com/andybalholm/brotli v1.0.5 // indirect
955+ github.com/go-sql-driver/mysql v1.7.1 // indirect
956+ github.com/jackc/pgpassfile v1.0.0 // indirect
957+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
958+ github.com/jackc/pgx/v5 v5.3.1 // indirect
959+ github.com/jinzhu/inflection v1.0.0 // indirect
960+ github.com/jinzhu/now v1.1.5 // indirect
961+ github.com/klauspost/compress v1.16.5 // indirect
962+ github.com/mattn/go-sqlite3 v1.14.16 // indirect
963+ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
964+ github.com/valyala/bytebufferpool v1.0.0 // indirect
965+ golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
966+ golang.org/x/mod v0.10.0 // indirect
967+ golang.org/x/net v0.10.0 // indirect
968+ golang.org/x/sys v0.8.0 // indirect
969+ golang.org/x/text v0.9.0 // indirect
970+ golang.org/x/tools v0.9.3 // indirect
971+ gorm.io/datatypes v1.2.0 // indirect
972+ gorm.io/driver/mysql v1.5.1 // indirect
973+ gorm.io/hints v1.1.2 // indirect
974+ gorm.io/plugin/dbresolver v1.4.1 // indirect
975+)
976diff --git a/go.sum b/go.sum
977new file mode 100644
978index 0000000000000000000000000000000000000000..bfa9ec66960413c6d9f9dd98db3dbd1ea06d936f
979--- /dev/null
980+++ b/go.sum
981@@ -0,0 +1,93 @@
982+github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
983+github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
984+github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
985+github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
986+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
987+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
988+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
989+github.com/fasthttp/router v1.4.19 h1:RLE539IU/S4kfb4MP56zgP0TIBU9kEg0ID9GpWO0vqk=
990+github.com/fasthttp/router v1.4.19/go.mod h1:+Fh3YOd8x1+he6ZS+d2iUDBH9MGGZ1xQFUor0DE9rKE=
991+github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
992+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
993+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
994+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
995+github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
996+github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
997+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
998+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
999+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
1000+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1001+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
1002+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
1003+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
1004+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
1005+github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU=
1006+github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
1007+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
1008+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
1009+github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
1010+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
1011+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
1012+github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
1013+github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
1014+github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
1015+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
1016+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
1017+github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
1018+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1019+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1020+github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
1021+github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
1022+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
1023+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
1024+github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
1025+github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
1026+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
1027+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
1028+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
1029+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
1030+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
1031+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
1032+github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
1033+github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
1034+golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
1035+golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
1036+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
1037+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
1038+golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
1039+golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
1040+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
1041+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
1042+golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
1043+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1044+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
1045+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1046+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
1047+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
1048+golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
1049+golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
1050+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1051+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
1052+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1053+gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
1054+gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
1055+gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
1056+gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
1057+gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
1058+gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
1059+gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
1060+gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c=
1061+gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
1062+gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
1063+gorm.io/gen v0.3.22 h1:K7u5tCyaZfe1cbQFD8N2xrTqUuqximNFSRl7zOFPq+M=
1064+gorm.io/gen v0.3.22/go.mod h1:dQcELeF/7Kf82M6AQF+O/rKT5r1sjv49TlGz0cerPn4=
1065+gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
1066+gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
1067+gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
1068+gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
1069+gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
1070+gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
1071+gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
1072+gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
1073+gorm.io/plugin/dbresolver v1.4.1 h1:Ug4LcoPhrvqq71UhxtF346f+skTYoCa/nEsdjvHwEzk=
1074+gorm.io/plugin/dbresolver v1.4.1/go.mod h1:CTbCtMWhsjXSiJqiW2R8POvJ2cq18RVOl4WGyT5nhNc=
1075diff --git a/golangci.yml b/golangci.yml
1076new file mode 100644
1077index 0000000000000000000000000000000000000000..25b47fd54de1addbcb04a64dd6eaf8a9d4305cf1
1078--- /dev/null
1079+++ b/golangci.yml
1080@@ -0,0 +1,38 @@
1081+output:
1082+ sort-results: true
1083+
1084+linters:
1085+ enable:
1086+ - revive
1087+ - whitespace
1088+ - unconvert
1089+ - promlinter
1090+ - nilerr
1091+ - gofmt
1092+ - unparam
1093+ - gci
1094+ - bodyclose
1095+ - makezero
1096+
1097+linters-settings:
1098+ gci:
1099+ sections:
1100+ - standard
1101+ - default
1102+ - prefix(git.sr.ht/~gabrielgio/img)
1103+ skip-generated: true
1104+ custom-order: true
1105+ errcheck:
1106+ ignore: Close
1107+ revive:
1108+ rules:
1109+ - name: unused-parameter
1110+ severity: error
1111+ disabled: false
1112+ - name: package-comments
1113+ disabled: true
1114+
1115+issues:
1116+ exclude-use-default: false
1117+
1118+ timeout: 2m
1119diff --git a/main b/main
1120new file mode 100644
1121index 0000000000000000000000000000000000000000..c2b077f19312c3a1e7db65c962d89510cd2e1a3e
1122Binary files /dev/null and b/main differ
1123diff --git a/pkg/components/auth/controller.go b/pkg/components/auth/controller.go
1124new file mode 100644
1125index 0000000000000000000000000000000000000000..4da607129965146d0a5cef01599d464e421f5875
1126--- /dev/null
1127+++ b/pkg/components/auth/controller.go
1128@@ -0,0 +1,57 @@
1129+package auth
1130+
1131+import (
1132+ "context"
1133+
1134+ "golang.org/x/crypto/bcrypt"
1135+
1136+ "git.sr.ht/~gabrielgio/img/pkg/ext"
1137+)
1138+
1139+type Controller struct {
1140+ repository Repository
1141+ key []byte
1142+}
1143+
1144+func NewController(repository Repository, key []byte) *Controller {
1145+ return &Controller{
1146+ repository: repository,
1147+ key: key,
1148+ }
1149+}
1150+
1151+func (c *Controller) Login(ctx context.Context, username, password []byte) ([]byte, error) {
1152+ id, err := c.repository.GetIDByUsername(ctx, string(username))
1153+ if err != nil {
1154+ return nil, err
1155+ }
1156+
1157+ hashedPassword, err := c.repository.GetPassword(ctx, id)
1158+ if err != nil {
1159+ return nil, err
1160+ }
1161+
1162+ if err := bcrypt.CompareHashAndPassword(hashedPassword, password); err != nil {
1163+ return nil, err
1164+ }
1165+
1166+ token := &ext.Token{
1167+ UserID: id,
1168+ Username: string(username),
1169+ }
1170+ return ext.WriteToken(token, c.key)
1171+}
1172+
1173+func (c *Controller) Register(ctx context.Context, username, password []byte) error {
1174+ hash, err := bcrypt.GenerateFromPassword(password, bcrypt.MinCost)
1175+ if err != nil {
1176+ return err
1177+ }
1178+
1179+ _, err = c.repository.Create(ctx, &CreateUser{
1180+ Username: string(username),
1181+ Password: hash,
1182+ })
1183+
1184+ return err
1185+}
1186diff --git a/pkg/components/auth/controller_test.go b/pkg/components/auth/controller_test.go
1187new file mode 100644
1188index 0000000000000000000000000000000000000000..33aa901cf701dcc8675776c97f36b57e7a202882
1189--- /dev/null
1190+++ b/pkg/components/auth/controller_test.go
1191@@ -0,0 +1,190 @@
1192+//go:build unit
1193+
1194+package auth
1195+
1196+import (
1197+ "context"
1198+ "errors"
1199+ "testing"
1200+
1201+ "github.com/samber/lo"
1202+
1203+ "git.sr.ht/~gabrielgio/img/pkg/ext"
1204+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
1205+)
1206+
1207+type (
1208+ scene struct {
1209+ ctx context.Context
1210+ mockRepository *MockUserRepository
1211+ controller Controller
1212+ }
1213+
1214+ mockUser struct {
1215+ id uint
1216+ username string
1217+ password []byte
1218+ }
1219+
1220+ MockUserRepository struct {
1221+ index uint
1222+ users []*mockUser
1223+ err error
1224+ }
1225+)
1226+
1227+var (
1228+ _ Repository = &MockUserRepository{}
1229+ key = []byte("6368616e676520746869732070617373")
1230+)
1231+
1232+func setUp() *scene {
1233+ mockUserRepository := &MockUserRepository{}
1234+ return &scene{
1235+ ctx: context.Background(),
1236+ mockRepository: mockUserRepository,
1237+ controller: *NewController(mockUserRepository, key),
1238+ }
1239+}
1240+
1241+func TestRegisterAndLogin(t *testing.T) {
1242+ testCases := []struct {
1243+ name string
1244+ username string
1245+ password []byte
1246+ }{
1247+ {
1248+ name: "Normal register",
1249+ username: "username",
1250+ password: []byte("password"),
1251+ },
1252+ }
1253+
1254+ for _, tc := range testCases {
1255+ t.Run(tc.name, func(t *testing.T) {
1256+ scene := setUp()
1257+
1258+ err := scene.controller.Register(scene.ctx, []byte(tc.username), tc.password)
1259+ testkit.TestFatalError(t, "Register", err)
1260+
1261+ userID := scene.mockRepository.GetLastId()
1262+
1263+ user, err := scene.mockRepository.Get(scene.ctx, userID)
1264+ testkit.TestFatalError(t, "Get", err)
1265+ testkit.TestValue(t, "Register", tc.username, user.Username)
1266+
1267+ auth, err := scene.controller.Login(scene.ctx, []byte(tc.username), tc.password)
1268+ testkit.TestFatalError(t, "Login", err)
1269+
1270+ token, err := ext.ReadToken(auth, key)
1271+ testkit.TestFatalError(t, "Login", err)
1272+
1273+ testkit.TestValue(t, "Login", tc.username, token.Username)
1274+ testkit.TestValue(t, "Login", userID, token.UserID)
1275+ })
1276+ }
1277+}
1278+
1279+func toUser(m *mockUser, _ int) *User {
1280+ return &User{
1281+ ID: m.id,
1282+ Username: m.username,
1283+ }
1284+}
1285+
1286+func (m *MockUserRepository) GetLastId() uint {
1287+ return m.index
1288+}
1289+
1290+func (m *MockUserRepository) List(ctx context.Context) ([]*User, error) {
1291+ if m.err != nil {
1292+ return nil, m.err
1293+ }
1294+
1295+ return lo.Map(m.users, toUser), nil
1296+}
1297+
1298+func (m *MockUserRepository) Get(ctx context.Context, id uint) (*User, error) {
1299+ if m.err != nil {
1300+ return nil, m.err
1301+ }
1302+
1303+ for _, m := range m.users {
1304+ if m.id == id {
1305+ return toUser(m, 0), nil
1306+ }
1307+ }
1308+ return nil, errors.New("Item not found")
1309+}
1310+
1311+func (m *MockUserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) {
1312+ if m.err != nil {
1313+ return 0, m.err
1314+ }
1315+
1316+ for _, m := range m.users {
1317+ if m.username == username {
1318+ return m.id, nil
1319+ }
1320+ }
1321+ return 0, errors.New("Item not found")
1322+}
1323+
1324+func (m *MockUserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) {
1325+ if m.err != nil {
1326+ return nil, m.err
1327+ }
1328+
1329+ for _, m := range m.users {
1330+ if m.id == id {
1331+ return m.password, nil
1332+ }
1333+ }
1334+ return nil, errors.New("Item not found")
1335+}
1336+
1337+func (m *MockUserRepository) Create(ctx context.Context, createUser *CreateUser) (uint, error) {
1338+ if m.err != nil {
1339+ return 0, m.err
1340+ }
1341+
1342+ m.index++
1343+
1344+ m.users = append(m.users, &mockUser{
1345+ id: m.index,
1346+ username: createUser.Username,
1347+ password: createUser.Password,
1348+ })
1349+
1350+ return m.index, nil
1351+}
1352+
1353+func (m *MockUserRepository) Update(ctx context.Context, id uint, update *UpdateUser) error {
1354+ if m.err != nil {
1355+ return m.err
1356+ }
1357+
1358+ for _, m := range m.users {
1359+ if m.id == id {
1360+ m.username = update.Username
1361+ }
1362+ }
1363+ return nil
1364+}
1365+
1366+func remove[T any](slice []T, s int) []T {
1367+ return append(slice[:s], slice[s+1:]...)
1368+}
1369+
1370+func (r *MockUserRepository) Delete(ctx context.Context, id uint) error {
1371+ if r.err != nil {
1372+ return r.err
1373+ }
1374+
1375+ for i, m := range r.users {
1376+ if m.id == id {
1377+ r.users = remove(r.users, i)
1378+ }
1379+ }
1380+ return nil
1381+}
1382diff --git a/pkg/components/auth/model.go b/pkg/components/auth/model.go
1383new file mode 100644
1384index 0000000000000000000000000000000000000000..e46ef4979fc10a82e1822ab6a692c19774093525
1385--- /dev/null
1386+++ b/pkg/components/auth/model.go
1387@@ -0,0 +1,32 @@
1388+package auth
1389+
1390+import "context"
1391+
1392+type (
1393+ // TODO: move to user later
1394+ User struct {
1395+ ID uint
1396+ Username string
1397+ Name string
1398+ }
1399+
1400+ // TODO: move to user later
1401+ UpdateUser struct {
1402+ Username string
1403+ Name string
1404+ }
1405+
1406+ // TODO: move to user later
1407+ CreateUser struct {
1408+ Username string
1409+ Name string
1410+ Password []byte
1411+ }
1412+
1413+ Repository interface {
1414+ GetIDByUsername(ctx context.Context, username string) (uint, error)
1415+ GetPassword(ctx context.Context, id uint) ([]byte, error)
1416+ // TODO: move to user later
1417+ Create(ctx context.Context, createUser *CreateUser) (uint, error)
1418+ }
1419+)
1420diff --git a/pkg/components/filesystem/controller.go b/pkg/components/filesystem/controller.go
1421new file mode 100644
1422index 0000000000000000000000000000000000000000..6b478a5ef6de92b9df254199dfc4958c65840765
1423--- /dev/null
1424+++ b/pkg/components/filesystem/controller.go
1425@@ -0,0 +1,89 @@
1426+package filesystem
1427+
1428+import (
1429+ "io/fs"
1430+ "net/url"
1431+ "path"
1432+ "strings"
1433+)
1434+
1435+type (
1436+ Controller struct {
1437+ repository Repository
1438+ }
1439+
1440+ DirectoryParam struct {
1441+ Name string
1442+ UrlEncodedPath string
1443+ }
1444+
1445+ FileParam struct {
1446+ UrlEncodedPath string
1447+ Info fs.FileInfo
1448+ }
1449+
1450+ Page struct {
1451+ History []*DirectoryParam
1452+ Files []*FileParam
1453+ }
1454+)
1455+
1456+func NewController(repository Repository) *Controller {
1457+ return &Controller{
1458+ repository: repository,
1459+ }
1460+}
1461+
1462+func getHistory(filepath string) []*DirectoryParam {
1463+ var (
1464+ paths = strings.Split(filepath, "/")
1465+ result = make([]*DirectoryParam, 0, len(paths))
1466+ acc = ""
1467+ )
1468+
1469+ // add root folder
1470+ result = append(result, &DirectoryParam{
1471+ Name: "...",
1472+ UrlEncodedPath: "",
1473+ })
1474+
1475+ if len(paths) == 1 && paths[0] == "" {
1476+ return result
1477+ }
1478+
1479+ for _, p := range paths {
1480+ acc = path.Join(acc, p)
1481+ result = append(result, &DirectoryParam{
1482+ Name: p,
1483+ UrlEncodedPath: url.QueryEscape(acc),
1484+ })
1485+ }
1486+ return result
1487+}
1488+
1489+func (self *Controller) GetPage(filepath string) (*Page, error) {
1490+ decodedPath, err := url.QueryUnescape(filepath)
1491+ if err != nil {
1492+ return nil, err
1493+ }
1494+
1495+ files, err := self.repository.List(decodedPath)
1496+ if err != nil {
1497+ return nil, err
1498+ }
1499+
1500+ params := make([]*FileParam, 0, len(files))
1501+ for _, info := range files {
1502+ fullPath := path.Join(decodedPath, info.Name())
1503+ scapedFullPath := url.QueryEscape(fullPath)
1504+ params = append(params, &FileParam{
1505+ Info: info,
1506+ UrlEncodedPath: scapedFullPath,
1507+ })
1508+ }
1509+
1510+ return &Page{
1511+ Files: params,
1512+ History: getHistory(decodedPath),
1513+ }, nil
1514+}
1515diff --git a/pkg/components/filesystem/model.go b/pkg/components/filesystem/model.go
1516new file mode 100644
1517index 0000000000000000000000000000000000000000..2caed8248292390e1d3c827855c977c5d7a38a89
1518--- /dev/null
1519+++ b/pkg/components/filesystem/model.go
1520@@ -0,0 +1,10 @@
1521+package filesystem
1522+
1523+import "io/fs"
1524+
1525+type (
1526+ Repository interface {
1527+ List(path string) ([]fs.FileInfo, error)
1528+ Stat(path string) (fs.FileInfo, error)
1529+ }
1530+)
1531diff --git a/pkg/components/media/model.go b/pkg/components/media/model.go
1532new file mode 100644
1533index 0000000000000000000000000000000000000000..f5c9ff6e725f7fc9bb25db01793b69835742e0ea
1534--- /dev/null
1535+++ b/pkg/components/media/model.go
1536@@ -0,0 +1,57 @@
1537+package media
1538+
1539+import (
1540+ "context"
1541+ "time"
1542+)
1543+
1544+type (
1545+ Media struct {
1546+ ID uint
1547+ Name string
1548+ Path string
1549+ PathHash string
1550+ MIMEType string
1551+ }
1552+
1553+ MediaEXIF struct {
1554+ Description *string
1555+ Camera *string
1556+ Maker *string
1557+ Lens *string
1558+ DateShot *time.Time
1559+ Exposure *float64
1560+ Aperture *float64
1561+ Iso *int64
1562+ FocalLength *float64
1563+ Flash *int64
1564+ Orientation *int64
1565+ ExposureProgram *int64
1566+ GPSLatitude *float64
1567+ GPSLongitude *float64
1568+ }
1569+
1570+ Pagination struct {
1571+ Page int
1572+ Size int
1573+ }
1574+
1575+ CreateMedia struct {
1576+ Name string
1577+ Path string
1578+ PathHash string
1579+ MIMEType string
1580+ }
1581+
1582+ Repository interface {
1583+ Create(context.Context, *CreateMedia) error
1584+ Exists(context.Context, string) (bool, error)
1585+ List(context.Context, *Pagination) ([]*Media, error)
1586+ Get(context.Context, string) (*Media, error)
1587+ GetPath(context.Context, string) (string, error)
1588+
1589+ GetEmptyEXIF(context.Context, *Pagination) ([]*Media, error)
1590+ GetEXIF(context.Context, uint) (*MediaEXIF, error)
1591+ CreateEXIF(context.Context, uint, *MediaEXIF) error
1592+ }
1593+)
1594diff --git a/pkg/components/settings/model.go b/pkg/components/settings/model.go
1595new file mode 100644
1596index 0000000000000000000000000000000000000000..da07f2ce68d20584217269a31346b19ff4e7edb6
1597--- /dev/null
1598+++ b/pkg/components/settings/model.go
1599@@ -0,0 +1,15 @@
1600+package settings
1601+
1602+import "context"
1603+
1604+type (
1605+ Settings struct {
1606+ ShowMode bool
1607+ ShowOwner bool
1608+ }
1609+
1610+ Repository interface {
1611+ Save(context.Context, *Settings) error
1612+ Load(context.Context) (*Settings, error)
1613+ }
1614+)
1615diff --git a/pkg/database/localfs/filesystem.go b/pkg/database/localfs/filesystem.go
1616new file mode 100644
1617index 0000000000000000000000000000000000000000..c7c645817235c7c6b2f3fa2e5408bcc9e8caa63d
1618--- /dev/null
1619+++ b/pkg/database/localfs/filesystem.go
1620@@ -0,0 +1,49 @@
1621+package localfs
1622+
1623+import (
1624+ "io/fs"
1625+ "os"
1626+ "path"
1627+ "strings"
1628+)
1629+
1630+type FileSystemRepository struct {
1631+ root string
1632+}
1633+
1634+func NewFileSystemRepository(root string) *FileSystemRepository {
1635+ return &FileSystemRepository{
1636+ root: root,
1637+ }
1638+}
1639+
1640+func (self *FileSystemRepository) getFilesFromPath(filepath string) ([]fs.FileInfo, error) {
1641+ dirs, err := os.ReadDir(filepath)
1642+ if err != nil {
1643+ return nil, err
1644+ }
1645+
1646+ infos := make([]fs.FileInfo, 0, len(dirs))
1647+ for _, dir := range dirs {
1648+ if strings.HasPrefix(dir.Name(), ".") {
1649+ continue
1650+ }
1651+ info, err := dir.Info()
1652+ if err != nil {
1653+ return nil, err
1654+ }
1655+ infos = append(infos, info)
1656+ }
1657+
1658+ return infos, nil
1659+}
1660+
1661+func (self *FileSystemRepository) List(filepath string) ([]fs.FileInfo, error) {
1662+ workingPath := path.Join(self.root, filepath)
1663+ return self.getFilesFromPath(workingPath)
1664+}
1665+
1666+func (self *FileSystemRepository) Stat(filepath string) (fs.FileInfo, error) {
1667+ workingPath := path.Join(self.root, filepath)
1668+ return os.Stat(workingPath)
1669+}
1670diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go
1671new file mode 100644
1672index 0000000000000000000000000000000000000000..835e262efe59ac90d2cdb27604caabfcd9e27fa4
1673--- /dev/null
1674+++ b/pkg/database/sql/media.go
1675@@ -0,0 +1,238 @@
1676+package sql
1677+
1678+import (
1679+ "context"
1680+ "time"
1681+
1682+ "gorm.io/gorm"
1683+
1684+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
1685+ "git.sr.ht/~gabrielgio/img/pkg/list"
1686+)
1687+
1688+type (
1689+ Media struct {
1690+ gorm.Model
1691+ Name string `gorm:"not null"`
1692+ Path string `gorm:"not null;unique"`
1693+ PathHash string `gorm:"not null;unique"`
1694+ MIMEType string `gorm:"not null"`
1695+ }
1696+
1697+ MediaEXIF struct {
1698+ gorm.Model
1699+ MediaID uint
1700+ Media Media
1701+ Description *string
1702+ Camera *string
1703+ Maker *string
1704+ Lens *string
1705+ DateShot *time.Time
1706+ Exposure *float64
1707+ Aperture *float64
1708+ Iso *int64
1709+ FocalLength *float64
1710+ Flash *int64
1711+ Orientation *int64
1712+ ExposureProgram *int64
1713+ GPSLatitude *float64
1714+ GPSLongitude *float64
1715+ }
1716+
1717+ MediaRepository struct {
1718+ db *gorm.DB
1719+ }
1720+)
1721+
1722+var _ media.Repository = &MediaRepository{}
1723+
1724+func (self *Media) ToModel() *media.Media {
1725+ return &media.Media{
1726+ ID: self.ID,
1727+ Path: self.Path,
1728+ PathHash: self.PathHash,
1729+ Name: self.Name,
1730+ MIMEType: self.MIMEType,
1731+ }
1732+}
1733+
1734+func (m *MediaEXIF) ToModel() *media.MediaEXIF {
1735+ return &media.MediaEXIF{
1736+ Description: m.Description,
1737+ Camera: m.Camera,
1738+ Maker: m.Maker,
1739+ Lens: m.Lens,
1740+ DateShot: m.DateShot,
1741+ Exposure: m.Exposure,
1742+ Aperture: m.Aperture,
1743+ Iso: m.Iso,
1744+ FocalLength: m.FocalLength,
1745+ Flash: m.Flash,
1746+ Orientation: m.Orientation,
1747+ ExposureProgram: m.ExposureProgram,
1748+ GPSLatitude: m.GPSLatitude,
1749+ GPSLongitude: m.GPSLongitude,
1750+ }
1751+}
1752+
1753+func NewMediaRepository(db *gorm.DB) *MediaRepository {
1754+ return &MediaRepository{
1755+ db: db,
1756+ }
1757+}
1758+
1759+func (self *MediaRepository) Create(ctx context.Context, createMedia *media.CreateMedia) error {
1760+ media := &Media{
1761+ Name: createMedia.Name,
1762+ Path: createMedia.Path,
1763+ PathHash: createMedia.PathHash,
1764+ MIMEType: createMedia.MIMEType,
1765+ }
1766+
1767+ result := self.db.
1768+ WithContext(ctx).
1769+ Create(media)
1770+ if result.Error != nil {
1771+ return result.Error
1772+ }
1773+
1774+ return nil
1775+}
1776+
1777+func (self *MediaRepository) Exists(ctx context.Context, path string) (bool, error) {
1778+ var exists bool
1779+ result := self.db.
1780+ WithContext(ctx).
1781+ Model(&Media{}).
1782+ Select("count(id) > 0").
1783+ Where("path_hash = ?", path).
1784+ Find(&exists)
1785+
1786+ if result.Error != nil {
1787+ return false, result.Error
1788+ }
1789+
1790+ return exists, nil
1791+}
1792+
1793+func (self *MediaRepository) List(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) {
1794+ medias := make([]*Media, 0)
1795+ result := self.db.
1796+ WithContext(ctx).
1797+ Model(&Media{}).
1798+ Offset(pagination.Page * pagination.Size).
1799+ Limit(pagination.Size).
1800+ Order("created_at DESC").
1801+ Find(&medias)
1802+
1803+ if result.Error != nil {
1804+ return nil, result.Error
1805+ }
1806+
1807+ m := list.Map(medias, func(s *Media) *media.Media {
1808+ return s.ToModel()
1809+ })
1810+
1811+ return m, nil
1812+}
1813+
1814+func (self *MediaRepository) Get(ctx context.Context, pathHash string) (*media.Media, error) {
1815+ m := &Media{}
1816+ result := self.db.
1817+ WithContext(ctx).
1818+ Model(&Media{}).
1819+ Where("path_hash = ?", pathHash).
1820+ Limit(1).
1821+ Take(m)
1822+
1823+ if result.Error != nil {
1824+ return nil, result.Error
1825+ }
1826+
1827+ return m.ToModel(), nil
1828+}
1829+
1830+func (self *MediaRepository) GetPath(ctx context.Context, pathHash string) (string, error) {
1831+ var path string
1832+ result := self.db.
1833+ WithContext(ctx).
1834+ Model(&Media{}).
1835+ Select("path").
1836+ Where("path_hash = ?", pathHash).
1837+ Limit(1).
1838+ Find(&path)
1839+
1840+ if result.Error != nil {
1841+ return "", result.Error
1842+ }
1843+
1844+ return path, nil
1845+}
1846+
1847+func (m *MediaRepository) GetEXIF(ctx context.Context, mediaID uint) (*media.MediaEXIF, error) {
1848+ exif := &MediaEXIF{}
1849+ result := m.db.
1850+ WithContext(ctx).
1851+ Model(&Media{}).
1852+ Where("media_id = ?", mediaID).
1853+ Limit(1).
1854+ Take(m)
1855+
1856+ if result.Error != nil {
1857+ return nil, result.Error
1858+ }
1859+
1860+ return exif.ToModel(), nil
1861+}
1862+
1863+func (s *MediaRepository) CreateEXIF(ctx context.Context, id uint, info *media.MediaEXIF) error {
1864+ media := &MediaEXIF{
1865+ MediaID: id,
1866+ Description: info.Description,
1867+ Camera: info.Camera,
1868+ Maker: info.Maker,
1869+ Lens: info.Lens,
1870+ DateShot: info.DateShot,
1871+ Exposure: info.Exposure,
1872+ Aperture: info.Aperture,
1873+ Iso: info.Iso,
1874+ FocalLength: info.FocalLength,
1875+ Flash: info.Flash,
1876+ Orientation: info.Orientation,
1877+ ExposureProgram: info.ExposureProgram,
1878+ GPSLatitude: info.GPSLatitude,
1879+ GPSLongitude: info.GPSLongitude,
1880+ }
1881+
1882+ result := s.db.
1883+ WithContext(ctx).
1884+ Create(media)
1885+ if result.Error != nil {
1886+ return result.Error
1887+ }
1888+
1889+ return nil
1890+}
1891+
1892+func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) {
1893+ medias := make([]*Media, 0)
1894+ result := r.db.
1895+ WithContext(ctx).
1896+ Model(&Media{}).
1897+ Joins("left join media_exifs on media.id = media_exifs.media_id").
1898+ Where("media_exifs.media_id IS NULL").
1899+ Offset(pagination.Page * pagination.Size).
1900+ Limit(pagination.Size).
1901+ Order("created_at DESC").
1902+ Find(&medias)
1903+
1904+ if result.Error != nil {
1905+ return nil, result.Error
1906+ }
1907+
1908+ m := list.Map(medias, func(s *Media) *media.Media {
1909+ return s.ToModel()
1910+ })
1911+
1912+ return m, nil
1913+}
1914diff --git a/pkg/database/sql/migration.go b/pkg/database/sql/migration.go
1915new file mode 100644
1916index 0000000000000000000000000000000000000000..019eb91371ca24ec8f1b55fa1e6b80730c716ac0
1917--- /dev/null
1918+++ b/pkg/database/sql/migration.go
1919@@ -0,0 +1,17 @@
1920+package sql
1921+
1922+import "gorm.io/gorm"
1923+
1924+func Migrate(db *gorm.DB) error {
1925+ for _, m := range []any{
1926+ &User{},
1927+ &Settings{},
1928+ &Media{},
1929+ &MediaEXIF{},
1930+ } {
1931+ if err := db.AutoMigrate(m); err != nil {
1932+ return err
1933+ }
1934+ }
1935+ return nil
1936+}
1937diff --git a/pkg/database/sql/settings.go b/pkg/database/sql/settings.go
1938new file mode 100644
1939index 0000000000000000000000000000000000000000..7ad718b826874b93c4f4491874f3098fd9032080
1940--- /dev/null
1941+++ b/pkg/database/sql/settings.go
1942@@ -0,0 +1,69 @@
1943+package sql
1944+
1945+import (
1946+ "context"
1947+
1948+ "gorm.io/gorm"
1949+
1950+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
1951+)
1952+
1953+type (
1954+ Settings struct {
1955+ gorm.Model
1956+ ShowMode bool
1957+ ShowOwner bool
1958+ }
1959+
1960+ SettingsRepository struct {
1961+ db *gorm.DB
1962+ }
1963+)
1964+
1965+var _ settings.Repository = &SettingsRepository{}
1966+
1967+func NewSettingsRespository(db *gorm.DB) *SettingsRepository {
1968+ return &SettingsRepository{
1969+ db: db,
1970+ }
1971+}
1972+
1973+func (self *SettingsRepository) ensureSettings(ctx context.Context) (*Settings, error) {
1974+ var (
1975+ db = self.db.WithContext(ctx)
1976+ s = &Settings{}
1977+ )
1978+ result := db.Limit(1).Find(s)
1979+ if result.Error != nil {
1980+ return nil, result.Error
1981+ }
1982+
1983+ return s, nil
1984+}
1985+
1986+func (self *SettingsRepository) Save(ctx context.Context, toSaveSettings *settings.Settings) error {
1987+ db := self.db.WithContext(ctx)
1988+
1989+ s, err := self.ensureSettings(ctx)
1990+ if err != nil {
1991+ return err
1992+ }
1993+
1994+ s.ShowMode = toSaveSettings.ShowMode
1995+ s.ShowOwner = toSaveSettings.ShowOwner
1996+
1997+ result := db.Save(s)
1998+ return result.Error
1999+}
2000+
2001+func (self *SettingsRepository) Load(ctx context.Context) (*settings.Settings, error) {
2002+ s, err := self.ensureSettings(ctx)
2003+ if err != nil {
2004+ return nil, err
2005+ }
2006+
2007+ return &settings.Settings{
2008+ ShowMode: s.ShowMode,
2009+ ShowOwner: s.ShowOwner,
2010+ }, nil
2011+}
2012diff --git a/pkg/database/sql/user.go b/pkg/database/sql/user.go
2013new file mode 100644
2014index 0000000000000000000000000000000000000000..d449b05078914829508c79dd8da8583b7f66cbc9
2015--- /dev/null
2016+++ b/pkg/database/sql/user.go
2017@@ -0,0 +1,182 @@
2018+package sql
2019+
2020+import (
2021+ "context"
2022+
2023+ "golang.org/x/crypto/bcrypt"
2024+ "gorm.io/gorm"
2025+
2026+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
2027+ user "git.sr.ht/~gabrielgio/img/pkg/components/auth"
2028+)
2029+
2030+type (
2031+ User struct {
2032+ gorm.Model
2033+ Username string
2034+ Name string
2035+ Password string
2036+ }
2037+
2038+ Users []*User
2039+
2040+ UserRepository struct {
2041+ db *gorm.DB
2042+ }
2043+)
2044+
2045+var _ auth.Repository = &UserRepository{}
2046+
2047+func NewUserRepository(db *gorm.DB) *UserRepository {
2048+ return &UserRepository{
2049+ db: db,
2050+ }
2051+}
2052+
2053+func (self *User) ToModel() *user.User {
2054+ return &user.User{
2055+ ID: self.Model.ID,
2056+ Name: self.Name,
2057+ Username: self.Username,
2058+ }
2059+}
2060+
2061+func (self Users) ToModel() (users []*user.User) {
2062+ for _, user := range self {
2063+ users = append(users, user.ToModel())
2064+ }
2065+ return
2066+}
2067+
2068+// Testing function, will remove later
2069+// TODO: remove later
2070+func (self *UserRepository) EnsureAdmin(ctx context.Context) {
2071+ var exists bool
2072+ self.db.
2073+ WithContext(ctx).
2074+ Model(&User{}).
2075+ Select("count(*) > 0").
2076+ Where("username = ?", "admin").
2077+ Find(&exists)
2078+
2079+ if !exists {
2080+ hash, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.MinCost)
2081+ self.db.Save(&User{
2082+ Username: "admin",
2083+ Password: string(hash),
2084+ })
2085+ }
2086+}
2087+
2088+func (self *UserRepository) List(ctx context.Context) ([]*user.User, error) {
2089+ users := Users{}
2090+ result := self.db.
2091+ WithContext(ctx).
2092+ Find(&users)
2093+
2094+ if result.Error != nil {
2095+ return nil, result.Error
2096+ }
2097+
2098+ return users.ToModel(), nil
2099+}
2100+
2101+func (self *UserRepository) Get(ctx context.Context, id uint) (*user.User, error) {
2102+ var user = &user.User{ID: id}
2103+ result := self.db.
2104+ WithContext(ctx).
2105+ First(user)
2106+
2107+ if result.Error != nil {
2108+ return nil, result.Error
2109+ }
2110+
2111+ return user, nil
2112+}
2113+
2114+func (self *UserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) {
2115+ userID := struct {
2116+ ID uint
2117+ }{}
2118+
2119+ result := self.db.
2120+ WithContext(ctx).
2121+ Model(&User{}).
2122+ Where("username = ?", username).
2123+ First(&userID)
2124+
2125+ if result.Error != nil {
2126+ return 0, result.Error
2127+ }
2128+
2129+ return userID.ID, nil
2130+}
2131+
2132+func (self *UserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) {
2133+ userPassword := struct {
2134+ Password []byte
2135+ }{}
2136+
2137+ result := self.db.
2138+ WithContext(ctx).
2139+ Model(&User{}).
2140+ Where("id = ?", id).
2141+ First(&userPassword)
2142+
2143+ if result.Error != nil {
2144+ return nil, result.Error
2145+ }
2146+
2147+ return userPassword.Password, nil
2148+}
2149+
2150+func (self *UserRepository) Create(ctx context.Context, createUser *user.CreateUser) (uint, error) {
2151+ user := &User{
2152+ Username: createUser.Username,
2153+ Name: createUser.Name,
2154+ Password: string(createUser.Password),
2155+ }
2156+
2157+ result := self.db.
2158+ WithContext(ctx).
2159+ Create(user)
2160+ if result.Error != nil {
2161+ return 0, result.Error
2162+ }
2163+
2164+ return user.Model.ID, nil
2165+}
2166+
2167+func (self *UserRepository) Update(ctx context.Context, id uint, update *user.UpdateUser) error {
2168+ user := &User{
2169+ Model: gorm.Model{
2170+ ID: id,
2171+ },
2172+ Username: update.Username,
2173+ Name: update.Name,
2174+ }
2175+
2176+ result := self.db.
2177+ WithContext(ctx).
2178+ Save(user)
2179+ if result.Error != nil {
2180+ return result.Error
2181+ }
2182+
2183+ return nil
2184+}
2185+
2186+func (self *UserRepository) Delete(ctx context.Context, id uint) error {
2187+ userID := struct {
2188+ ID uint
2189+ }{
2190+ ID: id,
2191+ }
2192+ result := self.db.
2193+ WithContext(ctx).
2194+ Delete(userID)
2195+ if result.Error != nil {
2196+ return result.Error
2197+ }
2198+ return nil
2199+}
2200diff --git a/pkg/database/sql/user_test.go b/pkg/database/sql/user_test.go
2201new file mode 100644
2202index 0000000000000000000000000000000000000000..875b8e6022b0b97c1fbe0b6c0363a4beb12912df
2203--- /dev/null
2204+++ b/pkg/database/sql/user_test.go
2205@@ -0,0 +1,110 @@
2206+//go:build integration
2207+
2208+package sql
2209+
2210+import (
2211+ "context"
2212+ "os"
2213+ "testing"
2214+
2215+ "github.com/google/go-cmp/cmp"
2216+ "gorm.io/driver/sqlite"
2217+ "gorm.io/gorm"
2218+ "gorm.io/gorm/logger"
2219+
2220+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
2221+)
2222+
2223+func setup(t *testing.T) (*gorm.DB, func()) {
2224+ t.Helper()
2225+
2226+ file, err := os.CreateTemp("", "img_user_*.db")
2227+ if err != nil {
2228+ t.Fatalf("Error creating tmp error: %s", err.Error())
2229+ }
2230+
2231+ db, err := gorm.Open(sqlite.Open(file.Name()), &gorm.Config{
2232+ Logger: logger.Default.LogMode(logger.Info),
2233+ })
2234+ if err != nil {
2235+ t.Fatalf("Error openning db, error %s", err.Error())
2236+ }
2237+
2238+ err = Migrate(db)
2239+ if err != nil {
2240+ t.Fatalf("Error migrating db, error %s", err.Error())
2241+ }
2242+
2243+ return db, func() {
2244+ //nolint:errcheck
2245+ os.Remove(file.Name())
2246+ }
2247+}
2248+
2249+func TestCreate(t *testing.T) {
2250+ t.Parallel()
2251+ db, tearDown := setup(t)
2252+ defer tearDown()
2253+
2254+ repository := NewUserRepository(db)
2255+
2256+ id, err := repository.Create(context.Background(), &auth.CreateUser{
2257+ Username: "new_username",
2258+ Name: "new_name",
2259+ })
2260+ if err != nil {
2261+ t.Fatalf("Error creating: %s", err.Error())
2262+ }
2263+
2264+ got, err := repository.Get(context.Background(), id)
2265+ if err != nil {
2266+ t.Fatalf("Error getting: %s", err.Error())
2267+ }
2268+ want := &auth.User{
2269+ ID: id,
2270+ Username: "new_username",
2271+ Name: "new_name",
2272+ }
2273+
2274+ if diff := cmp.Diff(want, got); diff != "" {
2275+ t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff)
2276+ }
2277+}
2278+
2279+func TestUpdate(t *testing.T) {
2280+ t.Parallel()
2281+ db, tearDown := setup(t)
2282+ defer tearDown()
2283+
2284+ repository := NewUserRepository(db)
2285+
2286+ id, err := repository.Create(context.Background(), &auth.CreateUser{
2287+ Username: "username",
2288+ Name: "name",
2289+ })
2290+ if err != nil {
2291+ t.Fatalf("Error creating user: %s", err.Error())
2292+ }
2293+
2294+ err = repository.Update(context.Background(), id, &auth.UpdateUser{
2295+ Username: "new_username",
2296+ Name: "new_name",
2297+ })
2298+ if err != nil {
2299+ t.Fatalf("Error update user: %s", err.Error())
2300+ }
2301+
2302+ got, err := repository.Get(context.Background(), id)
2303+ if err != nil {
2304+ t.Fatalf("Error getting user: %s", err.Error())
2305+ }
2306+ want := &auth.User{
2307+ ID: id,
2308+ Username: "new_username",
2309+ Name: "new_name",
2310+ }
2311+
2312+ if diff := cmp.Diff(want, got); diff != "" {
2313+ t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff)
2314+ }
2315+}
2316diff --git a/pkg/ext/auth.go b/pkg/ext/auth.go
2317new file mode 100644
2318index 0000000000000000000000000000000000000000..d9fbfba5d27d98ab72acbb5dec58d6906248723e
2319--- /dev/null
2320+++ b/pkg/ext/auth.go
2321@@ -0,0 +1,72 @@
2322+package ext
2323+
2324+import (
2325+ "bytes"
2326+ "crypto/aes"
2327+ "crypto/cipher"
2328+ "crypto/rand"
2329+ "encoding/gob"
2330+ "fmt"
2331+ "io"
2332+)
2333+
2334+type Token struct {
2335+ UserID uint
2336+ Username string
2337+}
2338+
2339+var nonce []byte
2340+
2341+func init() {
2342+ nonce = make([]byte, 12)
2343+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
2344+ fmt.Println("Erro while generating nonce " + err.Error())
2345+ panic(1)
2346+ }
2347+}
2348+
2349+func ReadToken(data []byte, key []byte) (*Token, error) {
2350+ block, err := aes.NewCipher(key)
2351+ if err != nil {
2352+ return nil, err
2353+ }
2354+
2355+ aesgcm, err := cipher.NewGCM(block)
2356+ if err != nil {
2357+ panic(err.Error())
2358+ }
2359+
2360+ plaintext, err := aesgcm.Open(nil, nonce, data, nil)
2361+ if err != nil {
2362+ return nil, err
2363+ }
2364+
2365+ r := bytes.NewReader(plaintext)
2366+ var token Token
2367+ dec := gob.NewDecoder(r)
2368+ if err = dec.Decode(&token); err != nil {
2369+ return nil, err
2370+ }
2371+ return &token, nil
2372+}
2373+
2374+func WriteToken(token *Token, key []byte) ([]byte, error) {
2375+ block, err := aes.NewCipher(key)
2376+ if err != nil {
2377+ return nil, err
2378+ }
2379+
2380+ aesgcm, err := cipher.NewGCM(block)
2381+ if err != nil {
2382+ return nil, err
2383+ }
2384+
2385+ var buffer bytes.Buffer
2386+ enc := gob.NewEncoder(&buffer)
2387+ if err := enc.Encode(token); err != nil {
2388+ return nil, err
2389+ }
2390+
2391+ ciphertext := aesgcm.Seal(nil, nonce, buffer.Bytes(), nil)
2392+ return ciphertext, nil
2393+}
2394diff --git a/pkg/ext/auth_test.go b/pkg/ext/auth_test.go
2395new file mode 100644
2396index 0000000000000000000000000000000000000000..dc72a0cd66d92a1a07de9ee36a08120d954f6e60
2397--- /dev/null
2398+++ b/pkg/ext/auth_test.go
2399@@ -0,0 +1,40 @@
2400+//go:build unit
2401+
2402+package ext
2403+
2404+import (
2405+ "testing"
2406+
2407+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
2408+)
2409+
2410+func TestReadWriteToken(t *testing.T) {
2411+ t.Parallel()
2412+
2413+ testCases := []struct {
2414+ name string
2415+ key []byte
2416+ token *Token
2417+ }{
2418+ {
2419+ name: "Normal write",
2420+ key: []byte("AES256Key-32Characters1234567890"),
2421+ token: &Token{
2422+ UserID: 3,
2423+ Username: "username",
2424+ },
2425+ },
2426+ }
2427+
2428+ for _, tc := range testCases {
2429+ t.Run(tc.name, func(t *testing.T) {
2430+ data, err := WriteToken(tc.token, tc.key)
2431+ testkit.TestFatalError(t, "WriteToken", err)
2432+
2433+ token, err := ReadToken(data, tc.key)
2434+ testkit.TestFatalError(t, "ReadToken", err)
2435+
2436+ testkit.TestValue(t, "ReadWriteToken", token, tc.token)
2437+ })
2438+ }
2439+}
2440diff --git a/pkg/ext/gorm_logger.go b/pkg/ext/gorm_logger.go
2441new file mode 100644
2442index 0000000000000000000000000000000000000000..bfb26d2615da4851bd9c4590350d7b0f47c7832e
2443--- /dev/null
2444+++ b/pkg/ext/gorm_logger.go
2445@@ -0,0 +1,58 @@
2446+package ext
2447+
2448+import (
2449+ "context"
2450+ "fmt"
2451+ "time"
2452+
2453+ "github.com/sirupsen/logrus"
2454+ "gorm.io/gorm/logger"
2455+ "gorm.io/gorm/utils"
2456+)
2457+
2458+type Log struct {
2459+ logrus *logrus.Entry
2460+}
2461+
2462+func getFullMsg(msg string, data ...interface{}) string {
2463+ return fmt.Sprintf(msg, append([]interface{}{utils.FileWithLineNum()}, data...)...)
2464+}
2465+
2466+func (self *Log) LogMode(log logger.LogLevel) logger.Interface {
2467+ return self
2468+}
2469+
2470+func (self *Log) Info(ctx context.Context, msg string, data ...interface{}) {
2471+ fullMsg := getFullMsg(msg, data)
2472+ self.logrus.
2473+ WithContext(ctx).
2474+ Info(fullMsg)
2475+}
2476+
2477+func (self *Log) Warn(ctx context.Context, msg string, data ...interface{}) {
2478+ fullMsg := getFullMsg(msg, data)
2479+ self.logrus.
2480+ WithContext(ctx).
2481+ Warn(fullMsg)
2482+}
2483+func (self *Log) Error(ctx context.Context, msg string, data ...interface{}) {
2484+ fullMsg := getFullMsg(msg, data)
2485+ self.logrus.
2486+ WithContext(ctx).
2487+ Error(fullMsg)
2488+}
2489+
2490+func (self *Log) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
2491+ elapsed := time.Since(begin)
2492+ sql, _ := fc()
2493+ self.logrus.
2494+ WithContext(ctx).
2495+ WithField("time", elapsed).
2496+ Printf(sql)
2497+}
2498+
2499+func Wraplog(log *logrus.Entry) *Log {
2500+ return &Log{
2501+ logrus: log,
2502+ }
2503+}
2504diff --git a/pkg/ext/middleware.go b/pkg/ext/middleware.go
2505new file mode 100644
2506index 0000000000000000000000000000000000000000..771c0ac6cf729661c1a6c33967910d908c1411bc
2507--- /dev/null
2508+++ b/pkg/ext/middleware.go
2509@@ -0,0 +1,89 @@
2510+package ext
2511+
2512+import (
2513+ "encoding/base64"
2514+ "time"
2515+
2516+ "github.com/sirupsen/logrus"
2517+ "github.com/valyala/fasthttp"
2518+)
2519+
2520+func HTML(next fasthttp.RequestHandler) fasthttp.RequestHandler {
2521+ return func(ctx *fasthttp.RequestCtx) {
2522+ ctx.Response.Header.SetContentType("text/html")
2523+ next(ctx)
2524+ }
2525+}
2526+
2527+type LogMiddleware struct {
2528+ entry *logrus.Entry
2529+}
2530+
2531+func NewLogMiddleare(log *logrus.Entry) *LogMiddleware {
2532+ return &LogMiddleware{
2533+ entry: log,
2534+ }
2535+}
2536+
2537+func (l *LogMiddleware) HTTP(next fasthttp.RequestHandler) fasthttp.RequestHandler {
2538+ return func(ctx *fasthttp.RequestCtx) {
2539+ start := time.Now()
2540+ next(ctx)
2541+ elapsed := time.Since(start)
2542+ l.entry.
2543+ WithField("time", elapsed).
2544+ WithField("code", ctx.Response.StatusCode()).
2545+ WithField("path", string(ctx.Path())).
2546+ WithField("bytes", len(ctx.Response.Body())).
2547+ Info(string(ctx.Request.Header.Method()))
2548+ }
2549+}
2550+
2551+type AuthMiddleware struct {
2552+ key []byte
2553+ entry *logrus.Entry
2554+}
2555+
2556+func NewAuthMiddleware(key []byte, log *logrus.Entry) *AuthMiddleware {
2557+ return &AuthMiddleware{
2558+ key: key,
2559+ entry: log.WithField("context", "auth"),
2560+ }
2561+}
2562+
2563+func (a *AuthMiddleware) LoggedIn(next fasthttp.RequestHandler) fasthttp.RequestHandler {
2564+ return func(ctx *fasthttp.RequestCtx) {
2565+ path := string(ctx.Path())
2566+ if path == "/login" {
2567+ next(ctx)
2568+ return
2569+ }
2570+
2571+ redirectLogin := "/login?redirect=" + path
2572+ authBase64 := ctx.Request.Header.Cookie("auth")
2573+ if authBase64 == nil {
2574+ a.entry.Info("No auth provided")
2575+ ctx.Redirect(redirectLogin, 307)
2576+ return
2577+ }
2578+
2579+ auth, err := base64.StdEncoding.DecodeString(string(authBase64))
2580+ if err != nil {
2581+ a.entry.Error(err)
2582+ return
2583+ }
2584+
2585+ token, err := ReadToken(auth, a.key)
2586+ if err != nil {
2587+ a.entry.Error(err)
2588+ ctx.Redirect(redirectLogin, 307)
2589+ return
2590+ }
2591+ ctx.SetUserValue("token", token)
2592+ a.entry.
2593+ WithField("userID", token.UserID).
2594+ WithField("username", token.Username).
2595+ Info("user recognized")
2596+ next(ctx)
2597+ }
2598+}
2599diff --git a/pkg/ext/responses.go b/pkg/ext/responses.go
2600new file mode 100644
2601index 0000000000000000000000000000000000000000..73543951024733b067f4349abded14992d618f34
2602--- /dev/null
2603+++ b/pkg/ext/responses.go
2604@@ -0,0 +1,50 @@
2605+package ext
2606+
2607+import (
2608+ "bytes"
2609+ "fmt"
2610+
2611+ "github.com/valyala/fasthttp"
2612+
2613+ "git.sr.ht/~gabrielgio/img"
2614+)
2615+
2616+var (
2617+ ContentTypeJSON = []byte("application/json")
2618+ ContentTypeHTML = []byte("text/html")
2619+ ContentTypeMARKDOWN = []byte("text/markdown")
2620+ ContentTypeJPEG = []byte("image/jpeg")
2621+)
2622+
2623+func NotFoundHTML(ctx *fasthttp.RequestCtx) {
2624+ ctx.Response.Header.SetContentType("text/html")
2625+ //nolint:errcheck
2626+ img.Render(ctx, "error.html", &img.HTMLView[string]{
2627+ Data: "NotFound",
2628+ })
2629+}
2630+
2631+func NotFound(ctx *fasthttp.RequestCtx) {
2632+ ctx.Response.SetStatusCode(404)
2633+ ct := ctx.Response.Header.ContentType()
2634+ if bytes.Equal(ct, ContentTypeHTML) {
2635+ NotFoundHTML(ctx)
2636+ }
2637+}
2638+
2639+func InternalServerError(ctx *fasthttp.RequestCtx, err error) {
2640+ ctx.Response.Header.SetContentType("text/html")
2641+ message := fmt.Sprintf("Internal Server Error:\n%+v", err)
2642+ //nolint:errcheck
2643+ respErr := img.Render(ctx, "error.html", &img.HTMLView[string]{
2644+ Data: message,
2645+ })
2646+
2647+ if respErr != nil {
2648+ fmt.Println(respErr.Error())
2649+ }
2650+}
2651+
2652+func NoContent(ctx *fasthttp.RequestCtx) {
2653+ ctx.Response.SetStatusCode(204)
2654+}
2655diff --git a/pkg/ext/router.go b/pkg/ext/router.go
2656new file mode 100644
2657index 0000000000000000000000000000000000000000..74f0a95f468cb3a484bdfa17a27e6085c35729a8
2658--- /dev/null
2659+++ b/pkg/ext/router.go
2660@@ -0,0 +1,51 @@
2661+package ext
2662+
2663+import (
2664+ "github.com/fasthttp/router"
2665+ "github.com/valyala/fasthttp"
2666+)
2667+
2668+type (
2669+ Router struct {
2670+ middlewares []Middleware
2671+ fastRouter *router.Router
2672+ }
2673+ Middleware func(next fasthttp.RequestHandler) fasthttp.RequestHandler
2674+ ErrorRequestHandler func(ctx *fasthttp.RequestCtx) error
2675+)
2676+
2677+func NewRouter(nestedRouter *router.Router) *Router {
2678+ return &Router{
2679+ fastRouter: nestedRouter,
2680+ }
2681+}
2682+
2683+func (self *Router) AddMiddleware(middleware Middleware) {
2684+ self.middlewares = append(self.middlewares, middleware)
2685+}
2686+
2687+func wrapError(next ErrorRequestHandler) fasthttp.RequestHandler {
2688+ return func(ctx *fasthttp.RequestCtx) {
2689+ if err := next(ctx); err != nil {
2690+ ctx.Response.SetStatusCode(500)
2691+ InternalServerError(ctx, err)
2692+ }
2693+ }
2694+}
2695+
2696+func (self *Router) run(next ErrorRequestHandler) fasthttp.RequestHandler {
2697+ return func(ctx *fasthttp.RequestCtx) {
2698+ req := wrapError(next)
2699+ for _, r := range self.middlewares {
2700+ req = r(req)
2701+ }
2702+ req(ctx)
2703+ }
2704+}
2705+
2706+func (self *Router) GET(path string, handler ErrorRequestHandler) {
2707+ self.fastRouter.GET(path, self.run(handler))
2708+}
2709+func (self *Router) POST(path string, handler ErrorRequestHandler) {
2710+ self.fastRouter.POST(path, self.run(handler))
2711+}
2712diff --git a/pkg/fileop/exif.go b/pkg/fileop/exif.go
2713new file mode 100644
2714index 0000000000000000000000000000000000000000..48e495c3dc59079fc17ddbbe4c4c1439fe3581f1
2715--- /dev/null
2716+++ b/pkg/fileop/exif.go
2717@@ -0,0 +1,165 @@
2718+package fileop
2719+
2720+import (
2721+ "math"
2722+ "time"
2723+
2724+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
2725+ "github.com/barasher/go-exiftool"
2726+)
2727+
2728+func ReadExif(path string) (*media.MediaEXIF, error) {
2729+ et, err := exiftool.NewExiftool()
2730+ if err != nil {
2731+ return nil, err
2732+ }
2733+ defer et.Close()
2734+
2735+ newExif := &media.MediaEXIF{}
2736+ fileInfo := et.ExtractMetadata(path)[0]
2737+
2738+ // Get description
2739+ description, err := fileInfo.GetString("ImageDescription")
2740+ if err == nil {
2741+ newExif.Description = &description
2742+ }
2743+
2744+ // Get camera model
2745+ model, err := fileInfo.GetString("Model")
2746+ if err == nil {
2747+ newExif.Camera = &model
2748+ }
2749+
2750+ // Get Camera make
2751+ make, err := fileInfo.GetString("Make")
2752+ if err == nil {
2753+ newExif.Maker = &make
2754+ }
2755+
2756+ // Get lens
2757+ lens, err := fileInfo.GetString("LensModel")
2758+ if err == nil {
2759+ newExif.Lens = &lens
2760+ }
2761+
2762+ //Get time of photo
2763+ createDateKeys := []string{
2764+ "CreationDate",
2765+ "DateTimeOriginal",
2766+ "CreateDate",
2767+ "TrackCreateDate",
2768+ "MediaCreateDate",
2769+ "FileCreateDate",
2770+ "ModifyDate",
2771+ "TrackModifyDate",
2772+ "MediaModifyDate",
2773+ "FileModifyDate",
2774+ }
2775+ for _, createDateKey := range createDateKeys {
2776+ date, err := fileInfo.GetString(createDateKey)
2777+ if err == nil {
2778+ layout := "2006:01:02 15:04:05"
2779+ dateTime, err := time.Parse(layout, date)
2780+ if err == nil {
2781+ newExif.DateShot = &dateTime
2782+ } else {
2783+ layoutWithOffset := "2006:01:02 15:04:05+02:00"
2784+ dateTime, err = time.Parse(layoutWithOffset, date)
2785+ if err == nil {
2786+ newExif.DateShot = &dateTime
2787+ }
2788+ }
2789+ break
2790+ }
2791+ }
2792+
2793+ // Get exposure time
2794+ exposureTime, err := fileInfo.GetFloat("ExposureTime")
2795+ if err == nil {
2796+ newExif.Exposure = &exposureTime
2797+ }
2798+
2799+ // Get aperture
2800+ aperture, err := fileInfo.GetFloat("Aperture")
2801+ if err == nil {
2802+ newExif.Aperture = &aperture
2803+ }
2804+
2805+ // Get ISO
2806+ iso, err := fileInfo.GetInt("ISO")
2807+ if err == nil {
2808+ newExif.Iso = &iso
2809+ }
2810+
2811+ // Get focal length
2812+ focalLen, err := fileInfo.GetFloat("FocalLength")
2813+ if err == nil {
2814+ newExif.FocalLength = &focalLen
2815+ }
2816+
2817+ // Get flash info
2818+ flash, err := fileInfo.GetInt("Flash")
2819+ if err == nil {
2820+ newExif.Flash = &flash
2821+ }
2822+
2823+ // Get orientation
2824+ orientation, err := fileInfo.GetInt("Orientation")
2825+ if err == nil {
2826+ newExif.Orientation = &orientation
2827+ }
2828+
2829+ // Get exposure program
2830+ expProgram, err := fileInfo.GetInt("ExposureProgram")
2831+ if err == nil {
2832+ newExif.ExposureProgram = &expProgram
2833+ }
2834+
2835+ // GPS coordinates - longitude
2836+ longitudeRaw, err := fileInfo.GetFloat("GPSLongitude")
2837+ if err == nil {
2838+ newExif.GPSLongitude = &longitudeRaw
2839+ }
2840+
2841+ // GPS coordinates - latitude
2842+ latitudeRaw, err := fileInfo.GetFloat("GPSLatitude")
2843+ if err == nil {
2844+ newExif.GPSLatitude = &latitudeRaw
2845+ }
2846+
2847+ sanitizeEXIF(newExif)
2848+
2849+ return newExif, nil
2850+}
2851+
2852+// isFloatReal returns true when the float value represents a real number
2853+// (different than +Inf, -Inf or NaN)
2854+func isFloatReal(v float64) bool {
2855+ if math.IsInf(v, 1) {
2856+ return false
2857+ } else if math.IsInf(v, -1) {
2858+ return false
2859+ } else if math.IsNaN(v) {
2860+ return false
2861+ }
2862+ return true
2863+}
2864+
2865+// sanitizeEXIF removes any EXIF float64 field that is not a real number (+Inf,
2866+// -Inf or Nan)
2867+func sanitizeEXIF(exif *media.MediaEXIF) {
2868+ if exif.Exposure != nil && !isFloatReal(*exif.Exposure) {
2869+ exif.Exposure = nil
2870+ }
2871+ if exif.Aperture != nil && !isFloatReal(*exif.Aperture) {
2872+ exif.Aperture = nil
2873+ }
2874+ if exif.FocalLength != nil && !isFloatReal(*exif.FocalLength) {
2875+ exif.FocalLength = nil
2876+ }
2877+ if (exif.GPSLatitude != nil && !isFloatReal(*exif.GPSLatitude)) ||
2878+ (exif.GPSLongitude != nil && !isFloatReal(*exif.GPSLongitude)) {
2879+ exif.GPSLatitude = nil
2880+ exif.GPSLongitude = nil
2881+ }
2882+}
2883diff --git a/pkg/list/list.go b/pkg/list/list.go
2884new file mode 100644
2885index 0000000000000000000000000000000000000000..ff259f7f3dbf057e373eb36ecac89f97f6f0c6fd
2886--- /dev/null
2887+++ b/pkg/list/list.go
2888@@ -0,0 +1,9 @@
2889+package list
2890+
2891+func Map[V any, T any](source []V, fun func(V) T) []T {
2892+ result := make([]T, 0, len(source))
2893+ for _, s := range source {
2894+ result = append(result, fun(s))
2895+ }
2896+ return result
2897+}
2898diff --git a/pkg/testkit/testkit.go b/pkg/testkit/testkit.go
2899new file mode 100644
2900index 0000000000000000000000000000000000000000..526e1b3b4ef1003bc6f35969befb9d3dc4fb24e7
2901--- /dev/null
2902+++ b/pkg/testkit/testkit.go
2903@@ -0,0 +1,31 @@
2904+//go:build unit || integration
2905+
2906+package testkit
2907+
2908+import (
2909+ "testing"
2910+
2911+ "github.com/google/go-cmp/cmp"
2912+)
2913+
2914+func TestValue[T any](t *testing.T, method string, want, got T) {
2915+ if diff := cmp.Diff(want, got); diff != "" {
2916+ t.Errorf("%s() mismatch (-want +got):\n%s", method, diff)
2917+ }
2918+}
2919+
2920+func TestFatalError(t *testing.T, method string, err error) {
2921+ if err != nil {
2922+ t.Fatalf("%s() fatal error : %+v", method, err)
2923+ }
2924+}
2925+
2926+func TestError(t *testing.T, method string, want, got error) {
2927+ if !equalError(want, got) {
2928+ t.Errorf("%s() err mismatch want: %+v got %+v", method, want, got)
2929+ }
2930+}
2931+
2932+func equalError(a, b error) bool {
2933+ return a == nil && b == nil || a != nil && b != nil && a.Error() == b.Error()
2934+}
2935diff --git a/pkg/view/auth.go b/pkg/view/auth.go
2936new file mode 100644
2937index 0000000000000000000000000000000000000000..5c83eba0225998cda97795f1747f3d73894e9f4d
2938--- /dev/null
2939+++ b/pkg/view/auth.go
2940@@ -0,0 +1,97 @@
2941+package view
2942+
2943+import (
2944+ "encoding/base64"
2945+
2946+ "github.com/valyala/fasthttp"
2947+
2948+ "git.sr.ht/~gabrielgio/img"
2949+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
2950+ "git.sr.ht/~gabrielgio/img/pkg/ext"
2951+)
2952+
2953+type AuthView struct {
2954+ userController *auth.Controller
2955+}
2956+
2957+func NewAuthView(userController *auth.Controller) *AuthView {
2958+ return &AuthView{
2959+ userController: userController,
2960+ }
2961+}
2962+
2963+func (v *AuthView) LoginView(ctx *fasthttp.RequestCtx) error {
2964+ return img.Render[interface{}](ctx, "login.html", nil)
2965+}
2966+
2967+func (v *AuthView) Logout(ctx *fasthttp.RequestCtx) error {
2968+ cook := fasthttp.Cookie{}
2969+ cook.SetKey("auth")
2970+ cook.SetValue("")
2971+ cook.SetMaxAge(-1)
2972+ cook.SetHTTPOnly(true)
2973+ cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode)
2974+ ctx.Response.Header.SetCookie(&cook)
2975+
2976+ ctx.Redirect("/", 307)
2977+ return nil
2978+}
2979+
2980+func (v *AuthView) Login(ctx *fasthttp.RequestCtx) error {
2981+ username := ctx.FormValue("username")
2982+ password := ctx.FormValue("password")
2983+
2984+ auth, err := v.userController.Login(ctx, username, password)
2985+ if err != nil {
2986+ return err
2987+ }
2988+
2989+ base64Auth := base64.StdEncoding.EncodeToString(auth)
2990+
2991+ cook := fasthttp.Cookie{}
2992+ cook.SetKey("auth")
2993+ cook.SetValue(base64Auth)
2994+ cook.SetHTTPOnly(true)
2995+ cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode)
2996+ ctx.Response.Header.SetCookie(&cook)
2997+
2998+ redirect := string(ctx.FormValue("redirect"))
2999+ if redirect == "" {
3000+ ctx.Redirect("/", 307)
3001+ } else {
3002+ ctx.Redirect(redirect, 307)
3003+ }
3004+ return nil
3005+}
3006+
3007+func (v *AuthView) RegisterView(ctx *fasthttp.RequestCtx) error {
3008+ return img.Render[interface{}](ctx, "register.html", nil)
3009+}
3010+
3011+func (v *AuthView) Register(ctx *fasthttp.RequestCtx) error {
3012+ username := ctx.FormValue("username")
3013+ password := ctx.FormValue("password")
3014+
3015+ err := v.userController.Register(ctx, username, password)
3016+ if err != nil {
3017+ return err
3018+ }
3019+
3020+ ctx.Redirect("/login", 307)
3021+ return nil
3022+}
3023+
3024+func Index(ctx *fasthttp.RequestCtx) {
3025+ ctx.Redirect("/login", 307)
3026+}
3027+
3028+func (v *AuthView) SetMyselfIn(r *ext.Router) {
3029+ r.GET("/login", v.LoginView)
3030+ r.POST("/login", v.Login)
3031+
3032+ r.GET("/register", v.RegisterView)
3033+ r.POST("/register", v.Register)
3034+
3035+ r.GET("/logout", v.Logout)
3036+ r.POST("/logout", v.Logout)
3037+}
3038diff --git a/pkg/view/filesystem.go b/pkg/view/filesystem.go
3039new file mode 100644
3040index 0000000000000000000000000000000000000000..f10d788971d34bebac0b6655b51752885fa51e48
3041--- /dev/null
3042+++ b/pkg/view/filesystem.go
3043@@ -0,0 +1,66 @@
3044+package view
3045+
3046+import (
3047+ "github.com/valyala/fasthttp"
3048+
3049+ "git.sr.ht/~gabrielgio/img"
3050+ "git.sr.ht/~gabrielgio/img/pkg/components/filesystem"
3051+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
3052+ "git.sr.ht/~gabrielgio/img/pkg/ext"
3053+)
3054+
3055+type (
3056+ FileSystemView struct {
3057+ controller filesystem.Controller
3058+ settings settings.Repository
3059+ }
3060+ FilePage struct {
3061+ Page *filesystem.Page
3062+ ShowMode bool
3063+ ShowOwner bool
3064+ }
3065+)
3066+
3067+func NewFileSystemView(
3068+ controller filesystem.Controller,
3069+ settingsRepository settings.Repository,
3070+) *FileSystemView {
3071+ return &FileSystemView{
3072+ controller: controller,
3073+ settings: settingsRepository,
3074+ }
3075+}
3076+
3077+func (self *FileSystemView) Index(ctx *fasthttp.RequestCtx) error {
3078+ pathValue := string(ctx.FormValue("path"))
3079+
3080+ page, err := self.controller.GetPage(pathValue)
3081+ if err != nil {
3082+ return err
3083+ }
3084+
3085+ settings, err := self.settings.Load(ctx)
3086+ if err != nil {
3087+ return err
3088+ }
3089+
3090+ err = img.Render(ctx, "fs.html", &img.HTMLView[*FilePage]{
3091+ Title: pathValue,
3092+ Data: &FilePage{
3093+ Page: page,
3094+ ShowMode: settings.ShowMode,
3095+ ShowOwner: settings.ShowOwner,
3096+ },
3097+ })
3098+ if err != nil {
3099+ return err
3100+ }
3101+ return nil
3102+}
3103+
3104+func (self *FileSystemView) SetMyselfIn(r *ext.Router) {
3105+ r.GET("/", self.Index)
3106+ r.POST("/", self.Index)
3107+ r.GET("/fs/", self.Index)
3108+ r.POST("/fs/", self.Index)
3109+}
3110diff --git a/pkg/view/media.go b/pkg/view/media.go
3111new file mode 100644
3112index 0000000000000000000000000000000000000000..22f950d8050cba87ad40c33a420cb80e29625dc0
3113--- /dev/null
3114+++ b/pkg/view/media.go
3115@@ -0,0 +1,101 @@
3116+package view
3117+
3118+import (
3119+ "strconv"
3120+
3121+ "github.com/valyala/fasthttp"
3122+
3123+ "git.sr.ht/~gabrielgio/img"
3124+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
3125+ "git.sr.ht/~gabrielgio/img/pkg/ext"
3126+)
3127+
3128+type (
3129+ MediaView struct {
3130+ mediaRepository media.Repository
3131+ }
3132+
3133+ Page struct {
3134+ Medias []*media.Media
3135+ Next *media.Pagination
3136+ }
3137+)
3138+
3139+func getPagination(ctx *fasthttp.RequestCtx) *media.Pagination {
3140+ var (
3141+ size int
3142+ page int
3143+ sizeStr = string(ctx.FormValue("size"))
3144+ pageStr = string(ctx.FormValue("page"))
3145+ )
3146+
3147+ if sizeStr == "" {
3148+ size = 100
3149+ } else if s, err := strconv.Atoi(sizeStr); err != nil {
3150+ size = 100
3151+ } else {
3152+ size = s
3153+ }
3154+
3155+ if pageStr == "" {
3156+ page = 0
3157+ } else if p, err := strconv.Atoi(pageStr); err != nil {
3158+ page = 0
3159+ } else {
3160+ page = p
3161+ }
3162+
3163+ return &media.Pagination{
3164+ Page: page,
3165+ Size: size,
3166+ }
3167+}
3168+
3169+func NewMediaView(mediaRepository media.Repository) *MediaView {
3170+ return &MediaView{
3171+ mediaRepository: mediaRepository,
3172+ }
3173+}
3174+
3175+func (self *MediaView) Index(ctx *fasthttp.RequestCtx) error {
3176+ p := getPagination(ctx)
3177+ medias, err := self.mediaRepository.List(ctx, p)
3178+ if err != nil {
3179+ return err
3180+ }
3181+
3182+ err = img.Render(ctx, "media.html", &img.HTMLView[*Page]{
3183+ Title: "Media",
3184+ Data: &Page{
3185+ Medias: medias,
3186+ Next: &media.Pagination{
3187+ Size: p.Size,
3188+ Page: p.Page + 1,
3189+ },
3190+ },
3191+ })
3192+ if err != nil {
3193+ return err
3194+ }
3195+ return nil
3196+}
3197+
3198+func (self *MediaView) GetImage(ctx *fasthttp.RequestCtx) error {
3199+ pathHash := string(ctx.FormValue("path_hash"))
3200+
3201+ media, err := self.mediaRepository.Get(ctx, pathHash)
3202+ if err != nil {
3203+ return err
3204+ }
3205+
3206+ ctx.Response.Header.SetContentType(media.MIMEType)
3207+ ctx.SendFile(media.Path)
3208+ return nil
3209+}
3210+
3211+func (self *MediaView) SetMyselfIn(r *ext.Router) {
3212+ r.GET("/media", self.Index)
3213+ r.POST("/media", self.Index)
3214+
3215+ r.GET("/media/image", self.GetImage)
3216+}
3217diff --git a/pkg/view/settings.go b/pkg/view/settings.go
3218new file mode 100644
3219index 0000000000000000000000000000000000000000..746dee4df99001f92e4b6703fee352ca4cb76998
3220--- /dev/null
3221+++ b/pkg/view/settings.go
3222@@ -0,0 +1,53 @@
3223+package view
3224+
3225+import (
3226+ "github.com/valyala/fasthttp"
3227+
3228+ "git.sr.ht/~gabrielgio/img"
3229+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
3230+ "git.sr.ht/~gabrielgio/img/pkg/ext"
3231+)
3232+
3233+type SettingsView struct {
3234+ // there is not need to create a controller for this
3235+ repository settings.Repository
3236+}
3237+
3238+func NewSettingsView(respository settings.Repository) *SettingsView {
3239+ return &SettingsView{
3240+ repository: respository,
3241+ }
3242+}
3243+
3244+func (self *SettingsView) Index(ctx *fasthttp.RequestCtx) error {
3245+ s, err := self.repository.Load(ctx)
3246+ if err != nil {
3247+ return err
3248+ }
3249+ return img.Render(ctx, "settings.html", &img.HTMLView[*settings.Settings]{
3250+ Title: "Settings",
3251+ Data: s,
3252+ })
3253+}
3254+
3255+func (self *SettingsView) Save(ctx *fasthttp.RequestCtx) error {
3256+ var (
3257+ showMode = string(ctx.FormValue("showMode")) == "on"
3258+ showOwner = string(ctx.FormValue("showOwner")) == "on"
3259+ )
3260+
3261+ err := self.repository.Save(ctx, &settings.Settings{
3262+ ShowMode: showMode,
3263+ ShowOwner: showOwner,
3264+ })
3265+ if err != nil {
3266+ return err
3267+ }
3268+
3269+ return self.Index(ctx)
3270+}
3271+
3272+func (self *SettingsView) SetMyselfIn(r *ext.Router) {
3273+ r.GET("/settings/", self.Index)
3274+ r.POST("/settings/", self.Save)
3275+}
3276diff --git a/pkg/view/view.go b/pkg/view/view.go
3277new file mode 100644
3278index 0000000000000000000000000000000000000000..663738b6c6ac87955d83753445b9ff868959e29a
3279--- /dev/null
3280+++ b/pkg/view/view.go
3281@@ -0,0 +1,7 @@
3282+package view
3283+
3284+import "git.sr.ht/~gabrielgio/img/pkg/ext"
3285+
3286+type View interface {
3287+ SetMyselfIn(r *ext.Router)
3288+}
3289diff --git a/pkg/worker/exif_scanner.go b/pkg/worker/exif_scanner.go
3290new file mode 100644
3291index 0000000000000000000000000000000000000000..66091cd33ff93ebc6dbfda28a74bddef860ab6d3
3292--- /dev/null
3293+++ b/pkg/worker/exif_scanner.go
3294@@ -0,0 +1,43 @@
3295+package worker
3296+
3297+import (
3298+ "context"
3299+
3300+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
3301+ "git.sr.ht/~gabrielgio/img/pkg/fileop"
3302+)
3303+
3304+type (
3305+ EXIFScanner struct {
3306+ repository media.Repository
3307+ }
3308+)
3309+
3310+var _ ListProcessor[*media.Media] = &EXIFScanner{}
3311+
3312+func NewEXIFScanner(root string, repository media.Repository) *EXIFScanner {
3313+ return &EXIFScanner{
3314+ repository: repository,
3315+ }
3316+}
3317+
3318+func (e *EXIFScanner) Query(ctx context.Context) ([]*media.Media, error) {
3319+ medias, err := e.repository.GetEmptyEXIF(ctx, &media.Pagination{
3320+ Page: 0,
3321+ Size: 100,
3322+ })
3323+ if err != nil {
3324+ return nil, err
3325+ }
3326+
3327+ return medias, nil
3328+}
3329+
3330+func (e *EXIFScanner) Process(ctx context.Context, m *media.Media) error {
3331+ newExif, err := fileop.ReadExif(m.Path)
3332+ if err != nil {
3333+ return err
3334+ }
3335+
3336+ return e.repository.CreateEXIF(ctx, m.ID, newExif)
3337+}
3338diff --git a/pkg/worker/file_scanner.go b/pkg/worker/file_scanner.go
3339new file mode 100644
3340index 0000000000000000000000000000000000000000..321fbcac11e422ea6221e8a476ac2f7704b06143
3341--- /dev/null
3342+++ b/pkg/worker/file_scanner.go
3343@@ -0,0 +1,81 @@
3344+package worker
3345+
3346+import (
3347+ "context"
3348+ "crypto/md5"
3349+ "encoding/hex"
3350+ "io/fs"
3351+ "path/filepath"
3352+
3353+ "github.com/gabriel-vasile/mimetype"
3354+
3355+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
3356+)
3357+
3358+type (
3359+ FileScanner struct {
3360+ root string
3361+ repository media.Repository
3362+ }
3363+)
3364+
3365+var _ ChanProcessor[string] = &FileScanner{}
3366+
3367+func NewFileScanner(root string, repository media.Repository) *FileScanner {
3368+ return &FileScanner{
3369+ root: root,
3370+ repository: repository,
3371+ }
3372+}
3373+
3374+func (f *FileScanner) Query(ctx context.Context) (<-chan string, error) {
3375+ c := make(chan string)
3376+ go func() {
3377+ defer close(c)
3378+ _ = filepath.Walk(f.root, func(path string, info fs.FileInfo, err error) error {
3379+ if info.IsDir() && filepath.Base(info.Name())[0] == '.' {
3380+ return filepath.SkipDir
3381+ }
3382+
3383+ if info.IsDir() {
3384+ return nil
3385+ }
3386+
3387+ if filepath.Ext(info.Name()) != ".jpg" &&
3388+ filepath.Ext(info.Name()) != ".jpeg" &&
3389+ filepath.Ext(info.Name()) != ".png" {
3390+ return nil
3391+ }
3392+ c <- path
3393+ return nil
3394+ })
3395+ }()
3396+ return c, nil
3397+}
3398+
3399+func (f *FileScanner) Process(ctx context.Context, path string) error {
3400+ hash := md5.Sum([]byte(path))
3401+ str := hex.EncodeToString(hash[:])
3402+ name := filepath.Base(path)
3403+
3404+ exists, errResp := f.repository.Exists(ctx, str)
3405+ if errResp != nil {
3406+ return errResp
3407+ }
3408+
3409+ if exists {
3410+ return nil
3411+ }
3412+
3413+ mime, errResp := mimetype.DetectFile(path)
3414+ if errResp != nil {
3415+ return errResp
3416+ }
3417+
3418+ return f.repository.Create(ctx, &media.CreateMedia{
3419+ Name: name,
3420+ Path: path,
3421+ PathHash: str,
3422+ MIMEType: mime.String(),
3423+ })
3424+}
3425diff --git a/pkg/worker/httpserver.go b/pkg/worker/httpserver.go
3426new file mode 100644
3427index 0000000000000000000000000000000000000000..181cf73f4d173437b0b37312b1a4999ad4221324
3428--- /dev/null
3429+++ b/pkg/worker/httpserver.go
3430@@ -0,0 +1,31 @@
3431+package worker
3432+
3433+import (
3434+ "context"
3435+
3436+ "github.com/valyala/fasthttp"
3437+)
3438+
3439+type ServerWorker struct {
3440+ server *fasthttp.Server
3441+}
3442+
3443+func (self *ServerWorker) Start(ctx context.Context) error {
3444+ go func() {
3445+ // nolint: errcheck
3446+ self.server.ListenAndServe("0.0.0.0:8080")
3447+ }()
3448+
3449+ <-ctx.Done()
3450+ return self.Shutdown()
3451+}
3452+
3453+func (self *ServerWorker) Shutdown() error {
3454+ return self.server.Shutdown()
3455+}
3456+
3457+func NewServerWorker(server *fasthttp.Server) *ServerWorker {
3458+ return &ServerWorker{
3459+ server: server,
3460+ }
3461+}
3462diff --git a/pkg/worker/list_processor.go b/pkg/worker/list_processor.go
3463new file mode 100644
3464index 0000000000000000000000000000000000000000..d53b7ea6a8e4a051ecd7c210e70c77cc97454900
3465--- /dev/null
3466+++ b/pkg/worker/list_processor.go
3467@@ -0,0 +1,102 @@
3468+package worker
3469+
3470+import (
3471+ "context"
3472+)
3473+
3474+type (
3475+
3476+ // A simple worker to deal with list.
3477+ ChanProcessor[T any] interface {
3478+ Query(context.Context) (<-chan T, error)
3479+ Process(context.Context, T) error
3480+ }
3481+
3482+ ListProcessor[T any] interface {
3483+ Query(context.Context) ([]T, error)
3484+ Process(context.Context, T) error
3485+ }
3486+
3487+ chanProcessorWorker[T any] struct {
3488+ chanProcessor ChanProcessor[T]
3489+ scheduler *Scheduler
3490+ }
3491+
3492+ listProcessorWorker[T any] struct {
3493+ listProcessor ListProcessor[T]
3494+ scheduler *Scheduler
3495+ }
3496+)
3497+
3498+func NewWorkerFromListProcessor[T any](
3499+ listProcessor ListProcessor[T],
3500+ scheduler *Scheduler,
3501+) Worker {
3502+ return &listProcessorWorker[T]{
3503+ listProcessor: listProcessor,
3504+ scheduler: scheduler,
3505+ }
3506+}
3507+
3508+func NewWorkerFromChanProcessor[T any](
3509+ listProcessor ChanProcessor[T],
3510+ scheduler *Scheduler,
3511+) Worker {
3512+ return &chanProcessorWorker[T]{
3513+ chanProcessor: listProcessor,
3514+ scheduler: scheduler,
3515+ }
3516+}
3517+
3518+func (l *listProcessorWorker[T]) Start(ctx context.Context) error {
3519+ for {
3520+ values, err := l.listProcessor.Query(ctx)
3521+ if err != nil {
3522+ return err
3523+ }
3524+
3525+ select {
3526+ case <-ctx.Done():
3527+ return ctx.Err()
3528+ default:
3529+ }
3530+
3531+ if len(values) == 0 {
3532+ return nil
3533+ }
3534+
3535+ for _, v := range values {
3536+ select {
3537+ case <-ctx.Done():
3538+ return ctx.Err()
3539+ default:
3540+ }
3541+
3542+ if err := l.listProcessor.Process(ctx, v); err != nil {
3543+ return err
3544+ }
3545+ }
3546+ }
3547+}
3548+
3549+func (l *chanProcessorWorker[T]) Start(ctx context.Context) error {
3550+ c, err := l.chanProcessor.Query(ctx)
3551+ if err != nil {
3552+ return err
3553+ }
3554+
3555+ for {
3556+ select {
3557+ case <-ctx.Done():
3558+ return ctx.Err()
3559+ case v, ok := <-c:
3560+ if !ok {
3561+ return nil
3562+ }
3563+
3564+ if err := l.chanProcessor.Process(ctx, v); err != nil {
3565+ return err
3566+ }
3567+ }
3568+ }
3569+}
3570diff --git a/pkg/worker/list_processor_test.go b/pkg/worker/list_processor_test.go
3571new file mode 100644
3572index 0000000000000000000000000000000000000000..b7373d183eed5685a26c30a4097a84cd0f36ea42
3573--- /dev/null
3574+++ b/pkg/worker/list_processor_test.go
3575@@ -0,0 +1,90 @@
3576+// go:build unit
3577+
3578+package worker
3579+
3580+import (
3581+ "context"
3582+ "errors"
3583+ "math/rand"
3584+ "sync"
3585+ "testing"
3586+
3587+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
3588+)
3589+
3590+type (
3591+ mockCounterListProcessor struct {
3592+ done bool
3593+ countTo int
3594+ counter int
3595+ }
3596+
3597+ mockContextListProcessor struct {
3598+ }
3599+)
3600+
3601+func TestListProcessorLimit(t *testing.T) {
3602+ mock := &mockCounterListProcessor{
3603+ countTo: 10000,
3604+ }
3605+ worker := NewWorkerFromListProcessor[int](mock, nil)
3606+
3607+ err := worker.Start(context.Background())
3608+ testkit.TestFatalError(t, "Start", err)
3609+
3610+ testkit.TestValue(t, "Start", mock.countTo, mock.counter)
3611+}
3612+
3613+func TestListProcessorContextCancelQuery(t *testing.T) {
3614+ mock := &mockContextListProcessor{}
3615+ worker := NewWorkerFromListProcessor[int](mock, nil)
3616+
3617+ ctx, cancel := context.WithCancel(context.Background())
3618+ var wg sync.WaitGroup
3619+
3620+ wg.Add(1)
3621+ go func() {
3622+ defer wg.Done()
3623+ err := worker.Start(ctx)
3624+ if errors.Is(err, context.Canceled) {
3625+ return
3626+ }
3627+ testkit.TestFatalError(t, "Start", err)
3628+ }()
3629+
3630+ cancel()
3631+ // this rely on timeout to test
3632+ wg.Wait()
3633+}
3634+
3635+func (m *mockCounterListProcessor) Query(_ context.Context) ([]int, error) {
3636+ if m.done {
3637+ return make([]int, 0), nil
3638+ }
3639+ values := make([]int, 0, m.countTo)
3640+ for i := 0; i < m.countTo; i++ {
3641+ values = append(values, rand.Int())
3642+ }
3643+
3644+ m.done = true
3645+ return values, nil
3646+}
3647+
3648+func (m *mockCounterListProcessor) Process(_ context.Context, _ int) error {
3649+ m.counter++
3650+ return nil
3651+}
3652+
3653+func (m *mockContextListProcessor) Query(_ context.Context) ([]int, error) {
3654+ // keeps returning the query so it can run in infinity loop
3655+ values := make([]int, 0, 10)
3656+ for i := 0; i < 10; i++ {
3657+ values = append(values, rand.Int())
3658+ }
3659+ return values, nil
3660+}
3661+
3662+func (m *mockContextListProcessor) Process(_ context.Context, _ int) error {
3663+ // do nothing
3664+ return nil
3665+}
3666diff --git a/pkg/worker/scheduler.go b/pkg/worker/scheduler.go
3667new file mode 100644
3668index 0000000000000000000000000000000000000000..b410b33714bcb0541a668f7af10fcce82b1dcf0b
3669--- /dev/null
3670+++ b/pkg/worker/scheduler.go
3671@@ -0,0 +1,29 @@
3672+package worker
3673+
3674+import (
3675+ "fmt"
3676+ "sync/atomic"
3677+)
3678+
3679+type Scheduler struct {
3680+ pool chan any
3681+ count atomic.Int64
3682+}
3683+
3684+func NewScheduler(count uint) *Scheduler {
3685+ return &Scheduler{
3686+ pool: make(chan any, count),
3687+ }
3688+}
3689+
3690+func (self *Scheduler) Take() {
3691+ self.pool <- nil
3692+ self.count.Add(1)
3693+ fmt.Printf("<- %d\n", self.count.Load())
3694+}
3695+
3696+func (self *Scheduler) Return() {
3697+ <-self.pool
3698+ self.count.Add(-1)
3699+ fmt.Printf("-> %d\n", self.count.Load())
3700+}
3701diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go
3702new file mode 100644
3703index 0000000000000000000000000000000000000000..c52f0becef3a84c09caa841e402977c9858e7fb3
3704--- /dev/null
3705+++ b/pkg/worker/worker.go
3706@@ -0,0 +1,54 @@
3707+package worker
3708+
3709+import (
3710+ "context"
3711+ "errors"
3712+ "fmt"
3713+ "sync"
3714+)
3715+
3716+type (
3717+ // Worker should watch for context
3718+ Worker interface {
3719+ Start(context.Context) error
3720+ }
3721+
3722+ Work struct {
3723+ Name string
3724+ Worker Worker
3725+ }
3726+
3727+ WorkerPool struct {
3728+ workers []*Work
3729+ wg sync.WaitGroup
3730+ }
3731+)
3732+
3733+func NewWorkerPool() *WorkerPool {
3734+ return &WorkerPool{}
3735+}
3736+
3737+func (self *WorkerPool) AddWorker(name string, worker Worker) {
3738+ self.workers = append(self.workers, &Work{
3739+ Name: name,
3740+ Worker: worker,
3741+ })
3742+}
3743+
3744+func (self *WorkerPool) Start(ctx context.Context) {
3745+ for _, w := range self.workers {
3746+ self.wg.Add(1)
3747+ go func(w *Work) {
3748+ defer self.wg.Done()
3749+ if err := w.Worker.Start(ctx); err != nil && !errors.Is(err, context.Canceled) {
3750+ fmt.Println("Error ", w.Name, err.Error())
3751+ } else {
3752+ fmt.Println(w.Name, "done")
3753+ }
3754+ }(w)
3755+ }
3756+}
3757+
3758+func (self *WorkerPool) Wait() {
3759+ self.wg.Wait()
3760+}
3761diff --git a/scss/main.scss b/scss/main.scss
3762new file mode 100644
3763index 0000000000000000000000000000000000000000..bf6b3d8dda08d6916c9bae414816f95c84239261
3764--- /dev/null
3765+++ b/scss/main.scss
3766@@ -0,0 +1,70 @@
3767+$breakpoint: 520px;
3768+
3769+$tablet: $breakpoint;
3770+$body-font-size: 1.3rem;
3771+$radius-rounded: 0;
3772+$container-max-width: 920px;
3773+
3774+$navbar-breakpoint: $breakpoint;
3775+
3776+$panel-item-border: 1px solid hsl(0, 0%, 93%);
3777+$panel-radius: 0;
3778+$panel-shadow: 0;
3779+
3780+$card-shadow: 0;
3781+$card-radius: 0;
3782+
3783+@import "bulma/sass/base/_all.sass";
3784+@import "bulma/sass/utilities/_all.sass";
3785+@import "bulma/sass/grid/_all.sass";
3786+@import "bulma/sass/components/_all.sass";
3787+@import "bulma/sass/form/_all.sass";
3788+@import "bulma/sass/helpers/_all.sass";
3789+@import "bulma/sass/layout/_all.sass";
3790+@import "bulma/sass/elements/_all.sass";
3791+
3792+body {
3793+ font-family: $family-primary
3794+}
3795+
3796+.input, .button{
3797+ border-radius: 0;
3798+}
3799+
3800+.file-row {
3801+ width: 100%;
3802+ font-family: monospace;
3803+}
3804+
3805+nav {
3806+ border-bottom: 1px solid;
3807+ max-width: 1024px;
3808+ margin: auto;
3809+}
3810+
3811+.container {
3812+ margin-top: 15px;
3813+
3814+ @include until($breakpoint) {
3815+ margin-left: 15px;
3816+ margin-right: 15px;
3817+ }
3818+}
3819+
3820+.card {
3821+ margin: 5px;
3822+}
3823+
3824+
3825+.image.is-fit {
3826+ height: auto;
3827+ width: 130px;
3828+
3829+ @include until($breakpoint) {
3830+ width: auto;
3831+ }
3832+}
3833+
3834+.img {
3835+ object-fit: cover;
3836+}
3837diff --git a/templates/error.html b/templates/error.html
3838new file mode 100644
3839index 0000000000000000000000000000000000000000..cbde4001e1afa037aa2cd64d268936c882b6c1cc
3840--- /dev/null
3841+++ b/templates/error.html
3842@@ -0,0 +1,5 @@
3843+{{template "layout.html" .}}
3844+{{define "title"}} Not Found {{end}}
3845+{{define "content"}}
3846+{{.}}
3847+{{end}}
3848diff --git a/templates/fs.html b/templates/fs.html
3849new file mode 100644
3850index 0000000000000000000000000000000000000000..608289d971d4f83e1c9b5155f63f6059084b5b85
3851--- /dev/null
3852+++ b/templates/fs.html
3853@@ -0,0 +1,29 @@
3854+{{template "layout.html" .}}
3855+{{define "title"}} {{.Title}} {{end}}
3856+{{define "content"}}
3857+<div class="panel">
3858+ <div class="panel-block">
3859+ <div class="columns file-row is-gapless is-mobile">
3860+ <div id="path" class="container-fluid">
3861+ <small>{{range .Data.Page.History}}<a href="/fs?path={{.UrlEncodedPath}}" >{{.Name}}/</a>{{end}}</small>
3862+ </div>
3863+ </div>
3864+ </div>
3865+ {{range .Data.Page.Files}}
3866+ <div class="panel-block">
3867+ <div class="columns file-row is-gapless is-mobile">
3868+ <div class="column">
3869+ {{if $.Data.ShowMode}}{{.Info.Mode}} {{end}}
3870+ {{if $.Data.ShowOwner}}{{.Info.Sys.Gid}}:{{.Info.Sys.Uid}} {{end}}
3871+ {{if .Info.IsDir}}
3872+ <a href="/?path={{.UrlEncodedPath}}">{{.Info.Name}}/</a>
3873+ {{else}}
3874+ {{.Info.Name}}
3875+ {{end}}
3876+ </div>
3877+ <div class="column has-text-right">{{.Info.Size}} B</div>
3878+ </div>
3879+ </div>
3880+ {{end}}
3881+</div>
3882+{{end}}
3883diff --git a/templates/layout.html b/templates/layout.html
3884new file mode 100644
3885index 0000000000000000000000000000000000000000..56d02f80858fd73559659118b4352bb342f12188
3886--- /dev/null
3887+++ b/templates/layout.html
3888@@ -0,0 +1,29 @@
3889+<!DOCTYPE html>
3890+<html lang="en">
3891+ <head>
3892+ <meta charset="utf-8">
3893+ <title>img | {{block "title" .}} noop {{end}}</title>
3894+ <link rel="stylesheet" href="/static/main.css">
3895+ <link rel="icon" href="static/square.svg" sizes="any" type="image/svg+xml">
3896+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3897+ </head>
3898+ <body>
3899+ <nav class="navbar">
3900+ <div class="navbar-start">
3901+ <a href="/fs" class="navbar-item">
3902+ files
3903+ </a>
3904+ <a href="/media" class="navbar-item">
3905+ media
3906+ </a>
3907+ <a href="/settings" class="navbar-item">
3908+ settings
3909+ </a>
3910+ </div>
3911+ </nav>
3912+ <div class="container is-max-desktop">
3913+ {{block "content" .}}noop{{end}}
3914+ </div>
3915+ </body>
3916+ {{block "script" .}}{{end}}
3917+</html>
3918diff --git a/templates/login.html b/templates/login.html
3919new file mode 100644
3920index 0000000000000000000000000000000000000000..f71d9d3d20b0ed31661aa7ccffc1f047c69c17cd
3921--- /dev/null
3922+++ b/templates/login.html
3923@@ -0,0 +1,24 @@
3924+{{template "layout.html" .}}
3925+{{define "title"}} Register {{end}}
3926+{{define "content"}}
3927+<form action="/login" method="post">
3928+ <div class="field">
3929+ <label class="label">Username</label>
3930+ <div class="control">
3931+ <input class="input" name="username" type="text">
3932+ </div>
3933+ </div>
3934+ <div class="field">
3935+ <label class="label">Password</label>
3936+ <div class="control">
3937+ <input class="input" name="password" type="password">
3938+ </div>
3939+ </div>
3940+ <div class="field">
3941+ <a href="#" class="is-pulled-left">forgot password?</a>
3942+ </div>
3943+ <div class="field">
3944+ <input class="button is-pulled-right" value="login" type="submit">
3945+ </div>
3946+</form>
3947+{{end}}
3948diff --git a/templates/media.html b/templates/media.html
3949new file mode 100644
3950index 0000000000000000000000000000000000000000..478d8aee45db8dd3950fd6d9876aaef035dcd82d
3951--- /dev/null
3952+++ b/templates/media.html
3953@@ -0,0 +1,18 @@
3954+{{template "layout.html" .}}
3955+{{define "title"}} {{.Title}} {{end}}
3956+{{define "content"}}
3957+<div class="columns is-multiline">
3958+{{range .Data.Medias}}
3959+<div class="card">
3960+ <div class="card-image">
3961+ <figure class="image is-fit">
3962+ <img src="/media/image?path_hash={{.PathHash}}">
3963+ </figure>
3964+ </div>
3965+</div>
3966+{{end}}
3967+</div>
3968+<div class="row">
3969+ <a href="/media?page={{.Data.Next.Page}}" class="button is-pulled-right">next</a>
3970+</div>
3971+{{end}}
3972diff --git a/templates/settings.html b/templates/settings.html
3973new file mode 100644
3974index 0000000000000000000000000000000000000000..f8423fc2753c7f9defc286d21cc551064c7942e8
3975--- /dev/null
3976+++ b/templates/settings.html
3977@@ -0,0 +1,25 @@
3978+{{template "layout.html" .}}
3979+{{define "title"}} {{.Title}} {{end}}
3980+{{define "content"}}
3981+<form action="/settings/", method="post">
3982+ <div class="field">
3983+ <div class="control">
3984+ <label class="checkbox">
3985+ <input type="checkbox" id="showMode" name="showMode" {{if .Data.ShowMode}}checked{{end}}>
3986+ Show File Modes
3987+ </label>
3988+ </div>
3989+ </div>
3990+ <div class="field">
3991+ <div class="control">
3992+ <label class="checkbox">
3993+ <input type="checkbox" id="showOwner" name="showOwner" {{if .Data.ShowOwner}}checked{{end}}>
3994+ Show File Owner
3995+ </label>
3996+ </div>
3997+ </div>
3998+ <div class="field">
3999+ <input class="button" value="save" type="submit">
4000+ </div>
4001+</form>
4002+{{end}}
4003diff --git a/tmpl.go b/tmpl.go
4004new file mode 100644
4005index 0000000000000000000000000000000000000000..b11f9624a8e640836f0cef52fc7fffada179b776
4006--- /dev/null
4007+++ b/tmpl.go
4008@@ -0,0 +1,29 @@
4009+package img
4010+
4011+import (
4012+ "embed"
4013+ "fmt"
4014+ "html/template"
4015+ "io"
4016+)
4017+
4018+//go:embed templates/*.html
4019+var TemplateFS embed.FS
4020+
4021+var Template *template.Template
4022+
4023+type HTMLView[T any] struct {
4024+ Title string
4025+ Username string
4026+ Data T
4027+}
4028+
4029+func Render[T any](w io.Writer, page string, view *HTMLView[T]) error {
4030+ pageFile := fmt.Sprintf("templates/%s", page)
4031+ tmpl, err := template.New("").ParseFS(TemplateFS, "templates/layout.html", pageFile)
4032+ if err != nil {
4033+ return err
4034+ }
4035+
4036+ return tmpl.ExecuteTemplate(w, page, view)
4037+}