feat(admin): add Glacier restore tool and admin dashboard

Admin-only page to move videos from AWS Glacier cold storage back to
hot storage. Kicks off a scoped S3 restore, polls until ready, then
reuploads via the existing hot-storage path and emails the requester.
Poller is gated by IS_PRIMARY so multi-server deployments don't race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
localhost 2026-09-23 18:10:09 +02:00
parent ca16c361aa
commit 0e4d966804
10 changed files with 532 additions and 1 deletions

View File

@ -9,12 +9,14 @@
"mcp": "bun run src/mcp.ts" "mcp": "bun run src/mcp.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.1" "@types/bun": "^1.3.1",
"@types/nodemailer": "^6.4.17"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@elysiajs/static": "^1.4.0", "@elysiajs/static": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@ -31,6 +33,7 @@
"isomorphic-dompurify": "^2.18.0", "isomorphic-dompurify": "^2.18.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"nodemailer": "^6.9.16",
"pg": "^8.13.1", "pg": "^8.13.1",
"rolling-rate-limiter": "^0.4.2" "rolling-rate-limiter": "^0.4.2"
} }

View File

@ -8,6 +8,8 @@ import video from '@/router/video'
import websocket from '@/router/websocket' import websocket from '@/router/websocket'
import html from '@/router/html' import html from '@/router/html'
import mcp from '@/router/mcp' import mcp from '@/router/mcp'
import admin from '@/router/admin'
import { startRestorePoller } from '@/utils/glacierPoller'
const app = new Elysia() const app = new Elysia()
app.use(latest) app.use(latest)
@ -17,6 +19,11 @@ app.use(video)
app.use(websocket) app.use(websocket)
app.use(html) app.use(html)
app.use(mcp) app.use(mcp)
app.use(admin)
if (process.env.IS_PRIMARY === 'true') {
startRestorePoller()
}
app.onRequest(({ set, request }: { set: { headers: Record<string, string> }, request: Request }) => { app.onRequest(({ set, request }: { set: { headers: Record<string, string> }, request: Request }) => {
set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1) set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1)
}) })

112
src/router/admin.ts Normal file
View File

@ -0,0 +1,112 @@
import { Elysia, t } from 'elysia'
import { db } from '@/utils/database'
import { m, eta, error as errorPage } from '@/utils/html'
import {
getSessionToken,
createAdminSession,
isValidAdminSession,
destroyAdminSession,
buildSessionCookie,
clearSessionCookie
} from '@/utils/adminAuth'
import { initiateRestore } from '@/utils/glacier'
const app = new Elysia({ prefix: '/admin' })
app.onBeforeHandle(async ({ path, request, set, headers, redirect }) => {
if (path === '/admin/login') return
const token = getSessionToken(headers.cookie)
if (await isValidAdminSession(token)) return
if (request.method === 'GET') return redirect('/admin/login')
set.status = 401
return { success: false, message: 'Unauthorized' }
})
app.get('/login', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./admin/login', {
title: 'Admin Login | PreserveTube'
}))
})
app.post('/login', async ({ body, set, redirect }) => {
if (body.password !== process.env.ADMIN_SECRET) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
set.status = 401
return await m(eta.render('./admin/login', {
title: 'Admin Login | PreserveTube',
loginError: 'Incorrect password.'
}))
}
const token = await createAdminSession()
set.headers['Set-Cookie'] = buildSessionCookie(token)
return redirect('/admin')
}, {
body: t.Object({
password: t.String()
})
})
app.post('/logout', async ({ headers, redirect }) => {
const token = getSessionToken(headers.cookie)
if (token) await destroyAdminSession(token)
return new Response(null, {
status: 302,
headers: { 'Set-Cookie': clearSessionCookie(), Location: '/admin/login' }
})
})
app.get('/', async ({ set }) => {
const requests = await db.selectFrom('restore_requests')
.selectAll()
.orderBy('created_at', 'desc')
.execute()
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./admin/dashboard', {
title: 'Admin | PreserveTube',
requests
}))
})
app.post('/restore', async ({ body, redirect, error }) => {
const { videoId, requesterEmail } = body
const video = await db.selectFrom('videos')
.select(['id', 'deletion_stage'])
.where('id', '=', videoId)
.executeTakeFirst()
if (!video) return error(404, 'No archived video found with that ID.')
if (video.deletion_stage !== 'cold_storage') return error(400, 'That video is not currently in cold storage.')
const inserted = await db.insertInto('restore_requests')
.values({
videoId,
requester_email: requesterEmail,
status: 'requested'
})
.returning('uuid')
.executeTakeFirstOrThrow()
await initiateRestore(videoId)
await db.updateTable('restore_requests')
.set({ status: 'restoring', aws_restore_requested_at: new Date(), updated_at: new Date() })
.where('uuid', '=', inserted.uuid)
.execute()
return redirect('/admin')
}, {
body: t.Object({
videoId: t.String(),
requesterEmail: t.String()
})
})
app.onError(errorPage)
export default app

