OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-umbrel-gallery.mjs

190 lines · javascript ref:
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const MAX_GALLERY_IMAGE_BYTES = 8 * 1024 * 1024

const usage = `
Usage:
  node scripts/check-umbrel-gallery.mjs [--manifest <path>] [--gallery-dir <path>]

Checks the optional Umbrel gallery handoff assets. Empty gallery lists are valid
for first official submission; populated lists must contain safe 1440x900 PNG or
JPEG screenshots.
`

const args = parseArgs(process.argv.slice(2))
const manifestFile = args.manifest ? path.resolve(args.manifest) : path.join(repoRoot, 'umbrel-app', 'umbrel-app.yml')
const galleryDir = args.galleryDir ? path.resolve(args.galleryDir) : path.join(path.dirname(manifestFile), 'gallery')
const gallery = readGallery(manifestFile)

if (gallery.length === 0) {
  console.log('umbrel-app.yml gallery is empty for the first official submission; attach screenshots in the PR/reviewer handoff.')
  process.exit(0)
}

if (gallery.length < 3) die(`populated gallery must list at least 3 images, got ${gallery.length}`)
if (gallery.length > 5) die(`gallery must list at most 5 images, got ${gallery.length}`)

const galleryRoot = validateGalleryDir(galleryDir)
const seen = new Set()
for (const name of gallery) {
  if (!/^[1-5]\.(png|jpe?g)$/i.test(name)) {
    die(`gallery image name must be a numbered PNG/JPEG filename like 1.png or 1.jpg; got ${JSON.stringify(name)}`)
  }
  if (seen.has(name.toLowerCase())) die(`gallery image is listed more than once: ${name}`)
  seen.add(name.toLowerCase())

  const file = path.join(galleryRoot, name)
  if (path.basename(file) !== name || path.dirname(file) !== galleryRoot) {
    die(`gallery image must not include path traversal: ${JSON.stringify(name)}`)
  }
  if (!fs.existsSync(file)) die(`Gallery image listed but missing: ${file}`)
  const stat = fs.lstatSync(file)
  if (stat.isSymbolicLink()) die(`gallery image must not be a symlink: ${file}`)
  if (!stat.isFile()) die(`gallery image must be a regular file: ${file}`)
  if (stat.size > MAX_GALLERY_IMAGE_BYTES) {
    die(`gallery image must be ${MAX_GALLERY_IMAGE_BYTES} bytes or smaller: ${file} is ${stat.size} bytes`)
  }

  const realFile = fs.realpathSync(file)
  if (!isPathInside(galleryRoot, realFile)) die(`gallery image must stay inside gallery directory: ${file}`)

  const size = imageSize(realFile)
  if (size.width !== 1440 || size.height !== 900) {
    die(`gallery image must be 1440x900 px: ${file} is ${size.width}x${size.height}`)
  }
}

console.log(`Umbrel gallery validates: ${gallery.length} image${gallery.length === 1 ? '' : 's'}.`)

function validateGalleryDir (dir) {
  if (!fs.existsSync(dir)) die(`Gallery directory missing: ${dir}`)
  const stat = fs.lstatSync(dir)
  if (stat.isSymbolicLink()) die(`gallery directory must not be a symlink: ${dir}`)
  if (!stat.isDirectory()) die(`gallery directory must be a directory: ${dir}`)
  return fs.realpathSync(dir)
}

function isPathInside (root, child) {
  const relative = path.relative(root, child)
  return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
}

function readGallery (file) {
  const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/)
  for (let i = 0; i < lines.length; i++) {
    const match = lines[i].match(/^gallery:\s*(.*)$/)
    if (!match) continue
    const rest = match[1].trim()
    if (rest) return parseInlineGallery(rest)

    const out = []
    for (let j = i + 1; j < lines.length; j++) {
      const line = lines[j]
      if (/^\S/.test(line)) break
      if (!line.trim() || /^\s*#/.test(line)) continue
      const item = line.match(/^\s*-\s*(.+?)\s*$/)
      if (!item) die(`unsupported gallery YAML line: ${line}`)
      out.push(unquoteYamlScalar(item[1].trim()))
    }
    return out
  }
  die(`Could not find gallery in ${file}.`)
}

function parseInlineGallery (value) {
  if (value === '[]') return []
  if (!value.startsWith('[') || !value.endsWith(']')) die(`unsupported inline gallery value: ${value}`)
  const inner = value.slice(1, -1).trim()
  if (!inner) return []
  return inner.split(',').map(part => unquoteYamlScalar(part.trim())).filter(Boolean)
}

function unquoteYamlScalar (value) {
  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
    return value.slice(1, -1)
  }
  return value
}

function imageSize (file) {
  const buf = fs.readFileSync(file)
  if (isPng(buf)) {
    return {
      width: buf.readUInt32BE(16),
      height: buf.readUInt32BE(20)
    }
  }
  if (isJpeg(buf)) return jpegSize(buf, file)
  die(`gallery image must be PNG or JPEG: ${file}`)
}

function isPng (buf) {
  return buf.length >= 24 &&
    buf[0] === 0x89 &&
    buf[1] === 0x50 &&
    buf[2] === 0x4e &&
    buf[3] === 0x47 &&
    buf[4] === 0x0d &&
    buf[5] === 0x0a &&
    buf[6] === 0x1a &&
    buf[7] === 0x0a
}

function isJpeg (buf) {
  return buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8
}

function jpegSize (buf, file) {
  let offset = 2
  while (offset + 9 < buf.length) {
    if (buf[offset] !== 0xff) die(`invalid JPEG marker while reading ${file}`)
    const marker = buf[offset + 1]
    offset += 2
    if (marker === 0xd9 || marker === 0xda) break
    if (offset + 2 > buf.length) break
    const length = buf.readUInt16BE(offset)
    if (length < 2 || offset + length > buf.length) break
    if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
      return {
        height: buf.readUInt16BE(offset + 3),
        width: buf.readUInt16BE(offset + 5)
      }
    }
    offset += length
  }
  die(`could not read JPEG dimensions from ${file}`)
}

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      console.log(usage.trim())
      process.exit(0)
    }
    if (arg === '--manifest' || arg === '--gallery-dir') {
      const value = argv[++i]
      if (!value || value.startsWith('--')) die(`Missing value for ${arg}`)
      out[camel(arg.slice(2))] = value
      continue
    }
    die(`Unknown argument: ${arg}`)
  }
  return out
}

function camel (value) {
  return value.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}

function die (message) {
  console.error(message)
  process.exit(1)
}