lens @ c8e1328164e9ffbd681c3c0e449f1e6b9856b896

feat: Inicial commit

It contains rough template for the server and runners.

It contains rough template for the server and runners.
   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}}&emsp;{{end}}
3870+              {{if $.Data.ShowOwner}}{{.Info.Sys.Gid}}:{{.Info.Sys.Uid}}&emsp;{{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+}