Butterfly主题 5.7 导航栏添加相册同时设置相册入口密码

本文所有命令均在 Git Bash(hexo 目录右键 → Git Bash Here)中执行,CMD/PowerShell 语法不同,请勿混用

🔶hexo 相册系统 · 从零搭建完整教程,这里以G盘为例,电脑本地操作

🔶适用:Windows + Git Bash(MINGW64)+ hexo + butterfly 主题

🔶媒体目录:G 盘根目录(G:\相册1、G:\相册2 = 两个相册)

【准备工作】

1. 已有 hexo 博客(含 butterfly 主题)
2. 照片放在 G 盘:G:\相册1\(一个文件夹 = 一个相册)
3. 在 hexo 文件夹空白处右键 → 打开 Git Bash

第 1 步:创建密码配置文件 _protected.json

bash 复制代码
echo '{"相册1": "123456", "__全局密码__": "123456789", "相册2": "123456"}' > _protected.json

全局密码 是导航栏进整个相册的密码; "相册1"/"相册2" = 单个相册的密码(可加多个),改密码就改这个文件

第 2 步:创建自定义封面配置 _covers.json

bash 复制代码
echo '{"相册1": "0d37a7a0cd1.jpg", "相册2": "01f34cdb81c.jpg"}' > _covers.json

格式:{"相册名": "封面文件名.jpg"}; 视频封面:{ "相册名": "video:视频文件名.mp4" }

第 3 步:创建缩略图生成器 _gen_thumbs.js

bash 复制代码
notepad _gen_thumbs.js

记事本打开后,粘贴下面全部内容,Ctrl+S 保存:

bash 复制代码
'use strict'
/**
 * 压缩图批量生成器
 * 把大照片生成长边 1600px 的 JPG(质量 80)到 MEDIA_ROOT/_thumbs/,用于相册网格/封面展示。
 * 原图不改动;小图(长边<=1600)不生成,直接使用原图。
 *
 * 用法:  MEDIA_ROOT=G:\相册1 node _gen_thumbs.js
 * 可断点续跑:已有 _thumbs 文件的直接跳过。
 */
const fs = require('fs')
const path = require('path')
const sharp = require('sharp')

const MEDIA_ROOT = process.env.MEDIA_ROOT || 'G:\\'
const THUMB_DIR = path.join(MEDIA_ROOT, '_thumbs')
const MAX_EDGE = 1600
const QUALITY = 80
const IMG_EXT = ['.jpg', '.jpeg', '.png', '.bmp', '.webp', '.tif', '.tiff', '.jfif']

function isSkipDir (n) {
  return n === '_thumbs' || n === 'System Volume Information' || n === 'RECYCLE.BIN' || n.startsWith('$')
}

function walk (dir, out) {
  let entries
  try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return }
  for (const e of entries) {
    const full = path.join(dir, e.name)
    if (e.isDirectory()) {
      if (!isSkipDir(e.name)) walk(full, out)
    } else if (e.isFile()) {
      if (IMG_EXT.includes(path.extname(e.name).toLowerCase())) out.push(full)
    }
  }
}

