本文所有命令均在 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 => `})`)
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
换盘后必做(顺序不能乱):
- 确认新盘的文件夹结构还是 盘:\相册名\ (相册名 = 顶层文件夹名)
- 先 node _album_gen.js 重新生成相册页(否则页面还是旧盘的照片列表)
- 再 hexo clean && hexo g 重新构建
- 最后重启 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 相册系统说明看这里