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:
parent
ca16c361aa
commit
0e4d966804
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
17
src/types.ts
17
src/types.ts
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
Loading…
Reference in New Issue