async function main () {
  const files = []
  walk(MEDIA_ROOT, files)
  console.log('待检查图片: ' + files.length)

  let generated = 0, skipped = 0, failed = 0, idx = 0
  const CONC = 4

  async function worker () {
    while (idx < files.length) {
      const src = files[idx++]
      const rel = path.relative(MEDIA_ROOT, src)
      const out = path.join(THUMB_DIR, rel.replace(/\.[^.]+$/, '') + '.jpg')
      try {
        if (fs.existsSync(out)) { skipped++; continue }
        const meta = await sharp(src, { failOn: 'none' }).metadata().catch(() => null)
        if (!meta) { failed++; continue }
        const longEdge = Math.max(meta.width || 0, meta.height || 0)
        if (longEdge <= MAX_EDGE) { skipped++; continue }
        fs.mkdirSync(path.dirname(out), { recursive: true })
        await sharp(src, { failOn: 'none' })
          .rotate()
          .resize(MAX_EDGE, MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
          .jpeg({ quality: QUALITY, mozjpeg: true })
          .toFile(out)
        generated++
        if (generated % 100 === 0) console.log('  已生成 ' + generated + ',失败 ' + failed)
      } catch (e) {
        failed++
        if (failed <= 10) console.error('  失败: ' + src + ' → ' + e.message)
      }
    }
  }

  await Promise.all(Array.from({ length: CONC }, worker))
  console.log('完成:生成 ' + generated + ',跳过(已有/小图) ' + skipped + ',失败 ' + failed)
}

main()

第 4 步:创建相册页面生成器 _album_gen.js

bash 复制代码
notepad _album_gen.js

粘贴下面全部内容,Ctrl+S 保存:

bash 复制代码
'use strict'
/**
 * 相册批量生成器 v2(全盘扫描版 · 跨平台可移植)
 *
 * 用法:  MEDIA_ROOT=G:\ node _album_gen.js(默认媒体根 G:\,可用环境变量 MEDIA_ROOT 覆盖)
 *
 * 结构:
 *   相册首页 source/相册/index.md             ------ 按年份分组(2008→2026 + 其他)
 *   source/相册/<顶层文件夹名>/index.md        ------ 每个顶层文件夹一个相册页
 */

const fs = require('fs')
const path = require('path')

const MEDIA_ROOT = process.env.MEDIA_ROOT || 'G:\\'
const SRC_ALBUM = path.join(__dirname, 'source', '相册')
const DATE = '2026-09-14'

const IMG_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.tif', '.tiff', '.jfif']
const VID_EXT = ['.mp4', '.mov', '.avi', '.mkv', '.wmv', '.flv', '.m4v', '.webm', '.ts', '.3gp']

function enc (s) { return encodeURIComponent(s) }
function safeDir (s) { return s.replace(/[\\/:*?"<>|]/g, '_') }

function relToUrl (rel) {
  return '/media/' + rel.split(/[\\/]/).map(enc).join('/')
}

function thumbRel (rel) {
  return '_thumbs/' + rel.replace(/\.[^.]+$/, '') + '.jpg'
}

function imgUrl (rel) {
  if (fs.existsSync(path.join(MEDIA_ROOT, thumbRel(rel)))) return relToUrl(thumbRel(rel))
  return relToUrl(rel)
}

// ===== 视频截帧(ffmpeg)=====
function findFfmpeg () {
  try { const p = require('ffmpeg-static'); if (p && fs.existsSync(p)) return p } catch (e) {}
  try {
    const r = require('child_process').execSync('where ffmpeg', { encoding: 'utf8', stdio: 'pipe' })
    const p = r.trim().split(/\r?\n/)[0].trim()
    if (p && fs.existsSync(p)) return p
  } catch (e) {}
  try {
    const r = require('child_process').execSync('which ffmpeg', { encoding: 'utf8', stdio: 'pipe' })
    const p = r.trim().split(/\r?\n/)[0].trim()
    if (p && fs.existsSync(p)) return p
  } catch (e) {}
  const local = path.join(__dirname, 'bin', 'ffmpeg.exe')
  if (fs.existsSync(local)) return local
  return null
}
const FFMPEG = findFfmpeg()
const VIDEO_COVER_DIR = '_video_covers'

function extractVideoFrame (videoAbs, albumName, seekSec) {
  if (!FFMPEG) return null
  const base = path.basename(videoAbs, path.extname(videoAbs))
  const outRel = `${VIDEO_COVER_DIR}/${safeDir(albumName)}/${base}.jpg`
  const outAbs = path.join(MEDIA_ROOT, outRel)
  if (fs.existsSync(outAbs)) return outRel
  fs.mkdirSync(path.dirname(outAbs), { recursive: true })
  try {
    let fsize = 0
    try { fsize = fs.statSync(videoAbs).size } catch (e) {}
    const isBig = fsize > 100 * 1024 * 1024
    const args = isBig
      ? ['-y', '-ss', '0.1', '-noaccurate_seek', '-i', videoAbs, '-frames:v', '1', '-q:v', '8', '-vf', 'scale=320:-1', outAbs]
      : ['-y', '-ss', String(seekSec || 1), '-noaccurate_seek', '-i', videoAbs, '-frames:v', '1', '-q:v', '5', '-vf', 'scale=640:-1', outAbs]
    require('child_process').execFileSync(FFMPEG, args, { stdio: 'pipe' })
    return fs.existsSync(outAbs) ? outRel : null
  } catch (e) { return null }
}

function findVideoByName (vids, name) {
  if (!name) return null
  const target = String(name).toLowerCase()
  let m = vids.find(v => v.rel.toLowerCase() === target)
  if (m) return m
  m = vids.find(v => v.name.toLowerCase() === path.basename(target).toLowerCase())
  return m || null
}

function collect (dirAbs, recursive) {
  const imgs = []
  const vids = []
  function walkDir (d) {
    let entries
    try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch (e) { return }
    for (const e of entries) {
      const full = path.join(d, e.name)
      if (e.isDirectory()) {
        if (recursive) walkDir(full)
      } else if (e.isFile()) {
        const ext = path.extname(e.name).toLowerCase()
        let size = 0
        try { size = fs.statSync(full).size } catch (e2) {}
        const rel = path.relative(MEDIA_ROOT, full).split(path.sep).join('/')
        const item = { name: e.name, size, rel }
        if (IMG_EXT.includes(ext)) imgs.push(item)
        else if (VID_EXT.includes(ext)) vids.push(item)
      }
    }
  }
  walkDir(dirAbs)
  imgs.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
  vids.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
  return { imgs, vids }
}

function altOf (name) {
  const base = path.basename(name, path.extname(name))
  return base.replace(/[[\]()]/g, '').trim() || name
}

function galleryMarkdown (imgs) {
  if (!imgs.length) return ''
  const lines = imgs.map(i => `![${altOf(i.name)}](${imgUrl(i.rel)})`)
  return '{% gallery %}\n' + lines.join('\n') + '\n{% endgallery %}'
}

function videoMarkdown (vids) {
  if (!vids.length) return ''
  const lines = vids.map(v => {
    const albumName = v.rel.split('/')[0]
    const coverRel = extractVideoFrame(path.join(MEDIA_ROOT, v.rel), albumName, 1)
    const poster = coverRel ? relToUrl(coverRel) : ''
    return `<video controls preload="metadata" poster="${poster}" style="width:100%;margin:6px 0;" src="${relToUrl(v.rel)}"></video>`
  })
  return '#### 视频记录\n\n' + lines.join('\n')
}

function loadCovers () {
  const f = path.join(__dirname, '_covers.json')
  try { return JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { return {} }
}

function loadProtected () {
  const f = path.join(__dirname, '_protected.json')
  try { return JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { return {} }
}

function findCoverByName (imgs, name) {
  if (!name) return null
  const target = String(name).toLowerCase()
  let m = imgs.find(i => i.rel.toLowerCase() === target)
  if (m) return m
  m = imgs.find(i => i.name.toLowerCase() === path.basename(target).toLowerCase())
  return m || null
}

function scanAlbums () {
  let topsRaw = []
  try { topsRaw = fs.readdirSync(MEDIA_ROOT, { withFileTypes: true }) } catch (e) { console.error('无法读取媒体根目录: ' + MEDIA_ROOT, e.message); process.exit(1) }
  const tops = topsRaw
    .filter(d => d.isDirectory())
    .map(d => d.name)
    .filter(n => !n.startsWith('$') && n !== 'System Volume Information' && n !== 'RECYCLE.BIN' && !n.startsWith('_'))
    .sort((a, b) => a.localeCompare(b, 'zh'))
  const albums = []
  const covers = loadCovers()
  const protSet = loadProtected()
  for (const name of tops) {
    const topAbs = path.join(MEDIA_ROOT, name)
    const sections = []
    const root = collect(topAbs, false)
    let subRaw = []
    try { subRaw = fs.readdirSync(topAbs, { withFileTypes: true }) } catch (e) {}
    const subDirs = subRaw
      .filter(d => d.isDirectory())
      .map(d => d.name)
      .sort((a, b) => a.localeCompare(b, 'zh'))
    if (root.imgs.length || root.vids.length) sections.push({ title: '根目录', ...root })
    for (const sd of subDirs) {
      const c = collect(path.join(topAbs, sd), true)
      if (c.imgs.length || c.vids.length) sections.push({ title: sd, ...c })
    }
    const allImgs = sections.reduce((a, s) => a.concat(s.imgs), [])
    const allVids = sections.reduce((a, s) => a.concat(s.vids), [])
    let cover = null
    const custom = covers[name]
    if (custom && String(custom).startsWith('video:')) {
      const spec = String(custom).slice(6)
      const at = spec.indexOf('@')
      const vidName = at >= 0 ? spec.slice(0, at) : spec
      const ts = at >= 0 ? parseFloat(spec.slice(at + 1)) : 1
      const vid = findVideoByName(allVids, vidName)
      if (vid) {
        const fr = extractVideoFrame(path.join(MEDIA_ROOT, vid.rel), name, ts)
        if (fr) cover = { rel: fr, name: path.basename(fr), abs: path.join(MEDIA_ROOT, fr), fromVideo: true }
      } else {
        console.log('  警告: 相册「' + name + '」指定的视频 "' + vidName + '" 未找到')
      }
    } else if (custom) {
      cover = findCoverByName(allImgs, custom)
      if (!cover) console.log('  警告: 相册「' + name + '」指定的封面 "' + custom + '" 未找到,回退用最大图')
    }
    if (!cover) cover = allImgs.slice().sort((a, b) => b.size - a.size)[0] || null
    if (!cover && allVids.length) {
      const fr = extractVideoFrame(path.join(MEDIA_ROOT, allVids[0].rel), name, 1)
      if (fr) cover = { rel: fr, name: path.basename(fr), abs: path.join(MEDIA_ROOT, fr), fromVideo: true }
      else cover = { defaultVideo: true, url: '/images/video-cover.svg' }
    }
    albums.push({ name, sections, imgs: allImgs, vids: allVids, cover })
  }
  return albums
}

function yearOf (name) {
  const m = /^(\d{4})/.exec(name)
  return m ? m[1] : '其他'
}

function displayTitle (name) {
  return name.replace(/^(\d{4})-/, '$1 · ')
}

function descrOf (a) {
  const parts = []
  if (a.imgs.length) parts.push(a.imgs.length + ' 张照片')
  if (a.vids.length) parts.push(a.vids.length + ' 视频')
  return parts.join(' + ')
}

// 把封面图复制到 source/相册/covers/(博客静态目录),返回静态 URL
// 相册首页封面不走 /media/,不受相册密码拦截;点进相册页才需要密码
function prepareCover (a) {
  if (!a.cover) return null
  if (a.cover.defaultVideo || a.cover.defaultPrivate) return a.cover.url
  const coverDir = path.join(SRC_ALBUM, 'covers')
  const destName = safeDir(a.name) + '.jpg'
  const destAbs = path.join(coverDir, destName)
  try {
    const thumb = path.join(MEDIA_ROOT, thumbRel(a.cover.rel))
    const srcAbs = fs.existsSync(thumb) ? thumb : path.join(MEDIA_ROOT, a.cover.rel)
    fs.mkdirSync(coverDir, { recursive: true })
    fs.copyFileSync(srcAbs, destAbs)
    return '/相册/covers/' + destName
  } catch (e) {
    console.log('  警告: 复制封面失败「' + a.name + '」→ ' + e.message)
    return imgUrl(a.cover.rel)
  }
}

function buildIndex (albums) {
  const groups = {}
  for (const a of albums) {
    const y = yearOf(a.name)
    ;(groups[y] = groups[y] || []).push(a)
  }
  const years = Object.keys(groups).filter(y => y !== '其他').sort()
  if (groups['其他']) years.push('其他')
  const out = []
  out.push('---')
  out.push('title: 相册')
  out.push('date: ' + DATE)
  out.push('top_img: false')
  out.push('comments: false')
  out.push('---')
  out.push('')
  out.push('<div class="note note-primary"><p>有些光,要慢慢拾;有些回忆,要轻轻翻。</p></div>')
  out.push('')
  for (const y of years) {
    out.push('## ' + y)
    out.push('')
    out.push('<div class="gallery-group-main">')
    for (const a of groups[y]) {
      const coverUrl = prepareCover(a)
      if (!coverUrl) { console.log('  跳过无封面相册: ' + a.name); continue }
      out.push(`{% galleryGroup '${displayTitle(a.name)}' '${descrOf(a)}' '/相册/${enc(a.name)}' '${coverUrl}' %}`)
    }
    out.push('</div>')
    out.push('')
  }
  return out.join('\n')
}

function buildAlbumPage (a) {
  const out = []
  out.push('---')
  out.push('title: ' + displayTitle(a.name))
  out.push('date: ' + DATE)
  out.push('top_img: false')
  out.push('comments: false')
  out.push('---')
  out.push('')
  out.push(`<div class="note note-primary"><p>${displayTitle(a.name)} · 共 ${descrOf(a)},点击可放大 / 播放。</p></div>`)
  out.push('')
  for (const s of a.sections) {
    if (a.sections.length > 1) {
      out.push('## ' + s.title)
      out.push('')
    }
    const g = galleryMarkdown(s.imgs)
    if (g) { out.push(g); out.push('') }
    const v = videoMarkdown(s.vids)
    if (v) { out.push(v); out.push('') }
  }
  return out.join('\n')
}

function main () {
  fs.rmSync(SRC_ALBUM, { recursive: true, force: true })
  fs.mkdirSync(SRC_ALBUM, { recursive: true })

  const albums = scanAlbums()

  fs.writeFileSync(path.join(SRC_ALBUM, 'index.md'), buildIndex(albums), 'utf8')

  for (const a of albums) {
    const dir = path.join(SRC_ALBUM, safeDir(a.name))
    fs.mkdirSync(dir, { recursive: true })
    fs.writeFileSync(path.join(dir, 'index.md'), buildAlbumPage(a), 'utf8')
  }

  let tImg = 0, tVid = 0
  for (const a of albums) { tImg += a.imgs.length; tVid += a.vids.length }
  console.log('生成完成:')
  console.log('  相册首页: source/相册/index.md')
  console.log('  相册详情页: ' + albums.length + ' 个')
  console.log('  合计照片 ' + tImg + ' 张,视频 ' + tVid + ' 段')
}

main()

第 5 步:创建相册服务 server.js(含密码锁定)

bash 复制代码
notepad server.js

粘贴下面全部内容,Ctrl+S 保存(内容较长,复制完整):

bash 复制代码
/**
 * 拾光小本 · 博客 + 媒体 统一本地服务
 *
 * 功能:
 *  - 在 4000 端口同时服务 hexo 构建产物(./public)和 G 盘媒体(/media/*)
 *  - /media/* 映射到外接硬盘 G:\,支持视频 Range 拖拽播放
 *  - 这样相册页用相对路径 /media/xxx 引用,电脑和手机连家里 WiFi 都能看
 *
 * 启动:
 *   node server.js                 (默认端口 4000)
 *   node server.js 8080             (自定义端口)
 *   set MEDIA_ROOT=D:\ && node server.js
 */
'use strict'

const http = require('http')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')

const PORT = parseInt(process.argv[2] || process.env.PORT || '80', 10)
const PUBLIC_DIR = path.join(__dirname, 'public')
const MEDIA_ROOT = process.env.MEDIA_ROOT || 'G:\\'
const AUTH_SECRET = 'shiguang-album-2026-local'
const AUTH_COOKIE = 'album_auth'
const GLOBAL_AUTH_COOKIE = 'album_global_auth'
const AUTH_DAYS = 1/24  // 1小时有效(1/24天)

// ===== 密码失败锁定(可自定义)=====
// MAX_ATTEMPTS: 最多连续输错次数;LOCK_HOURS: 达到次数后锁定的小时数
// 锁定记录保存在 _lock.json(与 server.js 同目录),重启服务锁定不失效
// 如需解锁:删除 server.js 同目录下的 _lock.json 文件,再重启服务即可
// MAX_ATTEMPTS = 5,输入次数,LOCK_HOURS = 72,锁定 72 小时
// 期间输入密码(无论对错)都会提示 "已锁定,请 X 小时后重试"
const MAX_ATTEMPTS = 5
const LOCK_HOURS = 72
const LOCK_FILE = path.join(__dirname, '_lock.json')

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.htm': 'text/html; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.mjs': 'application/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.xml': 'text/xml; charset=utf-8',
  '.txt': 'text/plain; charset=utf-8',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.bmp': 'image/bmp',
  '.webp': 'image/webp',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.mp4': 'video/mp4',
  '.m4v': 'video/mp4',
  '.mov': 'video/quicktime',
  '.webm': 'video/webm',
  '.mkv': 'video/x-matroska',
  '.avi': 'video/x-msvideo',
  '.wmv': 'video/x-ms-wmv',
  '.ts': 'video/mp2t',
  '.flv': 'video/x-flv',
  '.mp3': 'audio/mpeg',
  '.wav': 'audio/wav',
  '.woff': 'font/woff',
  '.woff2': 'font/woff2',
  '.ttf': 'font/ttf',
  '.eot': 'application/vnd.ms-fontobject'
}

function decodeUrlPath (p) {
  try { return decodeURIComponent(p) } catch (e) { return p }
}

function isInside (full, root) {
  const rel = path.relative(path.normalize(root), path.normalize(full))
  return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel))
}

// ===== 相册密码保护 =====
function loadProtected () {
  try {
    let raw = fs.readFileSync(path.join(__dirname, '_protected.json'), 'utf8')
    if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
    return JSON.parse(raw)
  } catch (e) { return {} }
}

// ===== 密码失败锁定 =====
function loadLocks () {
  try { return JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8')) } catch (e) { return {} }
}

function saveLocks (locks) {
  try { fs.writeFileSync(LOCK_FILE, JSON.stringify(locks)) } catch (e) {}
}

// 读取某个相册的锁定记录;锁定已过期的自动清零
function getLock (album) {
  const locks = loadLocks()
  const rec = locks[album] || { count: 0, lockUntil: 0 }
  if (rec.lockUntil && rec.lockUntil <= Date.now()) {
    delete locks[album]
    saveLocks(locks)
    return { count: 0, lockUntil: 0 }
  }
  return rec
}

// 记录一次密码错误;达到 MAX_ATTEMPTS 次则锁定 LOCK_HOURS 小时
function recordFail (album) {
  const locks = loadLocks()
  const rec = locks[album] || { count: 0, lockUntil: 0 }
  rec.count = (rec.count || 0) + 1
  if (rec.count >= MAX_ATTEMPTS) {
    rec.lockUntil = Date.now() + LOCK_HOURS * 3600 * 1000
    rec.count = 0
  }
  locks[album] = rec
  saveLocks(locks)
  return rec
}

// 密码正确时清零该相册的失败记录
function clearFail (album) {
  const locks = loadLocks()
  if (locks[album]) { delete locks[album]; saveLocks(locks) }
}

// 返回剩余锁定毫秒数(未锁定时为 0)
function lockRemainMs (album) {
  const rec = getLock(album)
  return rec.lockUntil ? rec.lockUntil - Date.now() : 0
}

// 验证某相册的密码是否正确
function checkPassword (album, password) {
  const p = loadProtected()
  if (album === GLOBAL_ALBUM) {
    const gp = getGlobalPassword()
    return !!(gp && gp === password)
  }
  return !!(p[album] && p[album] === password)
}

// 清除锁定(输入正确密码后解锁)
function unlockAlbum (album) {
  const locks = loadLocks()
  if (locks[album]) { delete locks[album]; saveLocks(locks) }
}

function sign (val) { return crypto.createHmac('sha256', AUTH_SECRET).update(val).digest('hex') }

function hashPassword (pw) {
  return crypto.createHash('sha256').update(pw).digest('hex').slice(0, 16)
}

function makeCookie (album, password) {
  const exp = Date.now() + AUTH_DAYS * 86400000
  const encAlbum = encodeURIComponent(album)
  const ph = hashPassword(password)
  const v = encAlbum + ':' + exp + ':' + ph
  return v + ':' + sign(v)
}

function parseCookies (req) {
  const out = {}
  const h = req.headers.cookie
  if (!h) return out
  for (const part of h.split(';')) {
    const idx = part.indexOf('=')
    if (idx > 0) out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
  }
  return out
}

function hasAuth (req, album, password, cookieName) {
  const raw = parseCookies(req)[cookieName || AUTH_COOKIE]
  if (!raw) return false
  const parts = raw.split(':')
  if (parts.length !== 4) return false
  const [albEnc, exp, passHash, sig] = parts
  if (decodeURIComponent(albEnc) !== album) return false
  if (parseInt(exp, 10) < Date.now()) return false
  if (hashPassword(password) !== passHash) return false
  return sign(albEnc + ':' + exp + ':' + passHash) === sig
}

function protectedAlbumOf (urlPath) {
  const p = loadProtected()
  let m = urlPath.match(/^\/相册\/([^/]+)/)
  if (m && p[m[1]]) return { name: m[1], pass: p[m[1]] }
  m = urlPath.match(/^\/media\/([^/]+)/)
  if (m) {
    // /media/<album>/...  或  /media/_video_covers/<album>/...
    let album = m[1]
    if (album === '_video_covers') {
      const m2 = urlPath.match(/^\/media\/_video_covers\/([^/]+)/)
      if (m2) album = m2[1]
    }
    if (p[album]) return { name: album, pass: p[album] }
  }
  return null
}

const GLOBAL_ALBUM = '__全局__'
const GLOBAL_KEY = '__全局密码__'

function getGlobalPassword () {
  const p = loadProtected()
  return p[GLOBAL_KEY] || null
}

function hasGlobalAuth (req) {
  const gp = getGlobalPassword()
  if (!gp) return false
  return hasAuth(req, GLOBAL_ALBUM, gp, GLOBAL_AUTH_COOKIE)
}

function isAlbumArea (urlPath) {
  return urlPath === '/相册' || urlPath.startsWith('/相册/') ||
         urlPath === '/media' || urlPath.startsWith('/media/')
}

function authPage (album, error) {
  const isGlobal = album === GLOBAL_ALBUM
  const displayName = isGlobal ? '相册' : album
  const desc = isGlobal ? '整个相册需要密码才能访问' : '这个相册需要密码才能访问'
  return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>需要密码 · ' + displayName + '</title>' +
    '<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f4f3ee;font-family:"PingFang SC","Microsoft YaHei",sans-serif}' +
    '.box{background:#fff;padding:40px;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);width:320px;text-align:center}' +
    'h2{margin:0 0 8px;color:#1a1b1c}p{color:#6b7280;font-size:14px;margin:0 0 24px}' +
    'input{width:100%;padding:12px;border:1px solid #e4e3dd;border-radius:8px;font-size:15px;box-sizing:border-box;margin-bottom:16px}' +
    'button{width:100%;padding:12px;background:#9eacea;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer}' +
    '.err{color:#ea6668;font-size:13px;margin-top:12px}</style></head><body>' +
    '<div class="box"><h2>🔒 ' + displayName + '</h2><p>' + desc + '</p>' +
    '<form method="POST" action="/__auth"><input type="hidden" name="album" value="' + album + '">' +
    '<input type="password" name="password" placeholder="请输入密码" autofocus>' +
    '<button type="submit">进入</button></form>' +
    (error ? '<div class="err">' + error + '</div>' : '') +
    '</div></body></html>'
}

function readBody (req) {
  return new Promise((resolve) => {
    let data = ''
    req.on('data', c => { data += c; if (data.length > 1e6) req.destroy() })
    req.on('end', () => resolve(data))
    req.on('error', () => resolve(''))
  })
}

function sendFile (res, filePath, stat, contentType, rangeHeader) {
  if (rangeHeader) {
    const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim())
    if (m) {
      let start = m[1] ? parseInt(m[1], 10) : 0
      let end = m[2] ? parseInt(m[2], 10) : stat.size - 1
      if (isNaN(start) || start < 0) start = 0
      if (isNaN(end) || end < 0) end = stat.size - 1
      if (start >= stat.size) {
        res.writeHead(416, { 'Content-Range': 'bytes */' + stat.size })
        res.end()
        return
      }
      if (end >= stat.size) end = stat.size - 1
      res.writeHead(206, {
        'Content-Type': contentType,
        'Content-Range': 'bytes ' + start + '-' + end + '/' + stat.size,
        'Accept-Ranges': 'bytes',
        'Content-Length': end - start + 1
      })
      fs.createReadStream(filePath, { start, end }).pipe(res)
      return
    }
  }
  res.writeHead(200, {
    'Content-Type': contentType,
    'Content-Length': stat.size,
    'Accept-Ranges': 'bytes',
    'Cache-Control': 'no-cache'
  })
  fs.createReadStream(filePath).pipe(res)
}

function resolvePublic (relPath) {
  const candidates = [
    relPath,
    path.join(relPath, 'index.html'),
    relPath + '.html',
    relPath + '.htm'
  ]
  for (const c of candidates) {
    const full = path.join(PUBLIC_DIR, c)
    if (isInside(full, PUBLIC_DIR) && fs.existsSync(full) && fs.statSync(full).isFile()) {
      return full
    }
  }
  return null
}

const server = http.createServer(async (req, res) => {
  try {
  const rawPath = (req.url || '/').split('?')[0]
  const urlPath = decodeUrlPath(rawPath)

  // 密码登录提交
  if (req.method === 'POST' && urlPath === '/__auth') {
    const body = await readBody(req)
    const params = new URLSearchParams(body)
    const album = params.get('album') || ''
    const password = params.get('password') || ''

    // 已锁定的相册:输入正确密码可解锁并进入;输错则继续锁定
    const remain = lockRemainMs(album)
    if (remain > 0) {
      if (checkPassword(album, password)) {
        unlockAlbum(album)
        const cookieName = (album === GLOBAL_ALBUM) ? GLOBAL_AUTH_COOKIE : AUTH_COOKIE
        const redirectTo = (album === GLOBAL_ALBUM) ? '/相册/' : '/相册/' + album + '/'
        res.writeHead(302, {
          'Set-Cookie': cookieName + '=' + makeCookie(album, password) + '; Path=/; Max-Age=' + (AUTH_DAYS * 86400) + '; HttpOnly; SameSite=Lax',
          'Location': encodeURI(redirectTo)
        })
        res.end()
      } else {
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
        res.end(authPage(album, '已锁定,请 ' + Math.ceil(remain / 3600000) + ' 小时后重试'))
      }
      return
    }

    const correct = checkPassword(album, password)
    const redirectTo = (album === GLOBAL_ALBUM) ? '/相册/' : '/相册/' + album + '/'
    if (correct) {
      clearFail(album)
      const cookieName = (album === GLOBAL_ALBUM) ? GLOBAL_AUTH_COOKIE : AUTH_COOKIE
      res.writeHead(302, {
        'Set-Cookie': cookieName + '=' + makeCookie(album, password) + '; Path=/; Max-Age=' + (AUTH_DAYS * 86400) + '; HttpOnly; SameSite=Lax',
        'Location': encodeURI(redirectTo)
      })
      res.end()
    } else {
      recordFail(album)
      const remain2 = lockRemainMs(album)
      let msg = '密码错误,请重试'
      if (remain2 > 0) {
        msg = '密码错误次数过多,已锁定,请 ' + Math.ceil(remain2 / 3600000) + ' 小时后重试'
      }
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(authPage(album, msg))
    }
    return
  }

  // 全局相册密码:保护 /相册/ 页面(/media/ 由受保护相册逻辑处理,公开相册视频可直接播放)
  const globalPass = getGlobalPassword()
  if (globalPass && urlPath.startsWith('/相册/') && !hasGlobalAuth(req)) {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end(authPage(GLOBAL_ALBUM))
    return
  }

  // 受保护相册:未登录则拦截(相册页显示密码框,媒体文件返回403)
  const prot = protectedAlbumOf(urlPath)
  if (prot && !hasAuth(req, prot.name, prot.pass)) {
    if (urlPath.startsWith('/media/')) {
      res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
      res.end('需要密码')
    } else {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(authPage(prot.name))
    }
    return
  }

  // 媒体路由:/media/* -> MEDIA_ROOT
  if (urlPath === '/media' || urlPath.startsWith('/media/')) {
    const rel = urlPath.replace(/^\/media\/?/, '')
    const mediaFile = path.join(MEDIA_ROOT, rel)
    if (rel && isInside(mediaFile, MEDIA_ROOT) && fs.existsSync(mediaFile) && fs.statSync(mediaFile).isFile()) {
      const stat = fs.statSync(mediaFile)
      const ext = path.extname(mediaFile).toLowerCase()
      sendFile(res, mediaFile, stat, MIME[ext] || 'application/octet-stream', req.headers.range)
      return
    }
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
    res.end('Media Not Found: ' + rawPath)
    return
  }

  // 博客页面路由
  const pubRel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '')
  const full = resolvePublic(pubRel)
  if (full) {
    const stat = fs.statSync(full)
    const ext = path.extname(full).toLowerCase()
    sendFile(res, full, stat, MIME[ext] || 'application/octet-stream', req.headers.range)
    return
  }

  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
  res.end('Not Found: ' + rawPath)
  } catch (err) {
    console.error('请求处理错误:', err.message)
    if (!res.headersSent) {
      res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
      res.end('Server Error')
    }
  }
})

server.listen(PORT, '::', () => {
  console.log('==============================================')
  console.log('  拾光小本 · 博客+媒体服务已启动')
  console.log('  本机访问:  http://localhost:' + PORT)
  console.log('  手机访问:  http://<本机局域网IP>:' + PORT)
  console.log('  媒体根目录: ' + MEDIA_ROOT)
  console.log('  提示: 按 Ctrl+C 停止服务')
  console.log('==============================================')
})

server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') {
    console.error('端口 ' + PORT + ' 已被占用(可能是另一个 server.js 或 hexo s 正在运行)。')
    console.error('请先关闭占用该端口的进程,再重新启动本服务。')
  } else {
    console.error('服务启动失败:', err.message)
  }
  process.exit(1)
})

