mirror of
https://github.com/alangrainger/immich-public-proxy.git
synced 2024-12-29 12:21:57 +00:00
Support password share links
This commit is contained in:
parent
ede15bcd68
commit
356d9280d4
10 changed files with 311 additions and 75 deletions
4
public/pico.min.css
vendored
Normal file
4
public/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -45,3 +45,7 @@ html {
|
||||||
#lightgallery a:has(.play-icon):hover .play-icon {
|
#lightgallery a:has(.play-icon):hover .play-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#password {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
20
public/web.js
Normal file
20
public/web.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
function initLightGallery () {
|
||||||
|
lightGallery(document.getElementById('lightgallery'), {
|
||||||
|
plugins: [lgZoom, lgThumbnail, lgVideo, lgFullscreen],
|
||||||
|
/*
|
||||||
|
This license key was graciously provided by LightGallery under their
|
||||||
|
GPLv3 open-source project license:
|
||||||
|
*/
|
||||||
|
licenseKey: '8FFA6495-676C4D30-8BFC54B6-4D0A6CEC',
|
||||||
|
/*
|
||||||
|
Please do not take it and use it for other projects, as it was provided
|
||||||
|
specifically for Immich Public Proxy.
|
||||||
|
|
||||||
|
For your own projects you can use the default license key of
|
||||||
|
0000-0000-000-0000 as per their docs:
|
||||||
|
|
||||||
|
https://www.lightgalleryjs.com/docs/settings/#licenseKey
|
||||||
|
*/
|
||||||
|
speed: 500
|
||||||
|
})
|
||||||
|
}
|
38
src/encrypt.ts
Normal file
38
src/encrypt.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
interface Payload {
|
||||||
|
iv: string;
|
||||||
|
cr: string; // Encrypted data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random 256-bit key on startup
|
||||||
|
const key = crypto.randomBytes(32)
|
||||||
|
const algorithm = 'aes-256-cbc'
|
||||||
|
|
||||||
|
export function encrypt (text: string): Payload {
|
||||||
|
try {
|
||||||
|
const ivBuf = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), ivBuf)
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
return {
|
||||||
|
iv: ivBuf.toString('hex'),
|
||||||
|
cr: encrypted
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
return {
|
||||||
|
cr: '',
|
||||||
|
iv: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt (payload: Payload) {
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), Buffer.from(payload.iv, 'hex'))
|
||||||
|
let decrypted = decipher.update(payload.cr, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
return decrypted
|
||||||
|
} catch (e) { }
|
||||||
|
return ''
|
||||||
|
}
|
128
src/immich.ts
128
src/immich.ts
|
@ -1,6 +1,9 @@
|
||||||
import { Asset, AssetType, ImageSize, SharedLink } from './types'
|
import { Asset, AssetType, ImageSize, IncomingShareRequest, SharedLink, SharedLinkResult } from './types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { log } from './index'
|
import { log } from './index'
|
||||||
|
import render from './render'
|
||||||
|
import { Response } from 'express-serve-static-core'
|
||||||
|
import { encrypt } from './encrypt'
|
||||||
|
|
||||||
class Immich {
|
class Immich {
|
||||||
/**
|
/**
|
||||||
|
@ -22,24 +25,100 @@ class Immich {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleShareRequest (request: IncomingShareRequest, res: Response) {
|
||||||
|
res.set('Cache-Control', 'public, max-age=' + process.env.CACHE_AGE)
|
||||||
|
if (!immich.isKey(request.key)) {
|
||||||
|
// This is not a valid key format
|
||||||
|
log('Invalid share key ' + request.key)
|
||||||
|
res.status(404).send()
|
||||||
|
} else {
|
||||||
|
// Get information about the shared link via Immich API
|
||||||
|
const sharedLinkRes = await immich.getShareByKey(request.key, request.password)
|
||||||
|
if (!sharedLinkRes.valid) {
|
||||||
|
// This isn't a valid request - check the console for more information
|
||||||
|
res.status(404).send()
|
||||||
|
} else if (sharedLinkRes.passwordRequired) {
|
||||||
|
// Password required - show the visitor the password page
|
||||||
|
// `req.params.key` should already be sanitised at this point, but it never hurts to be explicit
|
||||||
|
const key = request.key.replace(/[^\w-]/g, '')
|
||||||
|
res.render('password', { key })
|
||||||
|
} else if (sharedLinkRes.link) {
|
||||||
|
// Valid shared link
|
||||||
|
const link = sharedLinkRes.link
|
||||||
|
if (!link.assets.length) {
|
||||||
|
log('No assets for key ' + request.key)
|
||||||
|
res.status(404).send()
|
||||||
|
} else if (link.assets.length === 1) {
|
||||||
|
// This is an individual item (not a gallery)
|
||||||
|
log('Serving link ' + request.key)
|
||||||
|
const asset = link.assets[0]
|
||||||
|
if (asset.type === AssetType.image) {
|
||||||
|
// For photos, output the image directly
|
||||||
|
await render.assetBuffer(res, link.assets[0], request.size)
|
||||||
|
} else if (asset.type === AssetType.video) {
|
||||||
|
// For videos, show the video as a web player
|
||||||
|
await render.gallery(res, link, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple images - render as a gallery
|
||||||
|
log('Serving link ' + request.key)
|
||||||
|
await render.gallery(res, link)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Unknown error with key ' + request.key)
|
||||||
|
res.status(404).send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query Immich for the SharedLink metadata for a given key.
|
* Query Immich for the SharedLink metadata for a given key.
|
||||||
* The key is what is returned in the URL when you create a share in Immich.
|
* The key is what is returned in the URL when you create a share in Immich.
|
||||||
*/
|
*/
|
||||||
async getShareByKey (key: string) {
|
async getShareByKey (key: string, password?: string): Promise<SharedLinkResult> {
|
||||||
const link = (await this.request('/shared-links/me?key=' + encodeURIComponent(key))) as SharedLink
|
let link
|
||||||
if (link) {
|
const url = this.buildUrl(process.env.IMMICH_URL + '/api/shared-links/me', {
|
||||||
|
key,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
const res = await fetch(url)
|
||||||
|
const contentType = res.headers.get('Content-Type') || ''
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const jsonBody = await res.json()
|
||||||
|
if (jsonBody) {
|
||||||
|
if (res.status === 200) {
|
||||||
|
// Normal response - get the shared assets
|
||||||
|
link = jsonBody as SharedLink
|
||||||
if (link.expiresAt && dayjs(link.expiresAt) < dayjs()) {
|
if (link.expiresAt && dayjs(link.expiresAt) < dayjs()) {
|
||||||
// This link has expired
|
// This link has expired
|
||||||
log('Expired link ' + key)
|
log('Expired link ' + key)
|
||||||
} else {
|
} else {
|
||||||
// Filter assets to exclude trashed assets
|
// Filter assets to exclude trashed assets
|
||||||
link.assets = link.assets.filter(asset => !asset.isTrashed)
|
link.assets = link.assets.filter(asset => !asset.isTrashed)
|
||||||
// Populate the shared assets with the public key
|
// Populate the shared assets with the public key/password
|
||||||
link.assets.forEach(asset => { asset.key = key })
|
link.assets.forEach(asset => {
|
||||||
return link
|
asset.key = key
|
||||||
|
asset.password = password
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
link
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (res.status === 401 && jsonBody?.message === 'Invalid password') {
|
||||||
|
// Password authentication required
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
passwordRequired: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise return failure
|
||||||
|
log('Immich response ' + res.status + ' for key ' + key)
|
||||||
|
return {
|
||||||
|
valid: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -52,9 +131,15 @@ class Immich {
|
||||||
switch (asset.type) {
|
switch (asset.type) {
|
||||||
case AssetType.image:
|
case AssetType.image:
|
||||||
size = size === ImageSize.thumbnail ? ImageSize.thumbnail : ImageSize.original
|
size = size === ImageSize.thumbnail ? ImageSize.thumbnail : ImageSize.original
|
||||||
return this.request('/assets/' + encodeURIComponent(asset.id) + '/' + size + '?key=' + encodeURIComponent(asset.key))
|
return this.request(this.buildUrl('/assets/' + encodeURIComponent(asset.id) + '/' + size, {
|
||||||
|
key: asset.key,
|
||||||
|
password: asset.password
|
||||||
|
}))
|
||||||
case AssetType.video:
|
case AssetType.video:
|
||||||
return this.request('/assets/' + encodeURIComponent(asset.id) + '/video/playback?key=' + encodeURIComponent(asset.key))
|
return this.request(this.buildUrl('/assets/' + encodeURIComponent(asset.id) + '/video/playback', {
|
||||||
|
key: asset.key,
|
||||||
|
password: asset.password
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,18 +151,35 @@ class Immich {
|
||||||
return assetBuffer.headers.get('Content-Type')
|
return assetBuffer.headers.get('Content-Type')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build safely-encoded URL string
|
||||||
|
*/
|
||||||
|
buildUrl (baseUrl: string, params: { [key: string]: string | undefined } = {}) {
|
||||||
|
// Remove empty properties
|
||||||
|
params = Object.fromEntries(Object.entries(params).filter(([_, value]) => !!value))
|
||||||
|
let query = ''
|
||||||
|
// Safely encode query parameters
|
||||||
|
if (Object.entries(params).length) query = '?' + (new URLSearchParams(params as { [key: string]: string })).toString()
|
||||||
|
return baseUrl + query
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the image data URL for a photo
|
* Return the image data URL for a photo
|
||||||
*/
|
*/
|
||||||
photoUrl (key: string, id: string, size?: ImageSize) {
|
photoUrl (key: string, id: string, size?: ImageSize, password?: string) {
|
||||||
return `/photo/${key}/${id}` + (size ? `?size=${size}` : '')
|
const params = { key }
|
||||||
|
if (password) {
|
||||||
|
Object.assign(params, encrypt(password))
|
||||||
|
}
|
||||||
|
return this.buildUrl(`/photo/${key}/${id}`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the video data URL for a video
|
* Return the video data URL for a video
|
||||||
*/
|
*/
|
||||||
videoUrl (key: string, id: string) {
|
videoUrl (key: string, id: string, password?: string) {
|
||||||
return `/video/${key}/${id}`
|
const params = password ? encrypt(password) : {}
|
||||||
|
return this.buildUrl(`/video/${key}/${id}`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
68
src/index.ts
68
src/index.ts
|
@ -4,6 +4,7 @@ import render from './render'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { AssetType, ImageSize } from './types'
|
import { AssetType, ImageSize } from './types'
|
||||||
import { Request } from 'express-serve-static-core'
|
import { Request } from 'express-serve-static-core'
|
||||||
|
import { decrypt } from './encrypt'
|
||||||
|
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
|
|
||||||
|
@ -12,38 +13,23 @@ const app = express()
|
||||||
app.set('view engine', 'ejs')
|
app.set('view engine', 'ejs')
|
||||||
// Serve static assets from the /public folder
|
// Serve static assets from the /public folder
|
||||||
app.use(express.static('public'))
|
app.use(express.static('public'))
|
||||||
|
// For parsing the password unlock form
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
// An incoming request for a shared link
|
// An incoming request for a shared link
|
||||||
app.get('/share/:key', async (req, res) => {
|
app.get('/share/:key', async (req, res) => {
|
||||||
res.set('Cache-Control', 'public, max-age=' + process.env.CACHE_AGE)
|
await immich.handleShareRequest({
|
||||||
if (!immich.isKey(req.params.key)) {
|
key: req.params.key,
|
||||||
log('Invalid share key ' + req.params.key)
|
size: getSize(req)
|
||||||
res.status(404).send()
|
}, res)
|
||||||
} else {
|
})
|
||||||
const sharedLink = await immich.getShareByKey(req.params.key)
|
|
||||||
if (!sharedLink) {
|
// Receive an unlock request from the password page
|
||||||
log('Unknown share key ' + req.params.key)
|
app.post('/unlock', async (req, res) => {
|
||||||
res.status(404).send()
|
await immich.handleShareRequest({
|
||||||
} else if (!sharedLink.assets.length) {
|
key: toString(req.body.key),
|
||||||
log('No assets for key ' + req.params.key)
|
password: toString(req.body.password)
|
||||||
res.status(404).send()
|
}, res)
|
||||||
} else if (sharedLink.assets.length === 1) {
|
|
||||||
// This is an individual item (not a gallery)
|
|
||||||
log('Serving link ' + req.params.key)
|
|
||||||
const asset = sharedLink.assets[0]
|
|
||||||
if (asset.type === AssetType.image) {
|
|
||||||
// For photos, output the image directly
|
|
||||||
await render.assetBuffer(res, sharedLink.assets[0], getSize(req))
|
|
||||||
} else if (asset.type === AssetType.video) {
|
|
||||||
// For videos, show the video as a web player
|
|
||||||
await render.gallery(res, sharedLink, 1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Multiple images - render as a gallery
|
|
||||||
log('Serving link ' + req.params.key)
|
|
||||||
await render.gallery(res, sharedLink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Output the buffer data for a photo or video
|
// Output the buffer data for a photo or video
|
||||||
|
@ -51,8 +37,16 @@ app.get('/:type(photo|video)/:key/:id', async (req, res) => {
|
||||||
res.set('Cache-Control', 'public, max-age=' + process.env.CACHE_AGE)
|
res.set('Cache-Control', 'public, max-age=' + process.env.CACHE_AGE)
|
||||||
// Check for valid key and ID
|
// Check for valid key and ID
|
||||||
if (immich.isKey(req.params.key) && immich.isId(req.params.id)) {
|
if (immich.isKey(req.params.key) && immich.isId(req.params.id)) {
|
||||||
|
// Decrypt the password, if one was provided
|
||||||
|
let password
|
||||||
|
if (req.query?.cr && req.query?.iv) {
|
||||||
|
password = decrypt({
|
||||||
|
iv: toString(req.query.iv),
|
||||||
|
cr: toString(req.query.cr)
|
||||||
|
})
|
||||||
|
}
|
||||||
// Check if the key is a valid share link
|
// Check if the key is a valid share link
|
||||||
const sharedLink = await immich.getShareByKey(req.params.key)
|
const sharedLink = (await immich.getShareByKey(req.params.key, password))?.link
|
||||||
if (sharedLink?.assets.length) {
|
if (sharedLink?.assets.length) {
|
||||||
// Check that the requested asset exists in this share
|
// Check that the requested asset exists in this share
|
||||||
const asset = sharedLink.assets.find(x => x.id === req.params.id)
|
const asset = sharedLink.assets.find(x => x.id === req.params.id)
|
||||||
|
@ -78,18 +72,22 @@ app.get('*', (req, res) => {
|
||||||
res.status(404).send()
|
res.status(404).send()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output a console.log message with timestamp
|
||||||
|
*/
|
||||||
|
export const log = (message: string) => console.log(dayjs().format() + ' ' + message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitise the data for an incoming query string `size` parameter
|
* Sanitise the data for an incoming query string `size` parameter
|
||||||
* e.g. https://example.com/share/abc...xyz?size=thumbnail
|
* e.g. https://example.com/share/abc...xyz?size=thumbnail
|
||||||
*/
|
*/
|
||||||
const getSize = (req: Request) => {
|
const getSize = (req: Request) => {
|
||||||
return req?.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
|
return req.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const toString = (value: unknown) => {
|
||||||
* Output a console.log message with timestamp
|
return typeof value === 'string' ? value : ''
|
||||||
*/
|
}
|
||||||
export const log = (message: string) => console.log(dayjs().format() + ' ' + message)
|
|
||||||
|
|
||||||
// Handle process termination requests (e.g. Ctrl+C)
|
// Handle process termination requests (e.g. Ctrl+C)
|
||||||
process.on('SIGTERM', () => { process.exit(0) })
|
process.on('SIGTERM', () => { process.exit(0) })
|
||||||
|
|
|
@ -31,7 +31,7 @@ class Render {
|
||||||
video = JSON.stringify({
|
video = JSON.stringify({
|
||||||
source: [
|
source: [
|
||||||
{
|
{
|
||||||
src: immich.videoUrl(share.key, asset.id),
|
src: immich.videoUrl(share.key, asset.id, asset.password),
|
||||||
type: await immich.getContentType(asset)
|
type: await immich.getContentType(asset)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -42,8 +42,8 @@ class Render {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
originalUrl: immich.photoUrl(share.key, asset.id),
|
originalUrl: immich.photoUrl(share.key, asset.id, undefined, asset.password),
|
||||||
thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail),
|
thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail, asset.password),
|
||||||
video
|
video
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
14
src/types.ts
14
src/types.ts
|
@ -6,6 +6,7 @@ export enum AssetType {
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
password?: string;
|
||||||
type: AssetType;
|
type: AssetType;
|
||||||
isTrashed: boolean;
|
isTrashed: boolean;
|
||||||
}
|
}
|
||||||
|
@ -20,7 +21,20 @@ export interface SharedLink {
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SharedLinkResult {
|
||||||
|
valid: boolean;
|
||||||
|
key?: string;
|
||||||
|
passwordRequired?: boolean;
|
||||||
|
link?: SharedLink;
|
||||||
|
}
|
||||||
|
|
||||||
export enum ImageSize {
|
export enum ImageSize {
|
||||||
thumbnail = 'thumbnail',
|
thumbnail = 'thumbnail',
|
||||||
original = 'original'
|
original = 'original'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IncomingShareRequest {
|
||||||
|
key: string;
|
||||||
|
password?: string;
|
||||||
|
size?: ImageSize;
|
||||||
|
}
|
||||||
|
|
|
@ -21,30 +21,14 @@
|
||||||
<% }
|
<% }
|
||||||
}) %>
|
}) %>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/web.js"></script>
|
||||||
<script src="/lightgallery.min.js"></script>
|
<script src="/lightgallery.min.js"></script>
|
||||||
<script src="/lg-fullscreen.min.js"></script>
|
<script src="/lg-fullscreen.min.js"></script>
|
||||||
<script src="/lg-thumbnail.min.js"></script>
|
<script src="/lg-thumbnail.min.js"></script>
|
||||||
<script src="/lg-video.min.js"></script>
|
<script src="/lg-video.min.js"></script>
|
||||||
<script src="/lg-zoom.min.js"></script>
|
<script src="/lg-zoom.min.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
lightGallery(document.getElementById('lightgallery'), {
|
initLightGallery() // from web.js
|
||||||
plugins: [lgZoom, lgThumbnail, lgVideo, lgFullscreen],
|
|
||||||
/*
|
|
||||||
This license key was graciously provided by LightGallery under their
|
|
||||||
GPLv3 open-source project license:
|
|
||||||
*/
|
|
||||||
licenseKey: '8FFA6495-676C4D30-8BFC54B6-4D0A6CEC',
|
|
||||||
/*
|
|
||||||
Please do not take it and use it for other projects, as it was provided
|
|
||||||
specifically for Immich Public Proxy.
|
|
||||||
|
|
||||||
For your own projects you can use the default license key of
|
|
||||||
0000-0000-000-0000 as per their docs:
|
|
||||||
|
|
||||||
https://www.lightgalleryjs.com/docs/settings/#licenseKey
|
|
||||||
*/
|
|
||||||
speed: 500
|
|
||||||
})
|
|
||||||
<% if (openItem) { %>
|
<% if (openItem) { %>
|
||||||
const openItem = <%- openItem %>
|
const openItem = <%- openItem %>
|
||||||
const thumbs = document.querySelectorAll('#lightgallery a')
|
const thumbs = document.querySelectorAll('#lightgallery a')
|
||||||
|
|
72
views/password.ejs
Normal file
72
views/password.ejs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>Password required</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/lightgallery-bundle.min.css"/>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/pico.min.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<main class="container">
|
||||||
|
<div class="grid">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<form id="unlock" method="post">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
aria-label="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="key"
|
||||||
|
value="<%- key %>"
|
||||||
|
/>
|
||||||
|
<button type="submit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
class="lucide lucide-lock-open">
|
||||||
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||||
|
</svg>
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="/web.js"></script>
|
||||||
|
<script>
|
||||||
|
function submitForm (formElement) {
|
||||||
|
const formData = new FormData(formElement)
|
||||||
|
|
||||||
|
fetch('/unlock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(Object.fromEntries(formData.entries()))
|
||||||
|
})
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(html => {
|
||||||
|
document.documentElement.innerHTML = html
|
||||||
|
initLightGallery() // from web.js
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('unlock')
|
||||||
|
.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitForm(this)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<script src="/lightgallery.min.js"></script>
|
||||||
|
<script src="/lg-fullscreen.min.js"></script>
|
||||||
|
<script src="/lg-thumbnail.min.js"></script>
|
||||||
|
<script src="/lg-video.min.js"></script>
|
||||||
|
<script src="/lg-zoom.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue