feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+214
View File
@@ -0,0 +1,214 @@
'use strict';
var __extends =
(this && this.__extends) ||
(function () {
var extendStatics = function (d, b) {
extendStatics =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (d, b) {
d.__proto__ = b;
}) ||
function (d, b) {
for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p];
};
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== 'function' && b !== null)
throw new TypeError('Class extends value ' + String(b) + ' is not a constructor or null');
extendStatics(d, b);
function __() {
this.constructor = d;
}
d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
};
})();
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.JobCanceledError = void 0;
exports.isMediaJobCancelRequested = isMediaJobCancelRequested;
exports.throwIfCancelRequested = throwIfCancelRequested;
exports.errorMessage = errorMessage;
var jobs_1 = require('@cio/db/queries/jobs');
var logger_1 = require('./logger');
/**
* Lightweight cooperative cancellation. Workers check `isCancelRequested` at
* step boundaries and inside long ffmpeg/fetch loops. The plan calls for
* `cancelRequestedAt` on each run table, with the worker abandoning further
* work the moment it observes a request.
*/
var JobCanceledError = /** @class */ (function (_super) {
__extends(JobCanceledError, _super);
function JobCanceledError(mediaJobId) {
var _this = _super.call(this, 'Media job '.concat(mediaJobId, ' canceled by user request')) || this;
_this.mediaJobId = mediaJobId;
_this.name = 'JobCanceledError';
return _this;
}
return JobCanceledError;
})(Error);
exports.JobCanceledError = JobCanceledError;
function isMediaJobCancelRequested(mediaJobId) {
return __awaiter(this, void 0, void 0, function () {
var job, error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, (0, jobs_1.getMediaJobById)(mediaJobId)];
case 1:
job = _a.sent();
return [2 /*return*/, Boolean(job === null || job === void 0 ? void 0 : job.cancelRequestedAt)];
case 2:
error_1 = _a.sent();
logger_1.log.warn('cancel-check-failed', { mediaJobId: mediaJobId, error: errorMessage(error_1) });
return [2 /*return*/, false];
case 3:
return [2 /*return*/];
}
});
});
}
function throwIfCancelRequested(mediaJobId) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, isMediaJobCancelRequested(mediaJobId)];
case 1:
if (_a.sent()) {
throw new JobCanceledError(mediaJobId);
}
return [2 /*return*/];
}
});
});
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
+37
View File
@@ -0,0 +1,37 @@
import { getMediaJobById } from '@cio/db/queries/jobs';
import { log } from './logger';
/**
* Lightweight cooperative cancellation. Workers check `isCancelRequested` at
* step boundaries and inside long ffmpeg/fetch loops. The plan calls for
* `cancelRequestedAt` on each run table, with the worker abandoning further
* work the moment it observes a request.
*/
export class JobCanceledError extends Error {
constructor(public readonly mediaJobId: string) {
super(`Media job ${mediaJobId} canceled by user request`);
this.name = 'JobCanceledError';
}
}
export async function isMediaJobCancelRequested(mediaJobId: string): Promise<boolean> {
try {
const job = await getMediaJobById(mediaJobId);
return Boolean(job?.cancelRequestedAt);
} catch (error) {
log.warn('cancel-check-failed', { mediaJobId, error: errorMessage(error) });
return false;
}
}
export async function throwIfCancelRequested(mediaJobId: string): Promise<void> {
if (await isMediaJobCancelRequested(mediaJobId)) {
throw new JobCanceledError(mediaJobId);
}
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+250
View File
@@ -0,0 +1,250 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var _a, _b;
Object.defineProperty(exports, '__esModule', { value: true });
exports.FFPROBE_BIN = exports.FFMPEG_BIN = void 0;
exports.ffprobeJson = ffprobeJson;
exports.ffmpegRun = ffmpegRun;
exports.warnIfFfmpegMissing = warnIfFfmpegMissing;
var node_child_process_1 = require('node:child_process');
var node_util_1 = require('node:util');
var logger_1 = require('./logger');
var execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
/**
* Resolved at module load. Set `FFMPEG_PATH` / `FFPROBE_PATH` to point at
* non-default binary locations (e.g. when ffmpeg lives outside PATH for the
* worker process). When unset we fall through to `ffmpeg` / `ffprobe` on
* PATH, which is what most installs provide.
*/
exports.FFMPEG_BIN = (_a = process.env.FFMPEG_PATH) !== null && _a !== void 0 ? _a : 'ffmpeg';
exports.FFPROBE_BIN = (_b = process.env.FFPROBE_PATH) !== null && _b !== void 0 ? _b : 'ffprobe';
/** Cap subprocess stdout/stderr so a runaway log can't blow up the worker. */
var MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
/**
* Run `ffprobe -print_format json -show_format -show_streams <file>` and
* return the parsed JSON. Mirrors the shape we used to get from
* `fluent-ffmpeg.ffprobe()` but without the deprecated wrapper.
*/
function ffprobeJson(localPath) {
return __awaiter(this, void 0, void 0, function () {
var args, stdout;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', localPath];
return [4 /*yield*/, execFileAsync(exports.FFPROBE_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
stdout = _a.sent().stdout;
return [2 /*return*/, JSON.parse(stdout)];
}
});
});
}
/**
* Spawn ffmpeg with the supplied argv. Captures stderr and surfaces a useful
* tail in the error message when ffmpeg exits non-zero, since ffmpeg writes
* progress and diagnostics to stderr by default.
*/
function ffmpegRun(args) {
return __awaiter(this, void 0, void 0, function () {
var error_1, stderr, tail;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
_c.trys.push([0, 2, , 3]);
return [4 /*yield*/, execFileAsync(exports.FFMPEG_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
_c.sent();
return [3 /*break*/, 3];
case 2:
error_1 = _c.sent();
stderr =
(_b =
(_a = error_1 === null || error_1 === void 0 ? void 0 : error_1.stderr) === null || _a === void 0
? void 0
: _a.toString('utf8')) !== null && _b !== void 0
? _b
: '';
tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
throw new Error('ffmpeg exited with error'.concat(tail ? ': '.concat(tail) : ''), { cause: error_1 });
case 3:
return [2 /*return*/];
}
});
});
}
/**
* Probe for the binaries at worker startup. Logs a warning when missing so
* media jobs surface a clearer failure than a raw ENOENT from the first
* spawn. Non-media workers (emails, maintenance) keep booting either way.
*/
function warnIfFfmpegMissing() {
return __awaiter(this, void 0, void 0, function () {
var checks, missing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, Promise.all([isExecutable(exports.FFMPEG_BIN), isExecutable(exports.FFPROBE_BIN)])];
case 1:
checks = _a.sent();
missing = [];
if (!checks[0]) missing.push(exports.FFMPEG_BIN);
if (!checks[1]) missing.push(exports.FFPROBE_BIN);
if (missing.length === 0) {
logger_1.log.info('ffmpeg-binaries-resolved', { ffmpeg: exports.FFMPEG_BIN, ffprobe: exports.FFPROBE_BIN });
return [2 /*return*/];
}
logger_1.log.warn('ffmpeg-binaries-missing', {
missing: missing,
hint: 'Install ffmpeg (macOS: brew install ffmpeg, Debian/Ubuntu: apt install ffmpeg) or set FFMPEG_PATH / FFPROBE_PATH. Media jobs will fail until this is resolved; other workers are unaffected.'
});
return [2 /*return*/];
}
});
});
}
function isExecutable(bin) {
return __awaiter(this, void 0, void 0, function () {
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_b.trys.push([0, 2, , 3]);
return [4 /*yield*/, execFileAsync(bin, ['-version'], { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
_b.sent();
return [2 /*return*/, true];
case 2:
_a = _b.sent();
return [2 /*return*/, false];
case 3:
return [2 /*return*/];
}
});
});
}
+95
View File
@@ -0,0 +1,95 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { log } from './logger';
const execFileAsync = promisify(execFile);
/**
* Resolved at module load. Set `FFMPEG_PATH` / `FFPROBE_PATH` to point at
* non-default binary locations (e.g. when ffmpeg lives outside PATH for the
* worker process). When unset we fall through to `ffmpeg` / `ffprobe` on
* PATH, which is what most installs provide.
*/
export const FFMPEG_BIN = process.env.FFMPEG_PATH ?? 'ffmpeg';
export const FFPROBE_BIN = process.env.FFPROBE_PATH ?? 'ffprobe';
/** Cap subprocess stdout/stderr so a runaway log can't blow up the worker. */
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
export interface FfprobeStream {
codec_type?: 'video' | 'audio' | 'subtitle' | 'data';
codec_name?: string;
width?: number;
height?: number;
duration?: string;
}
export interface FfprobeFormat {
duration?: string;
size?: string;
bit_rate?: string;
}
export interface FfprobeData {
streams: FfprobeStream[];
format: FfprobeFormat;
}
/**
* Run `ffprobe -print_format json -show_format -show_streams <file>` and
* return the parsed JSON. Mirrors the shape we used to get from
* `fluent-ffmpeg.ffprobe()` but without the deprecated wrapper.
*/
export async function ffprobeJson(localPath: string): Promise<FfprobeData> {
const args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', localPath];
const { stdout } = await execFileAsync(FFPROBE_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES });
return JSON.parse(stdout) as FfprobeData;
}
/**
* Spawn ffmpeg with the supplied argv. Captures stderr and surfaces a useful
* tail in the error message when ffmpeg exits non-zero, since ffmpeg writes
* progress and diagnostics to stderr by default.
*/
export async function ffmpegRun(args: string[]): Promise<void> {
try {
await execFileAsync(FFMPEG_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES });
} catch (error) {
const stderr = (error as { stderr?: string | Buffer })?.stderr?.toString('utf8') ?? '';
const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
throw new Error(`ffmpeg exited with error${tail ? `: ${tail}` : ''}`, { cause: error as Error });
}
}
/**
* Probe for the binaries at worker startup. Logs a warning when missing so
* media jobs surface a clearer failure than a raw ENOENT from the first
* spawn. Non-media workers (emails, maintenance) keep booting either way.
*/
export async function warnIfFfmpegMissing(): Promise<void> {
const checks = await Promise.all([isExecutable(FFMPEG_BIN), isExecutable(FFPROBE_BIN)]);
const missing: string[] = [];
if (!checks[0]) missing.push(FFMPEG_BIN);
if (!checks[1]) missing.push(FFPROBE_BIN);
if (missing.length === 0) {
log.info('ffmpeg-binaries-resolved', { ffmpeg: FFMPEG_BIN, ffprobe: FFPROBE_BIN });
return;
}
log.warn('ffmpeg-binaries-missing', {
missing,
hint: 'Install ffmpeg (macOS: brew install ffmpeg, Debian/Ubuntu: apt install ffmpeg) or set FFMPEG_PATH / FFPROBE_PATH. Media jobs will fail until this is resolved; other workers are unaffected.'
});
}
async function isExecutable(bin: string): Promise<boolean> {
try {
await execFileAsync(bin, ['-version'], { maxBuffer: MAX_OUTPUT_BYTES });
return true;
} catch {
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
'use strict';
var __assign =
(this && this.__assign) ||
function () {
__assign =
Object.assign ||
function (t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.log = void 0;
function emit(level, message, fields) {
if (fields === void 0) {
fields = {};
}
var payload = __assign({ ts: new Date().toISOString(), level: level, msg: message }, fields);
var stream = level === 'error' || level === 'warn' ? console.error : console.log;
stream(JSON.stringify(payload));
}
exports.log = {
debug: function (msg, fields) {
return emit('debug', msg, fields);
},
info: function (msg, fields) {
return emit('info', msg, fields);
},
warn: function (msg, fields) {
return emit('warn', msg, fields);
},
error: function (msg, fields) {
return emit('error', msg, fields);
}
};
+28
View File
@@ -0,0 +1,28 @@
/**
* Tiny structured logger for the worker. Keeps log output greppable across
* processors without pulling in a full logger dependency.
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogFields {
[key: string]: unknown;
}
function emit(level: LogLevel, message: string, fields: LogFields = {}): void {
const payload = {
ts: new Date().toISOString(),
level,
msg: message,
...fields
};
const stream = level === 'error' || level === 'warn' ? console.error : console.log;
stream(JSON.stringify(payload));
}
export const log = {
debug: (msg: string, fields?: LogFields) => emit('debug', msg, fields),
info: (msg: string, fields?: LogFields) => emit('info', msg, fields),
warn: (msg: string, fields?: LogFields) => emit('warn', msg, fields),
error: (msg: string, fields?: LogFields) => emit('error', msg, fields)
};
+266
View File
@@ -0,0 +1,266 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.downloadObjectToTempFile = downloadObjectToTempFile;
exports.uploadFileToBucket = uploadFileToBucket;
exports.uploadBufferToBucket = uploadBufferToBucket;
exports.safeUnlink = safeUnlink;
exports.videosBucket = videosBucket;
exports.mediaBucket = mediaBucket;
var client_s3_1 = require('@aws-sdk/client-s3');
var node_fs_1 = require('node:fs');
var promises_1 = require('node:fs/promises');
var node_os_1 = require('node:os');
var node_path_1 = require('node:path');
var promises_2 = require('node:stream/promises');
var storage_1 = require('../config/storage');
/**
* Streams an S3 object to a temp file on disk and returns the absolute path.
* Workers prefer disk over buffers so ffmpeg can seek without holding the
* whole video in RAM.
*/
function downloadObjectToTempFile(bucket, key, fileNameHint) {
return __awaiter(this, void 0, void 0, function () {
var dir, safeName, localPath, response, body;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 1:
_b.sent();
safeName =
(_a =
fileNameHint === null || fileNameHint === void 0
? void 0
: fileNameHint.replace(/[^a-zA-Z0-9._-]/g, '_')) !== null && _a !== void 0
? _a
: 'media';
localPath = node_path_1.default.join(
dir,
''.concat(Date.now(), '_').concat(process.pid, '_').concat(safeName)
);
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key }))
];
case 2:
response = _b.sent();
if (!response.Body) {
throw new Error('S3 object '.concat(bucket, '/').concat(key, ' returned no body'));
}
body = response.Body;
return [4 /*yield*/, (0, promises_2.pipeline)(body, (0, node_fs_1.createWriteStream)(localPath))];
case 3:
_b.sent();
return [2 /*return*/, localPath];
}
});
});
}
function uploadFileToBucket(bucket, key, localPath, contentType, cacheControl) {
return __awaiter(this, void 0, void 0, function () {
var buffer;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, (0, promises_1.readFile)(localPath)];
case 1:
buffer = _a.sent();
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(
new client_s3_1.PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
)
];
case 2:
_a.sent();
return [2 /*return*/];
}
});
});
}
function uploadBufferToBucket(bucket, key, buffer, contentType, cacheControl) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(
new client_s3_1.PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
)
];
case 1:
_a.sent();
return [2 /*return*/];
}
});
});
}
function safeUnlink(filePath) {
return __awaiter(this, void 0, void 0, function () {
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
if (!filePath) return [2 /*return*/];
_b.label = 1;
case 1:
_b.trys.push([1, 3, , 4]);
return [4 /*yield*/, (0, promises_1.unlink)(filePath)];
case 2:
_b.sent();
return [3 /*break*/, 4];
case 3:
_a = _b.sent();
return [3 /*break*/, 4];
case 4:
return [2 /*return*/];
}
});
});
}
function videosBucket() {
return (0, storage_1.getStorageConfig)().bucketVideos;
}
function mediaBucket() {
return (0, storage_1.getStorageConfig)().bucketMedia;
}
+87
View File
@@ -0,0 +1,87 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'node:stream';
import { createWriteStream } from 'node:fs';
import { mkdir, readFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { getS3Client, getStorageConfig } from '../config/storage';
/**
* Streams an S3 object to a temp file on disk and returns the absolute path.
* Workers prefer disk over buffers so ffmpeg can seek without holding the
* whole video in RAM.
*/
export async function downloadObjectToTempFile(bucket: string, key: string, fileNameHint?: string): Promise<string> {
const dir = path.join(tmpdir(), 'cio-jobs');
await mkdir(dir, { recursive: true });
const safeName = fileNameHint?.replace(/[^a-zA-Z0-9._-]/g, '_') ?? 'media';
const localPath = path.join(dir, `${Date.now()}_${process.pid}_${safeName}`);
const response = await getS3Client().send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (!response.Body) {
throw new Error(`S3 object ${bucket}/${key} returned no body`);
}
const body = response.Body as Readable;
await pipeline(body, createWriteStream(localPath));
return localPath;
}
export async function uploadFileToBucket(
bucket: string,
key: string,
localPath: string,
contentType: string,
cacheControl?: string
): Promise<void> {
const buffer = await readFile(localPath);
await getS3Client().send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
);
}
export async function uploadBufferToBucket(
bucket: string,
key: string,
buffer: Buffer,
contentType: string,
cacheControl?: string
): Promise<void> {
await getS3Client().send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
);
}
export async function safeUnlink(filePath: string | undefined | null): Promise<void> {
if (!filePath) return;
try {
await unlink(filePath);
} catch {
// best-effort cleanup
}
}
export function videosBucket(): string {
return getStorageConfig().bucketVideos;
}
export function mediaBucket(): string {
return getStorageConfig().bucketMedia;
}