├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── .eslintrc ├── config.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── fonts │ │ ├── lg.svg │ │ ├── lg.ttf │ │ ├── lg.woff │ │ └── lg.woff2 │ ├── images │ │ ├── download-all.svg │ │ ├── ipp.svg │ │ ├── loading.gif │ │ └── play.png │ ├── lg │ │ ├── lg-fullscreen.min.js │ │ ├── lg-thumbnail.min.js │ │ ├── lg-video.min.js │ │ ├── lg-zoom.min.js │ │ ├── lightgallery-bundle.min.css │ │ └── lightgallery.min.js │ ├── pico.min.css │ ├── robots.txt │ ├── style.css │ └── web.js ├── src │ ├── encrypt.ts │ ├── functions.ts │ ├── immich.ts │ ├── index.ts │ ├── invalidRequestHandler.ts │ ├── render.ts │ └── types.ts ├── tsconfig.json └── views │ ├── gallery.ejs │ ├── home.ejs │ └── password.ejs ├── docker-compose.yml └── docs ├── cloudflare-video-cache.webp ├── custom-responses.md ├── external-link.png ├── ipp.svg ├── kubernetes.md ├── running-on-single-domain.md ├── screenshot.webp ├── securing-immich-with-mtls.md ├── server-settings.png ├── share-link.webp └── shield.png /.dockerignore: -------------------------------------------------------------------------------- 1 | /app/node_modules/ 2 | /app/dist/ 3 | /app/.env 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Vote for new features on the Discussions page 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | You can add and vote for feature requests on the Discussions page: 11 | 12 | https://github.com/alangrainger/immich-public-proxy/discussions/categories/feature-requests 13 | 14 | 🚨 Feature requests created as an Issue will be closed. 15 | 16 | Please note that my goal for this project is to keep it as lean as possible. I want anyone with a bit of coding knowledge to be able to read the codebase and fully understand everything it is doing. 17 | 18 | Things that not be considered for this project are: 19 | 20 | - Anything that modifies Immich or its files in any way. If it requires an API key or privileged accesss, it won't be considered as a new feature. 21 | - Uploading photos (see above). 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | env: 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Get version from package.json 24 | run: echo "PACKAGE_VERSION=$(jq -r '.version' app/package.json)" >> $GITHUB_ENV 25 | 26 | - name: Log in to GitHub Container Registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Log in to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 37 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v3.2.0 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v3 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@v5 47 | with: 48 | context: . 49 | file: Dockerfile 50 | push: true 51 | platforms: linux/amd64,linux/arm64,linux/arm/v7 52 | build-args: | 53 | PACKAGE_VERSION=${{ env.PACKAGE_VERSION }} 54 | tags: | 55 | ${{ github.repository }}:${{ env.PACKAGE_VERSION }} 56 | ${{ github.repository }}:latest 57 | ghcr.io/${{ github.repository }}:${{ env.PACKAGE_VERSION }} 58 | ghcr.io/${{ github.repository }}:latest 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .idea 4 | .env 5 | .directory 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS builder 2 | 3 | USER node 4 | WORKDIR /app 5 | COPY --chown=node:node app/ ./ 6 | 7 | RUN npm ci \ 8 | && npx tsc 9 | 10 | FROM node:lts-alpine AS runner 11 | 12 | RUN apk --no-cache add curl 13 | 14 | USER node 15 | WORKDIR /app 16 | COPY --from=builder --chown=node:node app/ ./ 17 | 18 | RUN npm ci --omit=dev 19 | 20 | ARG PACKAGE_VERSION 21 | ENV APP_VERSION=${PACKAGE_VERSION} 22 | ENV NODE_ENV=production 23 | 24 | CMD ["node", "dist/index.js" ] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Immich Public Proxy 2 | 3 |

4 | 5 | Docker pulls 6 | 7 | Latest release 8 | Open demo gallery 9 |

