本文所有命令均在 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

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

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

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

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

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

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

1
notepad _gen_thumbs.js

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
'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

1
notepad _album_gen.js

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
'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(含密码锁定)

1
notepad server.js

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
/**
* 拾光小本 · 博客 + 媒体 统一本地服务
*
* 功能:
* - 在 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(视频封面用,可选)

1
winget install ffmpeg

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

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

1
ls -la server.js _album_gen.js _gen_thumbs.js _protected.json _covers.json

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

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

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

1
npm install sharp

装完验证:

1
node -e "require('sharp'); console.log('sharp OK')"

显示 sharp OK 就成功了

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

1
notepad _config.butterfly.yml

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

1
相册: /相册/ || fas fa-images

保存后应该是:

1
2
3
4
5
6
7
8
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 步:生成相册页

1
node _album_gen.js

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

第 11 步:构建博客

1
hexo clean && hexo g

第 12 步:启动相册服务

1
node server.js 4000

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

浏览器打开:http://localhost:4000/
→ 点导航栏”相册”
→ 输入全局密码(默认 123456789)
→ 看到相册1、相册2 两个相册

【日常使用】

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

1
2
3
4
5
6
7
## 重新生成相册页
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),
不用改文件也能换盘,启动或生成时带环境变量即可

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

1
2
3
4
5
6
7
8
9
10
11
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:\‘(注意反斜杠要写两个 \),
然后重新生成相册页并启动:

1
2
3
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 相册系统说明看这里