第 6 步:安装 ffmpeg(视频封面用,可选)

bash 复制代码
winget install ffmpeg

说明:没装不影响照片相册,只是视频封面不会自动生成(视频仍可播放)

第 7 步:验证 5 个文件都创建好了

bash 复制代码
ls -la server.js _album_gen.js _gen_thumbs.js _protected.json _covers.json

应看到 5 个文件都存在,server.js 约 15KB

第 8 步:安装 sharp(首次需要)

在 hexo 文件夹的 Git Bash 里执行:

bash 复制代码
npm install sharp

装完验证:

bash 复制代码
node -e "require('sharp'); console.log('sharp OK')"

显示 sharp OK 就成功了

第 9 步:导航栏加相册入口

bash 复制代码
notepad _config.butterfly.yml

在 menu: 下面的 "分类" 那一行下面加一行:

bash 复制代码
  相册: /相册/ || fas fa-images

保存后应该是:

bash 复制代码
menu:
  首页: / || fas fa-home
  文章: /archives/ || fas fa-archive
  标签: /tags/ || fas fa-tags
  分类: /categories/ || fas fa-folder-open
  相册: /相册/ || fas fa-images
  友链: /link/ || fas fa-link
  关于: /about/ || fas fa-heart

第 10 步:生成相册页