View File

@ -0,0 +1,130 @@
<% layout('../layout') %>
<div class="page">
<div class="admin-nav">
<h2>Admin</h2>
<form method="POST" action="/admin/logout">
<button type="submit" class="admin-link-button">Log out</button>
</form>
</div>
<h3>Glacier Restore</h3>
<p class="stage-desc">Restore a video from Glacier cold storage back to hot storage. AWS retrieval takes up to 48 hours; once it's done, the video is automatically re-uploaded and the requester is emailed.</p>
<form method="POST" action="/admin/restore" class="admin-form">
<input type="text" name="videoId" placeholder="Video ID" required />
<input type="email" name="requesterEmail" placeholder="Requester email" required />
<button type="submit">Restore</button>
</form>
<table class="admin-table">
<thead>
<tr>
<th>Video</th>
<th>Requester</th>
<th>Status</th>
<th>Requested</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<% it.requests.forEach(function(r){ %>
<tr>
<td><a class="a" href="/watch?v=<%= r.videoId %>"><%= r.videoId %></a></td>
<td><%= r.requester_email %></td>
<td><span class="status status--<%= r.status %>"><%= r.status %></span></td>
<td><%= r.created_at ? new Date(r.created_at).toISOString() : '' %></td>
<td><%= r.error_message || '' %></td>
</tr>
<% }) %>
<% if (it.requests.length === 0) { %>
<tr><td colspan="5">No restore requests yet.</td></tr>
<% } %>
</tbody>
</table>
</div>
<style>
.page {
margin: 0 auto;
padding: 5px 10px 10px;
width: 65%;
}
.admin-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
.admin-link-button {
background: none;
border: none;
color: inherit;
text-decoration-line: underline;
text-decoration-style: dotted;
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0;
}
.stage-desc {
font-size: 0.9rem;
color: #aaa;
}
.admin-form {
display: flex;
gap: 8px;
margin: 12px 0 20px;
}
.admin-form input {
padding: 8px;
border: 1px solid #ccc;
font-family: inherit;
}
.admin-form button {
padding: 8px 16px;
border: 1px solid #1b1c1f;
background-color: #1b1c1f;
color: white;
cursor: pointer;
font-family: inherit;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
text-align: left;
padding: 8px;
border-bottom: 1px solid #eee;
font-size: 0.9rem;
}
.status {
padding: 2px 6px;
border-radius: 4px;
background-color: #eee;
}
.status--reuploaded {
background-color: #d5f0d5;
}
.status--failed {
background-color: #f0d5d5;
}
.a {
text-decoration-line: underline;
text-decoration-style: dotted;
}
</style>

View File

@ -0,0 +1,53 @@
<% layout('../layout') %>
<div class="text">
<h3>Admin Login</h3>
<% if (it.loginError) { %>
<p class="admin-error"><%= it.loginError %></p>
<% } %>
<form method="POST" action="/admin/login" class="admin-form">
<input type="password" name="password" placeholder="Password" required autofocus />
<button type="submit">Log in</button>
</form>
</div>
<style>
.text {
margin-top: 2.5%;
margin-bottom: 5%;
margin-left: auto;
margin-right: auto;
width: 75%;
}
h3 {
margin-top: 20px;
margin-bottom: 2px;
}
.admin-error {
color: #b3261e;
}
.admin-form {
display: flex;
gap: 8px;
margin-top: 12px;
}
.admin-form input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
font-family: inherit;
}
.admin-form button {
padding: 8px 16px;
border: 1px solid #1b1c1f;
background-color: #1b1c1f;
color: white;
cursor: pointer;
font-family: inherit;
}
</style>

