精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
+661
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,140 @@
|
||||
# gallery.fukun.net
|
||||
|
||||
基于 [Photoview](https://github.com/photoview/photoview) 定制的个人相册,部署在腾讯云 `62.234.90.54`。
|
||||
|
||||
---
|
||||
|
||||
## 访问
|
||||
|
||||
| 地址 | 说明 |
|
||||
|------|------|
|
||||
| `https://gallery.fukun.net` | 管理后台(需登录) |
|
||||
| `https://cdn.fukun.net/gallery/photos/...` | CDN 公开原图 |
|
||||
|
||||
---
|
||||
|
||||
## 定制项(与上游 Photoview 的差异)
|
||||
|
||||
- **品牌** — 左上角 FK 书法 logo,页面标题 `Gallery.FuKun.Net`
|
||||
- **外观** — 纯黑背景 (`#000`),菜单图标改为极简线条
|
||||
- **字体** — iA Writer Quattro S + Noto Sans SC(与主站 fukun.net 一致)
|
||||
- **CDN** — 公开原图走 `cdn.fukun.net`,带 token 的鉴权图片自动切 CDN
|
||||
- **无 Docker** — Go 二进制 + systemd,SQLite 单文件数据库
|
||||
- **人脸识别** — 已关闭(`PHOTOVIEW_DISABLE_FACE_RECOGNITION=1`)
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
/home/gallery/
|
||||
├── photoview-src/ # 源码 + 编译产物
|
||||
│ ├── api/
|
||||
│ │ ├── .env # 运行时环境变量
|
||||
│ │ └── photoview # Go 二进制(38MB)
|
||||
│ └── ui/
|
||||
│ └── dist/ # 前端构建产物
|
||||
├── photos/ # 相片库根目录
|
||||
└── data/
|
||||
├── photoview.db # SQLite 数据库
|
||||
└── media-cache/ # 缩略图缓存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
> 后端依赖 Linux C 库(dlib/libheif/ImageMagick),无法在 Windows 本地运行。
|
||||
> 只能改前端代码,API 指向生产环境。
|
||||
|
||||
```bash
|
||||
git clone ssh://git@fukun.net/data/git-repos/gallery.git
|
||||
cd ui
|
||||
|
||||
cat > .env << EOF
|
||||
REACT_APP_API_ENDPOINT=https://gallery.fukun.net/api/
|
||||
REACT_APP_BUILD_VERSION=dev
|
||||
REACT_APP_BUILD_DATE=$(date -u +%Y-%m-%d)
|
||||
REACT_APP_BUILD_COMMIT_SHA=local
|
||||
EOF
|
||||
|
||||
npm ci
|
||||
npm run dev # localhost:1234,API 连生产服务器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署到服务器
|
||||
|
||||
```bash
|
||||
# 构建前端
|
||||
scp -r ui/src ui/public ubuntu@62.234.90.54:/tmp/pv-ui/
|
||||
ssh ubuntu@62.234.90.54 '
|
||||
sudo cp -r /tmp/pv-ui/* /home/gallery/photoview-src/ui/
|
||||
sudo chown -R gallery:gallery /home/gallery/photoview-src/ui
|
||||
sudo -u gallery bash -c "cd /home/gallery/photoview-src/ui && npm ci && npm run build"
|
||||
'
|
||||
|
||||
# 构建后端 + 重启
|
||||
ssh ubuntu@62.234.90.54 '
|
||||
cd /home/gallery/photoview-src/api
|
||||
export PKG_CONFIG_PATH=/usr/local/im7/lib/pkgconfig CGO_ENABLED=1
|
||||
sudo -u gallery go build -o photoview .
|
||||
sudo systemctl restart photoview
|
||||
'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行时配置
|
||||
|
||||
**systemd** — `/etc/systemd/system/photoview.service`
|
||||
|
||||
| 环境变量 | 值 |
|
||||
|----------|-----|
|
||||
| `PHOTOVIEW_DATABASE_DRIVER` | `sqlite` |
|
||||
| `PHOTOVIEW_SQLITE_PATH` | `/home/gallery/data/photoview.db` |
|
||||
| `PHOTOVIEW_LISTEN_IP` | `127.0.0.1` |
|
||||
| `PHOTOVIEW_LISTEN_PORT` | `8000` |
|
||||
| `PHOTOVIEW_SERVE_UI` | `1` |
|
||||
| `PHOTOVIEW_UI_PATH` | `/home/gallery/photoview-src/ui/dist` |
|
||||
| `PHOTOVIEW_MEDIA_CACHE` | `/home/gallery/data/media-cache` |
|
||||
| `PHOTOVIEW_DISABLE_FACE_RECOGNITION` | `1` |
|
||||
| `LD_LIBRARY_PATH` | `/usr/local/im7/lib` |
|
||||
|
||||
**Nginx** — `/etc/nginx/sites-enabled/gallery.fukun.net`
|
||||
|
||||
- `gallery.fukun.net` + `cdn.fukun.net` → HTTPS → `127.0.0.1:8000`
|
||||
- `/photos/` → `alias /home/gallery/photos/`(CDN 回源口)
|
||||
- `/assets/` → 缓存 30 天
|
||||
|
||||
**SSL 证书** — 腾讯云证书,`/etc/nginx/ssl/gallery.fukun.net_*`
|
||||
|
||||
---
|
||||
|
||||
## 维护
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo systemctl status photoview
|
||||
sudo journalctl -u photoview -f
|
||||
|
||||
# 重启
|
||||
sudo systemctl restart photoview
|
||||
|
||||
# 备份数据库
|
||||
sudo cp /home/gallery/data/photoview.db /opt/backup/photoview_$(date +%Y%m%d).db
|
||||
|
||||
# 上传照片
|
||||
scp *.jpg ubuntu@62.234.90.54:/home/gallery/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 仓库
|
||||
|
||||
```
|
||||
ssh://git@fukun.net/data/git-repos/gallery.git
|
||||
```
|
||||
|
||||
服务器上 `/data/git-repos/gallery.git`(bare),推送到 `master`。
|
||||
@@ -0,0 +1,98 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
allow-parallel-runners: true
|
||||
allow-serial-runners: true
|
||||
output:
|
||||
formats:
|
||||
junit-xml:
|
||||
path: golang-lint.xml
|
||||
sarif:
|
||||
path: golang-lint.sarif
|
||||
sort-order:
|
||||
- linter
|
||||
- severity
|
||||
- file
|
||||
linters:
|
||||
enable:
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- copyloopvar
|
||||
- cyclop
|
||||
- decorder
|
||||
- depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- funlen
|
||||
- gocheckcompilerdirectives
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocyclo
|
||||
- godot
|
||||
- godox
|
||||
- goheader
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- grouper
|
||||
- inamedparam
|
||||
- interfacebloat
|
||||
- lll
|
||||
- maintidx
|
||||
- misspell
|
||||
- mnd
|
||||
- nakedret
|
||||
- nestif
|
||||
- nlreturn
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- tagalign
|
||||
- usestdlibvars
|
||||
- whitespace
|
||||
- wsl
|
||||
disable:
|
||||
- unused
|
||||
exclusions:
|
||||
rules:
|
||||
- linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
path: _test\.go
|
||||
- linters:
|
||||
- forbidigo
|
||||
path-except: _test\.go
|
||||
- linters:
|
||||
- lll
|
||||
source: "^//go:generate "
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
severity:
|
||||
default: error
|
||||
rules:
|
||||
- linters:
|
||||
- dupl
|
||||
severity: info
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceRoot}/server.go",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"env": {
|
||||
"PATH": "${env:PATH}:/Applications/darktable.app/Contents/MacOS/"
|
||||
},
|
||||
"args": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/database/migrations"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
gorm_mysql "gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func GetMysqlAddress(addressString string) (string, error) {
|
||||
if addressString == "" {
|
||||
return "", fmt.Errorf("Environment variable %s missing, exiting", utils.EnvMysqlURL.GetName())
|
||||
}
|
||||
|
||||
config, err := mysql.ParseDSN(addressString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not parse mysql url: %w", err)
|
||||
}
|
||||
|
||||
config.MultiStatements = true
|
||||
config.ParseTime = true
|
||||
|
||||
return config.FormatDSN(), nil
|
||||
}
|
||||
|
||||
func GetPostgresAddress(addressString string) (*url.URL, error) {
|
||||
if addressString == "" {
|
||||
return nil, fmt.Errorf("Environment variable %s missing, exiting", utils.EnvPostgresURL.GetName())
|
||||
}
|
||||
|
||||
address, err := url.Parse(addressString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse postgres url: %w", err)
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func GetSqliteAddress(path string) (*url.URL, error) {
|
||||
if path == "" {
|
||||
path = "photoview.db"
|
||||
}
|
||||
|
||||
address, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse sqlite url (%s): %w", path, err)
|
||||
}
|
||||
|
||||
queryValues := address.Query()
|
||||
queryValues.Add("cache", "shared")
|
||||
queryValues.Add("mode", "rwc")
|
||||
// queryValues.Add("_busy_timeout", "60000") // 1 minute
|
||||
queryValues.Add("_journal_mode", "WAL") // Write-Ahead Logging (WAL) mode
|
||||
queryValues.Add("_locking_mode", "NORMAL") // allows concurrent reads and writes
|
||||
queryValues.Add("_foreign_keys", "ON") // Enforc foreign key constraints.
|
||||
address.RawQuery = queryValues.Encode()
|
||||
|
||||
// log.Panicf("%s", address.String())
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func ConfigureDatabase(config *gorm.Config) (*gorm.DB, error) {
|
||||
var databaseDialect gorm.Dialector
|
||||
driver := drivers.DatabaseDriverFromEnv()
|
||||
log.Printf("Utilizing %s database driver based on environment variables", driver)
|
||||
|
||||
switch driver {
|
||||
case drivers.MYSQL:
|
||||
mysqlAddress, err := GetMysqlAddress(utils.EnvMysqlURL.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = gorm_mysql.Open(mysqlAddress)
|
||||
case drivers.SQLITE:
|
||||
sqliteAddress, err := GetSqliteAddress(utils.EnvSqlitePath.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = sqlite.Open(sqliteAddress.String())
|
||||
|
||||
case drivers.POSTGRES:
|
||||
postgresAddress, err := GetPostgresAddress(utils.EnvPostgresURL.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = postgres.Open(postgresAddress.String())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(databaseDialect, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// SetupDatabase connects to the database using environment variables
|
||||
func SetupDatabase() (*gorm.DB, error) {
|
||||
|
||||
config := gorm.Config{}
|
||||
|
||||
// Configure database logging
|
||||
if utils.DevelopmentMode() {
|
||||
config.Logger = logger.Default.LogMode(logger.Info)
|
||||
} else {
|
||||
config.Logger = logger.Default.LogMode(logger.Warn)
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
for retryCount := 1; retryCount <= 5; retryCount++ {
|
||||
|
||||
var err error
|
||||
db, err = ConfigureDatabase(&config)
|
||||
if err == nil {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
|
||||
sqlDB.SetMaxOpenConns(80)
|
||||
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("WARN: Could not ping database: %s. Will retry after 5 seconds\n", err)
|
||||
time.Sleep(time.Duration(5) * time.Second)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
var database_models []interface{} = []interface{}{
|
||||
&models.User{},
|
||||
&models.AccessToken{},
|
||||
&models.SiteInfo{},
|
||||
&models.Media{},
|
||||
&models.MediaURL{},
|
||||
&models.Album{},
|
||||
&models.MediaEXIF{},
|
||||
&models.VideoMetadata{},
|
||||
&models.ShareToken{},
|
||||
&models.UserMediaData{},
|
||||
&models.UserAlbums{},
|
||||
&models.UserPreferences{},
|
||||
|
||||
// Face detection
|
||||
&models.FaceGroup{},
|
||||
&models.ImageFace{},
|
||||
}
|
||||
|
||||
func MigrateDatabase(db *gorm.DB) error {
|
||||
|
||||
if err := db.SetupJoinTable(&models.User{}, "Albums", &models.UserAlbums{}); err != nil {
|
||||
log.Printf("Setup UserAlbums join table failed: %v\n", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(database_models...); err != nil {
|
||||
log.Printf("Auto migration failed: %v\n", err)
|
||||
}
|
||||
|
||||
// v2.1.0 - Replaced by Media.CreatedAt
|
||||
if db.Migrator().HasColumn(&models.Media{}, "date_imported") {
|
||||
db.Migrator().DropColumn(&models.Media{}, "date_imported")
|
||||
}
|
||||
|
||||
// v2.3.0 - Changed type of MediaEXIF.Exposure and MediaEXIF.Flash
|
||||
// from string values to decimal and int respectively
|
||||
if err := migrateExifFields(db); err != nil {
|
||||
log.Printf("Failed to run exif fields migration: %v\n", err)
|
||||
}
|
||||
|
||||
// Remove invalid GPS data from DB
|
||||
if err := migrations.MigrateForExifGPSCorrection(db); err != nil {
|
||||
log.Printf("Failed to run exif GPS correction migration: %v\n", err)
|
||||
}
|
||||
|
||||
// v2.5.0 - Remove Thumbnail Method for Downsampliing filters
|
||||
if db.Migrator().HasColumn(&models.SiteInfo{}, "thumbnail_method") {
|
||||
db.Migrator().DropColumn(&models.SiteInfo{}, "thumbnail_method")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearDatabase(db *gorm.DB) error {
|
||||
var errs []error
|
||||
for _, model := range database_models {
|
||||
if err := db.Migrator().DropTable(model); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return fmt.Errorf("drop tables error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DatabaseDriverType represents the name of a database driver
|
||||
type DatabaseDriverType string
|
||||
|
||||
const (
|
||||
MYSQL DatabaseDriverType = "mysql"
|
||||
SQLITE DatabaseDriverType = "sqlite"
|
||||
POSTGRES DatabaseDriverType = "postgres"
|
||||
)
|
||||
|
||||
func DatabaseDriverFromEnv() DatabaseDriverType {
|
||||
|
||||
var driver DatabaseDriverType
|
||||
driverString := strings.ToLower(utils.EnvDatabaseDriver.GetValue())
|
||||
|
||||
switch driverString {
|
||||
case "mysql":
|
||||
driver = MYSQL
|
||||
case "sqlite":
|
||||
driver = SQLITE
|
||||
case "postgres":
|
||||
driver = POSTGRES
|
||||
default:
|
||||
driver = MYSQL
|
||||
}
|
||||
|
||||
return driver
|
||||
}
|
||||
|
||||
func (driver DatabaseDriverType) MatchDatabase(db *gorm.DB) bool {
|
||||
return db.Dialector.Name() == string(driver)
|
||||
}
|
||||
|
||||
func GetDatabaseDriverType(db *gorm.DB) (driver DatabaseDriverType) {
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql":
|
||||
driver = MYSQL
|
||||
case "sqlite":
|
||||
driver = SQLITE
|
||||
case "postgres":
|
||||
driver = POSTGRES
|
||||
default:
|
||||
driver = MYSQL
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DateComponent a component of a date (day, month, year)
|
||||
type DateComponent string
|
||||
|
||||
const (
|
||||
DateCompYear DateComponent = "YEAR"
|
||||
DateCompMonth DateComponent = "MONTH"
|
||||
DateCompDay DateComponent = "DAY"
|
||||
)
|
||||
|
||||
// DateExtract is a helper function that is used to generate the proper SQL syntax
|
||||
// for extracting date components (day, month, year) for different database backends.
|
||||
func DateExtract(db *gorm.DB, component DateComponent, attribute string) string {
|
||||
|
||||
var result string
|
||||
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.MYSQL, drivers.POSTGRES:
|
||||
result = fmt.Sprintf("EXTRACT(%s FROM %s)", component, attribute)
|
||||
case drivers.SQLITE:
|
||||
var sqliteFormatted string
|
||||
switch component {
|
||||
case DateCompYear:
|
||||
sqliteFormatted = "%Y"
|
||||
case DateCompMonth:
|
||||
sqliteFormatted = "%m"
|
||||
case DateCompDay:
|
||||
sqliteFormatted = "%d"
|
||||
}
|
||||
|
||||
result = fmt.Sprintf("CAST(strftime('%s', %s) AS INTEGER)", sqliteFormatted, attribute)
|
||||
default:
|
||||
log.Panicf("unsupported database backend: %s", drivers.GetDatabaseDriverType(db))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type exifModel struct {
|
||||
ID int `gorm:"primarykey"`
|
||||
Exposure *string
|
||||
Flash *string
|
||||
}
|
||||
|
||||
var flashDescriptions = map[int]string{
|
||||
0x0: "No Flash",
|
||||
0x1: "Fired",
|
||||
0x5: "Fired, Return not detected",
|
||||
0x7: "Fired, Return detected",
|
||||
0x8: "On, Did not fire",
|
||||
0x9: "On, Fired",
|
||||
0xD: "On, Return not detected",
|
||||
0xF: "On, Return detected",
|
||||
0x10: "Off, Did not fire",
|
||||
0x14: "Off, Did not fire, Return not detected",
|
||||
0x18: "Auto, Did not fire",
|
||||
0x19: "Auto, Fired",
|
||||
0x1D: "Auto, Fired, Return not detected",
|
||||
0x1F: "Auto, Fired, Return detected",
|
||||
0x20: "No flash function",
|
||||
0x30: "Off, No flash function",
|
||||
0x41: "Fired, Red-eye reduction",
|
||||
0x45: "Fired, Red-eye reduction, Return not detected",
|
||||
0x47: "Fired, Red-eye reduction, Return detected",
|
||||
0x49: "On, Red-eye reduction",
|
||||
0x4D: "On, Red-eye reduction, Return not detected",
|
||||
0x4F: "On, Red-eye reduction, Return detected",
|
||||
0x50: "Off, Red-eye reduction",
|
||||
0x58: "Auto, Did not fire, Red-eye reduction",
|
||||
0x59: "Auto, Fired, Red-eye reduction",
|
||||
0x5D: "Auto, Fired, Red-eye reduction, Return not detected",
|
||||
0x5F: "Auto, Fired, Red-eye reduction, Return detected",
|
||||
}
|
||||
|
||||
// Migrate MediaExif fields "exposure" and "flash" from strings to integers
|
||||
func migrateExifFields(db *gorm.DB) error {
|
||||
mediaExifColumns, err := db.Migrator().ColumnTypes(&models.MediaEXIF{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, exifCol := range mediaExifColumns {
|
||||
if err := parseExposure(exifCol, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := parseFlash(exifCol, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.MediaEXIF{}); err != nil {
|
||||
return fmt.Errorf("failed to auto migrate media_exif after exposure conversion: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func parseFlash(exifCol gorm.ColumnType, db *gorm.DB) error {
|
||||
if exifCol.Name() == "flash" {
|
||||
switch exifCol.DatabaseTypeName() {
|
||||
case "double", "numeric", "real", "bigint", "integer":
|
||||
// correct type, do nothing
|
||||
default:
|
||||
// do migration
|
||||
if err := migrateExifFieldsFlash(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseExposure(exifCol gorm.ColumnType, db *gorm.DB) error {
|
||||
if exifCol.Name() == "exposure" {
|
||||
switch exifCol.DatabaseTypeName() {
|
||||
case "double", "numeric", "real", "bigint", "integer":
|
||||
// correct type, do nothing
|
||||
default:
|
||||
// do migration
|
||||
if err := migrateExifFieldsExposure(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExifFieldsExposure(db *gorm.DB) error {
|
||||
log.Println("Migrating `media_exif.exposure` from string to double")
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
if err := tx.Exec("UPDATE media_exif SET exposure = NULL WHERE exposure = ''").Error; err != nil {
|
||||
return fmt.Errorf("convert flash attribute empty values to NULL: %w", err)
|
||||
}
|
||||
|
||||
var results []exifModel
|
||||
|
||||
return calculateExposure(tx, results)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrating `media_exif.exposure` failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculateExposure(tx *gorm.DB, results []exifModel) error {
|
||||
return tx.Model(&exifModel{}).Table("media_exif").Where("exposure LIKE '%/%'").FindInBatches(
|
||||
&results, 100, func(tx *gorm.DB, batch int) error {
|
||||
for _, result := range results {
|
||||
|
||||
if result.Exposure == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
frac := strings.Split(*result.Exposure, "/")
|
||||
if len(frac) != 2 {
|
||||
return fmt.Errorf("failed to convert exposure value (%s) expected format x/y", frac)
|
||||
}
|
||||
|
||||
numerator, err := strconv.ParseFloat(frac[0], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
denominator, err := strconv.ParseFloat(frac[1], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decimalValue := numerator / denominator
|
||||
*result.Exposure = fmt.Sprintf("%f", decimalValue)
|
||||
}
|
||||
|
||||
tx.Save(&results)
|
||||
|
||||
return nil
|
||||
}).Error
|
||||
}
|
||||
|
||||
func migrateExifFieldsFlash(db *gorm.DB) error {
|
||||
log.Println("Migrating `media_exif.flash` from string to int")
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
var dataType string
|
||||
if err := tx.Raw(
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_name = 'media_exif' AND column_name = 'flash';").
|
||||
Find(&dataType).Error; err != nil {
|
||||
|
||||
return fmt.Errorf("read data_type of column media_exif.flash: %w", err)
|
||||
}
|
||||
|
||||
if dataType == "bigint" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Exec("UPDATE media_exif SET flash = NULL WHERE flash = ''").Error; err != nil {
|
||||
return fmt.Errorf("convert flash attribute empty values to NULL: %w", err)
|
||||
}
|
||||
|
||||
var results []exifModel
|
||||
|
||||
return replaceFlashValues(tx, results)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrating `media_exif.flash` failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceFlashValues(tx *gorm.DB, results []exifModel) error {
|
||||
return tx.Model(&exifModel{}).Table("media_exif").Where("flash IS NOT NULL").FindInBatches(
|
||||
&results, 100, func(tx *gorm.DB, batch int) error {
|
||||
for _, result := range results {
|
||||
|
||||
if result.Flash == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for index, name := range flashDescriptions {
|
||||
if *result.Flash == name {
|
||||
*result.Flash = fmt.Sprintf("%d", index)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Save(&results)
|
||||
|
||||
return nil
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MigrateForExifGPSCorrection finds and removes invalid GPS data from media_exif table
|
||||
func MigrateForExifGPSCorrection(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.MediaEXIF{}).
|
||||
Where("ABS(gps_longitude) > ?", 90).
|
||||
Or("ABS(gps_latitude) > ?", 90).
|
||||
Updates(map[string]interface{}{
|
||||
"gps_latitude": nil,
|
||||
"gps_longitude": nil,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("failed to remove invalid GPS data from media_exif table: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoview/photoview/api/database/migrations"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestExifMigration(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
defer db.Exec("DELETE FROM media_exif") // Clean up after test
|
||||
|
||||
// Create test data
|
||||
exifEntries := []models.MediaEXIF{
|
||||
{GPSLatitude: floatPtr(90.1), GPSLongitude: floatPtr(90.0)}, // Invalid GPSLatitude
|
||||
{GPSLatitude: floatPtr(-90.1), GPSLongitude: floatPtr(-90.0)}, // Invalid GPSLatitude
|
||||
{GPSLatitude: floatPtr(90.0), GPSLongitude: floatPtr(90.1)}, // Invalid GPSLongitude
|
||||
{GPSLatitude: floatPtr(-90.0), GPSLongitude: floatPtr(-90.1)}, // Invalid GPSLongitude
|
||||
{GPSLatitude: floatPtr(90.0), GPSLongitude: floatPtr(90.0)}, // Valid GPS data
|
||||
{GPSLatitude: floatPtr(-90.0), GPSLongitude: floatPtr(-90.0)}, // Valid GPS data
|
||||
{GPSLatitude: floatPtr(90.1), GPSLongitude: floatPtr(90.1)}, // Invalid GPSLatitude and GPSLongitude
|
||||
{GPSLatitude: floatPtr(-90.1), GPSLongitude: floatPtr(-90.1)}, // Invalid GPSLatitude and GPSLongitude
|
||||
}
|
||||
|
||||
// Insert test data
|
||||
for _, entry := range exifEntries {
|
||||
assert.NoError(t, db.Create(&entry).Error)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
assert.NoError(t, migrations.MigrateForExifGPSCorrection(db))
|
||||
|
||||
// Validate the results
|
||||
var results []models.MediaEXIF
|
||||
assert.NoError(t, db.Find(&results).Error)
|
||||
|
||||
for _, entry := range results {
|
||||
if entry.GPSLatitude != nil {
|
||||
assert.LessOrEqual(t, math.Abs(*entry.GPSLatitude), 90.0, "GPSLatitude should be within [-90, 90]: %+v", entry)
|
||||
}
|
||||
if entry.GPSLongitude != nil {
|
||||
assert.LessOrEqual(t, math.Abs(*entry.GPSLongitude), 90.0, "GPSLongitude should be within [-90, 90]: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
)
|
||||
|
||||
// MediaURLLoaderConfig captures the config to create a new MediaURLLoader
|
||||
type MediaURLLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []int) ([]*models.MediaURL, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewMediaURLLoader creates a new MediaURLLoader given a fetch, wait, and maxBatch
|
||||
func NewMediaURLLoader(config MediaURLLoaderConfig) *MediaURLLoader {
|
||||
return &MediaURLLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// MediaURLLoader batches and caches requests
|
||||
type MediaURLLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []int) ([]*models.MediaURL, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[int]*models.MediaURL
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *mediaURLLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type mediaURLLoaderBatch struct {
|
||||
keys []int
|
||||
data []*models.MediaURL
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a MediaURL by key, batching and caching will be applied automatically
|
||||
func (l *MediaURLLoader) Load(key int) (*models.MediaURL, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a MediaURL.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *MediaURLLoader) LoadThunk(key int) func() (*models.MediaURL, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() (*models.MediaURL, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &mediaURLLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() (*models.MediaURL, error) {
|
||||
<-batch.done
|
||||
|
||||
var data *models.MediaURL
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *MediaURLLoader) LoadAll(keys []int) ([]*models.MediaURL, []error) {
|
||||
results := make([]func() (*models.MediaURL, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
mediaURLs := make([]*models.MediaURL, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
mediaURLs[i], errors[i] = thunk()
|
||||
}
|
||||
return mediaURLs, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a MediaURLs.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *MediaURLLoader) LoadAllThunk(keys []int) func() ([]*models.MediaURL, []error) {
|
||||
results := make([]func() (*models.MediaURL, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([]*models.MediaURL, []error) {
|
||||
mediaURLs := make([]*models.MediaURL, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
mediaURLs[i], errors[i] = thunk()
|
||||
}
|
||||
return mediaURLs, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *MediaURLLoader) Prime(key int, value *models.MediaURL) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := *value
|
||||
l.unsafeSet(key, &cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *MediaURLLoader) Clear(key int) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *MediaURLLoader) unsafeSet(key int, value *models.MediaURL) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[int]*models.MediaURL{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *mediaURLLoaderBatch) keyIndex(l *MediaURLLoader, key int) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *mediaURLLoaderBatch) startTimer(l *MediaURLLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *mediaURLLoaderBatch) end(l *MediaURLLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
)
|
||||
|
||||
// UserFavoritesLoaderConfig captures the config to create a new UserFavoritesLoader
|
||||
type UserFavoritesLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []*models.UserMediaData) ([]bool, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewUserFavoritesLoader creates a new UserFavoritesLoader given a fetch, wait, and maxBatch
|
||||
func NewUserFavoritesLoader(config UserFavoritesLoaderConfig) *UserFavoritesLoader {
|
||||
return &UserFavoritesLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// UserFavoritesLoader batches and caches requests
|
||||
type UserFavoritesLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []*models.UserMediaData) ([]bool, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[*models.UserMediaData]bool
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *userFavoritesLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type userFavoritesLoaderBatch struct {
|
||||
keys []*models.UserMediaData
|
||||
data []bool
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a bool by key, batching and caching will be applied automatically
|
||||
func (l *UserFavoritesLoader) Load(key *models.UserMediaData) (bool, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a bool.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *UserFavoritesLoader) LoadThunk(key *models.UserMediaData) func() (bool, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() (bool, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &userFavoritesLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() (bool, error) {
|
||||
<-batch.done
|
||||
|
||||
var data bool
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *UserFavoritesLoader) LoadAll(keys []*models.UserMediaData) ([]bool, []error) {
|
||||
results := make([]func() (bool, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
bools := make([]bool, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
bools[i], errors[i] = thunk()
|
||||
}
|
||||
return bools, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a bools.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *UserFavoritesLoader) LoadAllThunk(keys []*models.UserMediaData) func() ([]bool, []error) {
|
||||
results := make([]func() (bool, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([]bool, []error) {
|
||||
bools := make([]bool, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
bools[i], errors[i] = thunk()
|
||||
}
|
||||
return bools, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *UserFavoritesLoader) Prime(key *models.UserMediaData, value bool) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
l.unsafeSet(key, value)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *UserFavoritesLoader) Clear(key *models.UserMediaData) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *UserFavoritesLoader) unsafeSet(key *models.UserMediaData, value bool) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[*models.UserMediaData]bool{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *userFavoritesLoaderBatch) keyIndex(l *UserFavoritesLoader, key *models.UserMediaData) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *userFavoritesLoaderBatch) startTimer(l *UserFavoritesLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *userFavoritesLoaderBatch) end(l *UserFavoritesLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
)
|
||||
|
||||
// UserLoaderConfig captures the config to create a new UserLoader
|
||||
type UserLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []string) ([]*models.User, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewUserLoader creates a new UserLoader given a fetch, wait, and maxBatch
|
||||
func NewUserLoader(config UserLoaderConfig) *UserLoader {
|
||||
return &UserLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// UserLoader batches and caches requests
|
||||
type UserLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []string) ([]*models.User, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[string]*models.User
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *userLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type userLoaderBatch struct {
|
||||
keys []string
|
||||
data []*models.User
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a User by key, batching and caching will be applied automatically
|
||||
func (l *UserLoader) Load(key string) (*models.User, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a User.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *UserLoader) LoadThunk(key string) func() (*models.User, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() (*models.User, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &userLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() (*models.User, error) {
|
||||
<-batch.done
|
||||
|
||||
var data *models.User
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *UserLoader) LoadAll(keys []string) ([]*models.User, []error) {
|
||||
results := make([]func() (*models.User, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
users := make([]*models.User, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
users[i], errors[i] = thunk()
|
||||
}
|
||||
return users, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a Users.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *UserLoader) LoadAllThunk(keys []string) func() ([]*models.User, []error) {
|
||||
results := make([]func() (*models.User, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([]*models.User, []error) {
|
||||
users := make([]*models.User, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
users[i], errors[i] = thunk()
|
||||
}
|
||||
return users, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *UserLoader) Prime(key string, value *models.User) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := *value
|
||||
l.unsafeSet(key, &cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *UserLoader) Clear(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *UserLoader) unsafeSet(key string, value *models.User) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[string]*models.User{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *userLoaderBatch) keyIndex(l *UserLoader, key string) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *userLoaderBatch) startTimer(l *UserLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *userLoaderBatch) end(l *UserLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const loadersKey contextKey = "dataloaders"
|
||||
|
||||
type Loaders struct {
|
||||
MediaThumbnail *MediaURLLoader
|
||||
MediaHighres *MediaURLLoader
|
||||
MediaVideoWeb *MediaURLLoader
|
||||
UserFromAccessToken *UserLoader
|
||||
UserMediaFavorite *UserFavoritesLoader
|
||||
}
|
||||
|
||||
func Middleware(db *gorm.DB) mux.MiddlewareFunc {
|
||||
return mux.MiddlewareFunc(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
|
||||
MediaThumbnail: NewThumbnailMediaURLLoader(db),
|
||||
MediaHighres: NewHighresMediaURLLoader(db),
|
||||
MediaVideoWeb: NewVideoWebMediaURLLoader(db),
|
||||
UserFromAccessToken: NewUserLoaderByToken(db),
|
||||
UserMediaFavorite: NewUserFavoriteLoader(db),
|
||||
})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func For(ctx context.Context) *Loaders {
|
||||
loaders, ok := ctx.Value(loadersKey).(*Loaders)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return loaders
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_type"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func makeMediaURLLoader(db *gorm.DB, filter func(query *gorm.DB) *gorm.DB) func(keys []int) ([]*models.MediaURL, []error) {
|
||||
return func(mediaIDs []int) ([]*models.MediaURL, []error) {
|
||||
|
||||
var urls []*models.MediaURL
|
||||
query := db.Where("media_id IN (?)", mediaIDs)
|
||||
|
||||
query = filter(query)
|
||||
|
||||
if err := query.Find(&urls).Error; err != nil {
|
||||
return nil, []error{errors.Wrap(err, "media url loader database query")}
|
||||
}
|
||||
|
||||
resultMap := make(map[int]*models.MediaURL, len(mediaIDs))
|
||||
for _, url := range urls {
|
||||
resultMap[url.MediaID] = url
|
||||
}
|
||||
|
||||
result := make([]*models.MediaURL, len(mediaIDs))
|
||||
for i, mediaID := range mediaIDs {
|
||||
mediaURL, found := resultMap[mediaID]
|
||||
if found {
|
||||
result[i] = mediaURL
|
||||
} else {
|
||||
result[i] = nil
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewThumbnailMediaURLLoader(db *gorm.DB) *MediaURLLoader {
|
||||
return &MediaURLLoader{
|
||||
maxBatch: 100,
|
||||
wait: 5 * time.Millisecond,
|
||||
fetch: makeMediaURLLoader(db, func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("purpose IN ?", []string{string(models.PhotoThumbnail), string(models.VideoThumbnail)})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func NewHighresMediaURLLoader(db *gorm.DB) *MediaURLLoader {
|
||||
return &MediaURLLoader{
|
||||
maxBatch: 100,
|
||||
wait: 5 * time.Millisecond,
|
||||
fetch: makeMediaURLLoader(db, func(query *gorm.DB) *gorm.DB {
|
||||
return query.
|
||||
Where("(purpose = ? OR (purpose = ? AND content_type IN ?))", models.PhotoHighRes, models.MediaOriginal, media_type.WebMimetypes).
|
||||
//PhotoHighRes consistently wins ordering when both exist, which is preferred for web delivery
|
||||
Order("media_id ASC, CASE purpose WHEN '" +
|
||||
string(models.MediaOriginal) + "' THEN 0 WHEN '" +
|
||||
string(models.PhotoHighRes) + "' THEN 1 END ASC")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func NewVideoWebMediaURLLoader(db *gorm.DB) *MediaURLLoader {
|
||||
return &MediaURLLoader{
|
||||
maxBatch: 100,
|
||||
wait: 5 * time.Millisecond,
|
||||
fetch: makeMediaURLLoader(db, func(query *gorm.DB) *gorm.DB {
|
||||
return query.
|
||||
Where("purpose IN ?", []string{string(models.VideoWeb), string(models.MediaOriginal)}).
|
||||
//VideoWeb consistently wins ordering when both exist, which is preferred for web delivery
|
||||
Order("media_id ASC, CASE purpose WHEN '" +
|
||||
string(models.MediaOriginal) + "' THEN 0 WHEN '" +
|
||||
string(models.VideoWeb) + "' THEN 1 END ASC")
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewUserFavoriteLoader(db *gorm.DB) *UserFavoritesLoader {
|
||||
return &UserFavoritesLoader{
|
||||
maxBatch: 100,
|
||||
wait: 5 * time.Millisecond,
|
||||
fetch: func(keys []*models.UserMediaData) ([]bool, []error) {
|
||||
|
||||
userIDMap := make(map[int]struct{}, len(keys))
|
||||
mediaIDMap := make(map[int]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
userIDMap[key.UserID] = struct{}{}
|
||||
mediaIDMap[key.MediaID] = struct{}{}
|
||||
}
|
||||
|
||||
|
||||
uniqueUserIDs := make([]int, len(userIDMap))
|
||||
uniqueMediaIDs := make([]int, len(mediaIDMap))
|
||||
|
||||
count := 0
|
||||
for id := range userIDMap {
|
||||
uniqueUserIDs[count] = id
|
||||
count++
|
||||
}
|
||||
|
||||
count = 0
|
||||
for id := range mediaIDMap {
|
||||
uniqueMediaIDs[count] = id
|
||||
count++
|
||||
}
|
||||
|
||||
var userMediaFavorites []*models.UserMediaData
|
||||
err := db.Where("user_id IN (?)", uniqueUserIDs).Where("media_id IN (?)", uniqueMediaIDs).Where("favorite = TRUE").Find(&userMediaFavorites).Error
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
result := make([]bool, len(keys))
|
||||
for i, key := range keys {
|
||||
favorite := false
|
||||
for _, fav := range userMediaFavorites {
|
||||
if fav.UserID == key.UserID && fav.MediaID == key.MediaID {
|
||||
favorite = true
|
||||
break
|
||||
}
|
||||
}
|
||||
result[i] = favorite
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dataloader
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewUserLoaderByToken(db *gorm.DB) *UserLoader {
|
||||
return &UserLoader{
|
||||
maxBatch: 100,
|
||||
wait: 5 * time.Millisecond,
|
||||
fetch: func(tokens []string) ([]*models.User, []error) {
|
||||
|
||||
var accessTokens []*models.AccessToken
|
||||
err := db.Where("expire > ?", time.Now()).Where("value IN (?)", tokens).Find(&accessTokens).Error
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
rows, err := db.Table("access_tokens").Select("distinct user_id").Where("expire > ?", time.Now()).Where("value IN (?)", tokens).Rows()
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
userIDs := make([]int, 0)
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := db.ScanRows(rows, &id); err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
var userMap map[int]*models.User
|
||||
if len(userIDs) > 0 {
|
||||
|
||||
var users []*models.User
|
||||
if err := db.Where("id IN (?)", userIDs).Find(&users).Error; err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
userMap = make(map[int]*models.User, len(users))
|
||||
for _, user := range users {
|
||||
userMap[user.ID] = user
|
||||
}
|
||||
} else {
|
||||
userMap = make(map[int]*models.User, 0)
|
||||
}
|
||||
|
||||
tokenMap := make(map[string]*models.AccessToken, len(tokens))
|
||||
for _, token := range accessTokens {
|
||||
tokenMap[token.Value] = token
|
||||
}
|
||||
|
||||
result := make([]*models.User, len(tokens))
|
||||
for i, token := range tokens {
|
||||
accessToken, tokenFound := tokenMap[token]
|
||||
if tokenFound {
|
||||
user, userFound := userMap[accessToken.UserID]
|
||||
if userFound {
|
||||
result[i] = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copy this file to .env
|
||||
|
||||
PHOTOVIEW_DATABASE_DRIVER=sqlite
|
||||
|
||||
# Specifies the filepath for the sqlite database, if PHOTOVIEW_DATABASE_DRIVER is set to 'sqlite'
|
||||
PHOTOVIEW_SQLITE_PATH=photoview.db
|
||||
|
||||
# Specifies the connection string for the mysql database, if PHOTOVIEW_DATABASE_DRIVER is set to 'mysql'
|
||||
# PHOTOVIEW_MYSQL_URL=user:password@tcp(localhost)/dbname
|
||||
|
||||
# Specifies the connection string for the postgres database, if PHOTOVIEW_DATABASE_DRIVER is set to 'postgres'
|
||||
# See https://www.postgresql.org/docs/current/libpq-ssl.html for possible ssl modes
|
||||
# PHOTOVIEW_POSTGRES_URL=postgres://user:password@host:port/dbname?sslmode=(disable|allow|...)
|
||||
|
||||
PHOTOVIEW_LISTEN_IP=0.0.0.0
|
||||
PHOTOVIEW_LISTEN_PORT=4001
|
||||
|
||||
# Path where media should be cached, defaults to ./media_cache
|
||||
# PHOTOVIEW_MEDIA_CACHE=./media_cache
|
||||
|
||||
# Set to 1 for the server to also serve the built static ui files
|
||||
PHOTOVIEW_SERVE_UI=0
|
||||
|
||||
# Enter a valid mapbox token, to enable maps feature
|
||||
# A token can be created for free at https://mapbox.com
|
||||
#MAPBOX_TOKEN=<insert mapbox token here>
|
||||
|
||||
# Set to 1 to set server in development mode, this enables graphql playground
|
||||
# Remove this if running in production
|
||||
PHOTOVIEW_DEVELOPMENT_MODE=1
|
||||
|
||||
# Set the hardware acceleration when encoding videos.
|
||||
# Support `qsv`, `vaapi`, `nvenc`.
|
||||
# Only `qsv` is verified with `/dev/dri//dev/dri` devices.
|
||||
# PHOTOVIEW_VIDEO_HARDWARE_ACCELERATION=
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
module github.com/photoview/photoview/api
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.90
|
||||
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/otiai10/copy v1.14.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/text v0.37.0
|
||||
gopkg.in/gographics/imagick.v3 v3.7.3
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.3.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/kr/text v0.1.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||
github.com/otiai10/mint v1.6.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/urfave/cli/v3 v3.8.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
tool github.com/99designs/gqlgen
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
|
||||
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
|
||||
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
|
||||
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
|
||||
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/gographics/imagick.v3 v3.7.3 h1:Hy2MbJKLJ/9T3ZuV1zwBOy09O9prf2MCCVpM7bcZdpY=
|
||||
gopkg.in/gographics/imagick.v3 v3.7.3/go.mod h1:7I4S9VWdwr88yzYi7g+ZL4H8oZuH9cmSQI7GsZCcYFM=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.3.0 h1:YhEzASq5eN8m73j/WqhRbqzJrx5gaqRueHZ9ZC53o/o=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.3.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,72 @@
|
||||
# Refer to https://gqlgen.com/config/
|
||||
|
||||
schema:
|
||||
- graphql/resolvers/*.graphql
|
||||
|
||||
exec:
|
||||
filename: graphql/generated.go
|
||||
|
||||
model:
|
||||
filename: graphql/models/generated.go
|
||||
package: models
|
||||
|
||||
resolver:
|
||||
layout: follow-schema
|
||||
dir: graphql/resolvers
|
||||
package: resolvers
|
||||
filename_template: "{name}.go"
|
||||
|
||||
autobind: []
|
||||
|
||||
models:
|
||||
ID:
|
||||
model: github.com/99designs/gqlgen/graphql.IntID
|
||||
User:
|
||||
model: github.com/photoview/photoview/api/graphql/models.User
|
||||
fields:
|
||||
albums:
|
||||
resolver: true
|
||||
UserPreferences:
|
||||
model: github.com/photoview/photoview/api/graphql/models.UserPreferences
|
||||
Media:
|
||||
model: github.com/photoview/photoview/api/graphql/models.Media
|
||||
fields:
|
||||
exif:
|
||||
resolver: true
|
||||
faces:
|
||||
resolver: true
|
||||
type:
|
||||
resolver: true
|
||||
album:
|
||||
resolver: true
|
||||
MediaURL:
|
||||
model: github.com/photoview/photoview/api/graphql/models.MediaURL
|
||||
MediaEXIF:
|
||||
model: github.com/photoview/photoview/api/graphql/models.MediaEXIF
|
||||
fields:
|
||||
dateShot:
|
||||
fieldName: DateShotWithOffset
|
||||
VideoMetadata:
|
||||
model: github.com/photoview/photoview/api/graphql/models.VideoMetadata
|
||||
Album:
|
||||
model: github.com/photoview/photoview/api/graphql/models.Album
|
||||
ShareToken:
|
||||
model: github.com/photoview/photoview/api/graphql/models.ShareToken
|
||||
FaceGroup:
|
||||
model: github.com/photoview/photoview/api/graphql/models.FaceGroup
|
||||
fields:
|
||||
imageFaces:
|
||||
resolver: true
|
||||
ImageFace:
|
||||
model: github.com/photoview/photoview/api/graphql/models.ImageFace
|
||||
fields:
|
||||
faceGroup:
|
||||
resolver: true
|
||||
media:
|
||||
resolver: true
|
||||
FaceRectangle:
|
||||
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
|
||||
SiteInfo:
|
||||
model: github.com/photoview/photoview/api/graphql/models.SiteInfo
|
||||
MediaType:
|
||||
model: github.com/photoview/photoview/api/graphql/models.MediaType
|
||||
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
var bearerRegex = regexp.MustCompile("^(?i)Bearer ([a-zA-Z0-9]{24})$")
|
||||
|
||||
const INVALID_AUTH_TOKEN = "invalid authorization token"
|
||||
const INTERNAL_SERVER_ERROR = "internal server error"
|
||||
|
||||
// A private key for context that only this package can access. This is important
|
||||
// to prevent collisions between different context uses
|
||||
var userCtxKey = &contextKey{"user"}
|
||||
|
||||
type contextKey struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// Middleware decodes the share session cookie and packs the session into context
|
||||
func Middleware(db *gorm.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if tokenCookie, err := r.Cookie("auth-token"); err == nil {
|
||||
loaders := dataloader.For(r.Context())
|
||||
if loaders == nil {
|
||||
log.Error(r.Context(), "Dataloader not available in HTTP context")
|
||||
http.Error(w, INTERNAL_SERVER_ERROR, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := loaders.UserFromAccessToken.Load(tokenCookie.Value)
|
||||
// Check for dataloader errors (database failures, etc.)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error loading user from token", "error", err)
|
||||
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If user is nil, the token doesn't exist or is invalid
|
||||
if user == nil {
|
||||
log.Error(r.Context(), "Token not found in database")
|
||||
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// put it in context
|
||||
ctx := AddUserToContext(r.Context(), user)
|
||||
|
||||
// and call the next with our new context
|
||||
r = r.WithContext(ctx)
|
||||
} else {
|
||||
log.Info(r.Context(), "Did not find auth-token cookie")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func AddUserToContext(ctx context.Context, user *models.User) context.Context {
|
||||
return context.WithValue(ctx, userCtxKey, user)
|
||||
}
|
||||
|
||||
func TokenFromBearer(bearer *string) (*string, error) {
|
||||
matches := bearerRegex.FindStringSubmatch(*bearer)
|
||||
if len(matches) != 2 {
|
||||
return nil, errors.New("invalid bearer format")
|
||||
}
|
||||
|
||||
token := matches[1]
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// UserFromContext finds the user from the context. REQUIRES Middleware to have run.
|
||||
func UserFromContext(ctx context.Context) *models.User {
|
||||
raw, _ := ctx.Value(userCtxKey).(*models.User)
|
||||
return raw
|
||||
}
|
||||
|
||||
func AuthWebsocketInit() func(context.Context, transport.InitPayload) (context.Context, *transport.InitPayload, error) {
|
||||
return func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) {
|
||||
|
||||
bearer, exists := initPayload["Authorization"].(string)
|
||||
if !exists {
|
||||
return ctx, nil, nil
|
||||
}
|
||||
|
||||
token, err := TokenFromBearer(&bearer)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Invalid bearer format (websocket)", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loaders := dataloader.For(ctx)
|
||||
if loaders == nil {
|
||||
log.Error(ctx, "Dataloader not available in websocket context")
|
||||
return nil, nil, errors.New(INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
user, err := loaders.UserFromAccessToken.Load(*token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading user from token (websocket)", "error", err)
|
||||
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
|
||||
}
|
||||
|
||||
// Check if token exists in database
|
||||
if user == nil {
|
||||
log.Error(ctx, "Token not found in database (websocket)")
|
||||
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
|
||||
}
|
||||
|
||||
// put it in context
|
||||
userCtx := context.WithValue(ctx, userCtxKey, user)
|
||||
|
||||
// and return it so the resolvers can see it
|
||||
// Return nil for the InitPayload acknowledgment (no custom ack payload needed)
|
||||
return userCtx, nil, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.UnitTestRun(m)
|
||||
}
|
||||
|
||||
func TestTokenFromBearer(t *testing.T) {
|
||||
|
||||
testsValues := []struct {
|
||||
name string
|
||||
bearer string
|
||||
out string
|
||||
valid bool
|
||||
}{
|
||||
{"Valid bearer", "Bearer ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
|
||||
{"Case insensitive bearer", "bEaReR ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
|
||||
{"Missing bearer start", "ZY9YfxFa3TapSAD37XUBFryo", "", false},
|
||||
{"Empty input", "", "", false},
|
||||
{"Invalid token value", "Bearer THIS_IS_INVALID", "", false},
|
||||
}
|
||||
|
||||
for _, test := range testsValues {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
token, err := auth.TokenFromBearer(&test.bearer)
|
||||
if test.valid {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, token)
|
||||
assert.Equal(t, test.out, *token)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthWebsocketInit(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
// Create test user and token
|
||||
password := "test_password"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token, err := user.GenerateAccessToken(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
initPayload transport.InitPayload
|
||||
expectError bool
|
||||
expectUser bool
|
||||
expectNilCtx bool
|
||||
}{
|
||||
{
|
||||
name: "Valid authorization",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer " + token.Value},
|
||||
expectError: false,
|
||||
expectUser: true,
|
||||
},
|
||||
{
|
||||
name: "Missing authorization",
|
||||
initPayload: transport.InitPayload{},
|
||||
expectError: false,
|
||||
expectUser: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid bearer format",
|
||||
initPayload: transport.InitPayload{"Authorization": "InvalidFormat"},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid token",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer INVALID_TOKEN_123456"},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
{
|
||||
name: "Empty token",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer "},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
initFunc := auth.AuthWebsocketInit()
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
var contextWithLoaders context.Context
|
||||
handler := dataloader.Middleware(db)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contextWithLoaders = r.Context()
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
resultCtx, ackPayload, err := initFunc(contextWithLoaders, tc.initPayload)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.expectNilCtx {
|
||||
assert.Nil(t, resultCtx)
|
||||
} else {
|
||||
assert.NotNil(t, resultCtx)
|
||||
}
|
||||
|
||||
// Verify InitPayload acknowledgment is always nil (as per PR implementation)
|
||||
assert.Nil(t, ackPayload)
|
||||
|
||||
if tc.expectUser {
|
||||
retrievedUser := auth.UserFromContext(resultCtx)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, user.ID, retrievedUser.ID)
|
||||
assert.Equal(t, "testuser", retrievedUser.Username)
|
||||
} else if !tc.expectNilCtx {
|
||||
retrievedUser := auth.UserFromContext(resultCtx)
|
||||
assert.Nil(t, retrievedUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
// Create test user and token
|
||||
password := "test_password"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token, err := user.GenerateAccessToken(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
cookieValue string
|
||||
setCookie bool
|
||||
expectStatus int
|
||||
expectUser bool
|
||||
}{
|
||||
{
|
||||
name: "Valid token cookie",
|
||||
cookieValue: token.Value,
|
||||
setCookie: true,
|
||||
expectStatus: 200,
|
||||
expectUser: true,
|
||||
},
|
||||
{
|
||||
name: "No cookie",
|
||||
setCookie: false,
|
||||
expectStatus: 200,
|
||||
expectUser: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid token",
|
||||
cookieValue: "INVALID_TOKEN",
|
||||
setCookie: true,
|
||||
expectStatus: 401,
|
||||
expectUser: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/graphql", nil)
|
||||
if tc.setCookie {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "auth-token",
|
||||
Value: tc.cookieValue,
|
||||
})
|
||||
}
|
||||
|
||||
var capturedContext context.Context
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedContext = r.Context()
|
||||
})
|
||||
|
||||
authHandler := auth.Middleware(db)(handler)
|
||||
fullHandler := dataloader.Middleware(db)(authHandler)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
fullHandler.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, recorder.Code)
|
||||
|
||||
if tc.expectUser {
|
||||
retrievedUser := auth.UserFromContext(capturedContext)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, user.ID, retrievedUser.ID)
|
||||
} else if recorder.Code == 200 {
|
||||
// Handler was called, verify no user in context
|
||||
retrievedUser := auth.UserFromContext(capturedContext)
|
||||
assert.Nil(t, retrievedUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUserOperations(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "test"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("AddUserToContext and UserFromContext", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially no user
|
||||
retrieved := auth.UserFromContext(ctx)
|
||||
assert.Nil(t, retrieved)
|
||||
|
||||
// Add user
|
||||
ctxWithUser := auth.AddUserToContext(ctx, user)
|
||||
|
||||
// Retrieve user
|
||||
retrieved = auth.UserFromContext(ctxWithUser)
|
||||
assert.NotNil(t, retrieved)
|
||||
assert.Equal(t, user.ID, retrieved.ID)
|
||||
assert.Equal(t, "testuser", retrieved.Username)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
)
|
||||
|
||||
func IsAdmin(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil || user.Admin == false {
|
||||
return nil, errors.New("user must be admin")
|
||||
}
|
||||
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
func IsAuthorized(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return next(ctx)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package graphql_endpoint
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
graphql_handler "github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/handler/extension"
|
||||
"github.com/99designs/gqlgen/graphql/handler/lru"
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
photoview_graphql "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/resolvers"
|
||||
"github.com/photoview/photoview/api/server"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/vektah/gqlparser/v2/ast"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func GraphqlEndpoint(db *gorm.DB) *graphql_handler.Server {
|
||||
graphqlResolver := resolvers.NewRootResolver(db)
|
||||
graphqlDirective := photoview_graphql.DirectiveRoot{}
|
||||
graphqlDirective.IsAdmin = photoview_graphql.IsAdmin
|
||||
graphqlDirective.IsAuthorized = photoview_graphql.IsAuthorized
|
||||
|
||||
graphqlConfig := photoview_graphql.Config{
|
||||
Resolvers: &graphqlResolver,
|
||||
Directives: graphqlDirective,
|
||||
}
|
||||
|
||||
graphqlServer := graphql_handler.New(photoview_graphql.NewExecutableSchema(graphqlConfig))
|
||||
graphqlServer.AddTransport(transport.Websocket{
|
||||
KeepAlivePingInterval: 10 * time.Second,
|
||||
Upgrader: server.WebsocketUpgrader(utils.DevelopmentMode()),
|
||||
InitFunc: auth.AuthWebsocketInit(),
|
||||
})
|
||||
graphqlServer.AddTransport(transport.Options{})
|
||||
graphqlServer.AddTransport(transport.GET{})
|
||||
graphqlServer.AddTransport(transport.POST{})
|
||||
graphqlServer.AddTransport(transport.MultipartForm{})
|
||||
|
||||
graphqlServer.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
||||
|
||||
graphqlServer.Use(extension.AutomaticPersistedQuery{
|
||||
Cache: lru.New[string](100),
|
||||
})
|
||||
|
||||
if utils.DevelopmentMode() {
|
||||
graphqlServer.Use(extension.Introspection{})
|
||||
}
|
||||
|
||||
return graphqlServer
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package graphql_endpoint_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
graphql_endpoint "github.com/photoview/photoview/api/graphql/endpoint"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.UnitTestRun(m)
|
||||
}
|
||||
|
||||
func TestGraphqlEndpoint(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
t.Run("creates server successfully", func(t *testing.T) {
|
||||
server := graphql_endpoint.GraphqlEndpoint(db)
|
||||
assert.NotNil(t, server)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyAlbums(db *gorm.DB, user *models.User, order *models.Ordering, paginate *models.Pagination,
|
||||
onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) {
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(user.Albums) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.Model(models.Album{}).Where("id IN (?)", userAlbumIDs)
|
||||
|
||||
if onlyRoot != nil && *onlyRoot {
|
||||
|
||||
singleRootAlbumID := getSingleRootAlbumID(user)
|
||||
|
||||
if singleRootAlbumID != -1 && len(user.Albums) > 1 {
|
||||
query = query.Where("parent_album_id = ?", singleRootAlbumID)
|
||||
} else {
|
||||
query = query.Where("parent_album_id IS NULL OR parent_album_id NOT IN (?)", userAlbumIDs)
|
||||
}
|
||||
}
|
||||
|
||||
query = favoritesQuery(showEmpty, db, onlyWithFavorites, user, query)
|
||||
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
var albums []*models.Album
|
||||
if err := query.Find(&albums).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func getSingleRootAlbumID(user *models.User) int {
|
||||
var singleRootAlbumID int = -1
|
||||
for _, album := range user.Albums {
|
||||
if album.ParentAlbumID == nil {
|
||||
if singleRootAlbumID == -1 {
|
||||
singleRootAlbumID = album.ID
|
||||
} else {
|
||||
singleRootAlbumID = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return singleRootAlbumID
|
||||
}
|
||||
|
||||
func favoritesQuery(showEmpty *bool, db *gorm.DB, onlyWithFavorites *bool, user *models.User, query *gorm.DB) *gorm.DB {
|
||||
if showEmpty == nil || !*showEmpty {
|
||||
subQuery := db.Model(&models.Media{}).Where("album_id = albums.id")
|
||||
|
||||
if onlyWithFavorites != nil && *onlyWithFavorites {
|
||||
favoritesSubquery := db.
|
||||
Model(&models.UserMediaData{UserID: user.ID}).
|
||||
Where("user_media_data.media_id = media.id").
|
||||
Where("user_media_data.favorite = true")
|
||||
|
||||
subQuery = subQuery.Where("EXISTS (?)", favoritesSubquery)
|
||||
}
|
||||
|
||||
query = query.Where("EXISTS (?)", subQuery)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func Album(db *gorm.DB, user *models.User, id int) (*models.Album, error) {
|
||||
var album models.Album
|
||||
if err := db.First(&album, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("album not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func AlbumPath(db *gorm.DB, user *models.User, album *models.Album) ([]*models.Album, error) {
|
||||
var albumPath []*models.Album
|
||||
|
||||
err := db.Raw(`
|
||||
WITH recursive path_albums AS (
|
||||
SELECT * FROM albums anchor WHERE anchor.id = ?
|
||||
UNION
|
||||
SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id
|
||||
)
|
||||
SELECT * FROM path_albums WHERE id != ?
|
||||
`, album.ID, album.ID).Scan(&albumPath).Error
|
||||
|
||||
// Make sure to only return albums this user owns
|
||||
for i := len(albumPath) - 1; i >= 0; i-- {
|
||||
album := albumPath[i]
|
||||
|
||||
owns, err := user.OwnsAlbum(db, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !owns {
|
||||
albumPath = albumPath[i+1:]
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albumPath, nil
|
||||
}
|
||||
|
||||
func SetAlbumCover(db *gorm.DB, user *models.User, mediaID int) (*models.Album, error) {
|
||||
var media models.Media
|
||||
|
||||
if err := db.Find(&media, mediaID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var album models.Album
|
||||
|
||||
if err := db.Find(&album, &media.AlbumID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
if err := db.Model(&album).Update("cover_id", mediaID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func ResetAlbumCover(db *gorm.DB, user *models.User, albumID int) (*models.Album, error) {
|
||||
var album models.Album
|
||||
if err := db.Find(&album, albumID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
if err := db.Model(&album).Update("cover_id", nil).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlbumPath(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
album := models.Album{
|
||||
Title: "Three",
|
||||
Path: "/one/two/three",
|
||||
ParentAlbum: &models.Album{
|
||||
Title: "Two",
|
||||
Path: "/one/two",
|
||||
ParentAlbum: &models.Album{
|
||||
Title: "One",
|
||||
Path: "/one",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&album).Error)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
db.Model(&user).Association("Albums").Append(album.ParentAlbum.ParentAlbum)
|
||||
|
||||
albumPath, err := actions.AlbumPath(db, user, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, albumPath, 2)
|
||||
assert.Equal(t, "Two", albumPath[0].Title)
|
||||
assert.Equal(t, "One", albumPath[1].Title)
|
||||
}
|
||||
|
||||
func TestAlbumCover(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
children := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: "/photos/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&children).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
photos := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/child1/pic3",
|
||||
AlbumID: children[0].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/child1/pic4",
|
||||
AlbumID: children[0].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic5",
|
||||
Path: "/photos/child2/pic5",
|
||||
AlbumID: children[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic6",
|
||||
Path: "/photos/child2/pic6",
|
||||
AlbumID: children[1].ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&photos).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&children[0]).Update("cover_id", &photos[3].ID).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
photoUrls := []models.MediaURL{
|
||||
{
|
||||
MediaID: photos[0].ID,
|
||||
Media: &photos[0],
|
||||
},
|
||||
{
|
||||
MediaID: photos[1].ID,
|
||||
Media: &photos[1],
|
||||
},
|
||||
{
|
||||
MediaID: photos[2].ID,
|
||||
Media: &photos[2],
|
||||
},
|
||||
{
|
||||
MediaID: photos[3].ID,
|
||||
Media: &photos[3],
|
||||
},
|
||||
{
|
||||
MediaID: photos[4].ID,
|
||||
Media: &photos[4],
|
||||
},
|
||||
{
|
||||
MediaID: photos[5].ID,
|
||||
Media: &photos[5],
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&photoUrls).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
userPass := "password"
|
||||
regularUser, err := models.RegisterUser(db, "user1", &userPass, false)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&rootAlbum)) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&children)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Single test since we cannot rely on the tests being performed sequentially
|
||||
t.Run("Album get and reset cover photos", func(t *testing.T) {
|
||||
{
|
||||
album, err := actions.Album(db, regularUser, rootAlbum.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should return the latest photo since no coverID has been set
|
||||
assert.EqualValues(t, "pic6", albumThumb.Title)
|
||||
}
|
||||
|
||||
{
|
||||
album, err := actions.Album(db, regularUser, children[0].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// coverID has already been set
|
||||
assert.EqualValues(t, "pic4", albumThumb.Title)
|
||||
}
|
||||
|
||||
resetAlbum, err := actions.ResetAlbumCover(db, regularUser, children[0].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Nil(t, resetAlbum.CoverID)
|
||||
|
||||
resetThumb, err := resetAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "pic4", resetThumb.Title)
|
||||
})
|
||||
|
||||
t.Run("Album change cover photos", func(t *testing.T) {
|
||||
assert.Nil(t, children[1].CoverID)
|
||||
|
||||
album, err := actions.SetAlbumCover(db, regularUser, photos[4].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, children[1].ID, album.ID)
|
||||
assert.NotNil(t, album.CoverID)
|
||||
assert.Equal(t, photos[4].ID, *album.CoverID)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, photos[4].ID, albumThumb.ID)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestAlbumsSingleRootExpand(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
boolFalse := false
|
||||
boolTrue := true
|
||||
|
||||
unrelatedAlbum := models.Album{
|
||||
Title: "unrelated_album",
|
||||
Path: "/another_place",
|
||||
}
|
||||
err := db.Create(&unrelatedAlbum).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
err = db.Model(&user).Association("Albums").Replace(&rootAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Single root album, no children", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
})
|
||||
|
||||
childAlbums := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: "/root/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/root/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child3",
|
||||
Path: "/root/child3",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
err = db.Model(&user).Association("Albums").Append(&childAlbums)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Single root album, multiple children", func(t *testing.T) {
|
||||
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 3)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Related to #658
|
||||
func TestNonRootAlbumPath(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "child",
|
||||
Path: "/root/child",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
|
||||
// Register user
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Assign album to user
|
||||
err = db.Model(&user).Association("Albums").Append(&childAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The child album is a "local root album" for the user, as it does not have access to the root album
|
||||
t.Run("User should only see child album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child", returnedAlbums[0].Title)
|
||||
})
|
||||
}
|
||||
|
||||
// Related to #658
|
||||
func TestNonRootAlbumPathMultipleUsers(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
child1Album := models.Album{
|
||||
Title: "child1",
|
||||
Path: "/root/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
child2Album := models.Album{
|
||||
Title: "child2",
|
||||
Path: "/root/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
// Admin should have access to all albums
|
||||
adminAlbums := []*models.Album{&rootAlbum, &child1Album, &child2Album}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
|
||||
// Register users
|
||||
admin, err := models.RegisterUser(db, "admin", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
user1, err := models.RegisterUser(db, "user1", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
user2, err := models.RegisterUser(db, "user2", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Assign albums to users
|
||||
err = db.Model(&admin).Association("Albums").Append(&adminAlbums)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.Model(&user1).Association("Albums").Append(&child1Album)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.Model(&user2).Association("Albums").Append(&child2Album)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Admin should see all albums", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, admin, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 2)
|
||||
assert.Equal(t, "child1", returnedAlbums[0].Title)
|
||||
assert.Equal(t, "child2", returnedAlbums[1].Title)
|
||||
})
|
||||
|
||||
t.Run("User 1 should only see child1 album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user1, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child1", returnedAlbums[0].Title)
|
||||
})
|
||||
|
||||
t.Run("User 2 should only see child2 album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user2, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child2", returnedAlbums[0].Title)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyMedia(db *gorm.DB, user *models.User, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) {
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := db.Where("media.album_id IN (SELECT user_albums.album_id FROM user_albums WHERE user_albums.user_id = ?)",
|
||||
user.ID)
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
var media []*models.Media
|
||||
if err := query.Find(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMyMedia(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
anotherUser, err := models.RegisterUser(db, "user2", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
anotherAlbum := models.Album{
|
||||
Title: "AnotherAlbum",
|
||||
Path: "/another",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherAlbum).Error)
|
||||
|
||||
anotherMedia := models.Media{
|
||||
Title: "anotherPic",
|
||||
Path: "/another/anotherPic",
|
||||
AlbumID: anotherAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherMedia).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum))
|
||||
|
||||
t.Run("Simple query", func(t *testing.T) {
|
||||
myMedia, err := actions.MyMedia(db, user, nil, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, myMedia, 4)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func Search(db *gorm.DB, query string, userID int, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||
limitMediaInternal := 10
|
||||
limitAlbumsInternal := 10
|
||||
|
||||
if limitMedia != nil {
|
||||
limitMediaInternal = *limitMedia
|
||||
}
|
||||
|
||||
if limitAlbums != nil {
|
||||
limitAlbumsInternal = *limitAlbums
|
||||
}
|
||||
|
||||
wildQuery := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
var media []*models.Media
|
||||
|
||||
userSubquery := db.Table("user_albums").Where("user_id = ?", userID)
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
userSubquery = userSubquery.Where("album_id = \"Album\".id")
|
||||
} else {
|
||||
userSubquery = userSubquery.Where("album_id = Album.id")
|
||||
}
|
||||
|
||||
err := db.Joins("Album").
|
||||
Where("EXISTS (?)", userSubquery).
|
||||
Where("LOWER(media.title) LIKE ? OR LOWER(media.path) LIKE ?", wildQuery, wildQuery).
|
||||
Clauses(clause.OrderBy{
|
||||
Expression: clause.Expr{
|
||||
SQL: "(CASE WHEN LOWER(media.title) LIKE ? THEN 2 WHEN LOWER(media.path) LIKE ? THEN 1 END) DESC",
|
||||
Vars: []interface{}{wildQuery, wildQuery},
|
||||
WithoutParentheses: true},
|
||||
}).
|
||||
Limit(limitMediaInternal).Find(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "searching media")
|
||||
}
|
||||
|
||||
var albums []*models.Album
|
||||
|
||||
err = db.
|
||||
Where("EXISTS (?)", db.Table("user_albums").Where("user_id = ?", userID).Where("album_id = albums.id")).
|
||||
Where("albums.title LIKE ? OR albums.path LIKE ?", wildQuery, wildQuery).
|
||||
Clauses(clause.OrderBy{
|
||||
Expression: clause.Expr{
|
||||
SQL: "(CASE WHEN albums.title LIKE ? THEN 2 WHEN albums.path LIKE ? THEN 1 END) DESC",
|
||||
Vars: []interface{}{wildQuery, wildQuery},
|
||||
WithoutParentheses: true},
|
||||
}).
|
||||
Limit(limitAlbumsInternal).
|
||||
Find(&albums).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "searching albums")
|
||||
}
|
||||
|
||||
result := models.SearchResult{
|
||||
Query: query,
|
||||
Media: media,
|
||||
Albums: albums,
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root_album",
|
||||
Path: "/media/",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
assert.NoError(t, db.Model(&rootAlbum).Association("Owners").Append(user))
|
||||
|
||||
type Result struct {
|
||||
ID int
|
||||
UserID int
|
||||
AlbumID int
|
||||
}
|
||||
|
||||
mediaTitles := []string{
|
||||
"SOME_IMAGE.jpg",
|
||||
"imageA.jpg",
|
||||
"imageB.jpg",
|
||||
"imageC.jpg",
|
||||
"movie.mp4",
|
||||
"person.png",
|
||||
"123.png",
|
||||
"ABC.gif",
|
||||
"dog.mov",
|
||||
"cat.mov",
|
||||
"IMG_3255.JPG",
|
||||
"IMG_5532.JPG",
|
||||
"IMG_5533.JPG",
|
||||
"IMG_5534.JPG",
|
||||
"IMG_5535.JPG",
|
||||
"IMG_5536.JPG",
|
||||
}
|
||||
|
||||
for _, mediaTitle := range mediaTitles {
|
||||
image := models.Media{
|
||||
Title: mediaTitle,
|
||||
Path: fmt.Sprintf("/media/%s", mediaTitle),
|
||||
AlbumID: rootAlbum.ID,
|
||||
}
|
||||
assert.NoError(t, db.Create(&image).Error)
|
||||
}
|
||||
|
||||
type SearchTest = struct {
|
||||
query string
|
||||
userID int
|
||||
limitMedia *int
|
||||
limitAlbum *int
|
||||
|
||||
expectedMediaCount int
|
||||
expectedAlbumCount int
|
||||
}
|
||||
|
||||
searchTests := []SearchTest{
|
||||
{
|
||||
query: "image",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 4,
|
||||
expectedAlbumCount: 0,
|
||||
},
|
||||
{
|
||||
query: "g",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 10,
|
||||
expectedAlbumCount: 0,
|
||||
},
|
||||
{
|
||||
query: "media",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 10,
|
||||
expectedAlbumCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range searchTests {
|
||||
t.Run(fmt.Sprintf("Search query: '%s'", test.query), func(t *testing.T) {
|
||||
result, err := actions.Search(db, test.query, test.userID, test.limitMedia, test.limitAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, result.Query, test.query)
|
||||
assert.Len(t, result.Albums, test.expectedAlbumCount)
|
||||
assert.Len(t, result.Media, test.expectedMediaCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddMediaShare(db *gorm.DB, user *models.User, mediaID int, expire *time.Time, password *string) (*models.ShareToken,
|
||||
error) {
|
||||
|
||||
var media models.Media
|
||||
|
||||
var query string
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
query = "EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = \"Album\".id AND user_albums.user_id = ?)"
|
||||
} else {
|
||||
query = "EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)"
|
||||
}
|
||||
|
||||
err := db.Joins("Album").
|
||||
Where(query, user.ID).
|
||||
First(&media, mediaID).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, auth.ErrUnauthorized
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "failed to validate media owner with database")
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := hashSharePassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shareToken := models.ShareToken{
|
||||
Value: utils.GenerateToken(),
|
||||
OwnerID: user.ID,
|
||||
Expire: expire,
|
||||
Password: hashedPassword,
|
||||
AlbumID: nil,
|
||||
MediaID: &mediaID,
|
||||
}
|
||||
|
||||
if err := db.Create(&shareToken).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert new share token into database")
|
||||
}
|
||||
|
||||
return &shareToken, nil
|
||||
}
|
||||
|
||||
func AddAlbumShare(db *gorm.DB, user *models.User, albumID int, expire *time.Time, password *string) (*models.ShareToken,
|
||||
error) {
|
||||
|
||||
var count int64
|
||||
err := db.
|
||||
Model(&models.Album{}).
|
||||
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = albums.id AND user_albums.user_id = ?)",
|
||||
user.ID).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to validate album owner with database")
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var hashedPassword *string = nil
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to hash token password")
|
||||
}
|
||||
hashedStr := string(hashedPassBytes)
|
||||
hashedPassword = &hashedStr
|
||||
}
|
||||
|
||||
shareToken := models.ShareToken{
|
||||
Value: utils.GenerateToken(),
|
||||
OwnerID: user.ID,
|
||||
Expire: expire,
|
||||
Password: hashedPassword,
|
||||
AlbumID: &albumID,
|
||||
MediaID: nil,
|
||||
}
|
||||
|
||||
if err := db.Create(&shareToken).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert new share token into database")
|
||||
}
|
||||
|
||||
return &shareToken, nil
|
||||
}
|
||||
|
||||
func DeleteShareToken(db *gorm.DB, userID int, tokenValue string) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Delete(&token).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to delete share token (%s) from database", tokenValue)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func ProtectShareToken(db *gorm.DB, userID int, tokenValue string, password *string) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashedPassword, err := hashSharePassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.Password = hashedPassword
|
||||
|
||||
if err := db.Save(&token).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update password for share token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func SetExpireShareToken(db *gorm.DB, userID int, tokenValue string, expire *time.Time) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.Expire = expire
|
||||
|
||||
if err := db.Save(&token).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update the expiration date for share token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func hashSharePassword(password *string) (*string, error) {
|
||||
var hashedPassword *string = nil
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to generate hash for share password")
|
||||
}
|
||||
hashedStr := string(hashedPassBytes)
|
||||
hashedPassword = &hashedStr
|
||||
}
|
||||
|
||||
return hashedPassword, nil
|
||||
}
|
||||
|
||||
func getUserToken(db *gorm.DB, userID int, tokenValue string) (*models.ShareToken, error) {
|
||||
|
||||
var query string
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
query = "\"Owner\".id = ? OR \"Owner\".admin = TRUE"
|
||||
} else {
|
||||
query = "Owner.id = ? OR Owner.admin = TRUE"
|
||||
}
|
||||
|
||||
var token models.ShareToken
|
||||
err := db.Where("share_tokens.value = ?", tokenValue).Joins("Owner").Where(query, userID).First(&token).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user share token from database")
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShareToken(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
expireTime := time.Unix(1632866400, 0)
|
||||
sharePassword := "secretSharePassword"
|
||||
|
||||
var mediaShare *models.ShareToken
|
||||
var albumShare *models.ShareToken
|
||||
|
||||
t.Run("Add album share", func(t *testing.T) {
|
||||
share, err := actions.AddAlbumShare(db, user, rootAlbum.ID, &expireTime, nil)
|
||||
albumShare = share
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, share)
|
||||
|
||||
assert.NotEmpty(t, share.Value)
|
||||
assert.Equal(t, rootAlbum.ID, *share.AlbumID)
|
||||
assert.Nil(t, share.MediaID)
|
||||
})
|
||||
|
||||
t.Run("Add media share", func(t *testing.T) {
|
||||
share, err := actions.AddMediaShare(db, user, media[0].ID, &expireTime, &sharePassword)
|
||||
mediaShare = share
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, share)
|
||||
|
||||
assert.NotEmpty(t, share.Value)
|
||||
assert.Equal(t, media[0].ID, *share.MediaID)
|
||||
assert.Nil(t, share.AlbumID)
|
||||
})
|
||||
|
||||
t.Run("Delete share token", func(t *testing.T) {
|
||||
deletedShare, err := actions.DeleteShareToken(db, user.ID, mediaShare.Value)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mediaShare.ID, deletedShare.ID)
|
||||
})
|
||||
|
||||
t.Run("Protect share token", func(t *testing.T) {
|
||||
|
||||
assert.Empty(t, albumShare.Password)
|
||||
|
||||
share, err := actions.ProtectShareToken(db, user.ID, albumShare.Value, &sharePassword)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, share.Password)
|
||||
|
||||
share, err = actions.ProtectShareToken(db, user.ID, albumShare.Value, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, share.Password)
|
||||
})
|
||||
|
||||
t.Run("Set Expiration date for share token", func(t *testing.T) {
|
||||
assert.NotEmpty(t, albumShare.Expire)
|
||||
time_ := time.Date(2025, 12, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
share, err := actions.SetExpireShareToken(db, user.ID, albumShare.Value, &time_)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, time_, *share.Expire)
|
||||
|
||||
share, err = actions.SetExpireShareToken(db, user.ID, albumShare.Value, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, share.Expire)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyTimeline(db *gorm.DB, user *models.User, paginate *models.Pagination, onlyFavorites *bool,
|
||||
fromDate *time.Time) ([]*models.Media, error) {
|
||||
|
||||
const albumsTitleASC = "albums.title ASC"
|
||||
|
||||
query := db.
|
||||
Joins("JOIN albums ON media.album_id = albums.id").
|
||||
Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID))
|
||||
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.POSTGRES:
|
||||
query = query.
|
||||
Order("DATE_TRUNC('year', date_shot) DESC").
|
||||
Order("DATE_TRUNC('month', date_shot) DESC").
|
||||
Order("DATE_TRUNC('day', date_shot) DESC").
|
||||
Order(albumsTitleASC).
|
||||
Order("media.date_shot DESC")
|
||||
case drivers.SQLITE:
|
||||
query = query.
|
||||
Order("strftime('%Y-%m-%d', media.date_shot) DESC"). // convert to YYYY-MM-DD
|
||||
Order(albumsTitleASC).
|
||||
Order("TIME(media.date_shot) DESC")
|
||||
default:
|
||||
query = query.
|
||||
Order("YEAR(media.date_shot) DESC").
|
||||
Order("MONTH(media.date_shot) DESC").
|
||||
Order("DAY(media.date_shot) DESC").
|
||||
Order(albumsTitleASC).
|
||||
Order("TIME(media.date_shot) DESC")
|
||||
}
|
||||
|
||||
if fromDate != nil {
|
||||
query = query.Where("media.date_shot < ?", fromDate)
|
||||
}
|
||||
|
||||
if onlyFavorites != nil && *onlyFavorites {
|
||||
query = query.
|
||||
Where("media.id IN (?)", db.Table("user_media_data").
|
||||
Select("user_media_data.media_id").
|
||||
Where("user_media_data.user_id = ?", user.ID).
|
||||
Where("user_media_data.favorite"))
|
||||
}
|
||||
|
||||
query = models.FormatSQL(query, nil, paginate)
|
||||
|
||||
var media []*models.Media
|
||||
if err := query.Find(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMyTimeline(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
DateShot: time.Unix(1632758400, 0), // Sep 27 2021 16:00:00
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
DateShot: time.Unix(1628762400, 0), // Aug 12 2021 10:00:00
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
DateShot: time.Unix(1632763800, 0), // Sep 27 2021 17:30:00
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
DateShot: time.Unix(1628775900, 0), // Aug 12 2021 13:45:00
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
_, err = user.FavoriteMedia(db, media[0].ID, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add media not owned by first user
|
||||
anotherUser, err := models.RegisterUser(db, "user2", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
anotherAlbum := models.Album{
|
||||
Title: "AnotherAlbum",
|
||||
Path: "/another",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherAlbum).Error)
|
||||
|
||||
anotherMedia := models.Media{
|
||||
Title: "anotherPic",
|
||||
Path: "/another/anotherPic",
|
||||
AlbumID: anotherAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherMedia).Error)
|
||||
assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum))
|
||||
|
||||
t.Run("MyTimeline with no filters", func(t *testing.T) {
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, nil, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 4)
|
||||
|
||||
for i, title := range []string{"pic1", "pic3", "pic2", "pic4"} {
|
||||
assert.Equalf(t, timelineMedia[i].Title, title, "Element %d didn't match: got %s expected %s", i, timelineMedia[i].Title, title)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("MyTimeline with only favorites", func(t *testing.T) {
|
||||
favorites := true
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, &favorites, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 1)
|
||||
})
|
||||
|
||||
t.Run("MyTimeline before date", func(t *testing.T) {
|
||||
beforeDate := time.Unix(1629792000, 0) // Aug 24 2021 08:00:00
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, nil, &beforeDate)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func DeleteUser(db *gorm.DB, userID int) (*models.User, error) {
|
||||
|
||||
// make sure the last admin user is not deleted
|
||||
var adminUsers []*models.User
|
||||
db.Model(&models.User{}).Where("admin = true").Limit(2).Find(&adminUsers)
|
||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID {
|
||||
return nil, errors.New("deleting sole admin user is not allowed")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
deletedAlbumIDs := make([]int, 0)
|
||||
|
||||
var err error
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err = tx.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userAlbums := user.Albums
|
||||
if err = tx.Model(&user).Association("Albums").Find(&userAlbums); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Model(&user).Association("Albums").Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletedAlbumIDs, err = deleteNotOwnedAlbums(userAlbums, tx, deletedAlbumIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Delete(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there is only one associated user, clean up the cache folder and delete the album row
|
||||
return &user, cleanup(deletedAlbumIDs)
|
||||
}
|
||||
|
||||
func cleanup(deletedAlbumIDs []int) error {
|
||||
var err error
|
||||
for _, deletedAlbumID := range deletedAlbumIDs {
|
||||
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
|
||||
if err = os.RemoveAll(cachePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteNotOwnedAlbums(userAlbums []models.Album, tx *gorm.DB, deletedAlbumIDs []int) ([]int, error) {
|
||||
for _, album := range userAlbums {
|
||||
var associatedUsers = tx.Model(album).Association("Owners").Count()
|
||||
|
||||
if associatedUsers == 0 {
|
||||
deletedAlbumIDs = append(deletedAlbumIDs, album.ID)
|
||||
if err := tx.Delete(album).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return deletedAlbumIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
t.Run("Delete regular user", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
regularUser, err := models.RegisterUser(db, "regular", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
deletedUser, err := actions.DeleteUser(db, regularUser.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, regularUser.ID, deletedUser.ID)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 1)
|
||||
assert.Equal(t, adminUser.ID, dbUsers[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Try to delete sole admin user", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = models.RegisterUser(db, "regular", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
_, err = actions.DeleteUser(db, adminUser.ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
})
|
||||
|
||||
t.Run("Delete admin user when multiple admins exist", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser1, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
adminUser2, err := models.RegisterUser(db, "another_admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
deletedUser, err := actions.DeleteUser(db, adminUser1.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, adminUser1.ID, deletedUser.ID)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 1)
|
||||
assert.Equal(t, adminUser2.ID, dbUsers[0].ID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Album struct {
|
||||
Model
|
||||
Title string `gorm:"not null"`
|
||||
ParentAlbumID *int `gorm:"index"`
|
||||
ParentAlbum *Album `gorm:"constraint:OnDelete:SET NULL;"`
|
||||
// OwnerID int `gorm:"not null"`
|
||||
// Owner User
|
||||
Owners []User `gorm:"many2many:user_albums;constraint:OnDelete:CASCADE;"`
|
||||
Path string `gorm:"not null"`
|
||||
PathHash string `gorm:"unique"`
|
||||
CoverID *int
|
||||
}
|
||||
|
||||
func (a *Album) FilePath() string {
|
||||
return a.Path
|
||||
}
|
||||
|
||||
func (a *Album) BeforeSave(tx *gorm.DB) (err error) {
|
||||
hash := md5.Sum([]byte(a.Path))
|
||||
a.PathHash = hex.EncodeToString(hash[:])
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChildren performs a recursive query to get all the children of the album.
|
||||
// An optional filter can be provided that can be used to modify the query on the children.
|
||||
func (a *Album) GetChildren(db *gorm.DB, filter func(*gorm.DB) *gorm.DB) (children []*Album, err error) {
|
||||
return GetChildrenFromAlbums(db, filter, []int{a.ID})
|
||||
}
|
||||
|
||||
func GetChildrenFromAlbums(db *gorm.DB, filter func(*gorm.DB) *gorm.DB, albumIDs []int) (children []*Album, err error) {
|
||||
query := db.Model(&Album{}).Table("sub_albums")
|
||||
|
||||
if filter != nil {
|
||||
query = filter(query)
|
||||
}
|
||||
|
||||
err = db.Raw(`
|
||||
WITH recursive sub_albums AS (
|
||||
SELECT * FROM albums AS root WHERE id IN (?)
|
||||
UNION ALL
|
||||
SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id
|
||||
)
|
||||
|
||||
?
|
||||
`, albumIDs, query).Find(&children).Error
|
||||
|
||||
return children, err
|
||||
}
|
||||
|
||||
func (a *Album) GetParents(db *gorm.DB, filter func(*gorm.DB) *gorm.DB) (parents []*Album, err error) {
|
||||
return GetParentsFromAlbums(db, filter, a.ID)
|
||||
}
|
||||
|
||||
func GetParentsFromAlbums(db *gorm.DB, filter func(*gorm.DB) *gorm.DB, albumID int) (parents []*Album, err error) {
|
||||
query := db.Model(&Album{}).Table("super_albums")
|
||||
|
||||
if filter != nil {
|
||||
query = filter(query)
|
||||
}
|
||||
|
||||
err = db.Raw(`
|
||||
WITH recursive super_albums AS (
|
||||
SELECT * FROM albums AS leaf WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT parent.* from albums AS parent JOIN super_albums ON parent.id = super_albums.parent_album_id
|
||||
)
|
||||
|
||||
?
|
||||
`, albumID, query).Find(&parents).Error
|
||||
|
||||
return parents, err
|
||||
}
|
||||
|
||||
func (a *Album) Thumbnail(db *gorm.DB) (*Media, error) {
|
||||
var media Media
|
||||
|
||||
if a.CoverID != nil {
|
||||
if err := db.First(&media, *a.CoverID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH RECURSIVE sub_albums AS (
|
||||
SELECT id FROM albums WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT children.id FROM albums AS children
|
||||
INNER JOIN sub_albums ON children.parent_album_id = sub_albums.id
|
||||
)
|
||||
SELECT * FROM media
|
||||
WHERE media.album_id IN (SELECT id FROM sub_albums)
|
||||
ORDER BY media.id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if err := db.Raw(query, a.ID).Scan(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if media.ID == 0 {
|
||||
return nil, nil // Return nil for empty albums
|
||||
}
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlbumGetChildrenAndParents(t *testing.T) {
|
||||
const photosPath = "/photos"
|
||||
const photosChild1Path = "/photos/child1"
|
||||
const photosChild1SubchildPath = "/photos/child1/subchild"
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
children := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "not_child",
|
||||
Path: "/videos",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&children).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
subChild := models.Album{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
ParentAlbumID: &children[0].ID,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&subChild).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
verifyResult := func(t *testing.T, expectedAlbums []*models.Album, result []*models.Album) {
|
||||
assert.Equal(t, len(expectedAlbums), len(result))
|
||||
|
||||
for _, expected := range expectedAlbums {
|
||||
foundExpected := false
|
||||
for _, item := range result {
|
||||
if item.Title == expected.Title && item.Path == expected.Path {
|
||||
foundExpected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundExpected {
|
||||
assert.Failf(t, "albums did not match", "expected to find item: %v", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Album get children", func(t *testing.T) {
|
||||
rootChildren, err := rootAlbum.GetChildren(db, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
expectedChildren := []*models.Album{
|
||||
{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
},
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
},
|
||||
{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
},
|
||||
}
|
||||
|
||||
verifyResult(t, expectedChildren, rootChildren)
|
||||
})
|
||||
|
||||
t.Run("Album get parents", func(t *testing.T) {
|
||||
parents, err := subChild.GetParents(db, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
expectedParents := []*models.Album{
|
||||
{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
},
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
},
|
||||
{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
},
|
||||
}
|
||||
|
||||
verifyResult(t, expectedParents, parents)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestAlbumThumbnail(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
mediaAlbum := models.Album{
|
||||
Title: "Media album",
|
||||
Path: "/media_album",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&mediaAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
media := models.Media{
|
||||
Path: "thumb.jpg",
|
||||
AlbumID: mediaAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&media).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Thumbnail from CoverID", func(t *testing.T) {
|
||||
album := models.Album{
|
||||
Title: "Album with cover",
|
||||
Path: "/cover_album",
|
||||
CoverID: &media.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&album).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, media.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("Thumbnail from child media", func(t *testing.T) {
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent album",
|
||||
Path: "/parent",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "Child album",
|
||||
Path: "/parent/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childMedia := models.Media{
|
||||
Path: "child_media.jpg",
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := parentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, childMedia.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("Empty album with no media", func(t *testing.T) {
|
||||
emptyAlbum := models.Album{
|
||||
Title: "Empty album",
|
||||
Path: "/empty",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&emptyAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := emptyAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, result, "Empty albums should have nil thumbnail")
|
||||
})
|
||||
|
||||
t.Run("Thumbnail from grandchild media", func(t *testing.T) {
|
||||
// Create grandparent-parent-child relationship with media only in child
|
||||
grandparentAlbum := models.Album{
|
||||
Title: "Grandparent",
|
||||
Path: "/grandparent",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&grandparentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent",
|
||||
Path: "/grandparent/parent",
|
||||
ParentAlbumID: &grandparentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "Child",
|
||||
Path: "/grandparent/parent/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childMedia := models.Media{
|
||||
Path: "deep_media.jpg",
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := grandparentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, childMedia.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("CoverID takes precedence over any media", func(t *testing.T) {
|
||||
// Create album with both direct media and a cover ID
|
||||
priorityAlbum := models.Album{
|
||||
Title: "Priority album",
|
||||
Path: "/priority",
|
||||
CoverID: &media.ID, // Using existing media as cover
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&priorityAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add direct media to the album with unique path
|
||||
directMedia := models.Media{
|
||||
Path: fmt.Sprintf("direct_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: priorityAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&directMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test that CoverID takes precedence
|
||||
result, err := priorityAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, media.ID, result.ID, "CoverID should take precedence over direct media")
|
||||
})
|
||||
|
||||
t.Run("Some media is returned when multiple exist in hierarchy", func(t *testing.T) {
|
||||
// Create a parent album
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent album",
|
||||
Path: "/parent_media_test",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add direct media to parent with unique path
|
||||
parentMedia := models.Media{
|
||||
Path: fmt.Sprintf("parent_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create child album with media
|
||||
childAlbum := models.Album{
|
||||
Title: "Child album",
|
||||
Path: "/parent_media_test/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add child media with unique path
|
||||
childMedia := models.Media{
|
||||
Path: fmt.Sprintf("child_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test that some media is returned
|
||||
result, err := parentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.ID == parentMedia.ID || result.ID == childMedia.ID,
|
||||
"Should return either direct media or child album media")
|
||||
t.Logf("For reference - Selected: %d, Parent media: %d, Child media: %d",
|
||||
result.ID, parentMedia.ID, childMedia.ID)
|
||||
})
|
||||
|
||||
t.Run("Database order determines which media is selected", func(t *testing.T) {
|
||||
// Create album with multiple media
|
||||
multiMediaAlbum := models.Album{
|
||||
Title: "Album with multiple media",
|
||||
Path: "/multi_media",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&multiMediaAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add multiple media to the album with unique paths
|
||||
mediaItems := []models.Media{
|
||||
{Path: fmt.Sprintf("media1_%d.jpg", time.Now().UnixNano()), AlbumID: multiMediaAlbum.ID},
|
||||
// Sleep briefly to ensure different timestamps
|
||||
{Path: fmt.Sprintf("media2_%d.jpg", time.Now().UnixNano()+1), AlbumID: multiMediaAlbum.ID},
|
||||
{Path: fmt.Sprintf("media3_%d.jpg", time.Now().UnixNano()+2), AlbumID: multiMediaAlbum.ID},
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&mediaItems).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test which media is selected
|
||||
result, err := multiMediaAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
// Log which item was selected for documentation purposes
|
||||
t.Logf("Selected media ID: %d", result.ID)
|
||||
for i, item := range mediaItems {
|
||||
t.Logf("Media %d: ID %d, Path %s", i+1, item.ID, item.Path)
|
||||
}
|
||||
|
||||
// Verify one of our media items was selected
|
||||
found := false
|
||||
for _, item := range mediaItems {
|
||||
if result.ID == item.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "One of the album's media should be selected")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID int `gorm:"primarykey"`
|
||||
ModelTimestamps
|
||||
}
|
||||
|
||||
type ModelTimestamps struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql/driver"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type FaceGroup struct {
|
||||
Model
|
||||
Label *string
|
||||
ImageFaces []ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
type ImageFace struct {
|
||||
Model
|
||||
FaceGroupID int `gorm:"not null;index"`
|
||||
FaceGroup *FaceGroup
|
||||
MediaID int `gorm:"not null;index"`
|
||||
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Descriptor FaceDescriptor `gorm:"not null"`
|
||||
Rectangle FaceRectangle `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (f *ImageFace) FillMedia(db *gorm.DB) error {
|
||||
if f.Media.ID != 0 {
|
||||
// media already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Model(&f).Association("Media").Find(&f.Media); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type FaceDescriptor [128]float32 // same as go-face's Descriptor
|
||||
|
||||
// GormDataType datatype used in database
|
||||
func (FaceDescriptor) GormDBDataType(db *gorm.DB, field *schema.Field) string {
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.MYSQL, drivers.SQLITE:
|
||||
return "BLOB"
|
||||
case drivers.POSTGRES:
|
||||
return "BYTEA"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Scan tells GORM how to convert database data to Go format
|
||||
func (fd *FaceDescriptor) Scan(value interface{}) error {
|
||||
byteValue := value.([]byte)
|
||||
reader := bytes.NewReader(byteValue)
|
||||
binary.Read(reader, binary.LittleEndian, fd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value tells GORM how to save into the database
|
||||
func (fd FaceDescriptor) Value() (driver.Value, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := binary.Write(buf, binary.LittleEndian, fd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// FaceRectangle stores a relative rectangle of a face in an image.
|
||||
type FaceRectangle struct {
|
||||
MinX, MaxX float64
|
||||
MinY, MaxY float64
|
||||
}
|
||||
|
||||
// GormDataType datatype used in database
|
||||
func (fr FaceRectangle) GormDataType() string {
|
||||
return "VARCHAR(64)"
|
||||
}
|
||||
|
||||
// Scan tells GORM how to convert database data to Go format
|
||||
func (fr *FaceRectangle) Scan(value interface{}) error {
|
||||
stringArray, ok := value.(string)
|
||||
if !ok {
|
||||
byteArray := value.([]uint8)
|
||||
stringArray = string(byteArray)
|
||||
}
|
||||
|
||||
slices := strings.Split(stringArray, ":")
|
||||
|
||||
if len(slices) != 4 {
|
||||
return fmt.Errorf("invalid face rectangle format, expected 4 values, got %d", len(slices))
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
fr.MinX, err = strconv.ParseFloat(slices[0], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MaxX, err = strconv.ParseFloat(slices[1], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MinY, err = strconv.ParseFloat(slices[2], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MaxY, err = strconv.ParseFloat(slices[3], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value tells GORM how to save into the database
|
||||
func (fr FaceRectangle) Value() (driver.Value, error) {
|
||||
result := fmt.Sprintf("%f:%f:%f:%f", fr.MinX, fr.MaxX, fr.MinY, fr.MaxY)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthorizeResult struct {
|
||||
Success bool `json:"success"`
|
||||
// A textual status message describing the result, can be used to show an error message when `success` is false
|
||||
Status string `json:"status"`
|
||||
// An access token used to authenticate new API requests as the newly authorized user. Is present when success is true
|
||||
Token *string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type Coordinates struct {
|
||||
// GPS latitude in degrees
|
||||
Latitude float64 `json:"latitude"`
|
||||
// GPS longitude in degrees
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type MediaDownload struct {
|
||||
// A description of the role of the media file
|
||||
Title string `json:"title"`
|
||||
MediaURL *MediaURL `json:"mediaUrl"`
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
// A key used to identify the notification, new notification updates with the same key, should replace the old notifications
|
||||
Key string `json:"key"`
|
||||
Type NotificationType `json:"type"`
|
||||
// The text for the title of the notification
|
||||
Header string `json:"header"`
|
||||
// The text for the body of the notification
|
||||
Content string `json:"content"`
|
||||
// A value between 0 and 1 when the notification type is `Progress`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
// Whether or not the message of the notification is positive, the UI might reflect this with a green color
|
||||
Positive bool `json:"positive"`
|
||||
// Whether or not the message of the notification is negative, the UI might reflect this with a red color
|
||||
Negative bool `json:"negative"`
|
||||
// Time in milliseconds before the notification should close
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// Used to specify how to sort items
|
||||
type Ordering struct {
|
||||
// A column in the database to order by
|
||||
OrderBy *string `json:"order_by,omitempty"`
|
||||
OrderDirection *OrderDirection `json:"order_direction,omitempty"`
|
||||
}
|
||||
|
||||
// Used to specify pagination on a list of items
|
||||
type Pagination struct {
|
||||
// How many items to maximally fetch
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
// How many items to skip from the beginning of the query, specified by the `Ordering`
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
}
|
||||
|
||||
type ScannerResult struct {
|
||||
Finished bool `json:"finished"`
|
||||
Success bool `json:"success"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
// The string that was searched for
|
||||
Query string `json:"query"`
|
||||
// A list of albums that matched the query
|
||||
Albums []*Album `json:"albums"`
|
||||
// A list of media that matched the query
|
||||
Media []*Media `json:"media"`
|
||||
}
|
||||
|
||||
// Credentials used to identify and authenticate a share token
|
||||
type ShareTokenCredentials struct {
|
||||
Token string `json:"token"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
}
|
||||
|
||||
// A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||
// NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||
type TimelineGroup struct {
|
||||
// The full album containing the media in this timeline group
|
||||
Album *Album `json:"album"`
|
||||
// The media contained in this timeline group
|
||||
Media []*Media `json:"media"`
|
||||
// The total amount of media in this timeline group
|
||||
MediaTotal int `json:"mediaTotal"`
|
||||
// The day shared for all media in this timeline group
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// Supported language translations of the user interface
|
||||
type LanguageTranslation string
|
||||
|
||||
const (
|
||||
LanguageTranslationEnglish LanguageTranslation = "English"
|
||||
LanguageTranslationFrench LanguageTranslation = "French"
|
||||
LanguageTranslationItalian LanguageTranslation = "Italian"
|
||||
LanguageTranslationSwedish LanguageTranslation = "Swedish"
|
||||
LanguageTranslationDanish LanguageTranslation = "Danish"
|
||||
LanguageTranslationSpanish LanguageTranslation = "Spanish"
|
||||
LanguageTranslationPolish LanguageTranslation = "Polish"
|
||||
LanguageTranslationUkrainian LanguageTranslation = "Ukrainian"
|
||||
LanguageTranslationGerman LanguageTranslation = "German"
|
||||
LanguageTranslationRussian LanguageTranslation = "Russian"
|
||||
LanguageTranslationTraditionalChineseTw LanguageTranslation = "TraditionalChineseTW"
|
||||
LanguageTranslationTraditionalChineseHk LanguageTranslation = "TraditionalChineseHK"
|
||||
LanguageTranslationSimplifiedChinese LanguageTranslation = "SimplifiedChinese"
|
||||
LanguageTranslationPortuguese LanguageTranslation = "Portuguese"
|
||||
LanguageTranslationBasque LanguageTranslation = "Basque"
|
||||
LanguageTranslationTurkish LanguageTranslation = "Turkish"
|
||||
LanguageTranslationJapanese LanguageTranslation = "Japanese"
|
||||
LanguageTranslationDutch LanguageTranslation = "Dutch"
|
||||
)
|
||||
|
||||
var AllLanguageTranslation = []LanguageTranslation{
|
||||
LanguageTranslationEnglish,
|
||||
LanguageTranslationFrench,
|
||||
LanguageTranslationItalian,
|
||||
LanguageTranslationSwedish,
|
||||
LanguageTranslationDanish,
|
||||
LanguageTranslationSpanish,
|
||||
LanguageTranslationPolish,
|
||||
LanguageTranslationUkrainian,
|
||||
LanguageTranslationGerman,
|
||||
LanguageTranslationRussian,
|
||||
LanguageTranslationTraditionalChineseTw,
|
||||
LanguageTranslationTraditionalChineseHk,
|
||||
LanguageTranslationSimplifiedChinese,
|
||||
LanguageTranslationPortuguese,
|
||||
LanguageTranslationBasque,
|
||||
LanguageTranslationTurkish,
|
||||
LanguageTranslationJapanese,
|
||||
LanguageTranslationDutch,
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) IsValid() bool {
|
||||
switch e {
|
||||
case LanguageTranslationEnglish, LanguageTranslationFrench, LanguageTranslationItalian, LanguageTranslationSwedish, LanguageTranslationDanish, LanguageTranslationSpanish, LanguageTranslationPolish, LanguageTranslationUkrainian, LanguageTranslationGerman, LanguageTranslationRussian, LanguageTranslationTraditionalChineseTw, LanguageTranslationTraditionalChineseHk, LanguageTranslationSimplifiedChinese, LanguageTranslationPortuguese, LanguageTranslationBasque, LanguageTranslationTurkish, LanguageTranslationJapanese, LanguageTranslationDutch:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *LanguageTranslation) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = LanguageTranslation(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid LanguageTranslation", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *LanguageTranslation) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Specified the type a particular notification is of
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
// A regular message with no special additions
|
||||
NotificationTypeMessage NotificationType = "Message"
|
||||
// A notification with an attached progress indicator
|
||||
NotificationTypeProgress NotificationType = "Progress"
|
||||
// Close a notification with a given key
|
||||
NotificationTypeClose NotificationType = "Close"
|
||||
)
|
||||
|
||||
var AllNotificationType = []NotificationType{
|
||||
NotificationTypeMessage,
|
||||
NotificationTypeProgress,
|
||||
NotificationTypeClose,
|
||||
}
|
||||
|
||||
func (e NotificationType) IsValid() bool {
|
||||
switch e {
|
||||
case NotificationTypeMessage, NotificationTypeProgress, NotificationTypeClose:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e NotificationType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *NotificationType) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = NotificationType(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid NotificationType", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e NotificationType) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *NotificationType) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e NotificationType) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Used to specify which order to sort items in
|
||||
type OrderDirection string
|
||||
|
||||
const (
|
||||
// Sort accending A-Z
|
||||
OrderDirectionAsc OrderDirection = "ASC"
|
||||
// Sort decending Z-A
|
||||
OrderDirectionDesc OrderDirection = "DESC"
|
||||
)
|
||||
|
||||
var AllOrderDirection = []OrderDirection{
|
||||
OrderDirectionAsc,
|
||||
OrderDirectionDesc,
|
||||
}
|
||||
|
||||
func (e OrderDirection) IsValid() bool {
|
||||
switch e {
|
||||
case OrderDirectionAsc, OrderDirectionDesc:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e OrderDirection) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *OrderDirection) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = OrderDirection(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OrderDirection", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e OrderDirection) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *OrderDirection) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e OrderDirection) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
Model
|
||||
Title string `gorm:"not null"`
|
||||
Path string `gorm:"not null"`
|
||||
PathHash string `gorm:"not null;unique"`
|
||||
AlbumID int `gorm:"not null;index"`
|
||||
Album Album `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
ExifID *int `gorm:"index"`
|
||||
Exif *MediaEXIF `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaURL []MediaURL `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
DateShot time.Time `gorm:"not null"`
|
||||
Type MediaType `gorm:"not null;index"`
|
||||
VideoMetadataID *int `gorm:"index"`
|
||||
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
SideCarPath *string
|
||||
SideCarHash *string `gorm:"unique"`
|
||||
Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Blurhash *string `gorm:""`
|
||||
}
|
||||
|
||||
func (Media) TableName() string {
|
||||
return "media"
|
||||
}
|
||||
|
||||
func (m *Media) BeforeSave(tx *gorm.DB) error {
|
||||
// Update path hash
|
||||
m.PathHash = MD5Hash(m.Path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) Date() time.Time {
|
||||
return m.DateShot
|
||||
}
|
||||
|
||||
func (m *Media) GetThumbnail() (*MediaURL, error) {
|
||||
if len(m.MediaURL) == 0 {
|
||||
return nil, errors.New("media.MediaURL is empty")
|
||||
}
|
||||
|
||||
for _, url := range m.MediaURL {
|
||||
if url.Purpose == PhotoThumbnail || url.Purpose == VideoThumbnail {
|
||||
url.Media = m
|
||||
return &url, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Media) GetHighRes() (*MediaURL, error) {
|
||||
if len(m.MediaURL) == 0 {
|
||||
return nil, errors.New("media.MediaURL is empty")
|
||||
}
|
||||
|
||||
for _, url := range m.MediaURL {
|
||||
if url.Purpose == PhotoHighRes {
|
||||
url.Media = m
|
||||
return &url, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Media) CachePath() (string, error) {
|
||||
return utils.CachePathForMedia(m.AlbumID, m.ID)
|
||||
}
|
||||
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
MediaTypePhoto MediaType = "photo"
|
||||
MediaTypeVideo MediaType = "video"
|
||||
)
|
||||
|
||||
var AllMediaType = []MediaType{
|
||||
MediaTypePhoto,
|
||||
MediaTypeVideo,
|
||||
}
|
||||
|
||||
type MediaPurpose string
|
||||
|
||||
const (
|
||||
PhotoThumbnail MediaPurpose = "thumbnail"
|
||||
PhotoHighRes MediaPurpose = "high-res"
|
||||
MediaOriginal MediaPurpose = "original"
|
||||
VideoWeb MediaPurpose = "video-web"
|
||||
VideoThumbnail MediaPurpose = "video-thumbnail"
|
||||
)
|
||||
|
||||
type MediaURL struct {
|
||||
Model
|
||||
MediaID int `gorm:"not null;index"`
|
||||
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaName string `gorm:"not null;index"`
|
||||
Width int `gorm:"not null"`
|
||||
Height int `gorm:"not null"`
|
||||
Purpose MediaPurpose `gorm:"not null;index"`
|
||||
ContentType string `gorm:"not null"`
|
||||
FileSize int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (p *MediaURL) URL() string {
|
||||
|
||||
imageURL := utils.ApiEndpointUrl()
|
||||
if p.Purpose != VideoWeb {
|
||||
imageURL.Path = path.Join(imageURL.Path, "photo", p.MediaName)
|
||||
} else {
|
||||
imageURL.Path = path.Join(imageURL.Path, "video", p.MediaName)
|
||||
}
|
||||
|
||||
return imageURL.String()
|
||||
}
|
||||
|
||||
func (p *MediaURL) CachedPath() (string, error) {
|
||||
var cachedPath string
|
||||
|
||||
if p.Media == nil {
|
||||
return "", errors.New("mediaURL.Media is nil")
|
||||
}
|
||||
|
||||
if p.Purpose == PhotoThumbnail || p.Purpose == PhotoHighRes || p.Purpose == VideoThumbnail || p.Purpose == VideoWeb {
|
||||
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(p.Media.AlbumID)), strconv.Itoa(int(p.MediaID)),
|
||||
p.MediaName)
|
||||
} else if p.Purpose == MediaOriginal {
|
||||
cachedPath = p.Media.Path
|
||||
} else {
|
||||
return "", errors.New(fmt.Sprintf("cannot determine cache path for purpose (%s)", p.Purpose))
|
||||
}
|
||||
|
||||
return cachedPath, nil
|
||||
}
|
||||
|
||||
func SanitizeMediaName(mediaName string) string {
|
||||
result := mediaName
|
||||
result = strings.ReplaceAll(result, "/", "")
|
||||
result = strings.ReplaceAll(result, "\\", "")
|
||||
result = strings.ReplaceAll(result, " ", "_")
|
||||
result = strings.ReplaceAll(result, ".", "_")
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaEXIF struct {
|
||||
Model
|
||||
Description *string
|
||||
Camera *string
|
||||
Maker *string
|
||||
Lens *string
|
||||
DateShot *time.Time
|
||||
OffsetSecShot *int
|
||||
Exposure *float64
|
||||
Aperture *float64
|
||||
Iso *int64
|
||||
FocalLength *float64
|
||||
Flash *int64
|
||||
Orientation *int64
|
||||
ExposureProgram *int64
|
||||
GPSLatitude *float64
|
||||
GPSLongitude *float64
|
||||
}
|
||||
|
||||
func (MediaEXIF) TableName() string {
|
||||
return "media_exif"
|
||||
}
|
||||
|
||||
func (exif *MediaEXIF) Media() *Media {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (exif *MediaEXIF) Coordinates() *Coordinates {
|
||||
if exif.GPSLatitude == nil || exif.GPSLongitude == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Coordinates{
|
||||
Latitude: *exif.GPSLatitude,
|
||||
Longitude: *exif.GPSLongitude,
|
||||
}
|
||||
}
|
||||
|
||||
const rfc3339WithoutTimezone = "2006-01-02T15:04:05.999"
|
||||
|
||||
func (exif *MediaEXIF) DateShotWithOffset() *string {
|
||||
if exif.DateShot == nil {
|
||||
return nil
|
||||
}
|
||||
dateShot := exif.DateShot.UTC()
|
||||
dateNoTimezone := dateShot.Format(rfc3339WithoutTimezone)
|
||||
|
||||
if exif.OffsetSecShot == nil {
|
||||
return &dateNoTimezone
|
||||
}
|
||||
|
||||
offsetAbs := *exif.OffsetSecShot
|
||||
sign := "+"
|
||||
if offsetAbs < 0 {
|
||||
offsetAbs = -offsetAbs
|
||||
sign = "-"
|
||||
}
|
||||
hour := offsetAbs / 60 / 60
|
||||
minute := offsetAbs / 60 % 60
|
||||
date := fmt.Sprintf("%s%s%02d:%02d", dateNoTimezone, sign, hour, minute)
|
||||
|
||||
return &date
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
// Different database has different behavior when storing date with timezone.
|
||||
// - SQLite: keep the original timezone
|
||||
// - MySQL/MariaDB: store in UTC
|
||||
// - PostgreSQL: store in the timezone of the db client
|
||||
// We cannot maintain consistent behavior across different databases without the extra offset field.
|
||||
|
||||
const (
|
||||
layout = "2006:01:02 15:04:05"
|
||||
layoutWithOffset = "2006:01:02 15:04:05-07:00"
|
||||
)
|
||||
|
||||
func TestDatabaseReproduceDateWithOffset(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
offsetSec *int
|
||||
want string
|
||||
}{
|
||||
{"NoSubSecNoOffset", "2025:11:01 14:02:03", nil, "2025-11-01T14:02:03"},
|
||||
{"SubSecNoOffset", "2025:11:01 14:02:03.123", nil, "2025-11-01T14:02:03.123"},
|
||||
{"NoSubSecWithOffset", "2025:11:01 14:02:03", new(60 * 60), "2025-11-01T14:02:03+01:00"},
|
||||
{"SubSecWithOffset", "2025:11:01 14:02:03.123", new(60 * 60), "2025-11-01T14:02:03.123+01:00"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
date, err := time.ParseInLocation(layout, tc.date, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q error: %v", tc.date, err)
|
||||
}
|
||||
|
||||
exif := models.MediaEXIF{
|
||||
DateShot: &date,
|
||||
OffsetSecShot: tc.offsetSec,
|
||||
}
|
||||
|
||||
if err := db.Save(&exif).Error; err != nil {
|
||||
t.Fatalf("store exif error: %v", err)
|
||||
}
|
||||
|
||||
var got models.MediaEXIF
|
||||
if err := db.Where("id = ?", exif.ID).First(&got).Error; err != nil {
|
||||
t.Fatalf("get exif error: %v", err)
|
||||
}
|
||||
|
||||
if got, want := *got.DateShotWithOffset(), tc.want; got != want {
|
||||
t.Errorf("got = %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const mimeJpeg = "image/jpeg"
|
||||
|
||||
func TestSanitizeMediaName(t *testing.T) {
|
||||
tests := [][2]string{
|
||||
{"filename.png", "filename_png"},
|
||||
{"../..\\escape", "____escape"},
|
||||
{"..", "__"},
|
||||
{"..\\/", "__"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("sanitize: %s", test[0]), func(t *testing.T) {
|
||||
assert.Equal(t, test[1], models.SanitizeMediaName(test[0]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaURLCachePath(t *testing.T) {
|
||||
mediaUrl := models.MediaURL{}
|
||||
mediaUrl.Media = nil
|
||||
|
||||
_, err := mediaUrl.CachedPath()
|
||||
assert.EqualError(t, err, "mediaURL.Media is nil")
|
||||
|
||||
mediaUrl = models.MediaURL{
|
||||
Purpose: models.PhotoThumbnail,
|
||||
MediaID: 1,
|
||||
Media: &models.Media{
|
||||
Model: models.Model{
|
||||
ID: 1,
|
||||
},
|
||||
Title: "media.jpg",
|
||||
AlbumID: 2,
|
||||
},
|
||||
MediaName: "media_thumb.jpg",
|
||||
}
|
||||
|
||||
path, err := mediaUrl.CachedPath()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "media_cache/2/1/media_thumb.jpg", path)
|
||||
|
||||
}
|
||||
|
||||
func TestMediaURLGetURL(t *testing.T) {
|
||||
t.Setenv(string(utils.EnvAPIEndpoint), "")
|
||||
|
||||
photo := models.MediaURL{
|
||||
MediaName: "photo.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoHighRes,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/api/photo/photo.jpg", photo.URL())
|
||||
|
||||
video := models.MediaURL{
|
||||
MediaName: "video.mp4",
|
||||
ContentType: "video/mp4",
|
||||
Purpose: models.VideoWeb,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/api/video/video.mp4", video.URL())
|
||||
}
|
||||
|
||||
func TestMediaGetThumbnail(t *testing.T) {
|
||||
photo := models.Media{
|
||||
Title: "test.png",
|
||||
Path: "path/test.png",
|
||||
Type: models.MediaTypePhoto,
|
||||
MediaURL: []models.MediaURL{
|
||||
{
|
||||
MediaName: "photo.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoHighRes,
|
||||
},
|
||||
{
|
||||
MediaName: "thumbnail.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoThumbnail,
|
||||
},
|
||||
{
|
||||
MediaName: "photo.png",
|
||||
ContentType: "image/png",
|
||||
Purpose: models.MediaOriginal,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
thumb, err := photo.GetThumbnail()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, thumb.MediaName, "thumbnail.jpg")
|
||||
assert.NotNil(t, thumb.Media)
|
||||
|
||||
video := models.Media{
|
||||
Title: "video-test.mp4",
|
||||
Path: "path/test.mp4",
|
||||
Type: models.MediaTypeVideo,
|
||||
MediaURL: []models.MediaURL{
|
||||
{
|
||||
MediaName: "video.mp4",
|
||||
ContentType: "video/mp4",
|
||||
Purpose: models.VideoWeb,
|
||||
},
|
||||
{
|
||||
MediaName: "video-thumbnail.jpg",
|
||||
ContentType: "image/jpg",
|
||||
Purpose: models.VideoThumbnail,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
thumb, err = video.GetThumbnail()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, thumb.MediaName, "video-thumbnail.jpg")
|
||||
assert.NotNil(t, thumb.Media)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ShareToken struct {
|
||||
Model
|
||||
Value string `gorm:"not null"`
|
||||
OwnerID int `gorm:"not null;index"`
|
||||
Owner User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Expire *time.Time `gorm:"index"`
|
||||
Password *string
|
||||
AlbumID *int `gorm:"index"`
|
||||
Album *Album `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaID *int `gorm:"index"`
|
||||
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
func (share *ShareToken) Token() string {
|
||||
return share.Value
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
db_drivers "github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SiteInfo struct {
|
||||
InitialSetup bool `gorm:"not null"`
|
||||
PeriodicScanInterval int `gorm:"not null"`
|
||||
ConcurrentWorkers int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SiteInfo) TableName() string {
|
||||
return "site_info"
|
||||
}
|
||||
|
||||
func DefaultSiteInfo(db *gorm.DB) SiteInfo {
|
||||
defaultConcurrentWorkers := 3
|
||||
if db_drivers.SQLITE.MatchDatabase(db) {
|
||||
defaultConcurrentWorkers = 1
|
||||
}
|
||||
|
||||
return SiteInfo{
|
||||
InitialSetup: true,
|
||||
PeriodicScanInterval: 0,
|
||||
ConcurrentWorkers: defaultConcurrentWorkers,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSiteInfo gets the site info row from the database, and creates it if it does not exist
|
||||
func GetSiteInfo(db *gorm.DB) (*SiteInfo, error) {
|
||||
|
||||
var siteInfo []*SiteInfo
|
||||
|
||||
if err := db.Limit(1).Find(&siteInfo).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get site info from database")
|
||||
}
|
||||
|
||||
if len(siteInfo) == 0 {
|
||||
newSiteInfo := DefaultSiteInfo(db)
|
||||
|
||||
if err := db.Create(&newSiteInfo).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "initialize site_info")
|
||||
}
|
||||
|
||||
return &newSiteInfo, nil
|
||||
} else {
|
||||
return siteInfo[0], nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSiteInfo(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
site_info, err := models.GetSiteInfo(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, models.DefaultSiteInfo(db), *site_info)
|
||||
|
||||
site_info.InitialSetup = false
|
||||
site_info.PeriodicScanInterval = 360
|
||||
site_info.ConcurrentWorkers = 10
|
||||
|
||||
if !assert.NoError(t, db.Session(&gorm.Session{AllowGlobalUpdate: true}).Save(&site_info).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
site_info, err = models.GetSiteInfo(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, models.SiteInfo{
|
||||
InitialSetup: false,
|
||||
PeriodicScanInterval: 360,
|
||||
ConcurrentWorkers: 10,
|
||||
}, *site_info)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Model
|
||||
Username string `gorm:"unique;size:128"`
|
||||
Password *string `gorm:"size:256"`
|
||||
// RootPath string `gorm:"size:512`
|
||||
Albums []Album `gorm:"many2many:user_albums;constraint:OnDelete:CASCADE;"`
|
||||
Admin bool `gorm:"default:false"`
|
||||
}
|
||||
|
||||
type UserMediaData struct {
|
||||
ModelTimestamps
|
||||
UserID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
MediaID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
Favorite bool `gorm:"not null;default:false"`
|
||||
}
|
||||
|
||||
type UserAlbums struct {
|
||||
UserID int `gorm:"primaryKey;autoIncrement:false;constraint:OnDelete:CASCADE;"`
|
||||
AlbumID int `gorm:"primaryKey;autoIncrement:false;constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
type AccessToken struct {
|
||||
Model
|
||||
UserID int `gorm:"not null;index"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Value string `gorm:"not null;size:24;index"`
|
||||
Expire time.Time `gorm:"not null;index"`
|
||||
}
|
||||
|
||||
type UserPreferences struct {
|
||||
Model
|
||||
UserID int `gorm:"not null;index"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Language *LanguageTranslation
|
||||
}
|
||||
|
||||
func (u *UserPreferences) BeforeSave(tx *gorm.DB) error {
|
||||
|
||||
if u.Language != nil && *u.Language == "" {
|
||||
u.Language = nil
|
||||
}
|
||||
|
||||
if u.Language != nil {
|
||||
langStr := string(*u.Language)
|
||||
foundMatch := false
|
||||
for _, lang := range AllLanguageTranslation {
|
||||
if string(lang) == langStr {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
return errors.New("invalid language value")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrorInvalidUserCredentials = errors.New("invalid credentials")
|
||||
|
||||
func AuthorizeUser(db *gorm.DB, username string, password string) (*User, error) {
|
||||
var user User
|
||||
|
||||
result := db.Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrorInvalidUserCredentials
|
||||
}
|
||||
return nil, errors.Wrap(result.Error, "failed to get user by username when authorizing")
|
||||
}
|
||||
|
||||
if user.Password == nil {
|
||||
return nil, errors.New("user does not have a password")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return nil, ErrorInvalidUserCredentials
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "compare user password hash")
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func RegisterUser(db *gorm.DB, username string, password *string, admin bool) (*User, error) {
|
||||
user := User{
|
||||
Username: username,
|
||||
Admin: admin,
|
||||
}
|
||||
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to hash password")
|
||||
}
|
||||
hashedPass := string(hashedPassBytes)
|
||||
|
||||
user.Password = &hashedPass
|
||||
}
|
||||
|
||||
result := db.Create(&user)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(result.Error, "insert new user with password into database")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (user *User) GenerateAccessToken(db *gorm.DB) (*AccessToken, error) {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Could not generate token: %s\n", err.Error()))
|
||||
}
|
||||
const CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
for i, b := range bytes {
|
||||
bytes[i] = CHARACTERS[b%byte(len(CHARACTERS))]
|
||||
}
|
||||
|
||||
tokenValue := string(bytes)
|
||||
expire := time.Now().Add(14 * 24 * time.Hour)
|
||||
|
||||
token := AccessToken{
|
||||
UserID: user.ID,
|
||||
Value: tokenValue,
|
||||
Expire: expire,
|
||||
}
|
||||
|
||||
result := db.Create(&token)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(result.Error, "saving access token to database")
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// FillAlbums fill user.Albums with albums from database
|
||||
func (user *User) FillAlbums(db *gorm.DB) error {
|
||||
// Albums already present
|
||||
if len(user.Albums) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Model(&user).Association("Albums").Find(&user.Albums); err != nil {
|
||||
return errors.Wrap(err, "fill user albums")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) {
|
||||
filter := func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where(
|
||||
"EXISTS (SELECT 1 FROM user_albums WHERE user_albums.user_id = ? AND user_albums.album_id = id LIMIT 1)",
|
||||
user.ID)
|
||||
}
|
||||
|
||||
ownedParents, err := album.GetParents(db, filter)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(ownedParents) > 0, nil
|
||||
}
|
||||
|
||||
// FavoriteMedia sets/clears a media as favorite for the user
|
||||
func (user *User) FavoriteMedia(db *gorm.DB, mediaID int, favorite bool) (*Media, error) {
|
||||
userMediaData := UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: mediaID,
|
||||
Favorite: favorite,
|
||||
}
|
||||
|
||||
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "update user favorite media in database")
|
||||
}
|
||||
|
||||
var media Media
|
||||
if err := db.First(&media, mediaID).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get media from database after favorite update")
|
||||
}
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserRegistrationAuthorization(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
t.Run("Register user", func(t *testing.T) {
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "admin", &password, true)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, user)
|
||||
assert.EqualValues(t, "admin", user.Username)
|
||||
assert.NotNil(t, user.Password)
|
||||
assert.NotEqualValues(t, "1234", user.Password) // should be hashed
|
||||
assert.True(t, user.Admin)
|
||||
})
|
||||
|
||||
t.Run("Authorize user", func(t *testing.T) {
|
||||
user, err := models.AuthorizeUser(db, "admin", "1234")
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, user)
|
||||
assert.EqualValues(t, "admin", user.Username)
|
||||
})
|
||||
|
||||
t.Run("Authorize invalid credentials", func(t *testing.T) {
|
||||
user, err := models.AuthorizeUser(db, "invalid_username", "1234")
|
||||
assert.ErrorIs(t, err, models.ErrorInvalidUserCredentials)
|
||||
assert.Nil(t, user)
|
||||
|
||||
user, err = models.AuthorizeUser(db, "admin", "invalid_password")
|
||||
assert.ErrorIs(t, err, models.ErrorInvalidUserCredentials)
|
||||
assert.Nil(t, user)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessToken(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
pass := "<hashed_password>"
|
||||
user := models.User{
|
||||
Username: "user1",
|
||||
Password: &pass,
|
||||
Admin: false,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
access_token, err := user.GenerateAccessToken(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, access_token)
|
||||
assert.Equal(t, user.ID, access_token.UserID)
|
||||
assert.NotEmpty(t, access_token.Value)
|
||||
assert.True(t, access_token.Expire.After(time.Now()))
|
||||
}
|
||||
|
||||
func TestUserFillAlbums(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user := models.User{
|
||||
Username: "user",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
err := user.FillAlbums(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, user.Albums)
|
||||
|
||||
albums := []models.Album{
|
||||
{
|
||||
Title: "album1",
|
||||
Path: "/photos/album1",
|
||||
},
|
||||
{
|
||||
Title: "album2",
|
||||
Path: "/photos/album2",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&albums)) {
|
||||
return
|
||||
}
|
||||
|
||||
user.Albums = make([]models.Album, 0)
|
||||
|
||||
err = user.FillAlbums(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, user.Albums, 2)
|
||||
|
||||
}
|
||||
|
||||
func TestUserOwnsAlbum(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user := models.User{
|
||||
Username: "user",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
albums := []models.Album{
|
||||
{
|
||||
Title: "album1",
|
||||
Path: "/photos/album1",
|
||||
},
|
||||
{
|
||||
Title: "album2",
|
||||
Path: "/photos/album2",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&albums)) {
|
||||
return
|
||||
}
|
||||
|
||||
subAlbums := []models.Album{
|
||||
{
|
||||
Title: "subalbum1",
|
||||
Path: "/photos/album2/subalbum1",
|
||||
ParentAlbumID: &albums[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "another_sub",
|
||||
Path: "/photos/album2/another_sub",
|
||||
ParentAlbumID: &albums[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "subalbum2",
|
||||
Path: "/photos/album1/subalbum2",
|
||||
ParentAlbumID: &albums[0].ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&subAlbums)) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, album := range albums {
|
||||
owns, err := user.OwnsAlbum(db, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, owns)
|
||||
}
|
||||
|
||||
for _, album := range subAlbums {
|
||||
owns, err := user.OwnsAlbum(db, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, owns)
|
||||
}
|
||||
|
||||
separateAlbum := models.Album{
|
||||
Title: "separate_album",
|
||||
Path: "/my_media/album123",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&separateAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
owns, err := user.OwnsAlbum(db, &separateAlbum)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, owns)
|
||||
}
|
||||
|
||||
func TestUserFavoriteMedia(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "user1", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
|
||||
media := models.Media{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
// test that it starts out being false
|
||||
favourite, err := dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: media.ID,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, favourite)
|
||||
|
||||
favMedia, err := user.FavoriteMedia(db, media.ID, true)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, favMedia)
|
||||
|
||||
// test that it is now true
|
||||
favourite, err = dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: media.ID,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, favourite)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func FormatSQL(tx *gorm.DB, order *Ordering, paginate *Pagination) *gorm.DB {
|
||||
|
||||
if paginate != nil {
|
||||
if paginate.Limit != nil {
|
||||
tx.Limit(*paginate.Limit)
|
||||
}
|
||||
|
||||
if paginate.Offset != nil {
|
||||
tx.Offset(*paginate.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
if order != nil && order.OrderBy != nil {
|
||||
desc := false
|
||||
if order.OrderDirection != nil && order.OrderDirection.IsValid() {
|
||||
if *order.OrderDirection == OrderDirectionDesc {
|
||||
desc = true
|
||||
}
|
||||
}
|
||||
|
||||
tx.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{
|
||||
Name: *order.OrderBy,
|
||||
},
|
||||
Desc: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return tx
|
||||
}
|
||||
|
||||
// MD5Hash hashes value to a 32 length digest, the result is the same as the MYSQL function md5()
|
||||
func MD5Hash(value string) string {
|
||||
hash := md5.Sum([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMD5Hash(t *testing.T) {
|
||||
assert.Equal(t, "5eb63bbbe01eeed093cb22bb8f5acdc3", models.MD5Hash("hello world"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
type VideoMetadata struct {
|
||||
Model
|
||||
Width int `gorm:"not null"`
|
||||
Height int `gorm:"not null"`
|
||||
Duration float64 `gorm:"not null"`
|
||||
Codec *string
|
||||
Framerate *float64
|
||||
Bitrate *string
|
||||
ColorProfile *string
|
||||
Audio *string
|
||||
}
|
||||
|
||||
func (metadata *VideoMetadata) Media() *Media {
|
||||
panic("not implemented")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
)
|
||||
|
||||
type NotificationChannel = chan<- *models.Notification
|
||||
|
||||
type NotificationListener struct {
|
||||
listenerID int
|
||||
user models.User
|
||||
channel NotificationChannel
|
||||
}
|
||||
|
||||
func NewListener(user models.User, channel NotificationChannel) *NotificationListener {
|
||||
nextNotificationId++
|
||||
return &NotificationListener{
|
||||
listenerID: nextNotificationId,
|
||||
user: user,
|
||||
channel: channel,
|
||||
}
|
||||
}
|
||||
|
||||
var notificationListeners []*NotificationListener = make([]*NotificationListener, 0)
|
||||
var nextNotificationId = 0
|
||||
var notificationLock = &sync.Mutex{}
|
||||
|
||||
func RegisterListener(user *models.User, channel NotificationChannel) int {
|
||||
log.Println("Registering notification listener")
|
||||
|
||||
notificationLock.Lock()
|
||||
defer notificationLock.Unlock()
|
||||
|
||||
notificationListeners = append(notificationListeners, NewListener(*user, channel))
|
||||
return nextNotificationId
|
||||
}
|
||||
|
||||
func DeregisterListener(listenerID int) error {
|
||||
|
||||
notificationLock.Lock()
|
||||
defer notificationLock.Unlock()
|
||||
|
||||
for i, listener := range notificationListeners {
|
||||
|
||||
log.Println("Deregistering notification listener")
|
||||
|
||||
if listener.listenerID == listenerID {
|
||||
|
||||
if len(notificationListeners) > 1 {
|
||||
lastIndex := len(notificationListeners) - 1
|
||||
lastListener := notificationListeners[lastIndex]
|
||||
notificationListeners[i] = lastListener
|
||||
notificationListeners[lastIndex] = nil
|
||||
notificationListeners = notificationListeners[:lastIndex]
|
||||
} else {
|
||||
notificationListeners = make([]*NotificationListener, 0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("ListenerID not found, while trying to deregister it")
|
||||
}
|
||||
|
||||
func BroadcastNotification(notification *models.Notification) {
|
||||
|
||||
if notification == nil {
|
||||
return
|
||||
}
|
||||
|
||||
notificationLock.Lock()
|
||||
defer notificationLock.Unlock()
|
||||
|
||||
for _, listener := range notificationListeners {
|
||||
listener.channel <- notification
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Media is the resolver for the media field.
|
||||
func (r *albumResolver) Media(ctx context.Context, obj *models.Album, order *models.Ordering, paginate *models.Pagination, onlyFavorites *bool) ([]*models.Media, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
query := db.
|
||||
Where("media.album_id = ?", obj.ID).
|
||||
Where("media.id IN (?)", db.Model(&models.MediaURL{}).
|
||||
Select("media_urls.media_id").
|
||||
Where("media_urls.media_id = media.id"))
|
||||
|
||||
if onlyFavorites != nil && *onlyFavorites == true {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("cannot get favorite media without being authorized")
|
||||
}
|
||||
|
||||
favoriteQuery := db.Model(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
}).Where("user_media_data.media_id = media.id").Where("user_media_data.favorite = true")
|
||||
|
||||
query = query.Where("EXISTS (?)", favoriteQuery)
|
||||
}
|
||||
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
var media []*models.Media
|
||||
if err := query.Find(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// SubAlbums is the resolver for the subAlbums field.
|
||||
func (r *albumResolver) SubAlbums(ctx context.Context, obj *models.Album, order *models.Ordering, paginate *models.Pagination) ([]*models.Album, error) {
|
||||
var albums []*models.Album
|
||||
|
||||
query := r.DB(ctx).Where("parent_album_id = ?", obj.ID)
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
if err := query.Find(&albums).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
// Owner is the resolver for the owner field.
|
||||
func (r *albumResolver) Owner(ctx context.Context, obj *models.Album) (*models.User, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// Thumbnail is the resolver for the thumbnail field.
|
||||
func (r *albumResolver) Thumbnail(ctx context.Context, obj *models.Album) (*models.Media, error) {
|
||||
return obj.Thumbnail(r.DB(ctx))
|
||||
}
|
||||
|
||||
// Path is the resolver for the path field.
|
||||
func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models.Album, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
empty := make([]*models.Album, 0)
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
return actions.AlbumPath(r.DB(ctx), user, obj)
|
||||
}
|
||||
|
||||
// Shares is the resolver for the shares field.
|
||||
func (r *albumResolver) Shares(ctx context.Context, obj *models.Album) ([]*models.ShareToken, error) {
|
||||
var shareTokens []*models.ShareToken
|
||||
if err := r.DB(ctx).Where("album_id = ?", obj.ID).Find(&shareTokens).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return shareTokens, nil
|
||||
}
|
||||
|
||||
// Takes album_id, resets album.cover_id to 0 (null)
|
||||
func (r *mutationResolver) ResetAlbumCover(ctx context.Context, albumID int) (*models.Album, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
return actions.ResetAlbumCover(r.DB(ctx), user, albumID)
|
||||
}
|
||||
|
||||
// SetAlbumCover is the resolver for the setAlbumCover field.
|
||||
func (r *mutationResolver) SetAlbumCover(ctx context.Context, coverID int) (*models.Album, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
return actions.SetAlbumCover(r.DB(ctx), user, coverID)
|
||||
}
|
||||
|
||||
// MyAlbums is the resolver for the myAlbums field.
|
||||
func (r *queryResolver) MyAlbums(ctx context.Context, order *models.Ordering, paginate *models.Pagination, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.MyAlbums(r.DB(ctx), user, order, paginate, onlyRoot, showEmpty, onlyWithFavorites)
|
||||
}
|
||||
|
||||
// Album is the resolver for the album field.
|
||||
func (r *queryResolver) Album(ctx context.Context, id int, tokenCredentials *models.ShareTokenCredentials) (*models.Album, error) {
|
||||
db := r.DB(ctx)
|
||||
if tokenCredentials != nil {
|
||||
|
||||
shareToken, err := r.ShareToken(ctx, *tokenCredentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if shareToken.Album != nil {
|
||||
if *shareToken.AlbumID == id {
|
||||
return shareToken.Album, nil
|
||||
}
|
||||
|
||||
subAlbum, err := shareToken.Album.GetChildren(db, func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("sub_albums.id = ?", id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find sub album of share token (%s): %w", tokenCredentials.Token, err)
|
||||
}
|
||||
|
||||
if len(subAlbum) > 0 {
|
||||
return subAlbum[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.Album(db, user, id)
|
||||
}
|
||||
|
||||
// Album returns api.AlbumResolver implementation.
|
||||
func (r *Resolver) Album() api.AlbumResolver { return &albumResolver{r} }
|
||||
|
||||
type albumResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,60 @@
|
||||
type Album {
|
||||
id: ID!
|
||||
title: String!
|
||||
|
||||
"The media inside this album"
|
||||
media(
|
||||
order: Ordering,
|
||||
paginate: Pagination
|
||||
"Return only the favorited media"
|
||||
onlyFavorites: Boolean
|
||||
): [Media!]!
|
||||
|
||||
"The albums contained in this album"
|
||||
subAlbums(
|
||||
order: Ordering,
|
||||
paginate: Pagination
|
||||
): [Album!]!
|
||||
|
||||
"The album which contains this album"
|
||||
parentAlbum: Album
|
||||
"The user who owns this album"
|
||||
owner: User!
|
||||
"The path on the filesystem of the server, where this album is located"
|
||||
filePath: String!
|
||||
"An image in this album used for previewing this album"
|
||||
thumbnail: Media
|
||||
"A breadcrumb list of all parent albums down to this one"
|
||||
path: [Album!]!
|
||||
|
||||
"A list of share tokens pointing to this album, owned by the logged in user"
|
||||
shares: [ShareToken!]!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"List of albums owned by the logged in user."
|
||||
myAlbums(
|
||||
order: Ordering,
|
||||
paginate: Pagination
|
||||
"Return only albums from the root directory of the user"
|
||||
onlyRoot: Boolean
|
||||
"Return also albums with no media directly in them"
|
||||
showEmpty: Boolean
|
||||
"Show only albums having favorites"
|
||||
onlyWithFavorites: Boolean
|
||||
): [Album!]! @isAuthorized
|
||||
|
||||
"""
|
||||
Get album by id, user must own the album or be admin
|
||||
If valid tokenCredentials are provided, the album may be retrived without further authentication
|
||||
"""
|
||||
album(id: ID!, tokenCredentials: ShareTokenCredentials): Album!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Reset the assigned cover photo for an album"
|
||||
resetAlbumCover(albumID: ID!): Album! @isAuthorized
|
||||
|
||||
"Assign a cover photo to an album"
|
||||
setAlbumCover(coverID: ID!): Album! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ImageFaces is the resolver for the imageFaces field.
|
||||
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
query = models.FormatSQL(query, nil, paginate)
|
||||
|
||||
var imageFaces []*models.ImageFace
|
||||
if err := query.Find(&imageFaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imageFaces, nil
|
||||
}
|
||||
|
||||
// ImageFaceCount is the resolver for the imageFaceCount field.
|
||||
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return -1, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return -1, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.
|
||||
Model(&models.ImageFace{}).
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// Media is the resolver for the media field.
|
||||
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
||||
if err := obj.FillMedia(r.DB(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &obj.Media, nil
|
||||
}
|
||||
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
||||
if obj.FaceGroup != nil {
|
||||
return obj.FaceGroup, nil
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj.FaceGroup = &faceGroup
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
|
||||
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faceGroup, nil
|
||||
}
|
||||
|
||||
// CombineFaceGroups is the resolver for the combineFaceGroups field.
|
||||
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupIDs []int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if len(sourceFaceGroupIDs) < 1 {
|
||||
return nil, errors.New("at least one source face group ID is required")
|
||||
}
|
||||
|
||||
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sourceFaceGroups []*models.FaceGroup
|
||||
|
||||
// Validate the source groups before beginning the merge
|
||||
for _, sourceID := range sourceFaceGroupIDs {
|
||||
if sourceID == destinationFaceGroup.ID {
|
||||
return nil, errors.New("source face groups cannot include the destination face group")
|
||||
}
|
||||
|
||||
// Ensure the user owns at least one picture with each source
|
||||
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceFaceGroups = append(sourceFaceGroups, sourceFaceGroup)
|
||||
}
|
||||
|
||||
// Perform the merge
|
||||
updateError := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where(faceGroupIDsInQuestion, sourceFaceGroupIDs).
|
||||
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the source face groups
|
||||
if err := deleteFaceGroups(sourceFaceGroups, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
subQuery := tx.Model(&models.ImageFace{}).
|
||||
Select("MIN(id)").
|
||||
Where("face_group_id = ?", destinationFaceGroup.ID).
|
||||
Group("media_id")
|
||||
|
||||
err := tx.Where("face_group_id = ?", destinationFaceGroup.ID).
|
||||
Where("id NOT IN (?)", subQuery).
|
||||
Delete(&models.ImageFace{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if updateError != nil {
|
||||
return nil, updateError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(sourceFaceGroupIDs, int32(destinationFaceGroupID))
|
||||
|
||||
return destinationFaceGroup, nil
|
||||
}
|
||||
|
||||
// MoveImageFaces is the resolver for the moveImageFaces field.
|
||||
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
var destFaceGroup *models.FaceGroup
|
||||
|
||||
transErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
var err error
|
||||
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
var sourceFaceGroups []*models.FaceGroup
|
||||
if err := tx.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
|
||||
Find(&sourceFaceGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete face groups if they have become empty
|
||||
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transErr != nil {
|
||||
return nil, transErr
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
||||
|
||||
return destFaceGroup, nil
|
||||
}
|
||||
|
||||
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
|
||||
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var updatedImageFaces []*models.ImageFace
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
return updatedImageFaces, nil
|
||||
}
|
||||
|
||||
// DetachImageFaces is the resolver for the detachImageFaces field.
|
||||
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
newFaceGroup := models.FaceGroup{}
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
||||
|
||||
return &newFaceGroup, nil
|
||||
}
|
||||
|
||||
// MyFaceGroups is the resolver for the myFaceGroups field.
|
||||
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
faceGroupQuery := db.
|
||||
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where("image_faces.media_id IN (?)",
|
||||
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
|
||||
Group("image_faces.face_group_id").
|
||||
Group("face_groups.id").
|
||||
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
|
||||
Order("COUNT(image_faces.id) DESC")
|
||||
|
||||
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
|
||||
|
||||
var faceGroups []*models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faceGroups, nil
|
||||
}
|
||||
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
faceGroupQuery := db.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
|
||||
Where("face_groups.id = ?", id).
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
// FaceGroup returns api.FaceGroupResolver implementation.
|
||||
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
|
||||
|
||||
// ImageFace returns api.ImageFaceResolver implementation.
|
||||
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
|
||||
|
||||
type faceGroupResolver struct{ *Resolver }
|
||||
type imageFaceResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,53 @@
|
||||
"A bounding box of where a face is present on an image. The values map from 0 to 1 as a fraction of the image width/height"
|
||||
type FaceRectangle {
|
||||
minX: Float!
|
||||
maxX: Float!
|
||||
minY: Float!
|
||||
maxY: Float!
|
||||
}
|
||||
|
||||
"A collection of faces of a particular person"
|
||||
type FaceGroup {
|
||||
id: ID!
|
||||
"The name of the person"
|
||||
label: String
|
||||
imageFaces(paginate: Pagination): [ImageFace!]!
|
||||
"The total number of images in this collection"
|
||||
imageFaceCount: Int!
|
||||
}
|
||||
|
||||
"A single face on a particular image"
|
||||
type ImageFace {
|
||||
id: ID!
|
||||
"A reference to the image the face appears on"
|
||||
media: Media!
|
||||
"A bounding box of where on the image the face is present"
|
||||
rectangle: FaceRectangle!
|
||||
"The `FaceGroup` that contains this `ImageFace`"
|
||||
faceGroup: FaceGroup!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"Get a list of `FaceGroup`s for the logged in user"
|
||||
myFaceGroups(paginate: Pagination): [FaceGroup!]! @isAuthorized
|
||||
|
||||
"Get a particular `FaceGroup` specified by its ID"
|
||||
faceGroup(id: ID!): FaceGroup! @isAuthorized
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Assign a label to a face group, set label to null to remove the current one"
|
||||
setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup! @isAuthorized
|
||||
|
||||
"Merge two face groups into a single one, all ImageFaces from source will be moved to destination"
|
||||
combineFaceGroups(destinationFaceGroupID: ID!, sourceFaceGroupIDs: [ID!]!): FaceGroup! @isAuthorized
|
||||
|
||||
"Move a list of ImageFaces to another face group"
|
||||
moveImageFaces(imageFaceIDs: [ID!]!, destinationFaceGroupID: ID!): FaceGroup! @isAuthorized
|
||||
|
||||
"Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match"
|
||||
recognizeUnlabeledFaces: [ImageFace!]! @isAuthorized
|
||||
|
||||
"Move a list of ImageFaces to a new face group"
|
||||
detachImageFaces(imageFaceIDs: [ID!]!): FaceGroup! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const faceGroupIDIsQuestion = "face_group_id = ?"
|
||||
const faceGroupIDsInQuestion = "face_group_id IN (?)"
|
||||
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
|
||||
const imageFacesIDInQuestion = "image_faces.id IN (?)"
|
||||
|
||||
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
|
||||
|
||||
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
||||
if user.Admin {
|
||||
var faceGroup models.FaceGroup
|
||||
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
// Verify that user owns at least one of the images in the face group
|
||||
imageFaceQuery := db.
|
||||
Select("image_faces.id").
|
||||
Table("image_faces").
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
|
||||
faceGroupQuery := db.
|
||||
Model(&models.FaceGroup{}).
|
||||
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
||||
Where("face_groups.id = ?", faceGroupID).
|
||||
Where(imageFacesIDInQuestion, imageFaceQuery)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
||||
if err := user.FillAlbums(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
var userOwnedImageFaces []*models.ImageFace
|
||||
if err := tx.
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs).
|
||||
Where(imageFacesIDInQuestion, imageFaceIDs).
|
||||
Find(&userOwnedImageFaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userOwnedImageFaces, nil
|
||||
}
|
||||
|
||||
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
||||
for _, faceGroup := range sourceFaceGroups {
|
||||
var count int64
|
||||
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
||||
for _, faceGroup := range sourceFaceGroups {
|
||||
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||
return fmt.Errorf("Delete FaceGroup(%d) error: %w", faceGroup.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestCombineFaceGroups(t *testing.T) {
|
||||
test_utils.FilesystemTest(t)
|
||||
db := test_utils.DatabaseTest(t)
|
||||
face_detection.InitializeFaceDetector(db)
|
||||
pass := "1234"
|
||||
user, err := models.RegisterUser(db, "test_user", &pass, true)
|
||||
if err != nil {
|
||||
t.Fatal("register user error:", err)
|
||||
}
|
||||
db.AutoMigrate(&models.ImageFace{}, &models.FaceGroup{}, &models.Media{}, &models.Album{})
|
||||
tests := []struct {
|
||||
name string
|
||||
dest int
|
||||
src []int
|
||||
}{
|
||||
{
|
||||
name: "merge multiple combinations with duplicates",
|
||||
dest: 1,
|
||||
src: []int{2, 3},
|
||||
},
|
||||
{
|
||||
name: "merge two combinations with duplicates",
|
||||
dest: 1,
|
||||
src: []int{2},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
db.Exec("DELETE FROM image_faces")
|
||||
db.Exec("DELETE FROM face_groups")
|
||||
db.Exec("DELETE FROM media")
|
||||
db.Exec("DELETE FROM albums")
|
||||
|
||||
testAlbum := models.Album{Title: "Test Album"}
|
||||
if err := db.Create(&testAlbum).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testMedia := []models.Media{
|
||||
{Model: models.Model{ID: 1}, Path: "test1", AlbumID: testAlbum.ID},
|
||||
{Model: models.Model{ID: 2}, Path: "test2", AlbumID: testAlbum.ID},
|
||||
{Model: models.Model{ID: 3}, Path: "test3", AlbumID: testAlbum.ID},
|
||||
{Model: models.Model{ID: 4}, Path: "test4", AlbumID: testAlbum.ID},
|
||||
}
|
||||
if err := db.Create(&testMedia).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testFaceGroup := []models.FaceGroup{
|
||||
{Model: models.Model{ID: 1}},
|
||||
{Model: models.Model{ID: 2}},
|
||||
{Model: models.Model{ID: 3}},
|
||||
{Model: models.Model{ID: 4}},
|
||||
}
|
||||
if err := db.Create(&testFaceGroup).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testDataList := []models.ImageFace{
|
||||
{FaceGroupID: 1, MediaID: 1},
|
||||
{FaceGroupID: 1, MediaID: 2},
|
||||
{FaceGroupID: 1, MediaID: 3},
|
||||
{FaceGroupID: 2, MediaID: 3},
|
||||
{FaceGroupID: 2, MediaID: 4},
|
||||
{FaceGroupID: 3, MediaID: 4},
|
||||
{FaceGroupID: 3, MediaID: 1},
|
||||
}
|
||||
if err := db.Create(&testDataList).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &mutationResolver{
|
||||
Resolver: &Resolver{
|
||||
database: db,
|
||||
},
|
||||
}
|
||||
ctx := auth.AddUserToContext(context.Background(), user)
|
||||
|
||||
combineFace, err := r.CombineFaceGroups(ctx, tt.dest, tt.src)
|
||||
if err != nil {
|
||||
t.Fatal("test CombineFaceGroups err:", err)
|
||||
}
|
||||
|
||||
m := make(map[int]struct{})
|
||||
for _, imageface := range combineFace.ImageFaces {
|
||||
if _, ok := m[imageface.MediaID]; ok {
|
||||
t.Fatal("filtering failed at", imageface.MediaID)
|
||||
}
|
||||
m[imageface.MediaID] = struct{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Thumbnail is the resolver for the thumbnail field.
|
||||
func (r *mediaResolver) Thumbnail(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
|
||||
return dataloader.For(ctx).MediaThumbnail.Load(obj.ID)
|
||||
}
|
||||
|
||||
// HighRes is the resolver for the highRes field.
|
||||
func (r *mediaResolver) HighRes(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
|
||||
if obj.Type != models.MediaTypePhoto {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return dataloader.For(ctx).MediaHighres.Load(obj.ID)
|
||||
}
|
||||
|
||||
// VideoWeb is the resolver for the videoWeb field.
|
||||
func (r *mediaResolver) VideoWeb(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
|
||||
if obj.Type != models.MediaTypeVideo {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return dataloader.For(ctx).MediaVideoWeb.Load(obj.ID)
|
||||
}
|
||||
|
||||
// Album is the resolver for the album field.
|
||||
func (r *mediaResolver) Album(ctx context.Context, obj *models.Media) (*models.Album, error) {
|
||||
var album models.Album
|
||||
err := r.DB(ctx).Find(&album, obj.AlbumID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
// Exif is the resolver for the exif field.
|
||||
func (r *mediaResolver) Exif(ctx context.Context, obj *models.Media) (*models.MediaEXIF, error) {
|
||||
if obj.Exif != nil {
|
||||
return obj.Exif, nil
|
||||
}
|
||||
|
||||
var exif models.MediaEXIF
|
||||
if err := r.DB(ctx).Model(obj).Association("Exif").Find(&exif); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &exif, nil
|
||||
}
|
||||
|
||||
// Favorite is the resolver for the favorite field.
|
||||
func (r *mediaResolver) Favorite(ctx context.Context, obj *models.Media) (bool, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return false, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return dataloader.For(ctx).UserMediaFavorite.Load(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: obj.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// Type is the resolver for the type field.
|
||||
func (r *mediaResolver) Type(ctx context.Context, obj *models.Media) (models.MediaType, error) {
|
||||
formattedType := models.MediaType(cases.Title(language.Und).String(string(obj.Type)))
|
||||
return formattedType, nil
|
||||
}
|
||||
|
||||
// Shares is the resolver for the shares field.
|
||||
func (r *mediaResolver) Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) {
|
||||
var shareTokens []*models.ShareToken
|
||||
if err := r.DB(ctx).Where("media_id = ?", obj.ID).Find(&shareTokens).Error; err != nil {
|
||||
return nil, fmt.Errorf("get shares for media (%s): %w", obj.Path, err)
|
||||
}
|
||||
|
||||
return shareTokens, nil
|
||||
}
|
||||
|
||||
// Downloads is the resolver for the downloads field.
|
||||
func (r *mediaResolver) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) {
|
||||
var mediaUrls []*models.MediaURL
|
||||
if err := r.DB(ctx).Where("media_id = ?", obj.ID).Find(&mediaUrls).Error; err != nil {
|
||||
return nil, fmt.Errorf("get downloads for media (%s): %w", obj.Path, err)
|
||||
}
|
||||
|
||||
downloads := make([]*models.MediaDownload, 0)
|
||||
|
||||
for _, url := range mediaUrls {
|
||||
|
||||
var title string
|
||||
switch {
|
||||
case url.Purpose == models.MediaOriginal:
|
||||
title = "Original"
|
||||
case url.Purpose == models.PhotoThumbnail:
|
||||
title = "Small"
|
||||
case url.Purpose == models.PhotoHighRes:
|
||||
title = "Large"
|
||||
case url.Purpose == models.VideoThumbnail:
|
||||
title = "Video thumbnail"
|
||||
case url.Purpose == models.VideoWeb:
|
||||
title = "Web optimized video"
|
||||
}
|
||||
|
||||
downloads = append(downloads, &models.MediaDownload{
|
||||
Title: title,
|
||||
MediaURL: url,
|
||||
})
|
||||
}
|
||||
|
||||
return downloads, nil
|
||||
}
|
||||
|
||||
// Faces is the resolver for the faces field.
|
||||
func (r *mediaResolver) Faces(ctx context.Context, obj *models.Media) ([]*models.ImageFace, error) {
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return []*models.ImageFace{}, nil
|
||||
}
|
||||
|
||||
if obj.Faces != nil {
|
||||
return obj.Faces, nil
|
||||
}
|
||||
|
||||
var faces []*models.ImageFace
|
||||
if err := r.DB(ctx).Model(obj).Association("Faces").Find(&faces); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faces, nil
|
||||
}
|
||||
|
||||
// FavoriteMedia is the resolver for the favoriteMedia field.
|
||||
func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return user.FavoriteMedia(r.DB(ctx), mediaID, favorite)
|
||||
}
|
||||
|
||||
// MyMedia is the resolver for the myMedia field.
|
||||
func (r *queryResolver) MyMedia(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
return actions.MyMedia(r.DB(ctx), user, order, paginate)
|
||||
}
|
||||
|
||||
// Media is the resolver for the media field.
|
||||
func (r *queryResolver) Media(ctx context.Context, id int, tokenCredentials *models.ShareTokenCredentials) (*models.Media, error) {
|
||||
db := r.DB(ctx)
|
||||
if tokenCredentials != nil {
|
||||
|
||||
shareToken, err := r.ShareToken(ctx, *tokenCredentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if *shareToken.MediaID == id {
|
||||
return shareToken.Media, nil
|
||||
}
|
||||
}
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var media models.Media
|
||||
|
||||
err := db.
|
||||
Joins("Album").
|
||||
Where("media.id = ?", id).
|
||||
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = media.album_id AND user_albums.user_id = ?)",
|
||||
user.ID).
|
||||
Where("media.id IN (?)", db.Model(&models.MediaURL{}).Select("media_id").Where("media_urls.media_id = media.id")).
|
||||
First(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get media by media_id and user_id from database: %w", err)
|
||||
}
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// MediaList is the resolver for the mediaList field.
|
||||
func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Media, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no ids provided")
|
||||
}
|
||||
|
||||
var media []*models.Media
|
||||
err := db.Model(&media).
|
||||
Joins("LEFT JOIN user_albums ON user_albums.album_id = media.album_id").
|
||||
Where("media.id IN ?", ids).
|
||||
Where("user_albums.user_id = ?", user.ID).
|
||||
Find(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get media list by media_id and user_id from database: %w", err)
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// Media returns api.MediaResolver implementation.
|
||||
func (r *Resolver) Media() api.MediaResolver { return &mediaResolver{r} }
|
||||
|
||||
type mediaResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,123 @@
|
||||
type MediaURL {
|
||||
"URL for previewing the image"
|
||||
url: String!
|
||||
"Width of the image in pixels"
|
||||
width: Int!
|
||||
"Height of the image in pixels"
|
||||
height: Int!
|
||||
"The file size of the resource in bytes"
|
||||
fileSize: Int!
|
||||
}
|
||||
|
||||
type MediaDownload {
|
||||
"A description of the role of the media file"
|
||||
title: String!
|
||||
mediaUrl: MediaURL!
|
||||
}
|
||||
|
||||
enum MediaType {
|
||||
Photo
|
||||
Video
|
||||
}
|
||||
|
||||
type Coordinates {
|
||||
"GPS latitude in degrees"
|
||||
latitude: Float!
|
||||
"GPS longitude in degrees"
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
|
||||
"EXIF metadata from the camera"
|
||||
type MediaEXIF {
|
||||
id: ID!
|
||||
media: Media!
|
||||
"The description of the image"
|
||||
description: String
|
||||
"The model name of the camera"
|
||||
camera: String
|
||||
"The maker of the camera"
|
||||
maker: String
|
||||
"The name of the lens"
|
||||
lens: String
|
||||
"The date when the photo is shot"
|
||||
dateShot: String
|
||||
"The exposure time of the image"
|
||||
exposure: Float
|
||||
"The aperature stops of the image"
|
||||
aperture: Float
|
||||
"The ISO setting of the image"
|
||||
iso: Int
|
||||
"The focal length of the lens, when the image was taken"
|
||||
focalLength: Float
|
||||
"A formatted description of the flash settings, when the image was taken"
|
||||
flash: Int
|
||||
"An index describing the mode for adjusting the exposure of the image"
|
||||
exposureProgram: Int
|
||||
"GPS coordinates of where the image was taken"
|
||||
coordinates: Coordinates
|
||||
}
|
||||
|
||||
"Metadata specific to video media"
|
||||
type VideoMetadata {
|
||||
id: ID!
|
||||
media: Media!
|
||||
width: Int!
|
||||
height: Int!
|
||||
duration: Float!
|
||||
codec: String
|
||||
framerate: Float
|
||||
bitrate: String
|
||||
colorProfile: String
|
||||
audio: String
|
||||
}
|
||||
|
||||
type Media {
|
||||
id: ID!
|
||||
title: String!
|
||||
"Local filepath for the media"
|
||||
path: String!
|
||||
"URL to display the media in a smaller resolution"
|
||||
thumbnail: MediaURL
|
||||
"URL to display the photo in full resolution, will be null for videos"
|
||||
highRes: MediaURL
|
||||
"URL to get the video in a web format that can be played in the browser, will be null for photos"
|
||||
videoWeb: MediaURL
|
||||
"The album that holds the media"
|
||||
album: Album!
|
||||
exif: MediaEXIF
|
||||
videoMetadata: VideoMetadata
|
||||
favorite: Boolean!
|
||||
type: MediaType!
|
||||
"The date the image was shot or the date it was imported as a fallback"
|
||||
date: Time!
|
||||
"A short string that can be used to generate a blured version of the media, to show while the original is loading"
|
||||
blurhash: String
|
||||
|
||||
"A list of share tokens pointing to this media, owned byt the logged in user"
|
||||
shares: [ShareToken!]!
|
||||
"A list of different versions of files for this media that can be downloaded by the user"
|
||||
downloads: [MediaDownload!]!
|
||||
|
||||
"A list of faces present on the image"
|
||||
faces: [ImageFace!]!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"List of media owned by the logged in user"
|
||||
myMedia(order: Ordering, paginate: Pagination): [Media!]! @isAuthorized
|
||||
|
||||
"""
|
||||
Get media by id, user must own the media or be admin.
|
||||
If valid tokenCredentials are provided, the media may be retrived without further authentication
|
||||
"""
|
||||
media(id: ID!, tokenCredentials: ShareTokenCredentials): Media!
|
||||
|
||||
"Get a list of media by their ids, user must own the media or be admin"
|
||||
mediaList(ids: [ID!]!): [Media!]!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Mark or unmark a media as being a favorite"
|
||||
favoriteMedia(mediaId: ID!, favorite: Boolean!): Media! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
|
||||
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var media []*geoMedia
|
||||
|
||||
err := r.DB(ctx).Table("media").
|
||||
Select("media.id AS media_id, media.title AS media_title, "+
|
||||
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
|
||||
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
|
||||
"media_exif.gps_longitude AS longitude").
|
||||
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
|
||||
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
|
||||
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
|
||||
Where("media_exif.gps_latitude IS NOT NULL").
|
||||
Where("media_exif.gps_longitude IS NOT NULL").
|
||||
Where("media_urls.purpose = 'thumbnail'").
|
||||
Where("user_albums.user_id = ?", user.ID).
|
||||
Scan(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
features := make([]geoJSONFeature, 0)
|
||||
|
||||
for _, item := range media {
|
||||
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
|
||||
|
||||
thumbnailURL := utils.ApiEndpointUrl()
|
||||
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
|
||||
|
||||
properties := geoJSONMediaProperties{
|
||||
MediaID: item.MediaID,
|
||||
MediaTitle: item.MediaTitle,
|
||||
Thumbnail: struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}{
|
||||
URL: thumbnailURL.String(),
|
||||
Width: item.ThumbnailWidth,
|
||||
Height: item.ThumbnailHeight,
|
||||
},
|
||||
}
|
||||
|
||||
features = append(features, makeGeoJSONFeature(properties, geoPoint))
|
||||
}
|
||||
|
||||
featureCollection := makeGeoJSONFeatureCollection(features)
|
||||
return featureCollection, nil
|
||||
}
|
||||
|
||||
// MapboxToken is the resolver for the mapboxToken field.
|
||||
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
|
||||
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
|
||||
if mapboxTokenEnv == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &mapboxTokenEnv, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
extend type Query {
|
||||
"Get media owned by the logged in user, returned in GeoJson format"
|
||||
myMediaGeoJson: Any! @isAuthorized
|
||||
|
||||
"Get the mapbox api token, returns null if mapbox is not enabled"
|
||||
mapboxToken: String
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package resolvers
|
||||
|
||||
type geoMedia struct {
|
||||
MediaID int
|
||||
MediaTitle string
|
||||
ThumbnailName string
|
||||
ThumbnailWidth int
|
||||
ThumbnailHeight int
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
type geoJSONFeatureCollection struct {
|
||||
Type string `json:"type"`
|
||||
Features []geoJSONFeature `json:"features"`
|
||||
}
|
||||
|
||||
type geoJSONFeature struct {
|
||||
Type string `json:"type"`
|
||||
Properties interface{} `json:"properties"`
|
||||
Geometry geoJSONFeatureGeometry `json:"geometry"`
|
||||
}
|
||||
|
||||
type geoJSONMediaProperties struct {
|
||||
MediaID int `json:"media_id"`
|
||||
MediaTitle string `json:"media_title"`
|
||||
Thumbnail struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
}
|
||||
|
||||
type geoJSONFeatureGeometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
|
||||
return geoJSONFeatureCollection{
|
||||
Type: "FeatureCollection",
|
||||
Features: features,
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
|
||||
return geoJSONFeature{
|
||||
Type: "Feature",
|
||||
Properties: properties,
|
||||
Geometry: geometry,
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
|
||||
coordinates := [2]float64{long, lat}
|
||||
|
||||
return geoJSONFeatureGeometry{
|
||||
Type: "Point",
|
||||
Coordinates: coordinates,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/notification"
|
||||
)
|
||||
|
||||
// Notification is the resolver for the notification field.
|
||||
func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models.Notification, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
notificationChannel := make(chan *models.Notification, 1)
|
||||
|
||||
listenerID := notification.RegisterListener(user, notificationChannel)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
notification.DeregisterListener(listenerID)
|
||||
}()
|
||||
|
||||
return notificationChannel, nil
|
||||
}
|
||||
|
||||
// Subscription returns api.SubscriptionResolver implementation.
|
||||
func (r *Resolver) Subscription() api.SubscriptionResolver { return &subscriptionResolver{r} }
|
||||
|
||||
type subscriptionResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,31 @@
|
||||
type Notification {
|
||||
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
|
||||
key: String!
|
||||
type: NotificationType!
|
||||
"The text for the title of the notification"
|
||||
header: String!
|
||||
"The text for the body of the notification"
|
||||
content: String!
|
||||
"A value between 0 and 1 when the notification type is `Progress`"
|
||||
progress: Float
|
||||
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
|
||||
positive: Boolean!
|
||||
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
|
||||
negative: Boolean!
|
||||
"Time in milliseconds before the notification should close"
|
||||
timeout: Int
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
notification: Notification!
|
||||
}
|
||||
|
||||
"Specified the type a particular notification is of"
|
||||
enum NotificationType {
|
||||
"A regular message with no special additions"
|
||||
Message
|
||||
"A notification with an attached progress indicator"
|
||||
Progress
|
||||
"Close a notification with a given key"
|
||||
Close
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//go:generate go tool github.com/99designs/gqlgen
|
||||
|
||||
type Resolver struct {
|
||||
database *gorm.DB
|
||||
}
|
||||
|
||||
func NewRootResolver(db *gorm.DB) Resolver {
|
||||
return Resolver{
|
||||
database: db,
|
||||
}
|
||||
}
|
||||
|
||||
// DB returns a database instance that is tied to the given context
|
||||
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
|
||||
return r.database.WithContext(ctx)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
)
|
||||
|
||||
// Mutation returns api.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
// Query returns api.QueryResolver implementation.
|
||||
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,47 @@
|
||||
directive @isAuthorized on FIELD_DEFINITION
|
||||
directive @isAdmin on FIELD_DEFINITION
|
||||
|
||||
scalar Time
|
||||
scalar Any
|
||||
|
||||
"Used to specify which order to sort items in"
|
||||
enum OrderDirection {
|
||||
"Sort accending A-Z"
|
||||
ASC
|
||||
"Sort decending Z-A"
|
||||
DESC
|
||||
}
|
||||
|
||||
"Used to specify pagination on a list of items"
|
||||
input Pagination {
|
||||
"How many items to maximally fetch"
|
||||
limit: Int
|
||||
"How many items to skip from the beginning of the query, specified by the `Ordering`"
|
||||
offset: Int
|
||||
}
|
||||
|
||||
"Used to specify how to sort items"
|
||||
input Ordering {
|
||||
"A column in the database to order by"
|
||||
order_by: String
|
||||
order_direction: OrderDirection
|
||||
}
|
||||
|
||||
type Query
|
||||
|
||||
type Mutation
|
||||
|
||||
"""
|
||||
A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||
NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||
"""
|
||||
type TimelineGroup {
|
||||
"The full album containing the media in this timeline group"
|
||||
album: Album!
|
||||
"The media contained in this timeline group"
|
||||
media: [Media!]!
|
||||
"The total amount of media in this timeline group"
|
||||
mediaTotal: Int!
|
||||
"The day shared for all media in this timeline group"
|
||||
date: Time!
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/periodic_scanner"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_queue"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ScanAll is the resolver for the scanAll field.
|
||||
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
|
||||
err := scanner_queue.AddAllToQueue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startMessage := "Scanner started"
|
||||
|
||||
return &models.ScannerResult{
|
||||
Finished: false,
|
||||
Success: true,
|
||||
Message: &startMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScanUser is the resolver for the scanUser field.
|
||||
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
|
||||
var user models.User
|
||||
if err := r.DB(ctx).First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("get user from database: %w", err)
|
||||
}
|
||||
|
||||
scanner_queue.AddUserToQueue(&user)
|
||||
|
||||
startMessage := "Scanner started"
|
||||
return &models.ScannerResult{
|
||||
Finished: false,
|
||||
Success: true,
|
||||
Message: &startMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPeriodicScanInterval is the resolver for the setPeriodicScanInterval field.
|
||||
func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
if interval < 0 {
|
||||
return 0, errors.New("interval must be 0 or above")
|
||||
}
|
||||
|
||||
if err := db.
|
||||
Session(&gorm.Session{AllowGlobalUpdate: true}).
|
||||
Model(&models.SiteInfo{}).
|
||||
Update("periodic_scan_interval", interval).
|
||||
Error; err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var siteInfo models.SiteInfo
|
||||
if err := db.First(&siteInfo).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
periodic_scanner.ChangePeriodicScanInterval(time.Duration(siteInfo.PeriodicScanInterval) * time.Second)
|
||||
|
||||
return siteInfo.PeriodicScanInterval, nil
|
||||
}
|
||||
|
||||
// SetScannerConcurrentWorkers is the resolver for the setScannerConcurrentWorkers field.
|
||||
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
if workers < 1 {
|
||||
return 0, errors.New("concurrent workers must at least be 1")
|
||||
}
|
||||
|
||||
if workers > 1 && drivers.DatabaseDriverFromEnv() == drivers.SQLITE {
|
||||
return 0, errors.New("multiple workers not supported for SQLite databases")
|
||||
}
|
||||
|
||||
if err := db.
|
||||
Session(&gorm.Session{AllowGlobalUpdate: true}).
|
||||
Model(&models.SiteInfo{}).
|
||||
Update("concurrent_workers", workers).
|
||||
Error; err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var siteInfo models.SiteInfo
|
||||
if err := db.First(&siteInfo).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
scanner_queue.ChangeScannerConcurrentWorkers(siteInfo.ConcurrentWorkers)
|
||||
|
||||
return siteInfo.ConcurrentWorkers, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type ScannerResult {
|
||||
finished: Boolean!
|
||||
success: Boolean!
|
||||
progress: Float
|
||||
message: String
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Scan all users for new media"
|
||||
scanAll: ScannerResult! @isAdmin
|
||||
|
||||
"Scan a single user for new media"
|
||||
scanUser(userId: ID!): ScannerResult! @isAdmin
|
||||
|
||||
"""
|
||||
Set how often, in seconds, the server should automatically scan for new media,
|
||||
a value of 0 will disable periodic scans
|
||||
"""
|
||||
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
|
||||
|
||||
"Set max number of concurrent scanner jobs running at once"
|
||||
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
)
|
||||
|
||||
// Search is the resolver for the search field.
|
||||
func (r *queryResolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.Search(r.DB(ctx), query, user.ID, limitMedia, limitAlbums)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
type SearchResult {
|
||||
"The string that was searched for"
|
||||
query: String!
|
||||
"A list of albums that matched the query"
|
||||
albums: [Album!]!
|
||||
"A list of media that matched the query"
|
||||
media: [Media!]!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"Perform a search query on the contents of the media library"
|
||||
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ShareAlbum is the resolver for the shareAlbum field.
|
||||
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
|
||||
}
|
||||
|
||||
// ShareMedia is the resolver for the shareMedia field.
|
||||
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
|
||||
}
|
||||
|
||||
// DeleteShareToken is the resolver for the deleteShareToken field.
|
||||
func (r *mutationResolver) DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.DeleteShareToken(r.DB(ctx), user.ID, token)
|
||||
}
|
||||
|
||||
// ProtectShareToken is the resolver for the protectShareToken field.
|
||||
func (r *mutationResolver) ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.ProtectShareToken(r.DB(ctx), user.ID, token, password)
|
||||
}
|
||||
|
||||
// SetExpireShareToken is the resolver for the setExpireShareToken field.
|
||||
func (r *mutationResolver) SetExpireShareToken(ctx context.Context, token string, expire *time.Time) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.SetExpireShareToken(r.DB(ctx), user.ID, token, expire)
|
||||
}
|
||||
|
||||
// ShareToken is the resolver for the shareToken field.
|
||||
func (r *queryResolver) ShareToken(ctx context.Context, credentials models.ShareTokenCredentials) (*models.ShareToken, error) {
|
||||
var token models.ShareToken
|
||||
if err := r.DB(ctx).Preload(clause.Associations).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("share not found")
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to get share token from database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
fakeTime := time.Date(
|
||||
now.Year(),
|
||||
now.Month(),
|
||||
now.Day(),
|
||||
now.Hour(),
|
||||
now.Minute(),
|
||||
now.Second(),
|
||||
0,
|
||||
time.UTC,
|
||||
)
|
||||
|
||||
if token.Expire != nil && fakeTime.After(*token.Expire) {
|
||||
return nil, errors.New("share expired")
|
||||
}
|
||||
|
||||
if token.Password != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*credentials.Password)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return nil, errors.New("unauthorized")
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to compare token password hashes: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// ShareTokenValidatePassword is the resolver for the shareTokenValidatePassword field.
|
||||
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credentials models.ShareTokenCredentials) (bool, error) {
|
||||
var token models.ShareToken
|
||||
if err := r.DB(ctx).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, errors.New("share not found")
|
||||
} else {
|
||||
return false, fmt.Errorf("failed to get share token from database: %w", err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
fakeTime := time.Date(
|
||||
now.Year(),
|
||||
now.Month(),
|
||||
now.Day(),
|
||||
now.Hour(),
|
||||
now.Minute(),
|
||||
now.Second(),
|
||||
0,
|
||||
time.UTC,
|
||||
)
|
||||
//Assume the client’s local time is UTC, without applying any timezone conversion.
|
||||
if token.Expire != nil && fakeTime.After(*token.Expire) {
|
||||
return false, errors.New("share expired")
|
||||
}
|
||||
|
||||
if token.Password == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if credentials.Password == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*credentials.Password)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return false, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("could not compare token password hashes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HasPassword is the resolver for the hasPassword field.
|
||||
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
|
||||
hasPassword := obj.Password != nil
|
||||
return hasPassword, nil
|
||||
}
|
||||
|
||||
// ShareToken returns api.ShareTokenResolver implementation.
|
||||
func (r *Resolver) ShareToken() api.ShareTokenResolver { return &shareTokenResolver{r} }
|
||||
|
||||
type shareTokenResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,47 @@
|
||||
"Credentials used to identify and authenticate a share token"
|
||||
input ShareTokenCredentials {
|
||||
token: String!
|
||||
password: String
|
||||
}
|
||||
|
||||
"A token used to publicly access an album or media"
|
||||
type ShareToken {
|
||||
id: ID!
|
||||
token: String!
|
||||
"The user who created the token"
|
||||
owner: User!
|
||||
"Optional expire date"
|
||||
expire: Time
|
||||
"Whether or not a password is needed to access the share"
|
||||
hasPassword: Boolean!
|
||||
|
||||
"The album this token shares"
|
||||
album: Album
|
||||
"The media this token shares"
|
||||
media: Media
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"Fetch a share token containing an `Album` or `Media`"
|
||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||
|
||||
"Check if the `ShareToken` credentials are valid"
|
||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Generate share token for album"
|
||||
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Generate share token for media"
|
||||
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Delete a share token by it's token value"
|
||||
deleteShareToken(token: String!): ShareToken! @isAuthorized
|
||||
|
||||
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
|
||||
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Set a Expiration Time for a token"
|
||||
setExpireShareToken(token: String!,expire: Time):ShareToken! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
|
||||
func TestShareTokenValidatePassword(t *testing.T) {
|
||||
test_utils.FilesystemTest(t)
|
||||
db := test_utils.DatabaseTest(t)
|
||||
pass := "1234"
|
||||
user, err := models.RegisterUser(db, "test_user", &pass, true)
|
||||
if err != nil {
|
||||
t.Fatal("register user error:", err)
|
||||
}
|
||||
hashBytes, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||
hashedPassword := string(hashBytes)
|
||||
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-24 * time.Hour)
|
||||
expiredTime = time.Date(
|
||||
expiredTime.Year(),
|
||||
expiredTime.Month(),
|
||||
expiredTime.Day(),
|
||||
expiredTime.Hour(),
|
||||
expiredTime.Minute(),
|
||||
expiredTime.Second(),
|
||||
0,
|
||||
time.UTC,
|
||||
)
|
||||
futureTime := now.Add(24 * time.Hour)
|
||||
futureTime = time.Date(
|
||||
futureTime.Year(),
|
||||
futureTime.Month(),
|
||||
futureTime.Day(),
|
||||
futureTime.Hour(),
|
||||
futureTime.Minute(),
|
||||
futureTime.Second(),
|
||||
0,
|
||||
time.UTC,
|
||||
)
|
||||
|
||||
db.AutoMigrate(&models.ShareToken{})
|
||||
testDataList := []models.ShareToken{
|
||||
{
|
||||
Value: "EXPIRED_TOKEN",
|
||||
OwnerID: user.ID,
|
||||
Expire: &expiredTime,
|
||||
},
|
||||
{
|
||||
Value: "CORRECT_PASS",
|
||||
OwnerID: user.ID,
|
||||
Expire: &futureTime,
|
||||
Password: &hashedPassword,
|
||||
},
|
||||
}
|
||||
db.Create(testDataList)
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials models.ShareTokenCredentials
|
||||
wantResult bool
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "Case 1: Token not exist",
|
||||
credentials: models.ShareTokenCredentials{
|
||||
Token: "NOT_EXIST",
|
||||
},
|
||||
wantResult: false,
|
||||
wantErr: true,
|
||||
wantErrMsg: "share not found",
|
||||
},
|
||||
{
|
||||
name: "Case 2: Token expired",
|
||||
credentials: models.ShareTokenCredentials{
|
||||
Token: "EXPIRED_TOKEN",
|
||||
},
|
||||
wantResult: false,
|
||||
wantErr: true,
|
||||
wantErrMsg: "share expired",
|
||||
},
|
||||
{
|
||||
name: "Case 3: correct pass",
|
||||
credentials: models.ShareTokenCredentials{
|
||||
Token: "CORRECT_PASS",
|
||||
Password: &pass,
|
||||
},
|
||||
wantResult: true,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &queryResolver{
|
||||
Resolver: &Resolver{
|
||||
database: db,
|
||||
},
|
||||
}
|
||||
got, err := r.ShareTokenValidatePassword(context.Background(), tt.credentials)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.wantErrMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.wantErrMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantResult, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
)
|
||||
|
||||
// SiteInfo is the resolver for the siteInfo field.
|
||||
func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) {
|
||||
return models.GetSiteInfo(r.DB(ctx))
|
||||
}
|
||||
|
||||
// FaceDetectionEnabled is the resolver for the faceDetectionEnabled field.
|
||||
func (r *siteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
|
||||
return face_detection.GlobalFaceDetector != nil, nil
|
||||
}
|
||||
|
||||
// SiteInfo returns api.SiteInfoResolver implementation.
|
||||
func (r *Resolver) SiteInfo() api.SiteInfoResolver { return &siteInfoResolver{r} }
|
||||
|
||||
type siteInfoResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,15 @@
|
||||
"General information about the site"
|
||||
type SiteInfo {
|
||||
"Whether or not the initial setup wizard should be shown"
|
||||
initialSetup: Boolean!
|
||||
"Whether or not face detection is enabled and working"
|
||||
faceDetectionEnabled: Boolean!
|
||||
"How often automatic scans should be initiated in seconds"
|
||||
periodicScanInterval: Int! @isAdmin
|
||||
"How many max concurrent scanner jobs that should run at once"
|
||||
concurrentWorkers: Int! @isAdmin
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
siteInfo: SiteInfo!
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
)
|
||||
|
||||
// MyTimeline is the resolver for the myTimeline field.
|
||||
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.MyTimeline(r.DB(ctx), user, paginate, onlyFavorites, fromDate)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
extend type Query {
|
||||
"""
|
||||
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
|
||||
"""
|
||||
myTimeline(
|
||||
paginate: Pagination,
|
||||
onlyFavorites: Boolean,
|
||||
"Only fetch media that is older than this date"
|
||||
fromDate: Time
|
||||
): [Media!]! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthorizeUser is the resolver for the authorizeUser field.
|
||||
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) {
|
||||
db := r.DB(ctx)
|
||||
user, err := models.AuthorizeUser(db, username, password)
|
||||
if err != nil {
|
||||
return &models.AuthorizeResult{
|
||||
Success: false,
|
||||
Status: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var token *models.AccessToken
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
token, err = user.GenerateAccessToken(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
return &models.AuthorizeResult{
|
||||
Success: true,
|
||||
Status: "ok",
|
||||
Token: &token.Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitialSetupWizard is the resolver for the initialSetupWizard field.
|
||||
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) {
|
||||
db := r.DB(ctx)
|
||||
siteInfo, err := models.GetSiteInfo(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !siteInfo.InitialSetup {
|
||||
return nil, errors.New("not initial setup")
|
||||
}
|
||||
|
||||
rootPath = path.Clean(rootPath)
|
||||
|
||||
var token *models.AccessToken
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("UPDATE site_info SET initial_setup = false").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := models.RegisterUser(tx, username, &password, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = scanner.NewRootAlbum(tx, rootPath, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err = user.GenerateAccessToken(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return &models.AuthorizeResult{
|
||||
Success: false,
|
||||
Status: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &models.AuthorizeResult{
|
||||
Success: true,
|
||||
Status: "ok",
|
||||
Token: &token.Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateUser is the resolver for the updateUser field.
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
if username == nil && password == nil && admin == nil {
|
||||
return nil, errors.New("no updates requested")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if username != nil {
|
||||
user.Username = *username
|
||||
}
|
||||
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashedPass := string(hashedPassBytes)
|
||||
|
||||
user.Password = &hashedPass
|
||||
}
|
||||
|
||||
if admin != nil {
|
||||
user.Admin = *admin
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateUser is the resolver for the createUser field.
|
||||
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) {
|
||||
var user *models.User
|
||||
|
||||
transactionError := r.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
user, err = models.RegisterUser(tx, username, password, admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DeleteUser is the resolver for the deleteUser field.
|
||||
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||
return actions.DeleteUser(r.DB(ctx), id)
|
||||
}
|
||||
|
||||
// UserAddRootPath is the resolver for the userAddRootPath field.
|
||||
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
rootPath = path.Clean(rootPath)
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newAlbum, err := scanner.NewRootAlbum(db, rootPath, &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newAlbum, nil
|
||||
}
|
||||
|
||||
// UserRemoveRootAlbum is the resolver for the userRemoveRootAlbum field.
|
||||
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
var album models.Album
|
||||
if err := db.First(&album, albumID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deletedAlbumIDs []int = nil
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Raw("DELETE FROM user_albums WHERE user_id = ? AND album_id = ?", userID, albumID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
children, err := album.GetChildren(tx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
childAlbumIDs := make([]int, len(children))
|
||||
for i, child := range children {
|
||||
childAlbumIDs[i] = child.ID
|
||||
}
|
||||
|
||||
result := tx.Exec("DELETE FROM user_albums WHERE user_id = ? and album_id IN (?)", userID, childAlbumIDs)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("No relation deleted")
|
||||
}
|
||||
|
||||
// Cleanup if no user owns the album anymore
|
||||
deletedAlbumIDs, err = cleanup(tx, albumID, childAlbumIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
if err := clearCacheAndReloadFaces(db, deletedAlbumIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
// ChangeUserPreferences is the resolver for the changeUserPreferences field.
|
||||
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var langTrans *models.LanguageTranslation = nil
|
||||
if language != nil {
|
||||
lng := models.LanguageTranslation(*language)
|
||||
langTrans = &lng
|
||||
}
|
||||
|
||||
var userPref models.UserPreferences
|
||||
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userPref.UserID = user.ID
|
||||
userPref.Language = langTrans
|
||||
|
||||
if err := db.Save(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
// User is the resolver for the user field.
|
||||
func (r *queryResolver) User(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
|
||||
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// MyUser is the resolver for the myUser field.
|
||||
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// MyUserPreferences is the resolver for the myUserPreferences field.
|
||||
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
userPref := models.UserPreferences{
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
// Albums is the resolver for the albums field.
|
||||
func (r *userResolver) Albums(ctx context.Context, obj *models.User) ([]*models.Album, error) {
|
||||
obj.FillAlbums(r.DB(ctx))
|
||||
|
||||
pointerAlbums := make([]*models.Album, len(obj.Albums))
|
||||
for i, album := range obj.Albums {
|
||||
pointerAlbums[i] = &album
|
||||
}
|
||||
|
||||
return pointerAlbums, nil
|
||||
}
|
||||
|
||||
// RootAlbums is the resolver for the rootAlbums field.
|
||||
func (r *userResolver) RootAlbums(ctx context.Context, obj *models.User) (albums []*models.Album, err error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
err = db.Model(obj).
|
||||
Where("albums.parent_album_id NOT IN (?)",
|
||||
db.Table("user_albums").
|
||||
Select("albums.id").
|
||||
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", obj.ID),
|
||||
).Or("albums.parent_album_id IS NULL").Order("path ASC").
|
||||
Association("Albums").Find(&albums)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// User returns api.UserResolver implementation.
|
||||
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
|
||||
|
||||
type userResolver struct{ *Resolver }
|
||||
@@ -0,0 +1,100 @@
|
||||
type User {
|
||||
id: ID!
|
||||
username: String!
|
||||
"All albums owned by this user"
|
||||
albums: [Album!]! @isAdmin
|
||||
"Top level albums owned by this user"
|
||||
rootAlbums: [Album!]! @isAdmin
|
||||
"Whether or not the user has admin privileges"
|
||||
admin: Boolean!
|
||||
}
|
||||
|
||||
"Supported language translations of the user interface"
|
||||
enum LanguageTranslation {
|
||||
English,
|
||||
French,
|
||||
Italian,
|
||||
Swedish,
|
||||
Danish,
|
||||
Spanish,
|
||||
Polish,
|
||||
Ukrainian,
|
||||
German,
|
||||
Russian,
|
||||
TraditionalChineseTW,
|
||||
TraditionalChineseHK,
|
||||
SimplifiedChinese,
|
||||
Portuguese,
|
||||
Basque,
|
||||
Turkish,
|
||||
Japanese,
|
||||
Dutch,
|
||||
}
|
||||
|
||||
"Preferences for regular users"
|
||||
type UserPreferences {
|
||||
id: ID!
|
||||
language: LanguageTranslation
|
||||
}
|
||||
|
||||
type AuthorizeResult {
|
||||
success: Boolean!
|
||||
|
||||
"A textual status message describing the result, can be used to show an error message when `success` is false"
|
||||
status: String!
|
||||
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
|
||||
token: String
|
||||
}
|
||||
|
||||
|
||||
extend type Query {
|
||||
"List of registered users, must be admin to call"
|
||||
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
|
||||
|
||||
"Information about the currently logged in user"
|
||||
myUser: User! @isAuthorized
|
||||
|
||||
"User preferences for the logged in user"
|
||||
myUserPreferences: UserPreferences! @isAuthorized
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Authorizes a user and returns a token used to identify the new session"
|
||||
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
||||
|
||||
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
|
||||
initialSetupWizard(
|
||||
username: String!
|
||||
password: String!
|
||||
rootPath: String!
|
||||
): AuthorizeResult
|
||||
|
||||
"Update a user, fields left as `null` will not be changed"
|
||||
updateUser(
|
||||
id: ID!
|
||||
username: String
|
||||
password: String
|
||||
admin: Boolean
|
||||
): User! @isAdmin
|
||||
"Create a new user"
|
||||
createUser(
|
||||
username: String!
|
||||
password: String
|
||||
admin: Boolean!
|
||||
): User! @isAdmin
|
||||
"Delete an existing user"
|
||||
deleteUser(id: ID!): User! @isAdmin
|
||||
|
||||
"Add a root path from where to look for media for the given user, specified by their user id."
|
||||
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
|
||||
|
||||
"""
|
||||
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
||||
This album was returned when creating the path using `userAddRootPath`.
|
||||
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
||||
"""
|
||||
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
|
||||
|
||||
"Change user preferences for the logged in user"
|
||||
changeUserPreferences(language: String): UserPreferences! @isAuthorized
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
|
||||
var userAlbumCount int
|
||||
var deletedAlbumIDs []int = nil
|
||||
|
||||
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
|
||||
albumID).Scan(&userAlbumCount).Error; err != nil {
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userAlbumCount == 0 {
|
||||
deletedAlbumIDs = append(childAlbumIDs, albumID)
|
||||
childAlbumIDs = nil
|
||||
// Delete albums from database
|
||||
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
|
||||
deletedAlbumIDs = nil
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return deletedAlbumIDs, nil
|
||||
}
|
||||
|
||||
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
|
||||
if deletedAlbumIDs != nil {
|
||||
// Delete albums from cache
|
||||
for _, id := range deletedAlbumIDs {
|
||||
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
||||
|
||||
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Reload faces as media might have been deleted
|
||||
if face_detection.GlobalFaceDetector != nil {
|
||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var defaultLogger *slog.Logger
|
||||
|
||||
func init() {
|
||||
defaultLogger = slog.Default()
|
||||
}
|
||||
|
||||
type loggerKeyType string
|
||||
|
||||
const loggerKey loggerKeyType = "logger"
|
||||
|
||||
func getLogger(ctx context.Context) *slog.Logger {
|
||||
if ctx == nil {
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
logger := ctx.Value(loggerKey)
|
||||
if logger == nil {
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
ret, ok := logger.(*slog.Logger)
|
||||
if !ok {
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// WithAttrs creates a new context containing a new logger with [args] as logging attributes.
|
||||
func WithAttrs(ctx context.Context, args ...any) context.Context {
|
||||
old := getLogger(ctx)
|
||||
new := old.With(args...)
|
||||
return context.WithValue(ctx, loggerKey, new)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
func mockDefaultLogger(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
|
||||
var output bytes.Buffer
|
||||
handler := slog.NewJSONHandler(&output, &slog.HandlerOptions{
|
||||
AddSource: false,
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
// Remove time from the output for predictable test output.
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})
|
||||
|
||||
logger := slog.New(handler)
|
||||
oldLogger := defaultLogger
|
||||
defaultLogger = logger
|
||||
|
||||
t.Cleanup(func() {
|
||||
defaultLogger = oldLogger
|
||||
})
|
||||
|
||||
return &output
|
||||
}
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
output := mockDefaultLogger(t)
|
||||
|
||||
Debug(nil, "no_context")
|
||||
Info(nil, "no_context")
|
||||
Warn(nil, "no_context")
|
||||
Error(nil, "no_context")
|
||||
|
||||
bg := context.Background()
|
||||
|
||||
Debug(bg, "bg_context")
|
||||
Info(bg, "bg_context")
|
||||
Warn(bg, "bg_context")
|
||||
Error(bg, "bg_context")
|
||||
|
||||
ctx1 := WithAttrs(context.Background(), "arg1", "value")
|
||||
Debug(ctx1, "with_context_1")
|
||||
Info(ctx1, "with_context_1")
|
||||
Warn(ctx1, "with_context_1")
|
||||
Error(ctx1, "with_context_1")
|
||||
|
||||
ctx2 := WithAttrs(ctx1, "arg2", "value")
|
||||
Debug(ctx2, "with_context_2")
|
||||
Info(ctx2, "with_context_2")
|
||||
Warn(ctx2, "with_context_2")
|
||||
Error(ctx2, "with_context_2")
|
||||
|
||||
Debug(ctx1, "with_context_1")
|
||||
Info(ctx1, "with_context_1")
|
||||
Warn(ctx1, "with_context_1")
|
||||
Error(ctx1, "with_context_1")
|
||||
|
||||
// Debug messages are filtered out due to slog.LevelInfo in mock logger
|
||||
want := `{"level":"INFO","msg":"no_context"}
|
||||
{"level":"WARN","msg":"no_context"}
|
||||
{"level":"ERROR","msg":"no_context"}
|
||||
{"level":"INFO","msg":"bg_context"}
|
||||
{"level":"WARN","msg":"bg_context"}
|
||||
{"level":"ERROR","msg":"bg_context"}
|
||||
{"level":"INFO","msg":"with_context_1","arg1":"value"}
|
||||
{"level":"WARN","msg":"with_context_1","arg1":"value"}
|
||||
{"level":"ERROR","msg":"with_context_1","arg1":"value"}
|
||||
{"level":"INFO","msg":"with_context_2","arg1":"value","arg2":"value"}
|
||||
{"level":"WARN","msg":"with_context_2","arg1":"value","arg2":"value"}
|
||||
{"level":"ERROR","msg":"with_context_2","arg1":"value","arg2":"value"}
|
||||
{"level":"INFO","msg":"with_context_1","arg1":"value"}
|
||||
{"level":"WARN","msg":"with_context_1","arg1":"value"}
|
||||
{"level":"ERROR","msg":"with_context_1","arg1":"value"}
|
||||
`
|
||||
if diff := cmp.Diff(output.String(), want); diff != "" {
|
||||
t.Errorf("diff: (-got, +want)\n%s", diff)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Debug logs debug messages.
|
||||
func Debug(ctx context.Context, msg string, args ...any) {
|
||||
getLogger(ctx).DebugContext(ctx, msg, args...)
|
||||
}
|
||||
|
||||
// Info logs info messages.
|
||||
func Info(ctx context.Context, msg string, args ...any) {
|
||||
getLogger(ctx).InfoContext(ctx, msg, args...)
|
||||
}
|
||||
|
||||
// Warn logs warning messages.
|
||||
func Warn(ctx context.Context, msg string, args ...any) {
|
||||
getLogger(ctx).WarnContext(ctx, msg, args...)
|
||||
}
|
||||
|
||||
// Error logs error messages.
|
||||
func Error(ctx context.Context, msg string, args ...any) {
|
||||
getLogger(ctx).ErrorContext(ctx, msg, args...)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
|
||||
// "github.com/photoview/photoview/api/log"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const internalServerError = "internal server error"
|
||||
|
||||
func authenticateMedia(media *models.Media, db *gorm.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
if user != nil {
|
||||
var album models.Album
|
||||
if err := db.First(&album, media.AlbumID).Error; err != nil {
|
||||
// log.Debug(nil, "Failed to find album for media %d: %v", media.ID, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
// log.Debug(nil, "Failed to check if user owns album %d for media %d: %v", media.AlbumID, media.ID, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
// log.Debug(nil, "User does not own album %d for media %d", media.AlbumID, media.ID)
|
||||
return false, "invalid credentials", http.StatusForbidden, nil
|
||||
}
|
||||
} else {
|
||||
if success, respMsg, respStatus, err := shareTokenFromRequest(db, r, &media.ID, &media.AlbumID); !success {
|
||||
return success, respMsg, respStatus, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, "success", http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
func authenticateAlbum(album *models.Album, db *gorm.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
if user != nil {
|
||||
ownsAlbum, err := user.OwnsAlbum(db, album)
|
||||
if err != nil {
|
||||
// log.Debug(nil, "Failed to check if user owns album %d: %v", album.ID, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
// log.Debug(nil, "User does not own album %d", album.ID)
|
||||
return false, "invalid credentials", http.StatusForbidden, nil
|
||||
}
|
||||
} else {
|
||||
if success, respMsg, respStatus, err := shareTokenFromRequest(db, r, nil, &album.ID); !success {
|
||||
return success, respMsg, respStatus, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, "success", http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
func shareTokenFromRequest(db *gorm.DB, r *http.Request, mediaID *int, albumID *int) (success bool, responseMessage string, responseStatus int, errorMessage error) {
|
||||
// Check if photo is authorized with a share token
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("share token not provided")
|
||||
}
|
||||
|
||||
var shareToken models.ShareToken
|
||||
|
||||
if err := db.Where("value = ?", token).First(&shareToken).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// log.Debug(nil, "Share token not found: %s", token)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
// log.Debug(nil, "Error fetching share token: %s, error: %v", token, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if shareToken.Expire != nil && time.Now().UTC().After(shareToken.Expire.UTC()) {
|
||||
// log.Debug(nil, "Share token expired: %s", token)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
|
||||
// Validate share token password, if set
|
||||
if shareToken.Password != nil {
|
||||
tokenPasswordCookie, err := r.Cookie(fmt.Sprintf("share-token-pw-%s", shareToken.Value))
|
||||
if err != nil {
|
||||
// log.Debug(nil, "Error getting share token password cookie: %v", err)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.Wrap(err, "share token password invalid")
|
||||
}
|
||||
// tokenPassword := r.Header.Get("TokenPassword")
|
||||
tokenPassword := tokenPasswordCookie.Value
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
// log.Debug(nil, "Incorrect password for share token: %s", token)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("share token password invalid")
|
||||
} else {
|
||||
// log.Debug(nil, "Error comparing share token password: %s, error: %v", token, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shareToken.AlbumID != nil && albumID == nil {
|
||||
// log.Debug(nil, "Share token is of type album, but no albumID was provided to function")
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
|
||||
if shareToken.MediaID != nil && mediaID == nil {
|
||||
// log.Debug(nil, "Share token is of type media, but no mediaID was provided to function")
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
|
||||
if shareToken.AlbumID != nil && *albumID != *shareToken.AlbumID {
|
||||
// Check child albums
|
||||
|
||||
var count int
|
||||
err := db.Raw(`
|
||||
WITH recursive child_albums AS (
|
||||
SELECT * FROM albums WHERE parent_album_id = ?
|
||||
UNION ALL
|
||||
SELECT child.* FROM albums child JOIN child_albums parent ON parent.id = child.parent_album_id
|
||||
)
|
||||
SELECT COUNT(id) FROM child_albums WHERE id = ?
|
||||
`, *shareToken.AlbumID, albumID).Find(&count).Error
|
||||
|
||||
if err != nil {
|
||||
// log.Debug(nil, "Error checking child albums for share token: %s, error: %v", token, err)
|
||||
return false, internalServerError, http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
// log.Debug(nil, "No child albums found for share token: %s", token)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
}
|
||||
|
||||
if shareToken.MediaID != nil && *mediaID != *shareToken.MediaID {
|
||||
// log.Debug(nil, "Media share token does not match mediaID: %d != %d", *mediaID, *shareToken.MediaID)
|
||||
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
|
||||
}
|
||||
|
||||
return true, "", 0, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthenticateRoute(t *testing.T) {
|
||||
const imageData = "IMAGE DATA"
|
||||
const albumData = "ALBUM DATA"
|
||||
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "username", nil, false)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
album := models.Album{
|
||||
Title: "my_album",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&album)) {
|
||||
return
|
||||
}
|
||||
|
||||
media := models.Media{
|
||||
Title: "my_media",
|
||||
Path: "/photos/image.jpg",
|
||||
AlbumID: album.ID,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&media).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Authenticate Media", func(t *testing.T) {
|
||||
t.Run("Authorized request", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/photo/image.jpg", strings.NewReader(imageData))
|
||||
ctx := auth.AddUserToContext(req.Context(), user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, success)
|
||||
assert.Equal(t, responseMessage, "success")
|
||||
assert.Equal(t, responseStatus, http.StatusAccepted)
|
||||
})
|
||||
|
||||
t.Run("Request without access token", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/photo/image.jpg", strings.NewReader(imageData))
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, responseMessage, "unauthorized")
|
||||
assert.Equal(t, responseStatus, http.StatusForbidden)
|
||||
})
|
||||
|
||||
expire := time.Now().Add(time.Hour * 24 * 30)
|
||||
tokenPassword := "token-password-123"
|
||||
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Request with share token", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: tokenPassword,
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, success)
|
||||
assert.Equal(t, "success", responseMessage)
|
||||
assert.Equal(t, http.StatusAccepted, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with invalid share token", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/photo/image.jpg?token=%s", "invalid-token")
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
|
||||
// Even if a cookie is sent, the token is invalid
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", "invalid-token"),
|
||||
Value: "whatever",
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with share token but no password cookie", func(t *testing.T) {
|
||||
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
|
||||
// No cookie provided
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with share token and wrong password", func(t *testing.T) {
|
||||
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: "incorrect-password",
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with expired share token", func(t *testing.T) {
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expired, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: tokenPassword,
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Authenticate Album", func(t *testing.T) {
|
||||
t.Run("Authorized request", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/download/album/1", strings.NewReader(albumData))
|
||||
ctx := auth.AddUserToContext(req.Context(), user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, success)
|
||||
assert.Equal(t, "success", responseMessage)
|
||||
assert.Equal(t, http.StatusAccepted, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request without access token", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/download/album/1", strings.NewReader(albumData))
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
expire := time.Now().Add(time.Hour * 24 * 30)
|
||||
tokenPassword := "token-password-123"
|
||||
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Request with share token", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: tokenPassword,
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, success)
|
||||
assert.Equal(t, "success", responseMessage)
|
||||
assert.Equal(t, http.StatusAccepted, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with invalid album share token", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/download/album/1?token=%s", "invalid-token")
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", "invalid-token"),
|
||||
Value: "whatever",
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with album share token but no password cookie", func(t *testing.T) {
|
||||
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
|
||||
// No cookie provided
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with album share token and wrong password", func(t *testing.T) {
|
||||
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: "incorrect-password",
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
|
||||
t.Run("Request with expired album share token", func(t *testing.T) {
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expired, &tokenPassword)
|
||||
assert.NoError(t, err)
|
||||
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
|
||||
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
|
||||
cookie := http.Cookie{
|
||||
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
|
||||
Value: tokenPassword,
|
||||
}
|
||||
req.AddCookie(&cookie)
|
||||
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, success)
|
||||
assert.Equal(t, "unauthorized", responseMessage)
|
||||
assert.Equal(t, http.StatusForbidden, responseStatus)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterDownloadRoutes(db *gorm.DB, router *mux.Router) {
|
||||
router.HandleFunc("/album/{album_id}/{media_purpose}", func(w http.ResponseWriter, r *http.Request) {
|
||||
albumID := mux.Vars(r)["album_id"]
|
||||
mediaPurpose := mux.Vars(r)["media_purpose"]
|
||||
mediaPurposeList := strings.SplitN(mediaPurpose, ",", 10)
|
||||
|
||||
var album models.Album
|
||||
if err := db.Find(&album, albumID).Error; err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404"))
|
||||
return
|
||||
}
|
||||
|
||||
if success, response, status, err := authenticateAlbum(&album, db, r); !success {
|
||||
if err != nil {
|
||||
log.Printf("WARN: error authenticating album for download: %v\n", err)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(response))
|
||||
return
|
||||
}
|
||||
|
||||
var mediaWhereQuery string
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
mediaWhereQuery = "\"Media\".album_id = ?"
|
||||
} else {
|
||||
mediaWhereQuery = "Media.album_id = ?"
|
||||
}
|
||||
|
||||
var mediaURLs []*models.MediaURL
|
||||
if err := db.Joins("Media").Where(mediaWhereQuery, album.ID).Where("media_urls.purpose IN (?)", mediaPurposeList).Find(&mediaURLs).Error; err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if len(mediaURLs) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no media found"))
|
||||
return
|
||||
}
|
||||
|
||||
// Do not allow caching
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", album.Title))
|
||||
|
||||
zipWriter := zip.NewWriter(w)
|
||||
|
||||
for _, media := range mediaURLs {
|
||||
zipFile, err := zipWriter.Create(fmt.Sprintf("%s/%s", album.Title, media.MediaName))
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to create a file in zip, when downloading album (%d): %v\n", album.ID, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
filePath, err := media.CachedPath()
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get mediaURL cache path, when downloading album (%d): %v\n", album.ID, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
fileData, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to open file to include in zip, when downloading album (%d): %v\n", album.ID, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(zipFile, fileData)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to copy file data, when downloading album (%d): %v\n", album.ID, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if err := fileData.Close(); err != nil {
|
||||
log.Printf("ERROR: Failed to close file, when downloading album (%d): %v\n", album.ID, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// close the zip Writer to flush the contents to the ResponseWriter
|
||||
zipWriter.Close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
)
|
||||
|
||||
func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
|
||||
|
||||
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
mediaName := mux.Vars(r)["name"]
|
||||
|
||||
var mediaURL models.MediaURL
|
||||
result := db.Model(&models.MediaURL{}).Joins("Media").Select("media_urls.*").Where("media_urls.media_name = ?", mediaName).Scan(&mediaURL)
|
||||
if err := result.Error; err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404"))
|
||||
return
|
||||
}
|
||||
|
||||
media := mediaURL.Media
|
||||
if media == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404 - Media not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if success, response, status, err := authenticateMedia(media, db, r); !success {
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Unauthorized access to photo", "reason", err)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(response))
|
||||
return
|
||||
}
|
||||
|
||||
cachedPath, err := mediaURL.CachedPath()
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "error getting cached path for media URL", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cachedPath); os.IsNotExist((err)) {
|
||||
// err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err = scanner.ProcessSingleMediaFunc(r.Context(), db, media); err != nil {
|
||||
log.Error(r.Context(), "processing image not found in cache",
|
||||
"media_cache_path", cachedPath,
|
||||
"error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = os.Stat(cachedPath); err != nil {
|
||||
log.Error(r.Context(), "after reprocessing image not found in cache",
|
||||
"media_cache_path", cachedPath,
|
||||
"error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(internalServerError))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Allow caching the resource
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
if mediaURL.ContentType != "" {
|
||||
w.Header().Set("Content-Type", mediaURL.ContentType)
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, cachedPath)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPhotoRoutes(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "testuser", nil, false)
|
||||
assert.NoError(t, err)
|
||||
album := models.Album{Title: "test_album", Path: "/photos"}
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&album))
|
||||
|
||||
media := models.Media{
|
||||
Title: "test_media",
|
||||
Path: "/photos/test_image.jpg",
|
||||
AlbumID: album.ID,
|
||||
DateShot: time.Now(),
|
||||
Type: models.MediaTypePhoto,
|
||||
}
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
mediaURL := models.MediaURL{
|
||||
MediaID: media.ID,
|
||||
Media: &media,
|
||||
MediaName: "test_image.jpg",
|
||||
Width: 1000,
|
||||
Height: 800,
|
||||
Purpose: models.PhotoThumbnail,
|
||||
ContentType: "image/jpeg",
|
||||
FileSize: 1024,
|
||||
}
|
||||
assert.NoError(t, db.Save(&mediaURL).Error)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
orig := utils.MediaCachePath()
|
||||
utils.ConfigureTestCache(tempDir)
|
||||
defer utils.ConfigureTestCache(orig)
|
||||
|
||||
router := mux.NewRouter()
|
||||
RegisterPhotoRoutes(db, router)
|
||||
|
||||
// -- Test cases --
|
||||
|
||||
// Non-existent media_name => 404 (no auth required)
|
||||
t.Run("media not found", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/does_not_exist.jpg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
assert.Equal(t, "404 - Media not found", rec.Body.String())
|
||||
})
|
||||
|
||||
// Missing auth => 403
|
||||
t.Run("auth failure", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "unauthorized")
|
||||
})
|
||||
|
||||
// Cache miss + scan error => 500
|
||||
t.Run("scan failure yields 500", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
|
||||
ctx := auth.AddUserToContext(req.Context(), user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// ensure no cached file
|
||||
cachedPath, err := mediaURL.CachedPath()
|
||||
assert.NoError(t, err)
|
||||
os.Remove(cachedPath)
|
||||
|
||||
// mock scan to fail
|
||||
origScan := scanner.ProcessSingleMediaFunc
|
||||
scanner.ProcessSingleMediaFunc = func(ctx context.Context, db *gorm.DB, m *models.Media) error {
|
||||
return fmt.Errorf("scan error")
|
||||
}
|
||||
defer func() { scanner.ProcessSingleMediaFunc = origScan }()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "server error")
|
||||
})
|
||||
|
||||
// Cache hit => 200 with correct body and headers
|
||||
t.Run("cache hit serves file", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
|
||||
ctx := auth.AddUserToContext(req.Context(), user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// pre-create cached file
|
||||
cachedPath, err := mediaURL.CachedPath()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, os.MkdirAll(path.Dir(cachedPath), 0755))
|
||||
content := []byte("cached-binary")
|
||||
assert.NoError(t, os.WriteFile(cachedPath, content, 0644))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "cached-binary", rec.Body.String())
|
||||
assert.Equal(t, "private, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/log"
|
||||
)
|
||||
|
||||
// SpaHandler implements the http.Handler interface, so we can use it
|
||||
// to respond to HTTP requests. The path to the static directory and
|
||||
// path to the index file within that static directory are used to
|
||||
// serve the SPA in the given static directory.
|
||||
type SpaHandler struct {
|
||||
staticPath string
|
||||
indexPath string
|
||||
}
|
||||
|
||||
func NewSpaHandler(staticPath string, indexPath string) (SpaHandler, error) {
|
||||
// Keeping this validation here just because we need the absolute static path anyway
|
||||
staticPathAbs, err := filepath.Abs(staticPath)
|
||||
if err != nil {
|
||||
return SpaHandler{}, fmt.Errorf("static path %s is not valid: %w", staticPath, err)
|
||||
}
|
||||
|
||||
if err := validPath(staticPathAbs, true); err != nil {
|
||||
return SpaHandler{}, fmt.Errorf("static path %s validation error: %w", staticPath, err)
|
||||
}
|
||||
if err := validPath(filepath.Join(staticPathAbs, indexPath), false); err != nil {
|
||||
return SpaHandler{}, fmt.Errorf("index path %s validation error: %w", indexPath, err)
|
||||
}
|
||||
|
||||
return SpaHandler{
|
||||
indexPath: indexPath,
|
||||
staticPath: staticPathAbs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP inspects the URL path to locate a file within the static dir
|
||||
// on the SPA handler. If a file is found, it will be served. If not, the
|
||||
// file located at the index path on the SPA handler will be served. This
|
||||
// is suitable behavior for serving an SPA (single page application).
|
||||
// Pre-compressed files (.br, .zst, .gz) are served if the client supports
|
||||
// them, otherwise the original file is served.
|
||||
func (h SpaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
relPath := strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")
|
||||
fullPath := filepath.Join(h.staticPath, relPath)
|
||||
|
||||
r = r.WithContext(log.WithAttrs(r.Context(), "static_path", h.staticPath, "requested_path", r.URL.Path))
|
||||
|
||||
// Special case: root path should serve index.html
|
||||
if relPath == "" {
|
||||
h.serveIndexHTML(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "error building absolute path", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(h.staticPath, absPath)
|
||||
if err != nil || strings.Contains(rel, "..") {
|
||||
log.Error(r.Context(), "requested path is outside of static path", "error", err)
|
||||
http.Error(w, "Invalid request URI", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the original file exists
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
// File doesn't exist - let serveOriginal handle SPA routing
|
||||
h.serveOriginal(w, r, fullPath, relPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't compress already-compressed files
|
||||
if isCompressedFormat(filepath.Ext(absPath)) {
|
||||
h.serveOriginal(w, r, fullPath, relPath)
|
||||
return
|
||||
}
|
||||
// Try to serve pre-compressed file
|
||||
if h.servePrecompressedFile(w, r, fullPath, relPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to original SPA handler logic
|
||||
h.serveOriginal(w, r, fullPath, relPath)
|
||||
}
|
||||
|
||||
func (h SpaHandler) serveOriginal(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) {
|
||||
// Check whether a file exists at the given path
|
||||
_, err := os.Stat(fullPath)
|
||||
if os.IsNotExist(err) {
|
||||
// File does not exist, serve index.html (SPA routing)
|
||||
h.serveIndexHTML(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// If we got an error (that wasn't that the file doesn't exist) stating the file,
|
||||
// return a 500 internal server error and stop
|
||||
log.Error(r.Context(), "Error stating file, requested by client", "file_path", fullPath, "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set cache headers
|
||||
h.setCacheHeaders(w, relPath)
|
||||
|
||||
// Use http.FileServer to serve the static file with no compression
|
||||
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// servePrecompressedFile attempts to serve a pre-compressed variant of the file
|
||||
// Returns true if a pre-compressed file was served, false otherwise
|
||||
func (h SpaHandler) servePrecompressedFile(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) bool {
|
||||
// Parse Accept-Encoding header
|
||||
acceptEncoding := filterZeroQuality(strings.ToLower(r.Header.Get("Accept-Encoding")))
|
||||
if acceptEncoding == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for pre-compressed variants in order of preference (br > zstd > gzip)
|
||||
encodings := []struct {
|
||||
name string
|
||||
extension string
|
||||
}{
|
||||
{"br", ".br"},
|
||||
{"zstd", ".zst"},
|
||||
{"gzip", ".gz"},
|
||||
}
|
||||
|
||||
for _, enc := range encodings {
|
||||
if !strings.Contains(acceptEncoding, enc.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
precompressedPath := fullPath + enc.extension
|
||||
if stat, err := os.Stat(precompressedPath); err != nil || stat.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect Content-Type from the ORIGINAL file extension, not the compressed one
|
||||
contentType := mime.TypeByExtension(filepath.Ext(fullPath))
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", enc.name)
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
// Set cache headers based on request path
|
||||
h.setCacheHeaders(w, relPath)
|
||||
|
||||
// Serve pre-compressed file
|
||||
http.ServeFile(w, r, precompressedPath)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// serveIndexHTML serves index.html with pre-compressed priority
|
||||
func (h SpaHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
|
||||
indexPath := filepath.Join(h.staticPath, h.indexPath)
|
||||
|
||||
// Try to serve pre-compressed index.html first
|
||||
// Use full and relative paths to index.html instead of originally requested paths
|
||||
if h.servePrecompressedFile(w, r, indexPath, h.indexPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to uncompressed index.html
|
||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||
// Index file doesn't exist - this is a serious configuration error, not a regular 404
|
||||
log.Error(r.Context(), "Error: index.html not found", "index_path", indexPath)
|
||||
http.Error(w, "Application index file not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.setCacheHeaders(w, h.indexPath)
|
||||
http.ServeFile(w, r, indexPath)
|
||||
}
|
||||
|
||||
// setCacheHeaders sets appropriate cache headers based on the request path
|
||||
func (h SpaHandler) setCacheHeaders(w http.ResponseWriter, relPath string) {
|
||||
if strings.HasPrefix(relPath, "assets/") {
|
||||
// Long-term cache for fingerprinted assets
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
// Short cache with revalidation for other files
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
||||
}
|
||||
}
|
||||
|
||||
// validPath validates the given path string.
|
||||
//
|
||||
// Parameters:
|
||||
// - pathStr: The path to validate.
|
||||
// - isDir: A boolean indicating whether the path is expected to be a directory (true) or a file (false).
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the path is invalid, or nil if the path is valid.
|
||||
func validPath(pathStr string, isDir bool) error {
|
||||
pathAbs, err := filepath.Abs(pathStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path %s is not valid: %w", pathStr, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(pathAbs)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("path %s does not exist", pathAbs)
|
||||
}
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf("no permission to access path %s", pathAbs)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error accessing path %s: %w", pathAbs, err)
|
||||
}
|
||||
if isDir && !stat.IsDir() {
|
||||
return fmt.Errorf("path %s is not a directory", pathAbs)
|
||||
}
|
||||
if !isDir && stat.IsDir() {
|
||||
return fmt.Errorf("path %s is a directory, must be a file", pathAbs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCompressedFormat(ext string) bool {
|
||||
ext = strings.ToLower(ext)
|
||||
compressedExts := []string{
|
||||
".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp",
|
||||
".zip", ".gz", ".br", ".zst",
|
||||
".woff", ".woff2",
|
||||
}
|
||||
|
||||
for _, compExt := range compressedExts {
|
||||
if ext == compExt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterZeroQuality removes encodings with q=0 or q=0.0 from Accept-Encoding header
|
||||
func filterZeroQuality(acceptEncoding string) string {
|
||||
// If no q params, return unchanged (fast path for most requests)
|
||||
if !strings.Contains(acceptEncoding, "q=") {
|
||||
return acceptEncoding
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
parts := strings.Split(acceptEncoding, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this encoding has q=0 or q=0.0
|
||||
normalized := strings.ReplaceAll(part, " ", "")
|
||||
if strings.HasSuffix(normalized, ";q=0") || strings.HasSuffix(normalized, ";q=0.0") {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
|
||||
return strings.Join(filtered, ",")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user