bash 复制代码
node _album_gen.js

看到 生成完成:相册详情页 2 个,合计照片 8 张 就对了,(G 盘有几个相册文件夹,这里就是几个;照片总数也会显示)

第 11 步:构建博客

bash 复制代码
hexo clean && hexo g

第 12 步:启动相册服务

bash 复制代码
node server.js 4000

看到 拾光小本 · 博客+媒体服务已启动 就成功了

浏览器打开:http://localhost:4000/

→ 点导航栏"相册"

→ 输入全局密码(默认 123456789)

→ 看到相册1、相册2 两个相册

【日常使用】

新增照片:照片放到 G:\相册名\目录下,或是新建了相册,在 hexo 目录执行:

bash 复制代码
## 重新生成相册页
node _album_gen.js
## 本地预览
hexo clean && hexo g && hexo s

## 部署到 github
hexo clean && hexo g && hexo d

修改密码:编辑 _protected.json

忘记锁定:删除 server.js 同目录 _lock.json 后重启服务

【更换硬盘/盘符】(比如从 G: 换成 H:)

媒体根目录在 3 个文件里默认都是 G:\(server.js / _album_gen.js / _gen_thumbs.js),

不用改文件也能换盘,启动或生成时带环境变量即可

方法一(推荐,不改文件):用环境变量指定新盘符

