精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+661
View File
@@ -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/>.
+140
View File
@@ -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`。
+98
View File
@@ -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$
+20
View File
@@ -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": []
}
]
}
+220
View File
@@ -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
}
+55
View File
@@ -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
}
+46
View File
@@ -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
}
+214
View File
@@ -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)
}
+224
View File
@@ -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)
}
+221
View File
@@ -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)
}
+224
View File
@@ -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)
}
+48
View File
@@ -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
}
+82
View File
@@ -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")
}),
}
}
+60
View File
@@ -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
},
}
}
+71
View File
@@ -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
},
}
}
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+72
View File
@@ -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
+131
View File
@@ -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
}
}
+244
View File
@@ -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)
})
}
+27
View File
@@ -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)
}
+52
View File
@@ -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)
}
+188
View File
@@ -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(&regularUser).Association("Albums").Append(&rootAlbum)) {
return
}
if !assert.NoError(t, db.Model(&regularUser).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)
})
}
+115
View File
@@ -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
}
+369
View File
@@ -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")
})
}
+15
View File
@@ -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
}
+130
View File
@@ -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
}
+316
View File
@@ -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
}
+156
View File
@@ -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
}
+70
View File
@@ -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
}
+63
View File
@@ -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)
}
})
}
}
+127
View File
@@ -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)
}
+11
View File
@@ -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)
}
+22
View File
@@ -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
}
+52
View File
@@ -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
}
}
+41
View File
@@ -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)
}
+200
View File
@@ -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
}
+231
View File
@@ -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)
}
+46
View File
@@ -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[:])
}
+12
View File
@@ -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"))
}
+17
View File
@@ -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")
}
+83
View File
@@ -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
}
}
+167
View File
@@ -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 }
+60
View File
@@ -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
}
+442
View File
@@ -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 }
+53
View File
@@ -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
}
+106
View File
@@ -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
}
+106
View File
@@ -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{}{}
}
})
}
}
+236
View File
@@ -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 }
+123
View File
@@ -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
}
+81
View File
@@ -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,
}
}
+39
View File
@@ -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
}
+24
View File
@@ -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)
}
+19
View File
@@ -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 }
+47
View File
@@ -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!
}
+108
View File
@@ -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
}
+23
View File
@@ -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
}
+24
View File
@@ -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)
}
+13
View File
@@ -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!
}
+167
View File
@@ -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 }
+47
View File
@@ -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
}
+123
View File
@@ -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)
})
}
}
+29
View File
@@ -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 }
+15
View File
@@ -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!
}
+25
View File
@@ -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)
}
+11
View File
@@ -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
}
+343
View File
@@ -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 }
+100
View File
@@ -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
}
+54
View File
@@ -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
}
+41
View File
@@ -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)
}
+93
View File
@@ -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)
}
}
+25
View File
@@ -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...)
}
+155
View File
@@ -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
}
+276
View File
@@ -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)
})
})
}
+112
View File
@@ -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()
})
}
+81
View File
@@ -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)
})
}
+124
View File
@@ -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"))
})
}
+11
View File
@@ -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)
}
+274
View File
@@ -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