10 | 11 | Share your Immich photos and albums in a safe way without exposing your Immich instance to the public. 12 | 13 | 👉 See a [Live demo gallery](https://immich-demo.note.sx/share/gJfs8l4LcJJrBUpjhMnDoKXFt1Tm5vKXPbXl8BgwPtLtEBCOOObqbQdV5i0oun5hZjQ) 14 | serving straight out of my own Immich instance. 15 | 16 | Setup takes less than a minute, and you never need to touch it again as all of your sharing stays managed within Immich. 17 | 18 |

19 | 20 |

21 | 22 | ### Table of Contents 23 | 24 | - [About this project](#about-this-project) 25 | - [Installation](#installation) 26 | - [Install with Docker](#install-with-docker--podman) 27 | - [Install with Kubernetes](docs/kubernetes.md) 28 | - [How to use it](#how-to-use-it) 29 | - [How it works](#how-it-works) 30 | - [Additional configuration](#additional-configuration) 31 | - [IPP options](#immich-public-proxy-options) 32 | - [lightGallery](#lightgallery) 33 | - [Custom error pages](#customising-your-error-response-pages) 34 | - [Troubleshooting](#troubleshooting) 35 | - [Feature requests](#feature-requests) 36 | 37 | ## About this project 38 | 39 | [Immich](https://github.com/immich-app/immich) is a wonderful bit of software, but since it holds all your private photos it's 40 | best to keep it fully locked down. This presents a problem when you want to share a photo or a gallery with someone. 41 | 42 | **Immich Public Proxy** provides a barrier of security between the public and Immich, and _only_ allows through requests 43 | which you have publicly shared. 44 | 45 | It is stateless and does not know anything about your Immich instance. It does not require an API key which reduces the attack 46 | surface even further. The only things that the proxy can access are photos that you have made publicly available in Immich. 47 | 48 | ### Features 49 | 50 | - Supports sharing photos and videos. 51 | - Supports password-protected shares. 52 | - If sharing a single image, by default the link will directly open the image file so that you can embed it anywhere you would a normal image. (This is configurable.) 53 | - All usage happens through Immich - you won't need to touch this app after the initial configuration. 54 | 55 | ### Why not simply put Immich behind a reverse proxy and only expose the `/share/` path to the public? 56 | 57 | To view a shared album in Immich, you need access to the `/api/` path. If you're sharing a gallery with the public, you need 58 | to make that path public. Any existing or future vulnerability has the potential to compromise your Immich instance. 59 | 60 | For me, the ideal setup is to have Immich secured privately behind mTLS or VPN, and only allow public access to Immich Public Proxy. 61 | Here is an example setup for [securing Immich behind mTLS](./docs/securing-immich-with-mtls.md) using Caddy. 62 | 63 | ## Installation 64 | 65 | ### Install with Docker / Podman 66 | 67 | 1. Download the [docker-compose.yml](https://github.com/alangrainger/immich-public-proxy/blob/main/docker-compose.yml) file. 68 | 69 | 2. Update the value for `IMMICH_URL` in your docker-compose file to point to your local URL for Immich. This should not be a public URL. 70 | 71 | 3. Start the docker container. You can test that it is working by visiting `https://your-proxy-url.com/healthcheck`. 72 | Check the container console output for any error messages. 73 | 74 | ```bash 75 | docker-compose up -d 76 | ``` 77 | 78 | 4. Set the "External domain" in your Immich **Server Settings** to be whatever domain you use to publicly serve Immich Public Proxy: 79 | 80 | 81 | 82 | Now whenever you share an image or gallery through Immich, it will automatically create the correct public path for you. 83 | 84 | 🚨 **IMPORTANT**: If you're using Cloudflare, please make sure to set your `/share/video/*` path to Bypass Cache, otherwise you may 85 | run into video playback issues. See [Troubleshooting](#troubleshooting) for more information. 86 | 87 | #### Running on a single domain 88 | 89 | Because all IPP paths are under `/share/...`, you can run Immich Public Proxy and Immich on the same domain. 90 | 91 | See the instructions here: [Running on a single domain](./docs/running-on-single-domain.md). 92 | 93 | ### Install with Kubernetes 94 | 95 | [See the docs here](docs/kubernetes.md). 96 | 97 | ## How to use it 98 | 99 | Other than the initial configuration above, everything else is managed through Immich. 100 | 101 | You share your photos/videos as normal through Immich. Because you have set the **External domain** in Immich settings 102 | to be the URL for your proxy app, the links that Immich generates will automaticaly have the correct URL: 103 | 104 | 105 | 106 | ## How it works 107 | 108 | When the proxy receives a request, it will come as a link like this: 109 | 110 | ``` 111 | https://your-proxy-url.com/share/ffSw63qnIYMtpmg0RNvOui0Dpio7BbxsObjvH8YZaobIjIAzl5n7zTX5d6EDHdOYEvo 112 | ``` 113 | 114 | The part after `/share/` is Immich's shared link public ID (called the `key` [in the docs](https://immich.app/docs/api/get-my-shared-link)). 115 | 116 | **Immich Public Proxy** takes that key and makes an API call to your Immich instance over your local network, to ask what 117 | photos or videos are shared in that share URL. 118 | 119 | If it is a valid share URL, the proxy fetches just those assets via local API and returns them to the visitor as an 120 | individual image or gallery. 121 | 122 | If the shared link has expired or any of the assets have been put in the Immich trash, it will not return those. 123 | 124 | All incoming data is validated and sanitised, and anything unexpected is simply dropped with a 404. 125 | 126 | ## Additional configuration 127 | 128 | There are some additional configuration options you can change, for example the way the gallery is set up. 129 | 130 | 1. Make a copy of [config.json](https://github.com/alangrainger/immich-public-proxy/blob/main/app/config.json) in the same folder as your `docker-compose.yml`. 131 | 132 | 2. Pass the config to your docker container by adding a volume like this: 133 | 134 | ```yaml 135 | volumes: 136 | - ./config.json:/app/config.json:ro 137 | ``` 138 | 139 | 3. Restart your container and your custom configuration should be active. 140 | 141 | ### Immich Public Proxy options 142 | 143 | | Option | Type | Description | 144 | |-------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 145 | | `responseHeaders` | `object` | Change the headers sent with your web responses. By default there is `cache-control` and CORS added. | 146 | | `singleImageGallery` | `bool` | By default a link to a single image will directly open the image file. Set to `true` if you want to show a gallery page instead for a single item. | 147 | | `downloadOriginalPhoto` | `bool` | Set to `false` if you only want people to be able to download the 'preview' quality photo, rather than your original photo. | 148 | | `showGalleryTitle` | `bool` | Show a title on the gallery page. | 149 | | `allowDownloadAll` | `int` | Allow visitors to download all files as a zip.
`0` disable downloads
`1` follow Immich setting per share ([example](https://github.com/user-attachments/assets/79ea8c08-71ce-42ab-b025-10aec384938a))
`2` always allowed | 150 | | `showHomePage` | `bool` | Set to `false` to remove the IPP shield page at `/` and at `/share` | 151 | | `showMetadata` | `object` | See the [Metadata](#metadata) section below. | 152 | | `customInvalidResponse` | various | Send a custom response instead of the default 404 - see [Custom responses](docs/custom-responses.md) for more details. | 153 | 154 | For example, to disable the home page at `/` and at `/share` you need to change `showHomePage` to `false`: 155 | 156 | ```json 157 | { 158 | "ipp": { 159 | "showHomePage": false, 160 | ... 161 | } 162 | } 163 | ``` 164 | 165 | #### Metadata 166 | 167 | | Option | Type | Description | 168 | |---------------|--------|----------------------------------------------------| 169 | | `description` | `bool` | Show the description as a caption below the photo. | 170 | 171 | ### lightGallery 172 | 173 | The gallery is created using [lightGallery](https://github.com/sachinchoolur/lightGallery). 174 | You can find all of lightGallery's settings here: 175 | https://www.lightgalleryjs.com/docs/settings/ 176 | 177 | For example, to disable the download button for images, you would edit the `lightGallery` section and change `download` to `false`: 178 | 179 | ```json 180 | { 181 | "lightGallery": { 182 | "controls": true, 183 | "download": false, 184 | "mobileSettings": { 185 | "controls": false, 186 | "showCloseIcon": true, 187 | "download": false 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | ### Customising your error response pages 194 | 195 | You can customise the responses that IPP sends for invalid requests. For example you could: 196 | 197 | - Drop the connection entirely (no response). 198 | - Redirect to a new website. 199 | - Send a different status code. 200 | - Send a custom 404 page. 201 | - And so on... 202 | 203 | See [Custom responses](docs/custom-responses.md) for more details. 204 | 205 | ## Troubleshooting 206 | 207 | ### Video playback 208 | 209 | If you're using Cloudflare and having issues with videos not playing well, make sure your `/share/video/` paths are set to bypass cache. 210 | I ran into this issue myself, and found [some helpful advice here](https://community.cloudflare.com/t/mp4-wont-load-in-safari-using-cloudflare/10587/48). 211 | 212 | 213 | 214 | I use Linux/Android, so this project is tested with BrowserStack for Apple/Windows devices. 215 | 216 | ### Can't reach Immich using `localhost:2283` 217 | 218 | This is a normal Docker thing, nothing to do with IPP. 219 | 220 | From inside a Docker container, you can't reach another container using `localhost`. You need to use a Docker network IP or your server's IP address. 221 | 222 | [Here's a guide on connecting Docker containers](https://dionarodrigues.dev/blog/docker-networking-how-to-connect-different-containers). 223 | 224 | ## Feature requests 225 | 226 | You can [add feature requests here](https://github.com/alangrainger/immich-public-proxy/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop), 227 | however my goal with this project is to keep it as lean as possible. 228 | 229 | Due to the sensitivity of data contained within Immich, I want anyone with a bit of coding knowledge 230 | to be able to read this codebase and fully understand everything it is doing. 231 | 232 | The most basic rule for this project is that it has **read-only** access to Immich. 233 | 234 | Things that will not be considered for this project are: 235 | 236 | - Anything that modifies Immich or its files in any way. If it requires an API key or privileged accesss, it won't be considered as a new feature. 237 | - Uploading photos (see above). 238 | -------------------------------------------------------------------------------- /app/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "standard", 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "parserOptions": { 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "no-new": 0, 19 | "no-unused-vars": "off", 20 | "@typescript-eslint/no-var-requires": "off", 21 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 22 | "@typescript-eslint/ban-ts-comment": "off", 23 | "no-prototype-builtins": "off", 24 | "@typescript-eslint/no-empty-function": "off" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ipp": { 3 | "responseHeaders": { 4 | "Cache-Control": "public, max-age=2592000", 5 | "Access-Control-Allow-Origin": "*" 6 | }, 7 | "singleImageGallery": false, 8 | "singleItemAutoOpen": true, 9 | "downloadOriginalPhoto": true, 10 | "allowDownloadAll": 0, 11 | "showHomePage": true, 12 | "showGalleryTitle": false, 13 | "showMetadata": { 14 | "description": false 15 | }, 16 | "customInvalidResponse": false 17 | }, 18 | "lightGallery": { 19 | "controls": true, 20 | "download": true, 21 | "mobileSettings": { 22 | "controls": false, 23 | "showCloseIcon": true, 24 | "download": true 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immich-public-proxy", 3 | "version": "1.11.1", 4 | "scripts": { 5 | "dev": "ts-node src/index.ts", 6 | "build": "npx tsc", 7 | "test": "podman build --build-arg PACKAGE_VERSION=$(npm pkg get version | xargs) --format docker -t immich-proxy-test .. && podman run --init -it -p=3000:3000 --env-file .env --restart=always immich-proxy-test", 8 | "start": "node dist/index.js" 9 | }, 10 | "author": "Alan Grainger", 11 | "license": "AGPL-3.0", 12 | "description": "Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/alangrainger/immich-public-proxy.git" 16 | }, 17 | "main": "dist/index.js", 18 | "bin": "dist/index.js", 19 | "dependencies": { 20 | "archiver": "^7.0.1", 21 | "cookie-session": "^2.1.0", 22 | "dayjs": "^1.11.13", 23 | "dotenv": "^16.4.5", 24 | "ejs": "^3.1.10", 25 | "express": "^4.21.1", 26 | "tslib": "^2.8.1" 27 | }, 28 | "devDependencies": { 29 | "@types/archiver": "^6.0.3", 30 | "@types/cookie-session": "^2.0.49", 31 | "@types/express": "^4.17.21", 32 | "@types/node": "^16.18.111", 33 | "@typescript-eslint/eslint-plugin": "5.29.0", 34 | "@typescript-eslint/parser": "5.29.0", 35 | "eslint": "^8.49.0", 36 | "eslint-config-standard": "^17.1.0", 37 | "ts-node": "^10.9.2", 38 | "typescript": "^5.6.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/fonts/lg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | { 8 | "fontFamily": "lg", 9 | "majorVersion": 2, 10 | "minorVersion": 0, 11 | "fontURL": "", 12 | "copyright": "", 13 | "license": "", 14 | "licenseURL": "", 15 | "description": "Font generated by IcoMoon.", 16 | "version": "Version 2.0", 17 | "fontId": "lg", 18 | "psName": "lg", 19 | "subFamily": "Regular", 20 | "fullName": "lg" 21 | } 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/public/fonts/lg.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/fonts/lg.ttf -------------------------------------------------------------------------------- /app/public/fonts/lg.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/fonts/lg.woff -------------------------------------------------------------------------------- /app/public/fonts/lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/fonts/lg.woff2 -------------------------------------------------------------------------------- /app/public/images/download-all.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/public/images/ipp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 82 | -------------------------------------------------------------------------------- /app/public/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/images/loading.gif -------------------------------------------------------------------------------- /app/public/images/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/app/public/images/play.png -------------------------------------------------------------------------------- /app/public/lg/lg-fullscreen.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * lightgallery | 2.8.0-beta.3 | May 3rd 2024 3 | * http://www.lightgalleryjs.com/ 4 | * Copyright (c) 2020 Sachin Neravath; 5 | * @license GPLv3 6 | */ 7 | 8 | !function(e,l){"object"==typeof exports&&"undefined"!=typeof module?module.exports=l():"function"==typeof define&&define.amd?define(l):(e="undefined"!=typeof globalThis?globalThis:e||self).lgFullscreen=l()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var l,n=1,t=arguments.length;n',this.core.$toolbar.append(e),this.fullScreen()}},n.prototype.isFullScreen=function(){return document.fullscreenElement||document.mozFullScreenElement||document.webkitFullscreenElement||document.msFullscreenElement},n.prototype.requestFullscreen=function(){var e=document.documentElement;e.requestFullscreen?e.requestFullscreen():e.msRequestFullscreen?e.msRequestFullscreen():e.mozRequestFullScreen?e.mozRequestFullScreen():e.webkitRequestFullscreen&&e.webkitRequestFullscreen()},n.prototype.exitFullscreen=function(){document.exitFullscreen?document.exitFullscreen():document.msExitFullscreen?document.msExitFullscreen():document.mozCancelFullScreen?document.mozCancelFullScreen():document.webkitExitFullscreen&&document.webkitExitFullscreen()},n.prototype.fullScreen=function(){var e=this;this.$LG(document).on("fullscreenchange.lg.global"+this.core.lgId+" \n webkitfullscreenchange.lg.global"+this.core.lgId+" \n mozfullscreenchange.lg.global"+this.core.lgId+" \n MSFullscreenChange.lg.global"+this.core.lgId,(function(){e.core.lgOpened&&e.core.outer.toggleClass("lg-fullscreen-on")})),this.core.outer.find(".lg-fullscreen").first().on("click.lg",(function(){e.isFullScreen()?e.exitFullscreen():e.requestFullscreen()}))},n.prototype.closeGallery=function(){this.isFullScreen()&&this.exitFullscreen()},n.prototype.destroy=function(){this.$LG(document).off("fullscreenchange.lg.global"+this.core.lgId+" \n webkitfullscreenchange.lg.global"+this.core.lgId+" \n mozfullscreenchange.lg.global"+this.core.lgId+" \n MSFullscreenChange.lg.global"+this.core.lgId)},n}()})); 9 | -------------------------------------------------------------------------------- /app/public/lg/lg-thumbnail.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * lightgallery | 2.8.0-beta.3 | May 3rd 2024 3 | * http://www.lightgalleryjs.com/ 4 | * Copyright (c) 2020 Sachin Neravath; 5 | * @license GPLv3 6 | */ 7 | 8 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).lgThumbnail=e()}(this,(function(){"use strict";var t=function(){return(t=Object.assign||function(t){for(var e,i=1,s=arguments.length;i\n
\n
\n ';this.core.outer.addClass("lg-has-thumb"),".lg-components"===this.settings.appendThumbnailsTo?this.core.$lgComponents.append(e):this.core.outer.append(e),this.$thumbOuter=this.core.outer.find(".lg-thumb-outer").first(),this.$lgThumb=this.core.outer.find(".lg-thumb").first(),this.settings.animateThumb&&this.core.outer.find(".lg-thumb").css("transition-duration",this.core.settings.speed+"ms").css("width",this.thumbTotalWidth+"px").css("position","relative"),this.setThumbItemHtml(this.core.galleryItems)},o.prototype.enableThumbDrag=function(){var t=this,e={cords:{startX:0,endX:0},isMoved:!1,newTranslateX:0,startTime:new Date,endTime:new Date,touchMoveTime:0},i=!1;this.$thumbOuter.addClass("lg-grab"),this.core.outer.find(".lg-thumb").first().on("mousedown.lg.thumb",(function(s){t.thumbTotalWidth>t.thumbOuterWidth&&(s.preventDefault(),e.cords.startX=s.pageX,e.startTime=new Date,t.thumbClickable=!1,i=!0,t.core.outer.get().scrollLeft+=1,t.core.outer.get().scrollLeft-=1,t.$thumbOuter.removeClass("lg-grab").addClass("lg-grabbing"))})),this.$LG(window).on("mousemove.lg.thumb.global"+this.core.lgId,(function(s){t.core.lgOpened&&i&&(e.cords.endX=s.pageX,e=t.onThumbTouchMove(e))})),this.$LG(window).on("mouseup.lg.thumb.global"+this.core.lgId,(function(){t.core.lgOpened&&(e.isMoved?e=t.onThumbTouchEnd(e):t.thumbClickable=!0,i&&(i=!1,t.$thumbOuter.removeClass("lg-grabbing").addClass("lg-grab")))}))},o.prototype.enableThumbSwipe=function(){var t=this,e={cords:{startX:0,endX:0},isMoved:!1,newTranslateX:0,startTime:new Date,endTime:new Date,touchMoveTime:0};this.$lgThumb.on("touchstart.lg",(function(i){t.thumbTotalWidth>t.thumbOuterWidth&&(i.preventDefault(),e.cords.startX=i.targetTouches[0].pageX,t.thumbClickable=!1,e.startTime=new Date)})),this.$lgThumb.on("touchmove.lg",(function(i){t.thumbTotalWidth>t.thumbOuterWidth&&(i.preventDefault(),e.cords.endX=i.targetTouches[0].pageX,e=t.onThumbTouchMove(e))})),this.$lgThumb.on("touchend.lg",(function(){e.isMoved?e=t.onThumbTouchEnd(e):t.thumbClickable=!0}))},o.prototype.rebuildThumbnails=function(){var t=this;this.$thumbOuter.addClass("lg-rebuilding-thumbnails"),setTimeout((function(){t.thumbTotalWidth=t.core.galleryItems.length*(t.settings.thumbWidth+t.settings.thumbMargin),t.$lgThumb.css("width",t.thumbTotalWidth+"px"),t.$lgThumb.empty(),t.setThumbItemHtml(t.core.galleryItems),t.animateThumb(t.core.index)}),50),setTimeout((function(){t.$thumbOuter.removeClass("lg-rebuilding-thumbnails")}),200)},o.prototype.setTranslate=function(t){this.$lgThumb.css("transform","translate3d(-"+t+"px, 0px, 0px)")},o.prototype.getPossibleTransformX=function(t){return t>this.thumbTotalWidth-this.thumbOuterWidth&&(t=this.thumbTotalWidth-this.thumbOuterWidth),t<0&&(t=0),t},o.prototype.animateThumb=function(t){if(this.$lgThumb.css("transition-duration",this.core.settings.speed+"ms"),this.settings.animateThumb){var e=0;switch(this.settings.currentPagerPosition){case"left":e=0;break;case"middle":e=this.thumbOuterWidth/2-this.settings.thumbWidth/2;break;case"right":e=this.thumbOuterWidth-this.settings.thumbWidth}this.translateX=(this.settings.thumbWidth+this.settings.thumbMargin)*t-1-e,this.translateX>this.thumbTotalWidth-this.thumbOuterWidth&&(this.translateX=this.thumbTotalWidth-this.thumbOuterWidth),this.translateX<0&&(this.translateX=0),this.setTranslate(this.translateX)}},o.prototype.onThumbTouchMove=function(t){return t.newTranslateX=this.translateX,t.isMoved=!0,t.touchMoveTime=(new Date).valueOf(),t.newTranslateX-=t.cords.endX-t.cords.startX,t.newTranslateX=this.getPossibleTransformX(t.newTranslateX),this.setTranslate(t.newTranslateX),this.$thumbOuter.addClass("lg-dragging"),t},o.prototype.onThumbTouchEnd=function(t){t.isMoved=!1,t.endTime=new Date,this.$thumbOuter.removeClass("lg-dragging");var e=t.endTime.valueOf()-t.startTime.valueOf(),i=t.cords.endX-t.cords.startX,s=Math.abs(i)/e;return s>.15&&t.endTime.valueOf()-t.touchMoveTime<30?((s+=1)>2&&(s+=1),s+=s*(Math.abs(i)/this.thumbOuterWidth),this.$lgThumb.css("transition-duration",Math.min(s-1,2)+"settings"),i*=s,this.translateX=this.getPossibleTransformX(this.translateX-i),this.setTranslate(this.translateX)):this.translateX=t.newTranslateX,Math.abs(t.cords.endX-t.cords.startX)'),this.core.outer.find(".lg-toggle-thumb").first().on("click.lg",(function(){t.core.outer.toggleClass("lg-components-open")})))},o.prototype.thumbKeyPress=function(){var t=this;this.$LG(window).on("keydown.lg.thumb.global"+this.core.lgId,(function(e){t.core.lgOpened&&t.settings.toggleThumb&&(38===e.keyCode?(e.preventDefault(),t.core.outer.addClass("lg-components-open")):40===e.keyCode&&(e.preventDefault(),t.core.outer.removeClass("lg-components-open")))}))},o.prototype.destroy=function(){this.settings.thumbnail&&(this.$LG(window).off(".lg.thumb.global"+this.core.lgId),this.core.LGel.off(".lg.thumb"),this.core.LGel.off(".thumb"),this.$thumbOuter.remove(),this.core.outer.removeClass("lg-has-thumb"))},o}()})); 9 | -------------------------------------------------------------------------------- /app/public/lg/lg-video.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * lightgallery | 2.8.0-beta.3 | May 3rd 2024 3 | * http://www.lightgalleryjs.com/ 4 | * Copyright (c) 2020 Sachin Neravath; 5 | * @license GPLv3 6 | */ 7 | 8 | !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).lgVideo=o()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var o,i=1,t=arguments.length;i"}else if(n.vimeo){u="lg-vimeo"+i;var h=function(e,o){if(!o||!o.vimeo)return"";var i=o.vimeo[2]||"",t=Object.assign({},{autoplay:0,muted:1},e),s=t&&0!==Object.keys(t).length?r(t):"",n=((o.vimeo[0].split("/").pop()||"").split("?")[0]||"").split("#")[0],l=o.vimeo[1]!==n;l&&(i=i.replace("/"+n,""));var d=l?"h="+n:"";return"?"+d+(s=d?"&"+s:s)+("?"==i[0]?"&"+i.slice(1):i||"")}(this.settings.vimeoPlayerParams,n);s='"}else if(n.wistia){var g="lg-wistia"+i;h=(h=r(this.settings.wistiaPlayerParams))?"?"+h:"",s='"}else if(n.html5){for(var p="",y=0;y"}if(t.tracks){var w=function(e){var o="",i=t.tracks[e];Object.keys(i||{}).forEach((function(e){o+=e+'="'+i[e]+'" '})),p+=""};for(y=0;y\n "+p+"\n Your browser does not support HTML5 video.\n "}return s},a.prototype.appendVideos=function(e,o){var i,t=this.getVideoHtml(o.src,o.addClass,o.index,o.html5Video);e.find(".lg-video-cont").append(t);var s=e.find(".lg-video-object").first();if(o.html5Video&&s.on("mousedown.lg.video",(function(e){e.stopPropagation()})),this.settings.videojs&&(null===(i=this.core.galleryItems[o.index].__slideVideoInfo)||void 0===i?void 0:i.html5))try{return videojs(s.get(),this.settings.videojsOptions)}catch(e){console.error("lightGallery:- Make sure you have included videojs")}},a.prototype.gotoNextSlideOnVideoEnd=function(e,o){var i=this,t=this.core.getSlideItem(o).find(".lg-video-object").first(),s=this.core.galleryItems[o].__slideVideoInfo||{};if(this.settings.gotoNextSlideOnVideoEnd)if(s.html5)t.on("ended",(function(){i.core.goToNextSlide()}));else if(s.vimeo)try{new Vimeo.Player(t.get()).on("ended",(function(){i.core.goToNextSlide()}))}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(s.wistia)try{window._wq=window._wq||[],window._wq.push({id:t.attr("id"),onReady:function(e){e.bind("end",(function(){i.core.goToNextSlide()}))}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},a.prototype.controlVideo=function(e,o){var i=this.core.getSlideItem(e).find(".lg-video-object").first(),t=this.core.galleryItems[e].__slideVideoInfo||{};if(i.get())if(t.youtube)try{i.get().contentWindow.postMessage('{"event":"command","func":"'+o+'Video","args":""}',"*")}catch(e){console.error("lightGallery:- "+e)}else if(t.vimeo)try{new Vimeo.Player(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(t.html5)if(this.settings.videojs)try{videojs(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included videojs")}else i.get()[o]();else if(t.wistia)try{window._wq=window._wq||[],window._wq.push({id:i.attr("id"),onReady:function(e){e[o]()}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},a.prototype.loadVideoOnPosterClick=function(e,o){var i=this;if(e.hasClass("lg-video-loaded"))o&&this.playVideo(this.core.index);else if(e.hasClass("lg-has-video"))this.playVideo(this.core.index);else{e.addClass("lg-has-video");var t=void 0,s=this.core.galleryItems[this.core.index].src,n=this.core.galleryItems[this.core.index].video;n&&(t="string"==typeof n?JSON.parse(n):n);var l=this.appendVideos(e,{src:s,addClass:"",index:this.core.index,html5Video:t});this.gotoNextSlideOnVideoEnd(s,this.core.index);var r=e.find(".lg-object").first().get();e.find(".lg-video-cont").first().append(r),e.addClass("lg-video-loading"),l&&l.ready((function(){l.on("loadedmetadata",(function(){i.onVideoLoadAfterPosterClick(e,i.core.index)}))})),e.find(".lg-video-object").first().on("load.lg error.lg loadedmetadata.lg",(function(){setTimeout((function(){i.onVideoLoadAfterPosterClick(e,i.core.index)}),50)}))}},a.prototype.onVideoLoadAfterPosterClick=function(e,o){e.addClass("lg-video-loaded"),this.playVideo(o)},a.prototype.destroy=function(){this.core.LGel.off(".lg.video"),this.core.LGel.off(".video")},a}()})); 9 | -------------------------------------------------------------------------------- /app/public/lg/lg-zoom.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * lightgallery | 2.8.0-beta.3 | May 3rd 2024 3 | * http://www.lightgalleryjs.com/ 4 | * Copyright (c) 2020 Sachin Neravath; 5 | * @license GPLv3 6 | */ 7 | 8 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).lgZoom=t()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var t,o=1,i=arguments.length;o':"";this.settings.actualSize&&(e+=''),this.core.outer.addClass("lg-use-transition-for-zoom"),this.core.$toolbar.first().append(e)},h.prototype.enableZoom=function(e){var t=this,o=this.settings.enableZoomAfter+e.detail.delay;this.$LG("body").first().hasClass("lg-from-hash")&&e.detail.delay?o=0:this.$LG("body").first().removeClass("lg-from-hash"),this.zoomableTimeout=setTimeout((function(){t.isImageSlide(t.core.index)&&(t.core.getSlideItem(e.detail.index).addClass("lg-zoomable"),e.detail.index===t.core.index&&t.setZoomEssentials())}),o+30)},h.prototype.enableZoomOnSlideItemLoad=function(){this.core.LGel.on(a+".zoom",this.enableZoom.bind(this))},h.prototype.getDragCords=function(e){return{x:e.pageX,y:e.pageY}},h.prototype.getSwipeCords=function(e){return{x:e.touches[0].pageX,y:e.touches[0].pageY}},h.prototype.getDragAllowedAxises=function(e,t){if(!this.containerRect)return{allowX:!1,allowY:!1};var o=this.core.getSlideItem(this.core.index).find(".lg-image").first().get(),i=0,s=0,a=o.getBoundingClientRect();e?(i=o.offsetHeight*e,s=o.offsetWidth*e):t?(i=a.height+t*a.height,s=a.width+t*a.width):(i=a.height,s=a.width);var n=i>this.containerRect.height;return{allowX:s>this.containerRect.width,allowY:n}},h.prototype.setZoomEssentials=function(){this.containerRect=this.core.$content.get().getBoundingClientRect()},h.prototype.zoomImage=function(e,t,o,i){if(!(Math.abs(t)<=0)){var s,a,n=this.containerRect.width/2+this.containerRect.left,r=this.containerRect.height/2+this.containerRect.top+this.scrollTop;1===e&&(this.positionChanged=!1);var l=this.getDragAllowedAxises(0,t),c=l.allowY,g=l.allowX;this.positionChanged&&(s=this.left/(this.scale-t),a=this.top/(this.scale-t),this.pageX=n-s,this.pageY=r-a,this.positionChanged=!1);var h,m,u=this.getPossibleSwipeDragCords(t),d=n-this.pageX,f=r-this.pageY;if(e-t>1){var p=(e-t)/Math.abs(t);h=(d=(t<0?-d:d)+this.left*(p+(t<0?-1:1)))/p,m=(f=(t<0?-f:f)+this.top*(p+(t<0?-1:1)))/p}else{h=d*(p=(e-t)*t),m=f*p}o&&(g?this.isBeyondPossibleLeft(h,u.minX)?h=u.minX:this.isBeyondPossibleRight(h,u.maxX)&&(h=u.maxX):e>1&&(hu.maxX&&(h=u.maxX)),c?this.isBeyondPossibleTop(m,u.minY)?m=u.minY:this.isBeyondPossibleBottom(m,u.maxY)&&(m=u.maxY):e>1&&(mu.maxY&&(m=u.maxY))),this.setZoomStyles({x:h,y:m,scale:e}),this.left=h,this.top=m,i&&this.setZoomImageSize()}},h.prototype.resetImageTranslate=function(e){if(this.isImageSlide(e)){var t=this.core.getSlideItem(e).find(".lg-image").first();this.imageReset=!1,t.removeClass("reset-transition reset-transition-y reset-transition-x"),this.core.outer.removeClass("lg-actual-size"),t.css("width","auto").css("height","auto"),setTimeout((function(){t.removeClass("no-transition")}),10)}},h.prototype.setZoomImageSize=function(){var e=this,t=this.core.getSlideItem(this.core.index).find(".lg-image").first();setTimeout((function(){var o=e.getCurrentImageActualSizeScale();e.scale>=o&&(t.addClass("no-transition"),e.imageReset=!0)}),500),setTimeout((function(){var o=e.getCurrentImageActualSizeScale();if(e.scale>=o){var i=e.getDragAllowedAxises(e.scale);t.css("width",t.get().naturalWidth+"px").css("height",t.get().naturalHeight+"px"),e.core.outer.addClass("lg-actual-size"),i.allowX&&i.allowY?t.addClass("reset-transition"):i.allowX&&!i.allowY?t.addClass("reset-transition-x"):!i.allowX&&i.allowY&&t.addClass("reset-transition-y")}}),550)},h.prototype.setZoomStyles=function(e){var t=this.core.getSlideItem(this.core.index).find(".lg-img-wrap").first(),o=this.core.getSlideItem(this.core.index).find(".lg-image").first(),i=this.core.outer.find(".lg-current .lg-dummy-img").first();this.scale=e.scale,o.css("transform","scale3d("+e.scale+", "+e.scale+", 1)"),i.css("transform","scale3d("+e.scale+", "+e.scale+", 1)");var s="translate3d("+e.x+"px, "+e.y+"px, 0)";t.css("transform",s)},h.prototype.setActualSize=function(e,t){var o=this;if(!this.zoomInProgress){this.zoomInProgress=!0;var i=this.core.galleryItems[this.core.index];this.resetImageTranslate(e),setTimeout((function(){if(i.src&&!o.core.outer.hasClass("lg-first-slide-loading")){var e=o.getCurrentImageActualSizeScale(),s=o.scale;o.core.outer.hasClass("lg-zoomed")?o.scale=1:o.scale=o.getScale(e),o.setPageCords(t),o.beginZoom(o.scale),o.zoomImage(o.scale,o.scale-s,!0,!0)}}),50),setTimeout((function(){o.core.outer.removeClass("lg-grabbing").addClass("lg-grab")}),60),setTimeout((function(){o.zoomInProgress=!1}),610)}},h.prototype.getNaturalWidth=function(e){var t=this.core.getSlideItem(e).find(".lg-image").first(),o=this.core.galleryItems[e].width;return o?parseFloat(o):t.get().naturalWidth},h.prototype.getActualSizeScale=function(e,t){return e>=t?e/t||2:1},h.prototype.getCurrentImageActualSizeScale=function(){var e=this.core.getSlideItem(this.core.index).find(".lg-image").first().get().offsetWidth,t=this.getNaturalWidth(this.core.index)||e;return this.getActualSizeScale(t,e)},h.prototype.getPageCords=function(e){var t={};if(e)t.x=e.pageX||e.touches[0].pageX,t.y=e.pageY||e.touches[0].pageY;else{var o=this.core.$content.get().getBoundingClientRect();t.x=o.width/2+o.left,t.y=o.height/2+this.scrollTop+o.top}return t},h.prototype.setPageCords=function(e){var t=this.getPageCords(e);this.pageX=t.x,this.pageY=t.y},h.prototype.manageActualPixelClassNames=function(){this.core.getElementById("lg-actual-size").removeClass(this.settings.actualSizeIcons.zoomIn).addClass(this.settings.actualSizeIcons.zoomOut)},h.prototype.beginZoom=function(e){return this.core.outer.removeClass("lg-zoom-drag-transition lg-zoom-dragging"),e>1?(this.core.outer.addClass("lg-zoomed"),this.manageActualPixelClassNames()):this.resetZoom(),e>1},h.prototype.getScale=function(e){var t=this.getCurrentImageActualSizeScale();return e<1?e=1:e>t&&(e=t),e},h.prototype.init=function(){var e=this;if(this.settings.zoom){this.buildTemplates(),this.enableZoomOnSlideItemLoad();var t=null;this.core.outer.on("dblclick.lg",(function(t){e.$LG(t.target).hasClass("lg-image")&&e.setActualSize(e.core.index,t)})),this.core.outer.on("touchstart.lg",(function(o){var i=e.$LG(o.target);1===o.touches.length&&i.hasClass("lg-image")&&(t?(clearTimeout(t),t=null,o.preventDefault(),e.setActualSize(e.core.index,o)):t=setTimeout((function(){t=null}),300))})),this.core.LGel.on(o+".zoom "+l+".zoom "+r+".zoom "+c+".zoom "+g+".zoom",(function(){if(e.core.lgOpened&&e.isImageSlide(e.core.index)&&!e.core.touchAction){var t=e.core.getSlideItem(e.core.index).find(".lg-img-wrap").first();e.top=0,e.left=0,e.setZoomEssentials(),e.setZoomSwipeStyles(t,{x:0,y:0}),e.positionChanged=!0}})),this.$LG(window).on("scroll.lg.zoom.global"+this.core.lgId,(function(){e.core.lgOpened&&(e.scrollTop=e.$LG(window).scrollTop())})),this.core.getElementById("lg-zoom-out").on("click.lg",(function(){if(e.isImageSlide(e.core.index)){var t=0;e.imageReset&&(e.resetImageTranslate(e.core.index),t=50),setTimeout((function(){var t=e.scale-e.settings.scale;t<1&&(t=1),e.beginZoom(t),e.zoomImage(t,-e.settings.scale,!0,!e.settings.infiniteZoom)}),t)}})),this.core.getElementById("lg-zoom-in").on("click.lg",(function(){e.zoomIn()})),this.core.getElementById("lg-actual-size").on("click.lg",(function(){e.setActualSize(e.core.index)})),this.core.LGel.on(i+".zoom",(function(){e.core.outer.find(".lg-item").removeClass("lg-zoomable")})),this.core.LGel.on(s+".zoom",(function(){e.scrollTop=e.$LG(window).scrollTop(),e.pageX=e.core.outer.width()/2,e.pageY=e.core.outer.height()/2+e.scrollTop,e.scale=1})),this.core.LGel.on(n+".zoom",(function(t){var o=t.detail.prevIndex;e.scale=1,e.positionChanged=!1,e.zoomInProgress=!1,e.resetZoom(o),e.resetImageTranslate(o),e.isImageSlide(e.core.index)&&e.setZoomEssentials()})),this.zoomDrag(),this.pinchZoom(),this.zoomSwipe(),this.zoomableTimeout=!1,this.positionChanged=!1,this.zoomInProgress=!1}},h.prototype.zoomIn=function(){if(this.isImageSlide(this.core.index)){var e=this.scale+this.settings.scale;this.settings.infiniteZoom||(e=this.getScale(e)),this.beginZoom(e),this.zoomImage(e,Math.min(this.settings.scale,e-this.scale),!0,!this.settings.infiniteZoom)}},h.prototype.resetZoom=function(e){this.core.outer.removeClass("lg-zoomed lg-zoom-drag-transition");var t=this.core.getElementById("lg-actual-size"),o=this.core.getSlideItem(void 0!==e?e:this.core.index);t.removeClass(this.settings.actualSizeIcons.zoomOut).addClass(this.settings.actualSizeIcons.zoomIn),o.find(".lg-img-wrap").first().removeAttr("style"),o.find(".lg-image").first().removeAttr("style"),this.scale=1,this.left=0,this.top=0,this.setPageCords()},h.prototype.getTouchDistance=function(e){return Math.sqrt((e.touches[0].pageX-e.touches[1].pageX)*(e.touches[0].pageX-e.touches[1].pageX)+(e.touches[0].pageY-e.touches[1].pageY)*(e.touches[0].pageY-e.touches[1].pageY))},h.prototype.pinchZoom=function(){var e=this,t=0,o=!1,i=1,s=0,a=this.core.getSlideItem(this.core.index);this.core.outer.on("touchstart.lg",(function(o){if(a=e.core.getSlideItem(e.core.index),e.isImageSlide(e.core.index)&&2===o.touches.length){if(o.preventDefault(),e.core.outer.hasClass("lg-first-slide-loading"))return;i=e.scale||1,e.core.outer.removeClass("lg-zoom-drag-transition lg-zoom-dragging"),e.setPageCords(o),e.resetImageTranslate(e.core.index),e.core.touchAction="pinch",t=e.getTouchDistance(o)}})),this.core.$inner.on("touchmove.lg",(function(n){if(2===n.touches.length&&"pinch"===e.core.touchAction&&(e.$LG(n.target).hasClass("lg-item")||a.get().contains(n.target))){n.preventDefault();var r=e.getTouchDistance(n),l=t-r;if(!o&&Math.abs(l)>5&&(o=!0),o){s=e.scale;var c=Math.max(1,i+.02*-l);e.scale=Math.round(100*(c+Number.EPSILON))/100;var g=e.scale-s;e.zoomImage(e.scale,Math.round(100*(g+Number.EPSILON))/100,!1,!1)}}})),this.core.$inner.on("touchend.lg",(function(i){if("pinch"===e.core.touchAction&&(e.$LG(i.target).hasClass("lg-item")||a.get().contains(i.target))){if(o=!1,t=0,e.scale<=1)e.resetZoom();else{var s=e.getCurrentImageActualSizeScale();if(e.scale>=s){var n=s-e.scale;0===n&&(n=.01),e.zoomImage(s,n,!1,!0)}e.manageActualPixelClassNames(),e.core.outer.addClass("lg-zoomed")}e.core.touchAction=void 0}}))},h.prototype.touchendZoom=function(e,t,o,i,s){var a=t.x-e.x,n=t.y-e.y,r=Math.abs(a)/s+1,l=Math.abs(n)/s+1;r>2&&(r+=1),l>2&&(l+=1),a*=r,n*=l;var c=this.core.getSlideItem(this.core.index).find(".lg-img-wrap").first(),g={};g.x=this.left+a,g.y=this.top+n;var h=this.getPossibleSwipeDragCords();(Math.abs(a)>15||Math.abs(n)>15)&&(i&&(this.isBeyondPossibleTop(g.y,h.minY)?g.y=h.minY:this.isBeyondPossibleBottom(g.y,h.maxY)&&(g.y=h.maxY)),o&&(this.isBeyondPossibleLeft(g.x,h.minX)?g.x=h.minX:this.isBeyondPossibleRight(g.x,h.maxX)&&(g.x=h.maxX)),i?this.top=g.y:g.y=this.top,o?this.left=g.x:g.x=this.left,this.setZoomSwipeStyles(c,g),this.positionChanged=!0)},h.prototype.getZoomSwipeCords=function(e,t,o,i,s){var a={};if(i){if(a.y=this.top+(t.y-e.y),this.isBeyondPossibleTop(a.y,s.minY)){var n=s.minY-a.y;a.y=s.minY-n/6}else if(this.isBeyondPossibleBottom(a.y,s.maxY)){var r=a.y-s.maxY;a.y=s.maxY+r/6}}else a.y=this.top;if(o){if(a.x=this.left+(t.x-e.x),this.isBeyondPossibleLeft(a.x,s.minX)){var l=s.minX-a.x;a.x=s.minX-l/6}else if(this.isBeyondPossibleRight(a.x,s.maxX)){var c=a.x-s.maxX;a.x=s.maxX+c/6}}else a.x=this.left;return a},h.prototype.isBeyondPossibleLeft=function(e,t){return e>=t},h.prototype.isBeyondPossibleRight=function(e,t){return e<=t},h.prototype.isBeyondPossibleTop=function(e,t){return e>=t},h.prototype.isBeyondPossibleBottom=function(e,t){return e<=t},h.prototype.isImageSlide=function(e){var t=this.core.galleryItems[e];return"image"===this.core.getSlideType(t)},h.prototype.getPossibleSwipeDragCords=function(e){var t=this.core.getSlideItem(this.core.index).find(".lg-image").first(),o=this.core.mediaContainerPosition.bottom,i=t.get().getBoundingClientRect(),s=i.height,a=i.width;return e&&(s+=e*s,a+=e*a),{minY:(s-this.containerRect.height)/2,maxY:(this.containerRect.height-s)/2+o,minX:(a-this.containerRect.width)/2,maxX:(this.containerRect.width-a)/2}},h.prototype.setZoomSwipeStyles=function(e,t){e.css("transform","translate3d("+t.x+"px, "+t.y+"px, 0)")},h.prototype.zoomSwipe=function(){var e,t,o=this,i={},s={},a=!1,n=!1,r=!1,l=new Date,c=(new Date,this.core.getSlideItem(this.core.index));this.core.$inner.on("touchstart.lg",(function(s){if(o.isImageSlide(o.core.index)&&(c=o.core.getSlideItem(o.core.index),(o.$LG(s.target).hasClass("lg-item")||c.get().contains(s.target))&&1===s.touches.length&&o.core.outer.hasClass("lg-zoomed"))){s.preventDefault(),l=new Date,o.core.touchAction="zoomSwipe",t=o.core.getSlideItem(o.core.index).find(".lg-img-wrap").first();var a=o.getDragAllowedAxises(0);r=a.allowY,((n=a.allowX)||r)&&(i=o.getSwipeCords(s)),e=o.getPossibleSwipeDragCords(),o.core.outer.addClass("lg-zoom-dragging lg-zoom-drag-transition")}})),this.core.$inner.on("touchmove.lg",(function(l){if(1===l.touches.length&&"zoomSwipe"===o.core.touchAction&&(o.$LG(l.target).hasClass("lg-item")||c.get().contains(l.target))){l.preventDefault(),o.core.touchAction="zoomSwipe",s=o.getSwipeCords(l);var g=o.getZoomSwipeCords(i,s,n,r,e);(Math.abs(s.x-i.x)>15||Math.abs(s.y-i.y)>15)&&(a=!0,o.setZoomSwipeStyles(t,g))}})),this.core.$inner.on("touchend.lg",(function(e){if("zoomSwipe"===o.core.touchAction&&(o.$LG(e.target).hasClass("lg-item")||c.get().contains(e.target))){if(e.preventDefault(),o.core.touchAction=void 0,o.core.outer.removeClass("lg-zoom-dragging"),!a)return;a=!1;var t=(new Date).valueOf()-l.valueOf();o.touchendZoom(i,s,n,r,t)}}))},h.prototype.zoomDrag=function(){var e,t,o,i,s=this,a={},n={},r=!1,l=!1,c=!1,g=!1;this.core.outer.on("mousedown.lg.zoom",(function(t){if(s.isImageSlide(s.core.index)){var n=s.core.getSlideItem(s.core.index);if(s.$LG(t.target).hasClass("lg-item")||n.get().contains(t.target)){e=new Date,i=s.core.getSlideItem(s.core.index).find(".lg-img-wrap").first();var l=s.getDragAllowedAxises(0);g=l.allowY,c=l.allowX,s.core.outer.hasClass("lg-zoomed")&&s.$LG(t.target).hasClass("lg-object")&&(c||g)&&(t.preventDefault(),a=s.getDragCords(t),o=s.getPossibleSwipeDragCords(),r=!0,s.core.outer.removeClass("lg-grab").addClass("lg-grabbing lg-zoom-drag-transition lg-zoom-dragging"))}}})),this.$LG(window).on("mousemove.lg.zoom.global"+this.core.lgId,(function(e){if(r){l=!0,n=s.getDragCords(e);var t=s.getZoomSwipeCords(a,n,c,g,o);s.setZoomSwipeStyles(i,t)}})),this.$LG(window).on("mouseup.lg.zoom.global"+this.core.lgId,(function(o){if(r){if(t=new Date,r=!1,s.core.outer.removeClass("lg-zoom-dragging"),l&&(a.x!==n.x||a.y!==n.y)){n=s.getDragCords(o);var i=t.valueOf()-e.valueOf();s.touchendZoom(a,n,c,g,i)}l=!1}s.core.outer.removeClass("lg-grabbing").addClass("lg-grab")}))},h.prototype.closeGallery=function(){this.resetZoom(),this.zoomInProgress=!1},h.prototype.destroy=function(){this.$LG(window).off(".lg.zoom.global"+this.core.lgId),this.core.LGel.off(".lg.zoom"),this.core.LGel.off(".zoom"),clearTimeout(this.zoomableTimeout),this.zoomableTimeout=!1},h}()})); 9 | -------------------------------------------------------------------------------- /app/public/lg/lightgallery-bundle.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:lg;src:url(../fonts/lg.woff2?io9a6k) format("woff2"),url(../fonts/lg.ttf?io9a6k) format("truetype"),url(../fonts/lg.woff?io9a6k) format("woff"),url(../fonts/lg.svg?io9a6k#lg) format("svg");font-weight:400;font-style:normal;font-display:block}.lg-icon{font-family:lg!important;speak:never;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.lg-container{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}.lg-next,.lg-prev{background-color:rgba(0,0,0,.45);border-radius:2px;color:#999;cursor:pointer;display:block;font-size:22px;margin-top:-10px;padding:8px 10px 9px;position:absolute;top:50%;z-index:1084;outline:0;border:none}.lg-next.disabled,.lg-prev.disabled{opacity:0!important;cursor:default}.lg-next:hover:not(.disabled),.lg-prev:hover:not(.disabled){color:#fff}.lg-single-item .lg-next,.lg-single-item .lg-prev{display:none}.lg-next{right:20px}.lg-next:before{content:"\e095"}.lg-prev{left:20px}.lg-prev:after{content:"\e094"}@-webkit-keyframes lg-right-end{0%{left:0}50%{left:-30px}100%{left:0}}@-moz-keyframes lg-right-end{0%{left:0}50%{left:-30px}100%{left:0}}@-ms-keyframes lg-right-end{0%{left:0}50%{left:-30px}100%{left:0}}@keyframes lg-right-end{0%{left:0}50%{left:-30px}100%{left:0}}@-webkit-keyframes lg-left-end{0%{left:0}50%{left:30px}100%{left:0}}@-moz-keyframes lg-left-end{0%{left:0}50%{left:30px}100%{left:0}}@-ms-keyframes lg-left-end{0%{left:0}50%{left:30px}100%{left:0}}@keyframes lg-left-end{0%{left:0}50%{left:30px}100%{left:0}}.lg-outer.lg-right-end .lg-object{-webkit-animation:lg-right-end .3s;-o-animation:lg-right-end .3s;animation:lg-right-end .3s;position:relative}.lg-outer.lg-left-end .lg-object{-webkit-animation:lg-left-end .3s;-o-animation:lg-left-end .3s;animation:lg-left-end .3s;position:relative}.lg-toolbar{z-index:1082;left:0;position:absolute;top:0;width:100%}.lg-media-overlap .lg-toolbar{background-image:linear-gradient(0deg,rgba(0,0,0,0),rgba(0,0,0,.4))}.lg-toolbar .lg-icon{color:#999;cursor:pointer;float:right;font-size:24px;height:47px;line-height:27px;padding:10px 0;text-align:center;width:50px;text-decoration:none!important;outline:medium none;will-change:color;-webkit-transition:color .2s linear;-o-transition:color .2s linear;transition:color .2s linear;background:0 0;border:none;box-shadow:none}.lg-toolbar .lg-icon.lg-icon-18{font-size:18px}.lg-toolbar .lg-icon:hover{color:#fff}.lg-toolbar .lg-close:after{content:"\e070"}.lg-toolbar .lg-maximize{font-size:22px}.lg-toolbar .lg-maximize:after{content:"\e90a"}.lg-toolbar .lg-download:after{content:"\e0f2"}.lg-sub-html{color:#eee;font-size:16px;padding:10px 40px;text-align:center;z-index:1080;opacity:0;-webkit-transition:opacity .2s ease-out 0s;-o-transition:opacity .2s ease-out 0s;transition:opacity .2s ease-out 0s}.lg-sub-html h4{margin:0;font-size:13px;font-weight:700}.lg-sub-html p{font-size:12px;margin:5px 0 0}.lg-sub-html a{color:inherit}.lg-sub-html a:hover{text-decoration:underline}.lg-media-overlap .lg-sub-html{background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.6))}.lg-item .lg-sub-html{position:absolute;bottom:0;right:0;left:0}.lg-error-msg{font-size:14px;color:#999}.lg-counter{color:#999;display:inline-block;font-size:16px;padding-left:20px;padding-top:12px;height:47px;vertical-align:middle}.lg-closing .lg-next,.lg-closing .lg-prev,.lg-closing .lg-sub-html,.lg-closing .lg-toolbar{opacity:0;-webkit-transition:-webkit-transform .08 cubic-bezier(0,0,.25,1) 0s,opacity .08 cubic-bezier(0,0,.25,1) 0s,color .08 linear;-moz-transition:-moz-transform .08 cubic-bezier(0,0,.25,1) 0s,opacity .08 cubic-bezier(0,0,.25,1) 0s,color .08 linear;-o-transition:-o-transform .08 cubic-bezier(0,0,.25,1) 0s,opacity .08 cubic-bezier(0,0,.25,1) 0s,color .08 linear;transition:transform .08 cubic-bezier(0,0,.25,1) 0s,opacity .08 cubic-bezier(0,0,.25,1) 0s,color .08 linear}body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable) .lg-img-wrap,body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable) .lg-media-cont,body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable) .lg-video-cont{opacity:0;-moz-transform:scale3d(.5,.5,.5);-o-transform:scale3d(.5,.5,.5);-ms-transform:scale3d(.5,.5,.5);-webkit-transform:scale3d(.5,.5,.5);transform:scale3d(.5,.5,.5);will-change:transform,opacity;-webkit-transition:-webkit-transform 250ms cubic-bezier(0,0,.25,1) 0s,opacity 250ms cubic-bezier(0,0,.25,1)!important;-moz-transition:-moz-transform 250ms cubic-bezier(0,0,.25,1) 0s,opacity 250ms cubic-bezier(0,0,.25,1)!important;-o-transition:-o-transform 250ms cubic-bezier(0,0,.25,1) 0s,opacity 250ms cubic-bezier(0,0,.25,1)!important;transition:transform 250ms cubic-bezier(0,0,.25,1) 0s,opacity 250ms cubic-bezier(0,0,.25,1)!important}body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable).lg-complete .lg-img-wrap,body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable).lg-complete .lg-media-cont,body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item:not(.lg-zoomable).lg-complete .lg-video-cont{opacity:1;-moz-transform:scale3d(1,1,1);-o-transform:scale3d(1,1,1);-ms-transform:scale3d(1,1,1);-webkit-transform:scale3d(1,1,1);transform:scale3d(1,1,1)}.lg-icon:focus-visible{color:#fff;border-radius:3px;outline:1px dashed rgba(255,255,255,.6)}.lg-toolbar .lg-icon:focus-visible{border-radius:8px;outline-offset:-5px}.lg-outer .lg-thumb-outer{background-color:#0d0a0a;width:100%;max-height:350px;overflow:hidden;float:left}.lg-outer .lg-thumb-outer.lg-grab .lg-thumb-item{cursor:-webkit-grab;cursor:-moz-grab;cursor:-o-grab;cursor:-ms-grab;cursor:grab}.lg-outer .lg-thumb-outer.lg-grabbing .lg-thumb-item{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:-o-grabbing;cursor:-ms-grabbing;cursor:grabbing}.lg-outer .lg-thumb-outer.lg-dragging .lg-thumb{-webkit-transition-duration:0s!important;transition-duration:0s!important}.lg-outer .lg-thumb-outer.lg-rebuilding-thumbnails .lg-thumb{-webkit-transition-duration:0s!important;transition-duration:0s!important}.lg-outer .lg-thumb-outer.lg-thumb-align-middle{text-align:center}.lg-outer .lg-thumb-outer.lg-thumb-align-left{text-align:left}.lg-outer .lg-thumb-outer.lg-thumb-align-right{text-align:right}.lg-outer.lg-single-item .lg-thumb-outer{display:none}.lg-outer .lg-thumb{padding:5px 0;height:100%;margin-bottom:-5px;display:inline-block;vertical-align:middle}@media (min-width:768px){.lg-outer .lg-thumb{padding:10px 0}}.lg-outer .lg-thumb-item{cursor:pointer;float:left;overflow:hidden;height:100%;border-radius:2px;margin-bottom:5px;will-change:border-color}@media (min-width:768px){.lg-outer .lg-thumb-item{border-radius:4px;border:2px solid #fff;-webkit-transition:border-color .25s ease;-o-transition:border-color .25s ease;transition:border-color .25s ease}}.lg-outer .lg-thumb-item.active,.lg-outer .lg-thumb-item:hover{border-color:#a90707}.lg-outer .lg-thumb-item img{width:100%;height:100%;object-fit:cover;display:block}.lg-outer.lg-can-toggle .lg-item{padding-bottom:0}.lg-outer .lg-toggle-thumb:after{content:"\e1ff"}.lg-outer.lg-animate-thumb .lg-thumb{-webkit-transition-timing-function:cubic-bezier(.215,.61,.355,1);transition-timing-function:cubic-bezier(.215,.61,.355,1)}.lg-outer .lg-video-cont{text-align:center;display:inline-block;vertical-align:middle;position:relative}.lg-outer .lg-video-cont .lg-object{width:100%!important;height:100%!important}.lg-outer .lg-has-iframe .lg-video-cont{-webkit-overflow-scrolling:touch;overflow:auto}.lg-outer .lg-video-object{position:absolute;left:0;right:0;width:100%;height:100%;top:0;bottom:0;z-index:3}.lg-outer .lg-video-poster{z-index:1}.lg-outer .lg-has-video .lg-video-object{opacity:0;will-change:opacity;-webkit-transition:opacity .3s ease-in;-o-transition:opacity .3s ease-in;transition:opacity .3s ease-in}.lg-outer .lg-has-video.lg-video-loaded .lg-video-play-button,.lg-outer .lg-has-video.lg-video-loaded .lg-video-poster{opacity:0!important}.lg-outer .lg-has-video.lg-video-loaded .lg-video-object{opacity:1}@keyframes lg-play-stroke{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:89,200;stroke-dashoffset:-35px}100%{stroke-dasharray:89,200;stroke-dashoffset:-124px}}@keyframes lg-play-rotate{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.lg-video-play-button{width:18%;max-width:140px;position:absolute;top:50%;left:50%;z-index:2;cursor:pointer;transform:translate(-50%,-50%) scale(1);will-change:opacity,transform;-webkit-transition:-webkit-transform .25s cubic-bezier(.17,.88,.32,1.28),opacity .1s;-moz-transition:-moz-transform .25s cubic-bezier(.17,.88,.32,1.28),opacity .1s;-o-transition:-o-transform .25s cubic-bezier(.17,.88,.32,1.28),opacity .1s;transition:transform .25s cubic-bezier(.17,.88,.32,1.28),opacity .1s}.lg-video-play-button:hover .lg-video-play-icon,.lg-video-play-button:hover .lg-video-play-icon-bg{opacity:1}.lg-video-play-icon-bg{fill:none;stroke-width:3%;stroke:#fcfcfc;opacity:.6;will-change:opacity;-webkit-transition:opacity .12s ease-in;-o-transition:opacity .12s ease-in;transition:opacity .12s ease-in}.lg-video-play-icon-circle{position:absolute;top:0;left:0;bottom:0;right:0;fill:none;stroke-width:3%;stroke:rgba(30,30,30,.9);stroke-opacity:1;stroke-linecap:round;stroke-dasharray:200;stroke-dashoffset:200}.lg-video-play-icon{position:absolute;width:25%;max-width:120px;left:50%;top:50%;transform:translate3d(-50%,-50%,0);opacity:.6;will-change:opacity;-webkit-transition:opacity .12s ease-in;-o-transition:opacity .12s ease-in;transition:opacity .12s ease-in}.lg-video-play-icon .lg-video-play-icon-inner{fill:#fcfcfc}.lg-video-loading .lg-video-play-icon-circle{animation:lg-play-rotate 2s linear .25s infinite,lg-play-stroke 1.5s ease-in-out .25s infinite}.lg-video-loaded .lg-video-play-button{opacity:0;transform:translate(-50%,-50%) scale(.7)}.lg-progress-bar{background-color:#333;height:5px;left:0;position:absolute;top:0;width:100%;z-index:1083;opacity:0;will-change:opacity;-webkit-transition:opacity 80ms ease 0s;-moz-transition:opacity 80ms ease 0s;-o-transition:opacity 80ms ease 0s;transition:opacity 80ms ease 0s}.lg-progress-bar .lg-progress{background-color:#a90707;height:5px;width:0}.lg-progress-bar.lg-start .lg-progress{width:100%}.lg-show-autoplay .lg-progress-bar{opacity:1}.lg-autoplay-button:after{content:"\e01d"}.lg-show-autoplay .lg-autoplay-button:after{content:"\e01a"}.lg-single-item .lg-autoplay-button{opacity:.75;pointer-events:none}.lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-image,.lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-img-wrap{-webkit-transition-duration:0s!important;transition-duration:0s!important}.lg-outer.lg-use-transition-for-zoom .lg-item.lg-complete.lg-zoomable .lg-img-wrap{will-change:transform;-webkit-transition:-webkit-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s;-moz-transition:-moz-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s;-o-transition:-o-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s;transition:transform .5s cubic-bezier(.12,.415,.01,1.19) 0s}.lg-outer.lg-use-transition-for-zoom.lg-zoom-drag-transition .lg-item.lg-complete.lg-zoomable .lg-img-wrap{will-change:transform;-webkit-transition:-webkit-transform .8s cubic-bezier(0,0,.25,1) 0s;-moz-transition:-moz-transform .8s cubic-bezier(0,0,.25,1) 0s;-o-transition:-o-transform .8s cubic-bezier(0,0,.25,1) 0s;transition:transform .8s cubic-bezier(0,0,.25,1) 0s}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-img-wrap{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-dummy-img,.lg-outer .lg-item.lg-complete.lg-zoomable .lg-image{-webkit-transform:scale3d(1,1,1);transform:scale3d(1,1,1);-webkit-transition:-webkit-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s,opacity .15s!important;-moz-transition:-moz-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s,opacity .15s!important;-o-transition:-o-transform .5s cubic-bezier(.12,.415,.01,1.19) 0s,opacity .15s!important;transition:transform .5s cubic-bezier(.12,.415,.01,1.19) 0s,opacity .15s!important;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-dummy-img.no-transition,.lg-outer .lg-item.lg-complete.lg-zoomable .lg-image.no-transition{transition:none!important}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-dummy-img.reset-transition,.lg-outer .lg-item.lg-complete.lg-zoomable .lg-image.reset-transition{transform:scale3d(1,1,1) translate3d(-50%,-50%,0)!important;max-width:none!important;max-height:none!important;top:50%!important;left:50%!important}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-dummy-img.reset-transition-x,.lg-outer .lg-item.lg-complete.lg-zoomable .lg-image.reset-transition-x{transform:scale3d(1,1,1) translate3d(-50%,0,0)!important;top:0!important;left:50%!important;max-width:none!important;max-height:none!important}.lg-outer .lg-item.lg-complete.lg-zoomable .lg-dummy-img.reset-transition-y,.lg-outer .lg-item.lg-complete.lg-zoomable .lg-image.reset-transition-y{transform:scale3d(1,1,1) translate3d(0,-50%,0)!important;top:50%!important;left:0!important;max-width:none!important;max-height:none!important}.lg-icon.lg-zoom-in:after{content:"\e311"}.lg-actual-size .lg-icon.lg-zoom-in{opacity:1;pointer-events:auto}.lg-icon.lg-actual-size{font-size:20px}.lg-icon.lg-actual-size:after{content:"\e033"}.lg-icon.lg-zoom-out{opacity:.5;pointer-events:none}.lg-icon.lg-zoom-out:after{content:"\e312"}.lg-zoomed .lg-icon.lg-zoom-out{opacity:1;pointer-events:auto}.lg-outer.lg-first-slide-loading .lg-actual-size,.lg-outer.lg-first-slide-loading .lg-zoom-in,.lg-outer.lg-first-slide-loading .lg-zoom-out,.lg-outer[data-lg-slide-type=iframe] .lg-actual-size,.lg-outer[data-lg-slide-type=iframe] .lg-zoom-in,.lg-outer[data-lg-slide-type=iframe] .lg-zoom-out,.lg-outer[data-lg-slide-type=video] .lg-actual-size,.lg-outer[data-lg-slide-type=video] .lg-zoom-in,.lg-outer[data-lg-slide-type=video] .lg-zoom-out{opacity:.75;pointer-events:none}.lg-outer .lg-pager-outer{text-align:center;z-index:1080;height:10px;margin-bottom:10px}.lg-outer .lg-pager-outer.lg-pager-hover .lg-pager-cont{overflow:visible}.lg-outer.lg-single-item .lg-pager-outer{display:none}.lg-outer .lg-pager-cont{cursor:pointer;display:inline-block;overflow:hidden;position:relative;vertical-align:top;margin:0 5px}.lg-outer .lg-pager-cont:hover .lg-pager-thumb-cont{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.lg-outer .lg-pager-cont.lg-pager-active .lg-pager{box-shadow:0 0 0 2px #fff inset}.lg-outer .lg-pager-thumb-cont{background-color:#fff;color:#fff;bottom:100%;height:83px;left:0;margin-bottom:20px;margin-left:-60px;opacity:0;padding:5px;position:absolute;width:120px;border-radius:3px;will-change:transform,opacity;-webkit-transition:opacity .15s ease 0s,-webkit-transform .15s ease 0s;-moz-transition:opacity .15s ease 0s,-moz-transform .15s ease 0s;-o-transition:opacity .15s ease 0s,-o-transform .15s ease 0s;transition:opacity .15s ease 0s,transform .15s ease 0s;-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}.lg-outer .lg-pager-thumb-cont img{width:100%;height:100%}.lg-outer .lg-pager{background-color:rgba(255,255,255,.5);border-radius:50%;box-shadow:0 0 0 8px rgba(255,255,255,.7) inset;display:block;height:12px;-webkit-transition:box-shadow .3s ease 0s;-o-transition:box-shadow .3s ease 0s;transition:box-shadow .3s ease 0s;width:12px}.lg-outer .lg-pager:focus,.lg-outer .lg-pager:hover{box-shadow:0 0 0 8px #fff inset}.lg-outer .lg-caret{border-left:10px solid transparent;border-right:10px solid transparent;border-top:10px dashed;bottom:-10px;display:inline-block;height:0;left:50%;margin-left:-5px;position:absolute;vertical-align:middle;width:0}.lg-fullscreen:after{content:"\e20c"}.lg-fullscreen-on .lg-fullscreen:after{content:"\e20d"}.lg-outer .lg-dropdown-overlay{background-color:rgba(0,0,0,.25);bottom:0;cursor:default;left:0;position:absolute;right:0;top:0;z-index:1081;opacity:0;visibility:hidden;will-change:visibility,opacity;-webkit-transition:visibility 0s linear .18s,opacity .18s linear 0s;-o-transition:visibility 0s linear .18s,opacity .18s linear 0s;transition:visibility 0s linear .18s,opacity .18s linear 0s}.lg-outer.lg-dropdown-active .lg-dropdown,.lg-outer.lg-dropdown-active .lg-dropdown-overlay{-webkit-transition-delay:0s;transition-delay:0s;-moz-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1;visibility:visible}.lg-outer.lg-dropdown-active .lg-share{color:#fff}.lg-outer .lg-dropdown{background-color:#fff;border-radius:2px;font-size:14px;list-style-type:none;margin:0;padding:10px 0;position:absolute;right:0;text-align:left;top:50px;opacity:0;visibility:hidden;-moz-transform:translate3d(0,5px,0);-o-transform:translate3d(0,5px,0);-ms-transform:translate3d(0,5px,0);-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0);will-change:visibility,opacity,transform;-webkit-transition:-webkit-transform .18s linear 0s,visibility 0s linear .5s,opacity .18s linear 0s;-moz-transition:-moz-transform .18s linear 0s,visibility 0s linear .5s,opacity .18s linear 0s;-o-transition:-o-transform .18s linear 0s,visibility 0s linear .5s,opacity .18s linear 0s;transition:transform .18s linear 0s,visibility 0s linear .5s,opacity .18s linear 0s}.lg-outer .lg-dropdown:after{content:"";display:block;height:0;width:0;position:absolute;border:8px solid transparent;border-bottom-color:#fff;right:16px;top:-16px}.lg-outer .lg-dropdown>li:last-child{margin-bottom:0}.lg-outer .lg-dropdown>li:hover a{color:#333}.lg-outer .lg-dropdown a{color:#333;display:block;white-space:pre;padding:4px 12px;font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px}.lg-outer .lg-dropdown a:hover{background-color:rgba(0,0,0,.07)}.lg-outer .lg-dropdown .lg-dropdown-text{display:inline-block;line-height:1;margin-top:-3px;vertical-align:middle}.lg-outer .lg-dropdown .lg-icon{color:#333;display:inline-block;float:none;font-size:20px;height:auto;line-height:1;margin-right:8px;padding:0;vertical-align:middle;width:auto}.lg-outer .lg-share{position:relative}.lg-outer .lg-share:after{content:"\e80d"}.lg-outer .lg-share-facebook .lg-icon{color:#3b5998}.lg-outer .lg-share-facebook .lg-icon:after{content:"\e904"}.lg-outer .lg-share-twitter .lg-icon{color:#00aced}.lg-outer .lg-share-twitter .lg-icon:after{content:"\e907"}.lg-outer .lg-share-pinterest .lg-icon{color:#cb2027}.lg-outer .lg-share-pinterest .lg-icon:after{content:"\e906"}.lg-comment-box{width:420px;max-width:100%;position:absolute;right:0;top:0;bottom:0;z-index:9999;background-color:#fff;will-change:transform;-moz-transform:translate3d(100%,0,0);-o-transform:translate3d(100%,0,0);-ms-transform:translate3d(100%,0,0);-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);-webkit-transition:-webkit-transform .4s cubic-bezier(0,0,.25,1) 0s;-moz-transition:-moz-transform .4s cubic-bezier(0,0,.25,1) 0s;-o-transition:-o-transform .4s cubic-bezier(0,0,.25,1) 0s;transition:transform .4s cubic-bezier(0,0,.25,1) 0s}.lg-comment-box .lg-comment-title{margin:0;color:#fff;font-size:18px}.lg-comment-box .lg-comment-header{background-color:#000;padding:12px 20px;position:absolute;left:0;right:0;top:0}.lg-comment-box .lg-comment-body{height:100%!important;padding-top:43px!important;width:100%!important}.lg-comment-box .fb-comments{height:100%;width:100%;background:url(../images/loading.gif) no-repeat scroll center center #fff;overflow-y:auto;display:inline-block}.lg-comment-box .fb-comments[fb-xfbml-state=rendered]{background-image:none}.lg-comment-box .fb-comments>span{max-width:100%}.lg-comment-box .lg-comment-close{position:absolute;right:5px;top:12px;cursor:pointer;font-size:20px;color:#999;will-change:color;-webkit-transition:color .2s linear;-o-transition:color .2s linear;transition:color .2s linear}.lg-comment-box .lg-comment-close:hover{color:#fff}.lg-comment-box .lg-comment-close:after{content:"\e070"}.lg-comment-box iframe{max-width:100%!important;width:100%!important}.lg-comment-box #disqus_thread{padding:0 20px}.lg-outer .lg-comment-overlay{background-color:rgba(0,0,0,.25);bottom:0;cursor:default;left:0;position:fixed;right:0;top:0;z-index:1081;opacity:0;visibility:hidden;will-change:visibility,opacity;-webkit-transition:visibility 0s linear .18s,opacity .18s linear 0s;-o-transition:visibility 0s linear .18s,opacity .18s linear 0s;transition:visibility 0s linear .18s,opacity .18s linear 0s}.lg-outer .lg-comment-toggle:after{content:"\e908"}.lg-outer.lg-comment-active .lg-comment-overlay{-webkit-transition-delay:0s;transition-delay:0s;-moz-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1;visibility:visible}.lg-outer.lg-comment-active .lg-comment-toggle{color:#fff}.lg-outer.lg-comment-active .lg-comment-box{-moz-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.lg-outer .lg-img-rotate{position:absolute;left:0;right:0;top:0;bottom:0;-webkit-transition:-webkit-transform .4s cubic-bezier(0,0,.25,1) 0s;-moz-transition:-moz-transform .4s cubic-bezier(0,0,.25,1) 0s;-o-transition:-o-transform .4s cubic-bezier(0,0,.25,1) 0s;transition:transform .4s cubic-bezier(0,0,.25,1) 0s}.lg-outer[data-lg-slide-type=iframe] .lg-flip-hor,.lg-outer[data-lg-slide-type=iframe] .lg-flip-ver,.lg-outer[data-lg-slide-type=iframe] .lg-rotate-left,.lg-outer[data-lg-slide-type=iframe] .lg-rotate-right,.lg-outer[data-lg-slide-type=video] .lg-flip-hor,.lg-outer[data-lg-slide-type=video] .lg-flip-ver,.lg-outer[data-lg-slide-type=video] .lg-rotate-left,.lg-outer[data-lg-slide-type=video] .lg-rotate-right{opacity:.75;pointer-events:none}.lg-outer .lg-img-rotate:before{content:"";display:inline-block;height:100%;vertical-align:middle}.lg-rotate-left:after{content:"\e900"}.lg-rotate-right:after{content:"\e901"}.lg-icon.lg-flip-hor,.lg-icon.lg-flip-ver{font-size:26px}.lg-flip-ver:after{content:"\e903"}.lg-flip-hor:after{content:"\e902"}.lg-medium-zoom-item{cursor:zoom-in}.lg-medium-zoom .lg-outer{cursor:zoom-out}.lg-medium-zoom .lg-outer.lg-grab img.lg-object{cursor:zoom-out}.lg-medium-zoom .lg-outer.lg-grabbing img.lg-object{cursor:zoom-out}.lg-relative-caption .lg-outer .lg-sub-html{white-space:normal;bottom:auto;padding:0;background-image:none}.lg-relative-caption .lg-outer .lg-relative-caption-item{opacity:0;padding:16px 0;transition:.5s opacity ease}.lg-relative-caption .lg-outer .lg-show-caption .lg-relative-caption-item{opacity:1}.lg-group:after{content:"";display:table;clear:both}.lg-container{display:none;outline:0}.lg-container.lg-show{display:block}.lg-on{scroll-behavior:unset}.lg-overlay-open{overflow:hidden}.lg-hide-sub-html .lg-sub-html,.lg-next,.lg-pager-outer,.lg-prev,.lg-toolbar{opacity:0;will-change:transform,opacity;-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1) 0s,opacity .25s cubic-bezier(0,0,.25,1) 0s;-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1) 0s,opacity .25s cubic-bezier(0,0,.25,1) 0s;-o-transition:-o-transform .25s cubic-bezier(0,0,.25,1) 0s,opacity .25s cubic-bezier(0,0,.25,1) 0s;transition:transform .25s cubic-bezier(0,0,.25,1) 0s,opacity .25s cubic-bezier(0,0,.25,1) 0s}.lg-show-in .lg-next,.lg-show-in .lg-pager-outer,.lg-show-in .lg-prev,.lg-show-in .lg-toolbar{opacity:1}.lg-show-in.lg-hide-sub-html .lg-sub-html{opacity:1}.lg-show-in .lg-hide-items .lg-prev{opacity:0;-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}.lg-show-in .lg-hide-items .lg-next{opacity:0;-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}.lg-show-in .lg-hide-items .lg-toolbar{opacity:0;-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}.lg-show-in .lg-hide-items.lg-hide-sub-html .lg-sub-html{opacity:0;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}.lg-outer{width:100%;height:100%;position:fixed;top:0;left:0;z-index:1050;text-align:left;opacity:.001;outline:0;will-change:auto;overflow:hidden;-webkit-transition:opacity .15s ease 0s;-o-transition:opacity .15s ease 0s;transition:opacity .15s ease 0s}.lg-outer *{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.lg-outer.lg-zoom-from-image{opacity:1}.lg-outer.lg-visible{opacity:1}.lg-outer.lg-css3 .lg-item:not(.lg-start-end-progress).lg-current,.lg-outer.lg-css3 .lg-item:not(.lg-start-end-progress).lg-next-slide,.lg-outer.lg-css3 .lg-item:not(.lg-start-end-progress).lg-prev-slide{-webkit-transition-duration:inherit!important;transition-duration:inherit!important;-webkit-transition-timing-function:inherit!important;transition-timing-function:inherit!important}.lg-outer.lg-css3.lg-dragging .lg-item.lg-current,.lg-outer.lg-css3.lg-dragging .lg-item.lg-next-slide,.lg-outer.lg-css3.lg-dragging .lg-item.lg-prev-slide{-webkit-transition-duration:0s!important;transition-duration:0s!important;opacity:1}.lg-outer.lg-grab img.lg-object{cursor:-webkit-grab;cursor:-moz-grab;cursor:-o-grab;cursor:-ms-grab;cursor:grab}.lg-outer.lg-grabbing img.lg-object{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:-o-grabbing;cursor:-ms-grabbing;cursor:grabbing}.lg-outer .lg-content{position:absolute;top:0;left:0;right:0;bottom:0}.lg-outer .lg-inner{width:100%;position:absolute;left:0;top:0;bottom:0;-webkit-transition:opacity 0s;-o-transition:opacity 0s;transition:opacity 0s;white-space:nowrap}.lg-outer .lg-item{display:none!important}.lg-outer .lg-item:not(.lg-start-end-progress){background:url(../images/loading.gif) no-repeat scroll center center transparent}.lg-outer.lg-css3 .lg-current,.lg-outer.lg-css3 .lg-next-slide,.lg-outer.lg-css3 .lg-prev-slide{display:inline-block!important}.lg-outer.lg-css .lg-current{display:inline-block!important}.lg-outer .lg-img-wrap,.lg-outer .lg-item{display:inline-block;text-align:center;position:absolute;width:100%;height:100%}.lg-outer .lg-img-wrap:before,.lg-outer .lg-item:before{content:"";display:inline-block;height:100%;vertical-align:middle}.lg-outer .lg-img-wrap{position:absolute;left:0;right:0;top:0;bottom:0;white-space:nowrap;font-size:0}.lg-outer .lg-item.lg-complete{background-image:none}.lg-outer .lg-item.lg-current{z-index:1060}.lg-outer .lg-object{display:inline-block;vertical-align:middle;max-width:100%;max-height:100%;width:auto;height:auto;position:relative}.lg-outer .lg-empty-html .lg-sub-html,.lg-outer .lg-empty-html.lg-sub-html{display:none}.lg-outer.lg-hide-download .lg-download{opacity:.75;pointer-events:none}.lg-outer .lg-first-slide .lg-dummy-img{position:absolute;top:50%;left:50%}.lg-outer.lg-components-open:not(.lg-zoomed) .lg-components{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.lg-outer.lg-components-open:not(.lg-zoomed) .lg-sub-html{opacity:1;transition:opacity .2s ease-out .15s}.lg-outer .lg-media-cont{text-align:center;display:inline-block;vertical-align:middle;position:relative}.lg-outer .lg-media-cont .lg-object{width:100%!important;height:100%!important}.lg-outer .lg-has-iframe .lg-media-cont{-webkit-overflow-scrolling:touch;overflow:auto}.lg-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;z-index:1040;background-color:#000;opacity:0;will-change:auto;-webkit-transition:opacity 333ms ease-in 0s;-o-transition:opacity 333ms ease-in 0s;transition:opacity 333ms ease-in 0s}.lg-backdrop.in{opacity:1}.lg-css3.lg-no-trans .lg-current,.lg-css3.lg-no-trans .lg-next-slide,.lg-css3.lg-no-trans .lg-prev-slide{-webkit-transition:none 0s ease 0s!important;-moz-transition:none 0s ease 0s!important;-o-transition:none 0s ease 0s!important;transition:none 0s ease 0s!important}.lg-css3.lg-use-css3 .lg-item{-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden}.lg-css3.lg-fade .lg-item{opacity:0}.lg-css3.lg-fade .lg-item.lg-current{opacity:1}.lg-css3.lg-fade .lg-item.lg-current,.lg-css3.lg-fade .lg-item.lg-next-slide,.lg-css3.lg-fade .lg-item.lg-prev-slide{-webkit-transition:opacity .1s ease 0s;-moz-transition:opacity .1s ease 0s;-o-transition:opacity .1s ease 0s;transition:opacity .1s ease 0s}.lg-css3.lg-use-css3 .lg-item.lg-start-progress{-webkit-transition:-webkit-transform 1s cubic-bezier(.175,.885,.32,1.275) 0s;-moz-transition:-moz-transform 1s cubic-bezier(.175,.885,.32,1.275) 0s;-o-transition:-o-transform 1s cubic-bezier(.175,.885,.32,1.275) 0s;transition:transform 1s cubic-bezier(.175,.885,.32,1.275) 0s}.lg-css3.lg-use-css3 .lg-item.lg-start-end-progress{-webkit-transition:-webkit-transform 1s cubic-bezier(0,0,.25,1) 0s;-moz-transition:-moz-transform 1s cubic-bezier(0,0,.25,1) 0s;-o-transition:-o-transform 1s cubic-bezier(0,0,.25,1) 0s;transition:transform 1s cubic-bezier(0,0,.25,1) 0s}.lg-css3.lg-slide.lg-use-css3 .lg-item{opacity:0}.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current,.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide,.lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide{-webkit-transition:-webkit-transform 1s cubic-bezier(0,0,.25,1) 0s,opacity .1s ease 0s;-moz-transition:-moz-transform 1s cubic-bezier(0,0,.25,1) 0s,opacity .1s ease 0s;-o-transition:-o-transform 1s cubic-bezier(0,0,.25,1) 0s,opacity .1s ease 0s;transition:transform 1s cubic-bezier(0,0,.25,1) 0s,opacity .1s ease 0s}.lg-container{display:none}.lg-container.lg-show{display:block}.lg-container.lg-dragging-vertical .lg-backdrop{-webkit-transition-duration:0s!important;transition-duration:0s!important}.lg-container.lg-dragging-vertical .lg-css3 .lg-item.lg-current{-webkit-transition-duration:0s!important;transition-duration:0s!important;opacity:1}.lg-inline .lg-backdrop,.lg-inline .lg-outer{position:absolute}.lg-inline .lg-backdrop{z-index:1}.lg-inline .lg-outer{z-index:2}.lg-inline .lg-maximize:after{content:"\e909"}.lg-components{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);will-change:transform;-webkit-transition:-webkit-transform .35s ease-out 0s;-moz-transition:-moz-transform .35s ease-out 0s;-o-transition:-o-transform .35s ease-out 0s;transition:transform .35s ease-out 0s;z-index:1080;position:absolute;bottom:0;right:0;left:0} -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /app/public/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: #191919; 3 | } 4 | 5 | #lightgallery a { 6 | position: relative; 7 | display: inline-block; 8 | text-decoration: none; 9 | margin: 0; 10 | padding: 0; 11 | cursor: pointer; 12 | } 13 | 14 | #lightgallery img { 15 | max-height: 250px; 16 | margin: 4px; 17 | } 18 | 19 | @media (max-width: 800px) { 20 | #lightgallery a { 21 | width: calc(100vw / 3 - 10px); 22 | height: calc(100vw / 3 - 10px); 23 | overflow: hidden; 24 | } 25 | 26 | #lightgallery img { 27 | width: 100%; 28 | height: 100%; 29 | object-fit: cover; 30 | object-position: center; 31 | } 32 | } 33 | 34 | .play-icon { 35 | position: absolute; 36 | top: 50%; 37 | left: 50%; 38 | transform: translate(-50%, -50%); 39 | width: 50px; 40 | height: 50px; 41 | background-image: url('/share/static/images/play.png'); 42 | background-size: contain; 43 | background-repeat: no-repeat; 44 | opacity: 0.5; 45 | } 46 | 47 | #lightgallery a:has(.play-icon):hover .play-icon { 48 | opacity: 1; 49 | } 50 | 51 | #password { 52 | color: white; 53 | } 54 | 55 | #header { 56 | font-family: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif; 57 | color: white; 58 | width: 100%; 59 | display: flex; 60 | } 61 | 62 | #header h1 { 63 | font-size: 18pt; 64 | font-weight: bold; 65 | margin: 4px; 66 | } 67 | 68 | #download-all { 69 | margin: auto 4px auto auto; 70 | } 71 | -------------------------------------------------------------------------------- /app/public/web.js: -------------------------------------------------------------------------------- 1 | // How many thumbnails to load per "page" fetched from Immich 2 | const PER_PAGE = 50 3 | 4 | class LGallery { 5 | items 6 | lightGallery 7 | element 8 | index = PER_PAGE 9 | 10 | /** 11 | * Create a lightGallery instance and populate it with the first page of gallery items 12 | */ 13 | init (params = {}) { 14 | // Create the lightGallery instance 15 | this.element = document.getElementById('lightgallery') 16 | this.lightGallery = lightGallery(this.element, Object.assign({ 17 | plugins: [lgZoom, lgThumbnail, lgVideo, lgFullscreen], 18 | speed: 500, 19 | /* 20 | This license key was graciously provided by LightGallery under their 21 | GPLv3 open-source project license: 22 | */ 23 | licenseKey: '8FFA6495-676C4D30-8BFC54B6-4D0A6CEC' 24 | /* 25 | Please do not take it and use it for other projects, as it was provided 26 | specifically for Immich Public Proxy. 27 | 28 | For your own projects you can use the default license key of 29 | 0000-0000-000-0000 as per their docs: 30 | 31 | https://www.lightgalleryjs.com/docs/settings/#licenseKey 32 | */ 33 | }, params.lgConfig)) 34 | this.items = params.items 35 | 36 | let timeout 37 | window.addEventListener('scroll', () => { 38 | if (timeout) clearTimeout(timeout) 39 | timeout = setTimeout(lgallery.handleScroll, 100) 40 | }) 41 | lgallery.handleScroll() 42 | } 43 | 44 | /** 45 | * Listen for scroll events and load more gallery items 46 | */ 47 | handleScroll () { 48 | const rect = lgallery.element.getBoundingClientRect() 49 | const scrollPosition = Math.max(0, rect.bottom - window.innerHeight) 50 | const buffer = 200 // pixels before bottom to trigger load 51 | 52 | if (scrollPosition <= buffer) { 53 | lgallery.loadMoreItems() 54 | } 55 | } 56 | 57 | /** 58 | * Load more gallery items as per lightGallery docs 59 | * https://www.lightgalleryjs.com/demos/infinite-scrolling/ 60 | */ 61 | loadMoreItems () { 62 | if (this.index < this.items.length) { 63 | // Append new thumbnails 64 | this.items 65 | .slice(this.index, this.index + PER_PAGE) 66 | .forEach(item => { 67 | this.element.insertAdjacentHTML('beforeend', item) 68 | }) 69 | this.index += PER_PAGE 70 | this.lightGallery.refresh() 71 | } 72 | } 73 | } 74 | const lgallery = new LGallery() 75 | -------------------------------------------------------------------------------- /app/src/encrypt.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | interface EncryptedPayload { 4 | iv: string; // Initialization Vector (IV) 5 | cr: string; // Encrypted data 6 | } 7 | 8 | // Generate a random 256-bit key on startup 9 | const key = crypto.randomBytes(32) 10 | const algorithm = 'aes-256-cbc' 11 | 12 | /** 13 | * Encrypt text data for storing in the session cookie 14 | */ 15 | export function encrypt (text: string): EncryptedPayload { 16 | try { 17 | const ivBuf = crypto.randomBytes(16) 18 | const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), ivBuf) 19 | let encrypted = cipher.update(text, 'utf8', 'hex') 20 | encrypted += cipher.final('hex') 21 | return { 22 | iv: ivBuf.toString('hex'), 23 | cr: encrypted 24 | } 25 | } catch (e) { } 26 | 27 | return { 28 | cr: '', 29 | iv: '' 30 | } 31 | } 32 | 33 | /** 34 | * Decrypt data which was stored in the session cookie 35 | */ 36 | export function decrypt (payload: EncryptedPayload): string { 37 | try { 38 | const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), Buffer.from(payload.iv, 'hex')) 39 | let decrypted = decipher.update(payload.cr, 'hex', 'utf8') 40 | decrypted += decipher.final('utf8') 41 | return decrypted 42 | } catch (e) { } 43 | return '' 44 | } 45 | -------------------------------------------------------------------------------- /app/src/functions.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Response } from 'express-serve-static-core' 3 | import { DownloadAll, SharedLink } from './types' 4 | 5 | let config = {} 6 | try { 7 | const configJson = require(process.env.IPP_CONFIG || '../config.json') 8 | if (typeof configJson === 'object') config = configJson 9 | } catch (e) { 10 | console.log(e) 11 | } 12 | 13 | /** 14 | * Get a configuration option fron config.json using dotted notation. 15 | * 16 | * @param path 17 | * @param [defaultOption] - Specify a default option to return if no configuation value is found 18 | * 19 | * @example 20 | * getConfigOption('ipp.singleImageGallery') 21 | */ 22 | export const getConfigOption = (path: string, defaultOption?: unknown) => { 23 | const value = path.split('.').reduce((obj: { [key: string]: unknown }, key) => (obj || {})[key], config) 24 | if (value === undefined) { 25 | return defaultOption 26 | } else { 27 | return value 28 | } 29 | } 30 | 31 | /** 32 | * Output a console.log message with timestamp 33 | */ 34 | export const log = (message: string) => console.log(dayjs().format() + ' ' + message) 35 | 36 | /** 37 | * Force a value to be a string 38 | */ 39 | export function toString (value: unknown) { 40 | return typeof value === 'string' ? value : '' 41 | } 42 | 43 | /** 44 | * Add response headers from config.json 45 | */ 46 | export function addResponseHeaders (res: Response) { 47 | Object.entries(getConfigOption('ipp.responseHeaders', {}) as { [key: string]: string }) 48 | .forEach(([header, value]) => { 49 | res.set(header, value) 50 | }) 51 | } 52 | 53 | export function canDownload (share: SharedLink) { 54 | const allowDownloadConfig = getConfigOption('ipp.allowDownloadAll', 0) as DownloadAll 55 | if (!allowDownloadConfig) { 56 | // Downloading is disabled in config.json 57 | return false 58 | } else if (allowDownloadConfig === DownloadAll.always) { 59 | // Always allowed to download in config.json 60 | return true 61 | } else { 62 | // Return Immich's setting for this shared link 63 | return !!share.allowDownload 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/immich.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Album, 3 | AlbumType, 4 | Asset, 5 | AssetType, 6 | ImageSize, 7 | IncomingShareRequest, 8 | SharedLink, 9 | SharedLinkResult 10 | } from './types' 11 | import dayjs from 'dayjs' 12 | import { addResponseHeaders, canDownload, getConfigOption, log } from './functions' 13 | import render from './render' 14 | import { Response } from 'express-serve-static-core' 15 | import { respondToInvalidRequest } from './invalidRequestHandler' 16 | 17 | class Immich { 18 | /** 19 | * Make a request to Immich API. We're not using the SDK to limit 20 | * the possible attack surface of this app. 21 | */ 22 | async request (endpoint: string) { 23 | try { 24 | const res = await fetch(this.apiUrl() + endpoint) 25 | if (res.status === 200) { 26 | const contentType = res.headers.get('Content-Type') || '' 27 | if (contentType.includes('application/json')) { 28 | return res.json() 29 | } else { 30 | return res 31 | } 32 | } else { 33 | log('Immich API status ' + res.status) 34 | console.log(await res.text()) 35 | } 36 | } catch (e) { 37 | log('Unable to reach Immich on ' + process.env.IMMICH_URL) 38 | log(`From the server IPP is running on, see if you can curl to ${this.apiUrl()}/server/ping and receive a JSON result.`) 39 | } 40 | } 41 | 42 | apiUrl () { 43 | return (process.env.IMMICH_URL || '').replace(/\/*$/, '') + '/api' 44 | } 45 | 46 | /** 47 | * Handle an incoming request for a shared link `key`. This is the main function which 48 | * communicates with Immich and returns the output back to the visitor. 49 | * 50 | * Possible HTTP responses are: 51 | * 52 | * 200 - either a photo gallery or the unlock page. 53 | * 401 - the visitor provided a password but it was invalid. 54 | * 404 - any other failed request. Check console.log for details. 55 | */ 56 | async handleShareRequest (request: IncomingShareRequest, res: Response) { 57 | addResponseHeaders(res) 58 | 59 | // Check that the key is a valid format 60 | if (!immich.isKey(request.key)) { 61 | log('Invalid share key ' + request.key) 62 | respondToInvalidRequest(res, 404) 63 | return 64 | } 65 | 66 | // Get information about the shared link via Immich API 67 | const sharedLinkRes = await immich.getShareByKey(request.key, request.password) 68 | if (!sharedLinkRes.valid) { 69 | // This isn't a valid request - check the console for more information 70 | respondToInvalidRequest(res, 404) 71 | return 72 | } 73 | 74 | // A password is required, but the visitor-provided one doesn't match 75 | if (sharedLinkRes.passwordRequired && request.password) { 76 | log('Invalid password for key ' + request.key) 77 | res.status(401) 78 | // Delete the cookie-session data, so that it doesn't keep saying "Invalid password" 79 | if (request.req?.session) delete request.req.session[request.key] 80 | } 81 | 82 | // Don't cache password-protected albums 83 | if (sharedLinkRes.passwordRequired || request.password) { 84 | res.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') 85 | res.header('Pragma', 'no-cache') 86 | res.header('Expires', '0') 87 | } 88 | 89 | // Password required - show the visitor the password page 90 | if (sharedLinkRes.passwordRequired) { 91 | // `request.key` is already sanitised at this point, but it never hurts to be explicit 92 | const key = request.key.replace(/[^\w-]/g, '') 93 | res.render('password', { 94 | key, 95 | lgConfig: render.lgConfig, 96 | notifyInvalidPassword: !!request.password 97 | }) 98 | return 99 | } 100 | 101 | if (!sharedLinkRes.link) { 102 | log('Unknown error with key ' + request.key) 103 | respondToInvalidRequest(res, 404) 104 | return 105 | } 106 | const link = sharedLinkRes.link 107 | 108 | // Make sure there are some photo/video assets for this link 109 | /* 110 | if (!link.assets.length) { 111 | log('No assets for key ' + request.key) 112 | respondToInvalidRequest(res, 404) 113 | return 114 | } */ 115 | 116 | // Everything is ok - output the shared link data 117 | 118 | if (request.mode === 'download' && canDownload(link)) { 119 | // Download all assets as a zip file 120 | await render.downloadAll(res, link) 121 | } else if (link.assets.length === 1) { 122 | // This is an individual item (not a gallery) 123 | log('Serving link ' + request.key) 124 | const asset = link.assets[0] 125 | if (asset.type === AssetType.image && !getConfigOption('ipp.singleImageGallery') && !request.password) { 126 | // For photos, output the image directly unless configured to show a gallery, 127 | // or unless it's a password-protected link 128 | await render.assetBuffer(request, res, link.assets[0], ImageSize.preview) 129 | } else { 130 | // Show a gallery page 131 | const openItem = getConfigOption('ipp.singleItemAutoOpen', true) ? 1 : 0 132 | await render.gallery(res, link, openItem) 133 | } 134 | } else { 135 | // Multiple images - render as a gallery 136 | log('Serving link ' + request.key) 137 | await render.gallery(res, link) 138 | } 139 | } 140 | 141 | /** 142 | * Query Immich for the SharedLink metadata for a given key. 143 | * The key is what is returned in the URL when you create a share in Immich. 144 | */ 145 | async getShareByKey (key: string, password?: string): Promise { 146 | let link 147 | const url = this.buildUrl(this.apiUrl() + '/shared-links/me', { 148 | key, 149 | password 150 | }) 151 | const res = await fetch(url) 152 | if ((res.headers.get('Content-Type') || '').toLowerCase().includes('application/json')) { 153 | const jsonBody = await res.json() 154 | if (jsonBody) { 155 | if (res.status === 200) { 156 | // Normal response - get the shared assets 157 | link = jsonBody as SharedLink 158 | 159 | // For an album, we need to make a second request to Immich to populate 160 | // the array of assets 161 | if (link.type === AlbumType.album) { 162 | const albumRes = await fetch(this.buildUrl(this.apiUrl() + '/albums/' + link?.album?.id, { 163 | key, 164 | password 165 | })) 166 | const album = await albumRes.json() as Album 167 | if (!album?.id) { 168 | log('Invalid album ID - ' + link?.album?.id) 169 | return { 170 | valid: false 171 | } 172 | } 173 | // Replace the empty link.assets array with the array of assets from the album 174 | link.assets = album.assets 175 | } 176 | 177 | link.password = password 178 | if (link.expiresAt && dayjs(link.expiresAt) < dayjs()) { 179 | // This link has expired 180 | log('Expired link ' + key) 181 | } else { 182 | // Filter assets to exclude trashed assets 183 | link.assets = link.assets.filter(asset => !asset.isTrashed) 184 | // Populate the shared assets with the public key/password 185 | link.assets.forEach(asset => { 186 | asset.key = key 187 | asset.password = password 188 | }) 189 | // Sort album if there is a sort order specified 190 | const sortOrder = link.album?.order 191 | if (sortOrder === 'asc') { 192 | link.assets.sort((a, b) => a?.fileCreatedAt?.localeCompare(b.fileCreatedAt || '') || 0) 193 | } else if (sortOrder === 'desc') { 194 | link.assets.sort((a, b) => b?.fileCreatedAt?.localeCompare(a.fileCreatedAt || '') || 0) 195 | } 196 | return { 197 | valid: true, 198 | link 199 | } 200 | } 201 | } else if (res.status === 401 && jsonBody?.message === 'Invalid password') { 202 | // Password authentication required 203 | return { 204 | valid: true, 205 | passwordRequired: true 206 | } 207 | } else if (jsonBody?.message === 'Invalid share key') { 208 | log('Invalid share key ' + key) 209 | } else { 210 | console.log(JSON.stringify(jsonBody)) 211 | } 212 | } 213 | } else { 214 | // Otherwise return failure 215 | log('Immich response ' + res.status + ' for key ' + key) 216 | try { 217 | console.log(res.headers.get('Content-Type')) 218 | console.log((await res.text()).slice(0, 500)) 219 | log('Unexpected response from Immich API at ' + this.apiUrl()) 220 | log('Please make sure the IPP container is able to reach this path.') 221 | } catch (e) { 222 | console.log(e) 223 | } 224 | } 225 | return { 226 | valid: false 227 | } 228 | } 229 | 230 | /** 231 | * Get the content-type of a video, for passing back to lightGallery 232 | */ 233 | async getVideoContentType (asset: Asset) { 234 | const data = await this.request(this.buildUrl('/assets/' + encodeURIComponent(asset.id) + '/video/playback', { 235 | key: asset.key, 236 | password: asset.password 237 | })) 238 | return data.headers.get('Content-Type') 239 | } 240 | 241 | /** 242 | * Build safely-encoded URL string 243 | */ 244 | buildUrl (baseUrl: string, params: { [key: string]: string | undefined } = {}) { 245 | // Remove empty properties 246 | params = Object.fromEntries(Object.entries(params).filter(([_, value]) => !!value)) 247 | let query = '' 248 | // Safely encode query parameters 249 | if (Object.entries(params).length) { 250 | query = '?' + (new URLSearchParams(params as { 251 | [key: string]: string 252 | })).toString() 253 | } 254 | return baseUrl + query 255 | } 256 | 257 | /** 258 | * Return the image data URL for a photo 259 | */ 260 | photoUrl (key: string, id: string, size?: ImageSize) { 261 | const path = ['photo', key, id] 262 | if (size) path.push(size) 263 | return this.buildUrl('/share/' + path.join('/')) 264 | } 265 | 266 | /** 267 | * Return the video data URL for a video 268 | */ 269 | videoUrl (key: string, id: string) { 270 | return this.buildUrl(`/share/video/${key}/${id}`) 271 | } 272 | 273 | /** 274 | * Check if a provided ID matches the Immich ID format 275 | */ 276 | isId (id: string) { 277 | return !!id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) 278 | } 279 | 280 | /** 281 | * Check if a provided key matches the Immich shared-link key format. 282 | * It appears that the key is always 67 chars long, but since I don't know that this 283 | * will always be the case, I've left it open-ended. 284 | */ 285 | isKey (key: string) { 286 | return !!key.match(/^[\w-]+$/) 287 | } 288 | 289 | async accessible () { 290 | return !!(await immich.request('/server/ping')) 291 | } 292 | 293 | validateImageSize (size: unknown) { 294 | if (!size || !Object.values(ImageSize).includes(size as ImageSize)) { 295 | return ImageSize.preview 296 | } else { 297 | return size as ImageSize 298 | } 299 | } 300 | } 301 | 302 | const immich = new Immich() 303 | 304 | export default immich 305 | -------------------------------------------------------------------------------- /app/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import express from 'express' 4 | import cookieSession from 'cookie-session' 5 | import immich from './immich' 6 | import crypto from 'crypto' 7 | import render from './render' 8 | import dayjs from 'dayjs' 9 | import { Request, Response, NextFunction } from 'express-serve-static-core' 10 | import { AssetType, ImageSize } from './types' 11 | import { log, toString, addResponseHeaders, getConfigOption } from './functions' 12 | import { decrypt, encrypt } from './encrypt' 13 | import { respondToInvalidRequest } from './invalidRequestHandler' 14 | 15 | // Extend the Request type with a `password` property 16 | declare module 'express-serve-static-core' { 17 | interface Request { 18 | password?: string; 19 | } 20 | } 21 | 22 | require('dotenv').config() 23 | 24 | const app = express() 25 | app.use(cookieSession({ 26 | name: 'session', 27 | httpOnly: true, 28 | sameSite: 'strict', 29 | secret: crypto.randomBytes(32).toString('base64url') 30 | })) 31 | // Add the EJS view engine, to render the gallery page 32 | app.set('view engine', 'ejs') 33 | // For parsing the password unlock form 34 | app.use(express.json()) 35 | // Serve static assets from the 'public' folder as /share/static 36 | app.use('/share/static', express.static('public', { setHeaders: addResponseHeaders })) 37 | // Serve the same assets on /, to allow for /robots.txt and /favicon.ico 38 | app.use(express.static('public', { setHeaders: addResponseHeaders })) 39 | // Remove the X-Powered-By ExpressJS header 40 | app.disable('x-powered-by') 41 | 42 | /** 43 | * Middleware to decode the encrypted data stored in the session cookie 44 | */ 45 | const decodeCookie = (req: Request, _res: Response, next: NextFunction) => { 46 | const shareKey = req.params.key 47 | const session = req.session?.[shareKey] 48 | if (shareKey && session?.iv && session?.cr) { 49 | try { 50 | const payload = JSON.parse(decrypt({ 51 | iv: toString(session.iv), 52 | cr: toString(session.cr) 53 | })) 54 | if (payload?.expires && dayjs(payload.expires) > dayjs()) { 55 | req.password = payload.password 56 | } 57 | } catch (e) { } 58 | } 59 | next() 60 | } 61 | 62 | /* 63 | * [ROUTE] Healthcheck 64 | * The path matches for /share/healthcheck, and also the legacy /healthcheck 65 | */ 66 | app.get(/^(|\/share)\/healthcheck$/, async (_req, res) => { 67 | if (await immich.accessible()) { 68 | res.send('ok') 69 | } else { 70 | res.status(503).send() 71 | } 72 | }) 73 | 74 | /* 75 | * [ROUTE] This is the main URL that someone would visit if they are opening a shared link 76 | */ 77 | app.get('/share/:key/:mode(download)?', decodeCookie, async (req, res) => { 78 | await immich.handleShareRequest({ 79 | req, 80 | key: req.params.key, 81 | mode: req.params.mode, 82 | password: req.password 83 | }, res) 84 | }) 85 | 86 | /* 87 | * [ROUTE] Receive an unlock request from the password page 88 | * Stores a cookie with an encrypted payload which expires in 1 hour. 89 | * After that time, the visitor will need to provide the password again. 90 | */ 91 | app.post('/share/unlock', async (req, res) => { 92 | if (req.session && req.body.key) { 93 | req.session[req.body.key] = encrypt(JSON.stringify({ 94 | password: req.body.password, 95 | expires: dayjs().add(1, 'hour').format() 96 | })) 97 | } 98 | res.send() 99 | }) 100 | 101 | /* 102 | * [ROUTE] This is the direct link to a photo or video asset 103 | */ 104 | app.get('/share/:type(photo|video)/:key/:id/:size?', decodeCookie, async (req, res) => { 105 | // Add the headers configured in config.json (most likely `cache-control`) 106 | addResponseHeaders(res) 107 | 108 | // Check for valid key and ID 109 | if (!immich.isKey(req.params.key) || !immich.isId(req.params.id)) { 110 | log('Invalid key or ID for ' + req.path) 111 | respondToInvalidRequest(res, 404) 112 | return 113 | } 114 | 115 | // Validate the size parameter 116 | if (req.params.size && !Object.values(ImageSize).includes(req.params.size as ImageSize)) { 117 | log('Invalid size parameter ' + req.path) 118 | respondToInvalidRequest(res, 404) 119 | return 120 | } 121 | 122 | // Fetch the shared link information from Immich, so we can check to make sure that the requested asset 123 | // is allowed by this shared link. 124 | const sharedLink = (await immich.getShareByKey(req.params.key, req.password))?.link 125 | const request = { 126 | req, 127 | key: req.params.key, 128 | range: req.headers.range || '' 129 | } 130 | if (sharedLink?.assets.length) { 131 | // Check that the requested asset exists in this share 132 | const asset = sharedLink.assets.find(x => x.id === req.params.id) 133 | if (asset) { 134 | asset.type = req.params.type === 'video' ? AssetType.video : AssetType.image 135 | render.assetBuffer(request, res, asset, req.params.size).then() 136 | } 137 | } else { 138 | log('No asset found for ' + req.path) 139 | respondToInvalidRequest(res, 404) 140 | } 141 | }) 142 | 143 | /* 144 | * [ROUTE] Home page 145 | * 146 | * It was requested here to have *something* on the home page: 147 | * https://github.com/alangrainger/immich-public-proxy/discussions/19 148 | * 149 | * If you don't want to see this, set showHomePage as false in your config.json: 150 | * https://github.com/alangrainger/immich-public-proxy?tab=readme-ov-file#immich-public-proxy-options 151 | */ 152 | if (getConfigOption('ipp.showHomePage', true)) { 153 | app.get(/^\/(|share)\/*$/, (_req, res) => { 154 | addResponseHeaders(res) 155 | res.render('home') 156 | }) 157 | } 158 | 159 | /* 160 | * Send a 404 for all other routes 161 | */ 162 | app.get('*', (req, res) => { 163 | log('Invalid route ' + req.path) 164 | respondToInvalidRequest(res, 404) 165 | }) 166 | 167 | // Send the correct process error code for any uncaught exceptions 168 | // so that Docker can gracefully restart the container 169 | process.on('uncaughtException', (err) => { 170 | console.error('There was an uncaught error', err) 171 | server.close() 172 | process.exit(1) 173 | }) 174 | process.on('unhandledRejection', (reason, promise) => { 175 | console.error('Unhandled Rejection at:', promise, 'reason:', reason) 176 | server.close() 177 | process.exit(1) 178 | }) 179 | process.on('SIGTERM', () => { 180 | console.log('Received SIGTERM. Gracefully shutting down...') 181 | server.close() 182 | process.exit(0) 183 | }) 184 | 185 | // Start the ExpressJS server 186 | const port = process.env.IPP_PORT || 3000 187 | const server = app.listen(port, () => { 188 | console.log(dayjs().format() + ' Server started on port ' + port) 189 | }) 190 | -------------------------------------------------------------------------------- /app/src/invalidRequestHandler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This function is in its own file so that *if desired* someone can replace the entire 3 | function with their own custom one, by replacing the invalidRequestHandler.js file 4 | through a Docker volume mount. 5 | */ 6 | 7 | import { Response } from 'express-serve-static-core' 8 | import { getConfigOption } from './functions' 9 | 10 | export function respondToInvalidRequest (res: Response, defaultResponse: number | string | null) { 11 | let method = getConfigOption('ipp.customInvalidResponse', false) 12 | if (method === false) { 13 | // No custom method specified, use the default 14 | method = defaultResponse 15 | } 16 | 17 | if (typeof method === 'number') { 18 | // Respond with an HTTP status code 19 | res.status(method).send() 20 | } else if (method === null) { 21 | // Drop the connection without responding 22 | res.destroy() 23 | } else if (typeof method === 'string' && method.startsWith('http')) { 24 | // Redirect to another URL 25 | res.redirect(method) 26 | } else { 27 | // Fallback to 404 28 | res.status(404).send() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/render.ts: -------------------------------------------------------------------------------- 1 | import immich from './immich' 2 | import { Response } from 'express-serve-static-core' 3 | import { Asset, AssetType, ImageSize, IncomingShareRequest, SharedLink } from './types' 4 | import { canDownload, getConfigOption } from './functions' 5 | import archiver from 'archiver' 6 | import { respondToInvalidRequest } from './invalidRequestHandler' 7 | 8 | class Render { 9 | lgConfig 10 | 11 | constructor () { 12 | this.lgConfig = getConfigOption('lightGallery', {}) 13 | } 14 | 15 | /** 16 | * Stream data from Immich back to the client 17 | */ 18 | async assetBuffer (req: IncomingShareRequest, res: Response, asset: Asset, size?: ImageSize | string) { 19 | // Prepare the request 20 | const headerList = ['content-type', 'content-length', 'last-modified', 'etag'] 21 | size = immich.validateImageSize(size) 22 | let subpath, sizeQueryParam 23 | if (asset.type === AssetType.video) { 24 | subpath = '/video/playback' 25 | } else if (asset.type === AssetType.image) { 26 | // For images, there are three combinations of path + query string, depending on image size 27 | if (size === ImageSize.original && getConfigOption('ipp.downloadOriginalPhoto', true)) { 28 | subpath = '/original' 29 | } else if (size === ImageSize.preview || size === ImageSize.original) { 30 | // IPP is configured in config.json to send the preview size instead of the original size 31 | subpath = '/thumbnail' 32 | sizeQueryParam = 'preview' 33 | } else { 34 | subpath = '/' + size 35 | } 36 | } 37 | const headers = { range: '' } 38 | 39 | // For videos, request them in 2.5MB chunks rather than the entire video 40 | if (asset.type === AssetType.video) { 41 | const range = (req.range || '').replace(/bytes=/, '').split('-') 42 | const start = parseInt(range[0], 10) || 0 43 | const end = parseInt(range[1], 10) || start + 2499999 44 | headers.range = `bytes=${start}-${end}` 45 | headerList.push('cache-control', 'content-range') 46 | res.setHeader('accept-ranges', 'bytes') 47 | res.status(206) // Partial Content 48 | } 49 | 50 | // Request data from Immich 51 | const url = immich.buildUrl(immich.apiUrl() + '/assets/' + encodeURIComponent(asset.id) + subpath, { 52 | key: asset.key, 53 | size: sizeQueryParam, 54 | password: asset.password 55 | }) 56 | const data = await fetch(url, { headers }) 57 | 58 | // Add the filename for downloaded assets 59 | if (size === ImageSize.original && asset.originalFileName && getConfigOption('ipp.downloadOriginalPhoto', true)) { 60 | res.setHeader('Content-Disposition', `attachment; filename="${asset.originalFileName}"`) 61 | } 62 | 63 | // Return the response to the client 64 | if (data.status >= 200 && data.status < 300) { 65 | // Populate the whitelisted response headers 66 | headerList.forEach(header => { 67 | const value = data.headers.get(header) 68 | if (value) res.setHeader(header, value) 69 | }) 70 | // Return the Immich asset binary data 71 | await data.body?.pipeTo( 72 | new WritableStream({ 73 | write (chunk) { res.write(chunk) } 74 | }) 75 | ) 76 | res.end() 77 | } else { 78 | respondToInvalidRequest(res, 404) 79 | } 80 | } 81 | 82 | /** 83 | * Render a gallery page for a given SharedLink, using EJS and lightGallery. 84 | * 85 | * @param res - ExpressJS Response 86 | * @param share - Immich `shared-link` containing the assets to show in the gallery 87 | * @param [openItem] - Immediately open a lightbox to the Nth item when the gallery loads 88 | */ 89 | async gallery (res: Response, share: SharedLink, openItem?: number) { 90 | const items = [] 91 | 92 | // Originally I had the base URL as a config option in .env. 93 | // The issue with that is people who may be wanting to route in via multiple 94 | // public URLs. So instead we generate the base URL from the incoming request data. 95 | // This may cause issues with some reverse proxies, in which case just make sure 96 | // that you're sending the correct `host` header to IPP. 97 | const baseUrl = res.req.protocol + '://' + res.req.headers.host 98 | 99 | for (const asset of share.assets) { 100 | let video, downloadUrl 101 | if (asset.type === AssetType.video) { 102 | // Populate the data-video property 103 | video = JSON.stringify({ 104 | source: [ 105 | { 106 | src: immich.videoUrl(share.key, asset.id), 107 | type: await immich.getVideoContentType(asset) 108 | } 109 | ], 110 | attributes: { 111 | playsinline: 'playsinline', 112 | controls: 'controls' 113 | } 114 | }) 115 | } 116 | if (getConfigOption('ipp.downloadOriginalPhoto', true)) { 117 | // Add a download link for the original-size image, if configured in config.json 118 | downloadUrl = immich.photoUrl(share.key, asset.id, ImageSize.original) 119 | } 120 | 121 | const thumbnailUrl = baseUrl + immich.photoUrl(share.key, asset.id, ImageSize.thumbnail) 122 | const previewUrl = immich.photoUrl(share.key, asset.id, ImageSize.preview) 123 | const description = getConfigOption('ipp.showMetadata.description', false) && typeof asset?.exifInfo?.description === 'string' ? asset.exifInfo.description.replace(/'/g, ''') : '' 124 | 125 | // Create the full HTML element source to pass to the gallery view 126 | const itemHtml = [ 127 | video ? ``, 131 | video ? '
' : '', 132 | '
' 133 | ].join('') 134 | 135 | items.push(itemHtml) 136 | } 137 | res.render('gallery', { 138 | items, 139 | openItem, 140 | title: this.title(share), 141 | path: '/share/' + share.key, 142 | showDownload: canDownload(share), 143 | showTitle: getConfigOption('ipp.showGalleryTitle', false), 144 | lgConfig: getConfigOption('lightGallery', {}) 145 | }) 146 | } 147 | 148 | /** 149 | * Attempt to get a title from the link description or the album title 150 | */ 151 | title (share: SharedLink) { 152 | return share.description || share?.album?.albumName || 'Gallery' 153 | } 154 | 155 | /** 156 | * Download all assets as a zip file 157 | */ 158 | async downloadAll (res: Response, share: SharedLink) { 159 | res.setHeader('Content-Type', 'application/zip') 160 | const title = this.title(share).replace(/[^\w .-]/g, '') + '.zip' 161 | res.setHeader('Content-Disposition', `attachment; filename="${title}"`) 162 | const archive = archiver('zip', { zlib: { level: 6 } }) 163 | archive.pipe(res) 164 | for (const asset of share.assets) { 165 | const url = immich.buildUrl(immich.apiUrl() + '/assets/' + encodeURIComponent(asset.id) + '/original', { 166 | key: asset.key, 167 | password: asset.password 168 | }) 169 | const data = await fetch(url) 170 | // Check the response for validity 171 | if (!data.ok) { 172 | console.warn(`Failed to fetch asset: ${asset.id}`) 173 | continue 174 | } 175 | archive.append(Buffer.from(await data.arrayBuffer()), { name: asset.originalFileName || asset.id }) 176 | } 177 | await archive.finalize() 178 | archive.on('end', () => res.end()) 179 | } 180 | } 181 | 182 | const render = new Render() 183 | 184 | export default render 185 | -------------------------------------------------------------------------------- /app/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express-serve-static-core' 2 | 3 | export enum AssetType { 4 | image = 'IMAGE', 5 | video = 'VIDEO' 6 | } 7 | 8 | export interface ExifInfo { 9 | description?: string; 10 | } 11 | 12 | export enum AlbumType { 13 | album = 'ALBUM', 14 | individual = 'INDIVIDUAL' 15 | } 16 | 17 | export interface Asset { 18 | id: string; 19 | key: string; 20 | originalFileName?: string; 21 | password?: string; 22 | fileCreatedAt?: string; // May not exist - see https://github.com/alangrainger/immich-public-proxy/issues/61 23 | type: AssetType; 24 | isTrashed: boolean; 25 | exifInfo?: ExifInfo; 26 | } 27 | 28 | export interface Album { 29 | id: string; 30 | assets: Asset[]; 31 | } 32 | 33 | export interface SharedLink { 34 | key: string; 35 | type: string; 36 | description?: string; 37 | assets: Asset[]; 38 | allowDownload?: boolean; 39 | password?: string; 40 | album?: { 41 | id: string; 42 | albumName?: string; 43 | order?: string; 44 | } 45 | expiresAt: string | null; 46 | } 47 | 48 | export interface SharedLinkResult { 49 | valid: boolean; 50 | key?: string; 51 | passwordRequired?: boolean; 52 | link?: SharedLink; 53 | } 54 | 55 | export enum ImageSize { 56 | thumbnail = 'thumbnail', 57 | preview = 'preview', 58 | original = 'original' 59 | } 60 | 61 | export interface IncomingShareRequest { 62 | req: Request; 63 | key: string; 64 | password?: string; 65 | mode?: string; 66 | size?: ImageSize; 67 | range?: string; 68 | } 69 | 70 | export enum DownloadAll { 71 | disabled, 72 | perImmich, 73 | always 74 | } 75 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "outDir": "dist", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "CommonJS", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "esModuleInterop": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "isolatedModules": true, 15 | "strictNullChecks": true, 16 | "lib": [ 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7" 21 | ], 22 | "allowSyntheticDefaultImports": true 23 | }, 24 | "include": [ 25 | "**/*.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /app/views/gallery.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- title %> 5 | 6 | <% 7 | // Add an og-image using the first image's thumbnail 8 | if (items?.[0]?.thumbnailUrl) { 9 | %> 10 | 11 | <% } %> 12 | 13 | 14 | 15 | 16 | 17 | 31 |
32 | <% 33 | // Load the first 50 thumbnails, then load more as the user scrolls. See web.js 34 | items.slice(0, 50).forEach(item => { 35 | %><%- item %> 36 | <% }) %> 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/views/home.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /app/views/password.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Password required 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 | 25 | <% if (notifyInvalidPassword) { %> 26 | Invalid password 27 | <% } %> 28 | 33 | 42 |
43 |
44 |
45 |
46 |
47 | 48 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | immich-public-proxy: 3 | image: alangrainger/immich-public-proxy:latest 4 | container_name: immich-public-proxy 5 | restart: always 6 | ports: 7 | - "3000:3000" 8 | environment: 9 | - IMMICH_URL=http://your-internal-immich-server:2283 10 | healthcheck: 11 | test: curl -s http://localhost:3000/share/healthcheck -o /dev/null || exit 1 12 | start_period: 10s 13 | timeout: 5s 14 | -------------------------------------------------------------------------------- /docs/cloudflare-video-cache.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/cloudflare-video-cache.webp -------------------------------------------------------------------------------- /docs/custom-responses.md: -------------------------------------------------------------------------------- 1 | # Customising your web responses 2 | 3 | To avoid giving information away about your server, IPP responds with a limited set of HTTP status codes: 4 | 5 | | Code | Reason | 6 | |------|-------------------------------------------------------------------------------------------------| 7 | | 503 | Healthcheck failed: Immich is not accessible. Only on the `/healthcheck` route. | 8 | | 401 | Invalid password provided for a password-protected share link. | 9 | | 404 | All other invalid requests, e.g. album doesn't exist, share link is expired, non-existing file. | 10 | 11 | Instead of sending the 404 code, you can customise that response by [changing the configuration option](../README.md#immich-public-proxy-options) for `customInvalidResponse`. 12 | 13 | Possible options are: 14 | 15 | | Option | Data type | Example | Action | 16 | |------------------|-----------|-------------------------|------------------------------------------| 17 | | HTTP status code | `integer` | `404` | Sends an HTTP status code. | 18 | | Redirect URL | `string` | `"https://example.com"` | Redirects to another website. | 19 | | `null` | `null` | `null` | Drops the connection without responding. | 20 | | `false` | `boolean` | `false` | Responds with the default status code. | 21 | 22 | If you want to send a custom 404 page, you would do that with either of the below options - using a [custom function](#custom-function), or through your [reverse proxy](#customising-the-response-using-your-reverse-proxy). 23 | 24 | ## Custom function 25 | 26 | If you want to go even further, you can write your own custom function. Do this by taking a copy of the `app/dist/invalidRequestHandler.js` file, 27 | then mounting it back as a Docker volume into the correct location for the container to use. 28 | 29 | ## Customising the response using your Reverse Proxy 30 | 31 | You can also choose to customise these responses using your reverse proxy, which might give you more flexibility depending on your use-case. 32 | 33 | Here are some examples: 34 | 35 | ### Caddy 36 | 37 | ``` 38 | https://ipp.example.com { 39 | reverse_proxy 192.168.1.1:3000 { 40 | @404 status 404 41 | handle_response @404 { 42 | respond "This would be a custom response" 43 | } 44 | } 45 | } 46 | ``` 47 | 48 | ### Apache 49 | 50 | ``` 51 | 52 | ProxyErrorOverride On 404 53 | ErrorDocument 404 /custom_404.html 54 | Alias /custom_404.html /var/www/html/error-pages/404.html 55 | 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/external-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/external-link.png -------------------------------------------------------------------------------- /docs/ipp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 82 | -------------------------------------------------------------------------------- /docs/kubernetes.md: -------------------------------------------------------------------------------- 1 | # Install with Kubernetes 2 | 3 | Thanks to [@vanchaxy](https://github.com/vanchaxy) for providing these docs in [#42](https://github.com/alangrainger/immich-public-proxy/issues/42). 4 | 5 | See the [official Immich docs](https://immich.app/docs/install/kubernetes/) for additional information. 6 | 7 | 1. Add `app-template` chart to the dependencies. It's important to use the same `app-template` version as immich (1.4.0) if installing under the same umbrella chart. 8 | ``` 9 | apiVersion: v2 10 | name: immich 11 | version: 0.0.0 12 | dependencies: 13 | - name: immich 14 | repository: https://immich-app.github.io/immich-charts 15 | version: 0.9.0 16 | - name: app-template 17 | repository: https://bjw-s.github.io/helm-charts 18 | version: 1.4.0 19 | alias: immich-public-proxy 20 | ``` 21 | 2. Add deployment and service to values. 22 | ``` 23 | immich: 24 | # immich values 25 | ... 26 | 27 | immich-public-proxy: 28 | global: 29 | nameOverride: public-proxy 30 | 31 | env: 32 | IMMICH_URL: http://immich-server:2283 33 | 34 | image: 35 | repository: alangrainger/immich-public-proxy 36 | tag: 1.5.4 37 | pullPolicy: IfNotPresent 38 | 39 | service: 40 | main: 41 | enabled: true 42 | primary: true 43 | type: ClusterIP 44 | ports: 45 | http: 46 | enabled: true 47 | primary: true 48 | port: 3000 49 | protocol: HTTP 50 | 51 | ingress: 52 | main: 53 | enabled: true 54 | hosts: 55 | - host: &host-share immich-share.local 56 | paths: 57 | - path: "/" 58 | tls: 59 | - secretName: immich-share-tls 60 | hosts: 61 | - *host-share 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/running-on-single-domain.md: -------------------------------------------------------------------------------- 1 | # Running IPP on a single domain with Immich 2 | 3 | Because everything related to IPP happens within the `/share` path, 4 | you can serve Immich and IPP on the same domain by configuring your reverse 5 | proxy to send all `/share/*` requests to IPP. 6 | 7 | ## Caddy 8 | 9 | Here's an example of how to do this with Caddy: 10 | 11 | ``` 12 | https://your-domain.com { 13 | # Immich Public Proxy paths 14 | @public path /share /share/* 15 | handle @public { 16 | # Your IPP server and port 17 | reverse_proxy YOUR_SERVER:3000 18 | } 19 | 20 | # All other paths, require basic auth and send to Immich 21 | handle { 22 | basicauth { 23 | user password_hash 24 | } 25 | # Your Immich server and port 26 | reverse_proxy YOUR_SERVER:2283 27 | } 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/screenshot.webp -------------------------------------------------------------------------------- /docs/securing-immich-with-mtls.md: -------------------------------------------------------------------------------- 1 | # Securing Immich with mTLS using Caddy 2 | 3 | Immich supports using a custom client certificate on both web and mobile apps, so it's one of the easiest and safest ways to limit access to only clients of your choice. 4 | 5 | ## Caddy docker-compose.yml 6 | 7 | ```yaml 8 | version: "3.7" 9 | 10 | services: 11 | caddy: 12 | image: caddy:2 13 | restart: unless-stopped 14 | cap_add: 15 | - NET_ADMIN 16 | ports: 17 | - "6443:443" 18 | - "6443:443/udp" 19 | volumes: 20 | - ./Caddyfile:/etc/caddy/Caddyfile:Z 21 | - ./site:/srv:Z 22 | - ./data:/data:Z 23 | - ./config:/config:Z 24 | ``` 25 | 26 | ## Authenticating with mutual TLS 27 | 28 | ### Generate your client certificate 29 | 30 | This is a basic way to generate a certificate for a user. If it's only you using your own homelab, then you'll just need to make one certificate. 31 | 32 | This certificate will last for ~10 years (although of course you can revoke it at any time by deleting it from Caddy's key store). 33 | 34 | ```bash 35 | #!/bin/bash 36 | 37 | mkdir -p certs 38 | 39 | # Generate CA certificates 40 | openssl genrsa -out certs/client-ca.key 4096 41 | openssl req -new -x509 -nodes -days 3600 -key certs/client-ca.key -out certs/client-ca.crt 42 | 43 | # Generate a certificate signing request 44 | openssl req -newkey rsa:4096 -nodes -keyout certs/client.key -out certs/client.req 45 | 46 | # Have the CA sign the certificate requests and output the certificates. 47 | openssl x509 -req -in certs/client.req -days 3600 -CA certs/client-ca.crt -CAkey certs/client-ca.key -set_serial 01 -out certs/client.crt 48 | 49 | echo 50 | echo "Please enter a STRONG password. Many clients *require* a password for you to be able to import the certificate, and you want to protect it." 51 | echo 52 | 53 | # Convert the cerificate to PKCS12 format (for import into browser) 54 | openssl pkcs12 -export -out certs/client.pfx -inkey certs/client.key -in certs/client.crt 55 | 56 | # Clean up 57 | rm certs/client.req 58 | ``` 59 | 60 | ## Configure Caddyfile 61 | 62 | ```Caddyfile 63 | https://immich.mydomain.com { 64 | tls { 65 | client_auth { 66 | mode require_and_verify 67 | trusted_ca_cert_file /data/client_certs/client.crt 68 | } 69 | } 70 | reverse_proxy internal_server.lan:2283 71 | } 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/server-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/server-settings.png -------------------------------------------------------------------------------- /docs/share-link.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/share-link.webp -------------------------------------------------------------------------------- /docs/shield.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alangrainger/immich-public-proxy/0a58c6d921f5fbfcb4b96f51e6b4a2ffb3a353aa/docs/shield.png --------------------------------------------------------------------------------