bash 复制代码
  Git Bash 里执行:
    MEDIA_ROOT=H:\ node _gen_thumbs.js     (生成缩略图,可跳过)
    MEDIA_ROOT=H:\ node _album_gen.js      (重新生成相册页,必须)
    hexo clean && hexo g                   (重新构建)
    MEDIA_ROOT=H:\ node server.js 4000     (启动服务)

  CMD/PowerShell 里执行:
    set MEDIA_ROOT=H:\
    node _album_gen.js
    hexo clean && hexo g
    node server.js 4000

方法二:直接改文件

用记事本打开 server.js、_album_gen.js、_gen_thumbs.js,

把 const MEDIA_ROOT = process.env.MEDIA_ROOT || 'G:\' 里的 'G:\' 改成 'H:\'(注意反斜杠要写两个 \),

然后重新生成相册页并启动:

bash 复制代码
    node _album_gen.js
    hexo clean && hexo g
    node server.js 4000

换盘后必做(顺序不能乱):

  1. 确认新盘的文件夹结构还是 盘:\相册名\ (相册名 = 顶层文件夹名)
  2. 先 node _album_gen.js 重新生成相册页(否则页面还是旧盘的照片列表)
  3. 再 hexo clean && hexo g 重新构建
  4. 最后重启 node server.js
    (缩略图 _thumbs 和视频封面 _video_covers 会自动生成到新盘,不用手动拷贝;
    但第一次打开会慢一些,等 _gen_thumbs.js 跑一遍后就快了)

