1
0
Fork 0
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:
Alan Grainger 2024-11-01 11:58:27 +01:00
parent ede15bcd68
commit 356d9280d4
10 changed files with 311 additions and 75 deletions

4
public/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -45,3 +45,7 @@ html {
#lightgallery a:has(.play-icon):hover .play-icon {
opacity: 1;
}
#password {
color: white;
}

20
public/web.js Normal file
View 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
View 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 ''
}

View file

@ -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)
}
/**

View file

@ -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) })

View file

@ -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
})
}

View file

@ -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;
}

View file

@ -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
View 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>