feat: storage path layout, basename URLs, and bidirectional domain isolation

Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
This commit is contained in:
O96u committed 2026-08-28 09:56:45 +08:00
1 parent f7488753ca
commit 438c1d5529
22 files changed
+609 -219

No files matched your search

+6
View File
@@ -5,6 +5,7 @@ import {
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
@@ -31,6 +32,7 @@ interface SettingsPatchBody {
siteBaseUrl?: unknown
imageBaseUrl?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
@@ -135,5 +137,9 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
}
if (body.storageUseDatePath !== undefined) {
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
}
return getSettingsPayload(event)
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { shouldBlockImageHostRequest } from '../utils/host-isolation'
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event)) {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
* 仅当路径像图片 key 或短链时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。
*/
export default defineEventHandler(async (event) => {
+1
View File
@@ -374,6 +374,7 @@ export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
+48
View File
@@ -7,6 +7,7 @@ import {
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_AUTO_DELETE_ENABLED_AT,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
@@ -179,6 +180,49 @@ export function isHideFolderInUrl(_event?: H3Event): boolean {
return isHideFolderInUrlFromEnv()
}
export function isStorageUseDatePathFromEnv(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat') return false
if (layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
if (raw === 'false') return false
if (raw === 'true') return true
return true
} catch {
return true
}
}
export function isStorageUseDatePathEnvConfigured(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat' || layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
return raw === 'true' || raw === 'false'
} catch {
return false
}
}
export function getStorageUseDatePathSource(): SettingSource {
if (getSetting(SETTINGS_STORAGE_USE_DATE_PATH) !== null) return 'db'
if (isStorageUseDatePathEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量 → 默认 true(按年/月分组) */
export function isStorageUseDatePath(_event?: H3Event): boolean {
const dbRaw = getSetting(SETTINGS_STORAGE_USE_DATE_PATH)
if (dbRaw !== null) return dbRaw !== 'false'
if (isStorageUseDatePathEnvConfigured()) return isStorageUseDatePathFromEnv()
return true
}
export function getStorageLayout(event?: H3Event): 'date' | 'flat' {
return isStorageUseDatePath(event) ? 'date' : 'flat'
}
export function getApiUploadTokenFromEnv(event: H3Event): string {
return (
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
@@ -432,6 +476,8 @@ export function getSettingsPayload(event: H3Event) {
const domainSeparation = isDomainSeparationActive(event)
const hideFolderInUrl = isHideFolderInUrl(event)
const hideFolderInUrlSource = getHideFolderInUrlSource()
const storageUseDatePath = isStorageUseDatePath(event)
const storageUseDatePathSource = getStorageUseDatePathSource()
return {
apiUploadToken: getApiUploadToken(event),
@@ -451,6 +497,8 @@ export function getSettingsPayload(event: H3Event) {
domainSeparation,
hideFolderInUrl,
hideFolderInUrlSource,
storageUseDatePath,
storageUseDatePathSource,
autoDeleteDays,
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
+65 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import { shouldBlockImageHostRequest } from './host-isolation'
import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest
} from './host-isolation'
function mockEvent(): H3Event {
return {
@@ -10,7 +13,7 @@ function mockEvent(): H3Event {
}
describe('shouldBlockImageHostRequest', () => {
const imageHost = 'pic.example.com'
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
@@ -67,3 +70,63 @@ describe('shouldBlockImageHostRequest', () => {
})).toBe(false)
})
})
describe('shouldBlockSiteHostImageRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: false,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks image paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('blocks legacy date short paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/2026/08/demo.webp',
hideFolder: true
})).toBe(true)
})
it('allows admin paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/'
})).toBe(false)
})
it('does not block image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
})
+41 -1
View File
@@ -24,6 +24,8 @@ export function shouldBlockImageHostRequest(
requestHost?: string
separationActive?: boolean
imageHost?: string | null
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
@@ -45,7 +47,45 @@ export function shouldBlockImageHostRequest(
if (method !== 'GET' && method !== 'HEAD') return true
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder: isHideFolderInUrl(event)
hideFolder
}) === null
}
/** 网站域禁止直链出图(仅图片域可 GET/HEAD 图片路径) */
export function shouldBlockSiteHostImageRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
if (!siteHost) return false
const requestUrl = options?.requestHost
? null
: getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
if (requestHost !== siteHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return false
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) !== null
}
+1
View File
@@ -5,6 +5,7 @@ describe('image index key validation', () => {
it('accepts standard image keys used in index', () => {
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/abc.webp')).toBe(true)
})
it('rejects path traversal keys', () => {
+35 -10
View File
@@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { getDb } from './db'
import { getDataDir } from './data-dir'
import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key'
import { isHiddenImageDatePath } from './image-public-path'
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
ensureStorageSchema,
@@ -138,6 +138,29 @@ export function findImageKeyByDatePath(datePath: string): string | null {
return defaultFolder?.key ?? rows[0]!.key
}
/** 隐藏目录 URL(纯文件名)反查完整 key;多目录同名时优先 images */
export function findImageKeyByFilename(filename: string): string | null {
if (!isBareImageFilename(filename)) return null
ensureStorageSchema()
const suffix = `/${filename}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
return validateImageKey(flatFallback) ? flatFallback : null
}
if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
if (exactDefault) return exactDefault.key
const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`))
return defaultFolder?.key ?? rows[0]!.key
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
@@ -164,8 +187,16 @@ async function listLocalImageKeys(): Promise<string[]> {
for (const prefix of prefixes) {
if (!isValidFolderName(prefix)) continue
const root = join(dataDir, prefix)
const rootFiles = await readFileNames(root)
for (const file of rootFiles) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${file}`
if (validateImageKey(key)) keys.push(key)
}
const years = await readDirNames(root)
for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
const months = await readDirNames(join(root, year))
for (const month of months) {
const files = await readFileNames(join(root, year, month))
@@ -222,10 +253,7 @@ export async function listFolders(): Promise<string[]> {
`).all() as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
if (!folders.includes(DEFAULT_FOLDER)) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
return sortFolderNames(folders)
}
export async function listFoldersForUser(
@@ -240,10 +268,7 @@ export async function listFoldersForUser(
`).all(...params) as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
if (!folders.length) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
return sortFolderNames(folders)
}
function buildBackendFilterSql(
+29 -2
View File
@@ -1,12 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
describe('validateImageKey', () => {
it('accepts valid keys', () => {
it('accepts valid date-layout keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
})
it('accepts valid flat-layout keys', () => {
expect(validateImageKey('images/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
})
it('sorts images first then alphabetically', () => {
expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
})
it('rejects path traversal and malformed keys', () => {
expect(validateImageKey('')).toBe(false)
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
@@ -15,5 +25,22 @@ describe('validateImageKey', () => {
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
expect(validateImageKey('images/2026/a.webp')).toBe(false)
})
})
describe('generateImageKey', () => {
const date = new Date('2026-08-15T12:00:00Z')
it('generates date-layout keys by default', () => {
const key = generateImageKey('image/webp', date, 'images')
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
it('generates flat-layout keys when requested', () => {
const key = generateImageKey('image/webp', date, 'images', 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
})
+49 -8
View File
@@ -11,6 +11,8 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
export const DEFAULT_FOLDER = 'images'
export type StorageLayout = 'date' | 'flat'
/** 避免与站点路由 / 静态资源冲突 */
const RESERVED_FOLDERS = new Set([
'api',
@@ -25,6 +27,8 @@ const RESERVED_FOLDERS = new Set([
'sw'
])
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
@@ -49,15 +53,37 @@ export function normalizeFolderName(raw: string | undefined | null): string {
return trimmed
}
/** 默认目录 images 始终排在首位,其余按字母序 */
export function sortFolderNames(folders: string[]): string[] {
const seen = new Set<string>()
const valid: string[] = []
for (const folder of folders) {
if (!isValidFolderName(folder) || seen.has(folder)) continue
seen.add(folder)
valid.push(folder)
}
if (!seen.has(DEFAULT_FOLDER)) {
valid.push(DEFAULT_FOLDER)
}
const rest = valid
.filter(folder => folder !== DEFAULT_FOLDER)
.sort((a, b) => a.localeCompare(b))
return [DEFAULT_FOLDER, ...rest]
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
prefix = DEFAULT_FOLDER
prefix = DEFAULT_FOLDER,
layout: StorageLayout = 'date'
): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
if (layout === 'flat') {
return `${prefix}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${prefix}/${year}/${month}/${id}.${ext}`
}
@@ -67,9 +93,24 @@ export function validateImageKey(key: string): boolean {
if (key.includes('..')) return false
if (key.includes('//')) return false
const match = key.match(
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
)
if (!match) return false
return isValidFolderName(match[1]!)
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
const folder = parts[0]!
if (!isValidFolderName(folder)) return false
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false
if (parts.length === 2) {
return true
}
if (parts.length === 4) {
const year = parts[1]!
const month = parts[2]!
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
}
return false
}
+21 -4
View File
@@ -1,14 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isHiddenImageDatePath, toPublicImagePath } from './image-public-path'
import {
isBareImageFilename,
isHiddenImageDatePath,
toPublicImagePath
} from './image-public-path'
describe('toPublicImagePath', () => {
it('returns key unchanged when hideFolder is false', () => {
expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('images/2026/08/a.webp')
expect(toPublicImagePath('images/a.webp', false)).toBe('images/a.webp')
})
it('strips the first path segment when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('2026/08/a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('2026/08/a.webp')
it('returns basename when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
})
})
@@ -21,3 +27,14 @@ describe('isHiddenImageDatePath', () => {
expect(isHiddenImageDatePath('images/2026/08/demo.webp')).toBe(false)
})
})
describe('isBareImageFilename', () => {
it('accepts bare filenames', () => {
expect(isBareImageFilename('demo.webp')).toBe(true)
})
it('rejects paths with directories', () => {
expect(isBareImageFilename('2026/08/demo.webp')).toBe(false)
expect(isBareImageFilename('images/demo.webp')).toBe(false)
})
})
+11 -3
View File
@@ -1,13 +1,21 @@
/** 对外 URL 路径:可选去掉 key 的首段目录(如 images/) */
import { basename } from 'node:path'
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
export function toPublicImagePath(key: string, hideFolder: boolean): string {
if (!hideFolder) return key
const slash = key.indexOf('/')
return slash > 0 ? key.slice(slash + 1) : key
return basename(key)
}
const HIDDEN_DATE_PATH_PATTERN
= /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
const BARE_FILENAME_PATTERN
= /^[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
export function isHiddenImageDatePath(path: string): boolean {
return HIDDEN_DATE_PATH_PATTERN.test(path)
}
export function isBareImageFilename(path: string): boolean {
return BARE_FILENAME_PATTERN.test(path)
}
+4 -2
View File
@@ -14,7 +14,7 @@ import {
import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getWebpQuality } from './env'
import { getStorageLayout, getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logException } from './logger'
@@ -122,10 +122,12 @@ export async function processSingleImageUpload(
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event)
const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images'
input.prefix ?? 'images',
layout
)
let backendId: string
+15 -1
View File
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest'
import { requestPathToImageKey } from './serve-image'
describe('requestPathToImageKey hide folder', () => {
it('resolves bare filename to default folder flat key', () => {
expect(requestPathToImageKey('/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('resolves hidden date path to default folder key', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
@@ -10,10 +15,19 @@ describe('requestPathToImageKey hide folder', () => {
it('still accepts full key when hide folder is enabled', () => {
expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('does not resolve hidden path when hide folder is disabled', () => {
it('does not resolve short paths when hide folder is disabled', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
.toBeNull()
expect(requestPathToImageKey('/demo.webp', { hideFolder: false }))
.toBeNull()
})
it('accepts flat full key when hide folder is disabled', () => {
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false }))
.toBe('images/demo.webp')
})
})
+6 -3
View File
@@ -6,7 +6,7 @@ import {
getSiteBaseUrlConfigured,
hostnameFromBaseUrl
} from './env'
import { findImageKeyByDatePath } from './image-index'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
@@ -83,8 +83,11 @@ export function requestPathToImageKey(
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const resolved = findImageKeyByDatePath(raw)
if (resolved && validateImageKey(resolved)) return resolved
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
}
return null