注意:

  • _protected.json(密码)、_covers.json(封面)不依赖盘符,不用改
  • 如果新盘里相册文件夹名变了(比如 相册1 改叫 我的相册),直接按新文件夹名重新生成即可
  • 部署到 N1(Linux)时用绝对路径,如:
  • MEDIA_ROOT=/mnt/usb node _album_gen.js

重装博客或换电脑,恢复 hexo Butterfly主题 5.7 相册系统说明看这里

相关推荐
风合星语1 小时前
2026 机器人工程实例(五):让 Allegro Hand 在 MuJoCo 中完成接触抓取——抓取状态机、稳定抬升与受控释放
c++·python·机器人·仿真
隐擎fox2 小时前
解构无感人机验证底层机制:行为生物轨迹采样、环境评分模型与自动化对抗实战
自动化测试·python·网络协议·tcp/ip·网络爬虫
我家猫叫佩奇2 小时前
🦭 厌倦了千篇一律的线性图标?Naive Icons 正式开源
前端·javascript·css
FungLeo2 小时前
成为全栈·Node 后端篇·通知系统:事件消费与已读态管理
node.js·成为全栈·通知系统·事件消费·已读状态管理
haidao0312 小时前
氙灯光源技术特性及其在光催化实验中的标准化应用研究
人工智能·python·能源
用户0332126663672 小时前
使用 Python 设置 Excel 行列自适应 【代码示例】
python·excel
Ticnix3 小时前
RAG 的坑不在检索,在"对齐":pgvector 实战手记
python·agent·全栈
kyrie_sakura3 小时前
python学习笔记14 -- FastAPI
笔记·python·学习·fastapi
春涧草茶3 小时前
慢就是快12-2三种访问权限|getter与setter|魔法方法
开发语言·python