mirror of
https://github.com/alangrainger/immich-public-proxy.git
synced 2024-12-28 03:41:58 +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 {
|
||||
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 ''
|
||||
}
|
140
src/immich.ts
140
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 { log } from './index'
|
||||
import render from './render'
|
||||
import { Response } from 'express-serve-static-core'
|
||||
import { encrypt } from './encrypt'
|
||||
|
||||
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.
|
||||
* The key is what is returned in the URL when you create a share in Immich.
|
||||
*/
|
||||
async getShareByKey (key: string) {
|
||||
const link = (await this.request('/shared-links/me?key=' + encodeURIComponent(key))) as SharedLink
|
||||
if (link) {
|
||||
if (link.expiresAt && dayjs(link.expiresAt) < dayjs()) {
|
||||
// This link has expired
|
||||
log('Expired link ' + key)
|
||||
} else {
|
||||
// Filter assets to exclude trashed assets
|
||||
link.assets = link.assets.filter(asset => !asset.isTrashed)
|
||||
// Populate the shared assets with the public key
|
||||
link.assets.forEach(asset => { asset.key = key })
|
||||
return link
|
||||
async getShareByKey (key: string, password?: string): Promise<SharedLinkResult> {
|
||||
let 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()) {
|
||||
// This link has expired
|
||||
log('Expired link ' + key)
|
||||
} else {
|
||||
// Filter assets to exclude trashed assets
|
||||
link.assets = link.assets.filter(asset => !asset.isTrashed)
|
||||
// Populate the shared assets with the public key/password
|
||||
link.assets.forEach(asset => {
|
||||
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) {
|
||||
case AssetType.image:
|
||||
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:
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
photoUrl (key: string, id: string, size?: ImageSize) {
|
||||
return `/photo/${key}/${id}` + (size ? `?size=${size}` : '')
|
||||
photoUrl (key: string, id: string, size?: ImageSize, password?: string) {
|
||||
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
|
||||
*/
|
||||
videoUrl (key: string, id: string) {
|
||||
return `/video/${key}/${id}`
|
||||
videoUrl (key: string, id: string, password?: string) {
|
||||
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 { AssetType, ImageSize } from './types'
|
||||
import { Request } from 'express-serve-static-core'
|
||||
import { decrypt } from './encrypt'
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
|
@ -12,38 +13,23 @@ const app = express()
|
|||
app.set('view engine', 'ejs')
|
||||
// Serve static assets from the /public folder
|
||||
app.use(express.static('public'))
|
||||
// For parsing the password unlock form
|
||||
app.use(express.json())
|
||||
|
||||
// An incoming request for a shared link
|
||||
app.get('/share/:key', async (req, res) => {
|
||||
res.set('Cache-Control', 'public, max-age=' + process.env.CACHE_AGE)
|
||||
if (!immich.isKey(req.params.key)) {
|
||||
log('Invalid share key ' + req.params.key)
|
||||
res.status(404).send()
|
||||
} else {
|
||||
const sharedLink = await immich.getShareByKey(req.params.key)
|
||||
if (!sharedLink) {
|
||||
log('Unknown share key ' + req.params.key)
|
||||
res.status(404).send()
|
||||
} else if (!sharedLink.assets.length) {
|
||||
log('No assets for key ' + req.params.key)
|
||||
res.status(404).send()
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
await immich.handleShareRequest({
|
||||
key: req.params.key,
|
||||
size: getSize(req)
|
||||
}, res)
|
||||
})
|
||||
|
||||
// Receive an unlock request from the password page
|
||||
app.post('/unlock', async (req, res) => {
|
||||
await immich.handleShareRequest({
|
||||
key: toString(req.body.key),
|
||||
password: toString(req.body.password)
|
||||
}, res)
|
||||
})
|
||||
|
||||
// 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)
|
||||
// Check for valid key and 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
|
||||
const sharedLink = await immich.getShareByKey(req.params.key)
|
||||
const sharedLink = (await immich.getShareByKey(req.params.key, password))?.link
|
||||
if (sharedLink?.assets.length) {
|
||||
// Check that the requested asset exists in this share
|
||||
const asset = sharedLink.assets.find(x => x.id === req.params.id)
|
||||
|
@ -78,18 +72,22 @@ app.get('*', (req, res) => {
|
|||
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
|
||||
* e.g. https://example.com/share/abc...xyz?size=thumbnail
|
||||
*/
|
||||
const getSize = (req: Request) => {
|
||||
return req?.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
|
||||
return req.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a console.log message with timestamp
|
||||
*/
|
||||
export const log = (message: string) => console.log(dayjs().format() + ' ' + message)
|
||||
const toString = (value: unknown) => {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// Handle process termination requests (e.g. Ctrl+C)
|
||||
process.on('SIGTERM', () => { process.exit(0) })
|
||||
|
|
|
@ -31,7 +31,7 @@ class Render {
|
|||
video = JSON.stringify({
|
||||
source: [
|
||||
{
|
||||
src: immich.videoUrl(share.key, asset.id),
|
||||
src: immich.videoUrl(share.key, asset.id, asset.password),
|
||||
type: await immich.getContentType(asset)
|
||||
}
|
||||
],
|
||||
|
@ -42,8 +42,8 @@ class Render {
|
|||
})
|
||||
}
|
||||
items.push({
|
||||
originalUrl: immich.photoUrl(share.key, asset.id),
|
||||
thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail),
|
||||
originalUrl: immich.photoUrl(share.key, asset.id, undefined, asset.password),
|
||||
thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail, asset.password),
|
||||
video
|
||||
})
|
||||
}
|
||||
|
|
14
src/types.ts
14
src/types.ts
|
@ -6,6 +6,7 @@ export enum AssetType {
|
|||
export interface Asset {
|
||||
id: string;
|
||||
key: string;
|
||||
password?: string;
|
||||
type: AssetType;
|
||||
isTrashed: boolean;
|
||||
}
|
||||
|
@ -20,7 +21,20 @@ export interface SharedLink {
|
|||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SharedLinkResult {
|
||||
valid: boolean;
|
||||
key?: string;
|
||||
passwordRequired?: boolean;
|
||||
link?: SharedLink;
|
||||
}
|
||||
|
||||
export enum ImageSize {
|
||||
thumbnail = 'thumbnail',
|
||||
original = 'original'
|
||||
}
|
||||
|
||||
export interface IncomingShareRequest {
|
||||
key: string;
|
||||
password?: string;
|
||||
size?: ImageSize;
|
||||
}
|
||||
|
|
|
@ -21,30 +21,14 @@
|
|||
<% }
|
||||
}) %>
|
||||
</div>
|
||||
<script src="/web.js"></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>
|
||||
<script type="text/javascript">
|
||||
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
|
||||
})
|
||||
initLightGallery() // from web.js
|
||||
<% if (openItem) { %>
|
||||
const openItem = <%- openItem %>
|
||||
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