258 lines
8.2 KiB
TypeScript
Raw Permalink Normal View History

2024-11-01 11:58:27 +01:00
import { Asset, AssetType, ImageSize, IncomingShareRequest, SharedLink, SharedLinkResult } from './types'
2024-10-29 19:22:18 +01:00
import dayjs from 'dayjs'
2024-11-04 20:58:03 +01:00
import { addResponseHeaders, getConfigOption, log } from './functions'
2024-11-01 11:58:27 +01:00
import render from './render'
import { Response } from 'express-serve-static-core'
import { encrypt } from './encrypt'
2024-10-28 09:56:11 +01:00
2024-10-28 09:08:16 +01:00
class Immich {
2024-10-29 15:07:54 +01:00
/**
* Make a request to Immich API. We're not using the SDK to limit
* the possible attack surface of this app.
*/
2024-10-29 10:29:14 +01:00
async request (endpoint: string) {
2024-11-04 11:15:00 +01:00
try {
2024-11-10 21:00:41 +01:00
const res = await fetch(this.apiUrl() + endpoint)
2024-11-04 11:15:00 +01:00
if (res.status === 200) {
const contentType = res.headers.get('Content-Type') || ''
if (contentType.includes('application/json')) {
return res.json()
} else {
return res
}
2024-10-28 20:47:14 +01:00
} else {
2024-11-04 11:15:00 +01:00
log('Immich API status ' + res.status)
console.log(await res.text())
2024-10-28 20:47:14 +01:00
}
2024-11-04 11:15:00 +01:00
} catch (e) {
log('Unable to reach Immich on ' + process.env.IMMICH_URL)
2024-10-28 09:08:16 +01:00
}
}
2024-11-10 21:00:41 +01:00
apiUrl () {
return process.env.IMMICH_URL + '/api'
}
2024-11-01 12:37:04 +01:00
/**
* Handle an incoming request for a shared link `key`. This is the main function which
* communicates with Immich and returns the output back to the visitor.
*
* Possible HTTP responses are:
*
* 200 - either a photo gallery or the unlock page.
* 401 - the visitor provided a password but it was invalid.
* 404 - any other failed request. Check console.log for details.
*/
2024-11-01 11:58:27 +01:00
async handleShareRequest (request: IncomingShareRequest, res: Response) {
2024-11-12 21:01:39 +01:00
// Add the headers configured in config.json (most likely `cache-control`)
2024-11-04 20:58:03 +01:00
addResponseHeaders(res)
2024-11-12 21:01:39 +01:00
2024-11-11 19:34:49 +01:00
// Check that the key is a valid format
2024-11-01 11:58:27 +01:00
if (!immich.isKey(request.key)) {
log('Invalid share key ' + request.key)
res.status(404).send()
2024-11-12 21:01:39 +01:00
return
}
// 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()
return
}
// A password is required, but the visitor-provided one doesn't match
if (sharedLinkRes.passwordRequired && request.password) {
log('Invalid password for key ' + request.key)
res.status(401).send()
return
}
// Password required - show the visitor the password page
if (sharedLinkRes.passwordRequired) {
// `request.key` is already sanitised at this point, but it never hurts to be explicit
const key = request.key.replace(/[^\w-]/g, '')
res.render('password', {
key,
lgConfig: render.lgConfig
})
return
}
if (!sharedLinkRes.link) {
log('Unknown error with key ' + request.key)
res.status(404).send()
return
}
// Make sure there are some photo/video assets for this link
const link = sharedLinkRes.link
if (!link.assets.length) {
log('No assets for key ' + request.key)
res.status(404).send()
return
}
// Everything is ok - output the link page
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 && !getConfigOption('ipp.singleImageGallery') && !request.password) {
// For photos, output the image directly unless configured to show a gallery,
// or unless it's a password-protected link
2024-11-12 21:01:39 +01:00
await render.assetBuffer(request, res, link.assets[0], ImageSize.preview)
2024-11-01 11:58:27 +01:00
} else {
2024-11-12 21:01:39 +01:00
// Show a gallery page
const openItem = getConfigOption('ipp.singleItemAutoOpen', true) ? 1 : 0
await render.gallery(res, link, openItem)
2024-11-01 11:58:27 +01:00
}
2024-11-12 21:01:39 +01:00
} else {
// Multiple images - render as a gallery
log('Serving link ' + request.key)
await render.gallery(res, link)
2024-11-01 11:58:27 +01:00
}
}
2024-10-29 15:07:54 +01:00
/**
* 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.
*/
2024-11-01 11:58:27 +01:00
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)
2024-11-11 19:34:49 +01:00
if ((res.headers.get('Content-Type') || '').includes('application/json')) {
2024-11-01 11:58:27 +01:00
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
}
}
2024-10-29 19:22:18 +01:00
}
}
2024-11-01 11:58:27 +01:00
// Otherwise return failure
log('Immich response ' + res.status + ' for key ' + key)
return {
valid: false
}
2024-10-28 09:08:16 +01:00
}
2024-10-29 15:07:54 +01:00
/**
2024-11-11 16:48:35 +01:00
* Get the content-type of a video, for passing back to lightGallery
2024-10-29 15:07:54 +01:00
*/
2024-11-11 16:48:35 +01:00
async getVideoContentType (asset: Asset) {
const data = await this.request(this.buildUrl('/assets/' + encodeURIComponent(asset.id) + '/video/playback', {
key: asset.key,
password: asset.password
}))
return data.headers.get('Content-Type')
2024-10-29 10:29:14 +01:00
}
2024-11-01 11:58:27 +01:00
/**
* 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
2024-11-01 12:18:13 +01:00
if (Object.entries(params).length) {
query = '?' + (new URLSearchParams(params as {
[key: string]: string
})).toString()
}
2024-11-01 11:58:27 +01:00
return baseUrl + query
}
2024-10-29 15:07:54 +01:00
/**
* Return the image data URL for a photo
*/
2024-11-01 11:58:27 +01:00
photoUrl (key: string, id: string, size?: ImageSize, password?: string) {
const path = ['photo', key, id]
if (size) path.push(size)
const params = password ? this.encryptPassword(password) : {}
return this.buildUrl('/' + path.join('/'), params)
2024-10-28 09:08:16 +01:00
}
2024-10-29 10:29:14 +01:00
2024-10-29 15:07:54 +01:00
/**
* Return the video data URL for a video
*/
2024-11-01 11:58:27 +01:00
videoUrl (key: string, id: string, password?: string) {
2024-11-03 19:48:32 +01:00
const params = password ? this.encryptPassword(password) : {}
2024-11-01 11:58:27 +01:00
return this.buildUrl(`/video/${key}/${id}`, params)
2024-10-29 10:29:14 +01:00
}
2024-10-29 15:07:54 +01:00
/**
* Check if a provided ID matches the Immich ID format
*/
2024-10-29 10:29:14 +01:00
isId (id: string) {
return !!id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
}
2024-10-29 15:07:54 +01:00
/**
2024-11-01 12:41:05 +01:00
* Check if a provided key matches the Immich shared-link key format.
* It appears that the key is always 67 chars long, but since I don't know that this
* will always be the case, I've left it open-ended.
2024-10-29 15:07:54 +01:00
*/
isKey (key: string) {
return !!key.match(/^[\w-]+$/)
}
2024-11-03 19:48:32 +01:00
/**
* When loading assets from a password-protected link, make the decryption key valid for a
* short time. If the visitor loads the share link again, it will renew that expiry time.
2024-11-03 20:28:37 +01:00
* Even though the recipient already knows the password, this is just in case - for example
* to protect against the password-protected link being revoked, but the asset links still
* being valid.
2024-11-03 19:48:32 +01:00
*/
encryptPassword (password: string) {
return encrypt(JSON.stringify({
password,
expires: dayjs().add(1, 'hour').format()
}))
}
2024-11-04 11:15:00 +01:00
async accessible () {
return !!(await immich.request('/server/ping'))
}
validateImageSize (size: unknown) {
if (!size || !Object.values(ImageSize).includes(size as ImageSize)) {
return ImageSize.preview
} else {
return size as ImageSize
}
}
2024-10-28 09:08:16 +01:00
}
2024-10-29 10:29:14 +01:00
const immich = new Immich()
2024-10-28 09:56:11 +01:00
2024-10-29 10:29:14 +01:00
export default immich