View File

@ -9,6 +9,7 @@ export interface Database {
videos: VideosTable videos: VideosTable
reports: ReportsTable reports: ReportsTable
files: FilesTable files: FilesTable
restore_requests: RestoreRequestsTable
} }
export interface VideosTable { export interface VideosTable {
@ -64,3 +65,19 @@ export interface FilesTable {
export type File = Selectable<FilesTable> export type File = Selectable<FilesTable>
export type NewFile = Insertable<FilesTable> export type NewFile = Insertable<FilesTable>
export interface RestoreRequestsTable {
uuid: Generated<string>
videoId: string
requester_email: string
status: 'requested' | 'restoring' | 'restored' | 'reuploading' | 'reuploaded' | 'failed'
aws_restore_requested_at: Date | null
aws_restore_expiry: Date | null
error_message: string | null
created_at: Generated<Date>
updated_at: Generated<Date>
}
export type RestoreRequest = Selectable<RestoreRequestsTable>
export type NewRestoreRequest = Insertable<RestoreRequestsTable>
export type UpdateRestoreRequest = Updateable<RestoreRequestsTable>

52
src/utils/adminAuth.ts Normal file
View File

@ -0,0 +1,52 @@
import redis from '@/utils/redis'
const SESSION_COOKIE = 'pt_admin_session'
const SESSION_TTL_SECONDS = 60 * 60 * 12
const parseCookieHeader = (cookieHeader?: string): Record<string, string> => {
if (!cookieHeader) return {}
return cookieHeader.split(';').reduce<Record<string, string>>((acc, part) => {
const [rawKey, ...rawValue] = part.trim().split('=')
if (!rawKey || rawValue.length === 0) return acc
acc[rawKey] = decodeURIComponent(rawValue.join('='))
return acc
}, {})
}
function getSessionToken(cookieHeader?: string): string | undefined {
return parseCookieHeader(cookieHeader)[SESSION_COOKIE]
}
async function createAdminSession(): Promise<string> {
const token = crypto.randomUUID()
await redis.set(`admin:session:${token}`, '1', 'EX', SESSION_TTL_SECONDS)
return token
}
async function isValidAdminSession(token: string | undefined): Promise<boolean> {
if (!token) return false
return (await redis.get(`admin:session:${token}`)) === '1'
}
async function destroyAdminSession(token: string): Promise<void> {
await redis.del(`admin:session:${token}`)
}
function buildSessionCookie(token: string): string {
return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${SESSION_TTL_SECONDS}; Path=/admin; HttpOnly; SameSite=Strict; Secure`
}
function clearSessionCookie(): string {
return `${SESSION_COOKIE}=; Max-Age=0; Path=/admin; HttpOnly; SameSite=Strict; Secure`
}
export {
getSessionToken,
createAdminSession,
isValidAdminSession,
destroyAdminSession,
buildSessionCookie,
clearSessionCookie
}

60
src/utils/glacier.ts Normal file
View File

@ -0,0 +1,60 @@
import * as fs from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { S3Client, RestoreObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({ region: process.env.AWS_REGION })
function objectKey(videoId: string) {
return `${videoId}.mp4`
}
async function initiateRestore(videoId: string, days = 5) {
try {
await s3.send(new RestoreObjectCommand({
Bucket: process.env.GLACIER_BUCKET,
Key: objectKey(videoId),
RestoreRequest: {
Days: days,
GlacierJobParameters: { Tier: 'Standard' }
}
}))
} catch (error: unknown) {
const err = error as { name?: string }
if (err.name === 'RestoreAlreadyInProgress') return
throw error
}
}
async function checkRestoreStatus(videoId: string): Promise<{ ongoing: boolean, expiry: Date | null }> {
const head = await s3.send(new HeadObjectCommand({
Bucket: process.env.GLACIER_BUCKET,
Key: objectKey(videoId)
}))
const restoreHeader = head.Restore
if (!restoreHeader) return { ongoing: true, expiry: null }
const ongoing = /ongoing-request="true"/.test(restoreHeader)
const expiryMatch = restoreHeader.match(/expiry-date="([^"]+)"/)
const expiry = expiryMatch ? new Date(expiryMatch[1]) : null
return { ongoing, expiry }
}
async function downloadRestoredObjectToFile(videoId: string, destPath: string) {
const object = await s3.send(new GetObjectCommand({
Bucket: process.env.GLACIER_BUCKET,
Key: objectKey(videoId)
}))
if (!object.Body) throw new Error(`No body returned for restored object ${objectKey(videoId)}`)
const nodeStream = object.Body instanceof Readable
? object.Body
: Readable.fromWeb(object.Body as any)
await pipeline(nodeStream, fs.createWriteStream(destPath))
}
export { initiateRestore, checkRestoreStatus, downloadRestoredObjectToFile }

View File

@ -0,0 +1,74 @@
import * as fs from 'node:fs'
import { db } from '@/utils/database'
import redis from '@/utils/redis'
import { uploadVideo } from '@/utils/upload'
import { checkRestoreStatus, downloadRestoredObjectToFile } from '@/utils/glacier'
import { sendRestoreCompleteEmail } from '@/utils/mail'
const POLL_INTERVAL_MS = 10 * 60000
async function processRestoringRow(row: { uuid: string, videoId: string, requester_email: string }) {
const { ongoing, expiry } = await checkRestoreStatus(row.videoId)
if (ongoing) return
await db.updateTable('restore_requests')
.set({ status: 'reuploading', aws_restore_expiry: expiry, updated_at: new Date() })
.where('uuid', '=', row.uuid)
.execute()
const filePath = `./videos/${row.videoId}.mp4`
try {
await downloadRestoredObjectToFile(row.videoId, filePath)
const videoUrl = await uploadVideo(filePath)
await db.updateTable('videos')
.set({ deletion_stage: null, source: videoUrl })
.where('id', '=', row.videoId)
.execute()
await redis.del(`watch:${row.videoId}:html`)
await redis.del('deletion:html')
await db.updateTable('restore_requests')
.set({ status: 'reuploaded', updated_at: new Date() })
.where('uuid', '=', row.uuid)
.execute()
const video = await db.selectFrom('videos')
.select(['title'])
.where('id', '=', row.videoId)
.executeTakeFirst()
await sendRestoreCompleteEmail(row.requester_email, row.videoId, video?.title)
} finally {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
}
async function pollRestores() {
const restoringRows = await db.selectFrom('restore_requests')
.select(['uuid', 'videoId', 'requester_email'])
.where('status', '=', 'restoring')
.execute()
for (const row of restoringRows) {
try {
await processRestoringRow(row)
} catch (error: unknown) {
const err = error as Error
console.log(`[glacier-poller] failed to process restore ${row.uuid} (${row.videoId}): ${err.message}`)
await db.updateTable('restore_requests')
.set({ status: 'failed', error_message: err.message, updated_at: new Date() })
.where('uuid', '=', row.uuid)
.execute()
}
}
}
function startRestorePoller() {
pollRestores()
setInterval(pollRestores, POLL_INTERVAL_MS).unref()
console.log('glacier restore poller started (this server is primary)')
}
export { startRestorePoller, pollRestores }

23
src/utils/mail.ts Normal file
View File

@ -0,0 +1,23 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
})
async function sendRestoreCompleteEmail(to: string, videoId: string, title?: string | null) {
const watchUrl = `https://preservetube.com/watch?v=${videoId}`
await transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject: 'Your PreserveTube video has been restored',
text: `The video you requested${title ? ` ("${title}")` : ''} has been restored from cold storage and is available again:\n\n${watchUrl}`
})
}
export { sendRestoreCompleteEmail }