fix(auth+bugs): optional auth bypass, login routes, conform column name, panel metadata fields, login page: main.js

This commit is contained in:
Zac Gaetano 2026-05-15 23:40:14 -04:00
parent 47c113e6c3
commit 72c4a7f136

View file

@ -11,20 +11,20 @@ const csInterface = new CSInterface();
// ============================================================================ // ============================================================================
const state = { const state = {
serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434', serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434',
isConnected: false, isConnected: false,
isConnecting: false, isConnecting: false,
selectedAsset: null, selectedAsset: null,
assets: [], assets: [],
projects: [], projects: [],
selectedProject: 'all', selectedProject: 'all',
searchQuery: '', searchQuery: '',
currentPage: 0, currentPage: 0,
pageSize: 50, pageSize: 50,
totalAssets: 0, totalAssets: 0,
downloadProgress: 0, downloadProgress: 0,
isDownloading: false, isDownloading: false,
thumbCache: {}, // assetId -> signed URL thumbCache: {}, // assetId -> signed URL
}; };
// ============================================================================ // ============================================================================
@ -65,7 +65,7 @@ function initDOMElements() {
const thumbObserver = new IntersectionObserver((entries) => { const thumbObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
const img = entry.target; const img = entry.target;
const assetId = img.dataset.assetId; const assetId = img.dataset.assetId;
if (assetId && !img.dataset.loaded) loadThumbnail(img, assetId); if (assetId && !img.dataset.loaded) loadThumbnail(img, assetId);
thumbObserver.unobserve(img); thumbObserver.unobserve(img);
@ -90,7 +90,7 @@ async function loadThumbnail(img, assetId) {
img.src = url; img.src = url;
img.dataset.loaded = '1'; img.dataset.loaded = '1';
} catch (_) { } catch (_) {
// thumbnail unavailable — leave placeholder visible // thumbnail unavailable — leave placeholder
} }
} }
@ -109,9 +109,8 @@ function setupEventListeners() {
elements.serverUrlInput.addEventListener('change', (e) => { elements.serverUrlInput.addEventListener('change', (e) => {
state.serverUrl = e.target.value.trim().replace(/\/$/, ''); state.serverUrl = e.target.value.trim().replace(/\/$/, '');
localStorage.setItem('mam_server_url', state.serverUrl); localStorage.setItem('mam_server_url', state.serverUrl);
state.thumbCache = {}; // bust cache when server changes state.thumbCache = {};
}); });
elements.connectBtn.addEventListener('click', connectToServer); elements.connectBtn.addEventListener('click', connectToServer);
elements.searchInput.addEventListener('input', debounce(handleSearch, 300)); elements.searchInput.addEventListener('input', debounce(handleSearch, 300));
elements.projectFilter.addEventListener('change', handleProjectFilter); elements.projectFilter.addEventListener('change', handleProjectFilter);
@ -136,10 +135,11 @@ async function connectToServer() {
elements.connectBtn.disabled = true; elements.connectBtn.disabled = true;
try { try {
// Use the projects endpoint as a health check (no separate /health route exists) // Use the projects endpoint as a connectivity check
const response = await fetch(`${state.serverUrl}/api/v1/projects`, { const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
method: 'GET', method: 'GET',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
@ -148,7 +148,6 @@ async function connectToServer() {
elements.connectBtn.textContent = 'Reconnect'; elements.connectBtn.textContent = 'Reconnect';
logMessage('Connected to Wild Dragon MAM'); logMessage('Connected to Wild Dragon MAM');
// Pass the already-fetched JSON to avoid a second round-trip
const projectData = await response.json(); const projectData = await response.json();
await fetchProjects(projectData); await fetchProjects(projectData);
await fetchAssets(); await fetchAssets();
@ -170,7 +169,7 @@ async function connectToServer() {
function updateConnectionStatus(status) { function updateConnectionStatus(status) {
const indicator = elements.statusIndicator; const indicator = elements.statusIndicator;
indicator.classList.remove('connected', 'connecting'); indicator.classList.remove('connected', 'connecting');
if (status === 'connected') indicator.classList.add('connected'); if (status === 'connected') indicator.classList.add('connected');
else if (status === 'connecting') indicator.classList.add('connecting'); else if (status === 'connecting') indicator.classList.add('connecting');
} }
@ -178,19 +177,15 @@ function updateConnectionStatus(status) {
// API Calls // API Calls
// ============================================================================ // ============================================================================
/**
* Load projects into state and the filter dropdown.
* @param {Array} [preloadedData] - If provided, skips the fetch (reuse connection-check response).
*/
async function fetchProjects(preloadedData) { async function fetchProjects(preloadedData) {
try { try {
let projects; let projects;
if (preloadedData) { if (preloadedData) {
// GET /api/v1/projects returns a plain array (not { projects: [] })
projects = Array.isArray(preloadedData) ? preloadedData : []; projects = Array.isArray(preloadedData) ? preloadedData : [];
} else { } else {
const response = await fetch(`${state.serverUrl}/api/v1/projects`, { const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'include',
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
@ -203,7 +198,7 @@ async function fetchProjects(preloadedData) {
elements.projectFilter.innerHTML = '<option value="all">All Projects</option>'; elements.projectFilter.innerHTML = '<option value="all">All Projects</option>';
state.projects.forEach((p) => { state.projects.forEach((p) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.id; opt.value = p.id;
opt.textContent = p.name; opt.textContent = p.name;
elements.projectFilter.appendChild(opt); elements.projectFilter.appendChild(opt);
}); });
@ -217,24 +212,21 @@ async function fetchProjects(preloadedData) {
async function fetchAssets(page = 0) { async function fetchAssets(page = 0) {
if (!state.isConnected) return; if (!state.isConnected) return;
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
offset: page * state.pageSize, offset: page * state.pageSize,
limit: state.pageSize, limit: state.pageSize,
}); });
if (state.searchQuery) params.append('search', state.searchQuery); if (state.searchQuery) params.append('search', state.searchQuery);
if (state.selectedProject !== 'all') params.append('project_id', state.selectedProject); if (state.selectedProject !== 'all') params.append('project_id', state.selectedProject);
const response = await fetch( const response = await fetch(
`${state.serverUrl}/api/v1/assets?${params.toString()}`, `${state.serverUrl}/api/v1/assets?${params.toString()}`,
{ headers: { Accept: 'application/json' } } { headers: { Accept: 'application/json' }, credentials: 'include' }
); );
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
state.assets = data.assets || []; state.assets = data.assets || [];
state.totalAssets = data.total || 0; state.totalAssets = data.total || 0;
state.currentPage = page; state.currentPage = page;
@ -252,6 +244,7 @@ async function fetchAssetDetails(assetId) {
try { try {
const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}`, { const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'include',
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json(); return await response.json();
@ -263,11 +256,12 @@ async function fetchAssetDetails(assetId) {
/** /**
* Returns a short-lived presigned URL for the H.264 proxy of the given asset. * Returns a short-lived presigned URL for the H.264 proxy of the given asset.
* GET /api/v1/assets/:id/stream -> { url: '...' } * GET /api/v1/assets/:id/stream -> { url: '...' }
*/ */
async function getSignedDownloadUrl(assetId) { async function getSignedDownloadUrl(assetId) {
const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/stream`, { const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/stream`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'include',
}); });
if (!response.ok) throw new Error(`HTTP ${response.status} from /stream`); if (!response.ok) throw new Error(`HTTP ${response.status} from /stream`);
const { url } = await response.json(); const { url } = await response.json();
@ -301,44 +295,44 @@ function createAssetCard(asset) {
} }
card.dataset.assetId = asset.id; card.dataset.assetId = asset.id;
// Thumbnail — lazy loaded by IntersectionObserver // Thumbnail — lazy loaded via IntersectionObserver
const thumbnail = document.createElement('div'); const thumbnail = document.createElement('div');
thumbnail.className = 'asset-thumbnail'; thumbnail.className = 'asset-thumbnail';
const img = document.createElement('img'); const img = document.createElement('img');
img.dataset.assetId = asset.id; img.dataset.assetId = asset.id;
img.alt = escapeHtml(asset.display_name || asset.filename || ''); img.alt = escapeHtml(asset.display_name || asset.filename || '');
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
img.onerror = () => { img.style.display = 'none'; }; img.onerror = () => { img.style.display = 'none'; };
thumbnail.appendChild(img); thumbnail.appendChild(img);
thumbObserver.observe(img); thumbObserver.observe(img);
card.appendChild(thumbnail); card.appendChild(thumbnail);
// Info row // Info row
const info = document.createElement('div'); const info = document.createElement('div');
info.className = 'asset-info'; info.className = 'asset-info';
const name = asset.display_name || asset.filename || 'Untitled'; const name = asset.display_name || asset.filename || 'Untitled';
const filenameEl = document.createElement('div'); const filenameEl = document.createElement('div');
filenameEl.className = 'asset-filename'; filenameEl.className = 'asset-filename';
filenameEl.title = name; filenameEl.title = name;
filenameEl.textContent = name; filenameEl.textContent = name;
info.appendChild(filenameEl); info.appendChild(filenameEl);
const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null; const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
const codec = (asset.metadata && asset.metadata.codec) || asset.media_type || 'video'; // codec and media_type are top-level columns on the asset record
const meta = document.createElement('div'); const codec = asset.codec || asset.media_type || 'video';
meta.className = 'asset-meta'; const meta = document.createElement('div');
meta.innerHTML = [ meta.className = 'asset-meta';
meta.innerHTML = [
'<span>' + (durationSec ? formatDuration(durationSec) : 'N/A') + '</span>', '<span>' + (durationSec ? formatDuration(durationSec) : 'N/A') + '</span>',
'<span>' + escapeHtml(codec.toUpperCase()) + '</span>', '<span>' + escapeHtml(codec.toUpperCase()) + '</span>',
].join(''); ].join('');
info.appendChild(meta); info.appendChild(meta);
const statusStr = asset.status || 'ready'; const statusStr = asset.status || 'ready';
const statusBadge = document.createElement('div'); const statusBadge = document.createElement('div');
statusBadge.className = 'asset-status-badge status-badge ' + statusStr; statusBadge.className = 'asset-status-badge status-badge ' + statusStr;
statusBadge.textContent = statusStr.toUpperCase(); statusBadge.textContent = statusStr.toUpperCase();
info.appendChild(statusBadge); info.appendChild(statusBadge);
@ -350,24 +344,24 @@ function showAssetDetails(asset) {
state.selectedAsset = asset; state.selectedAsset = asset;
elements.detailsPanel.classList.remove('hidden'); elements.detailsPanel.classList.remove('hidden');
const meta = asset.metadata || {}; // All of these are top-level columns in the assets table
elements.detailsFilename.textContent = asset.display_name || asset.filename; elements.detailsFilename.textContent = asset.display_name || asset.filename;
elements.detailsCodec.textContent = meta.codec || asset.media_type || 'Unknown'; elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown';
elements.detailsResolution.textContent = meta.resolution || 'N/A'; elements.detailsResolution.textContent = asset.resolution || 'N/A';
elements.detailsFps.textContent = meta.fps ? meta.fps + ' fps' : 'N/A'; elements.detailsFps.textContent = asset.fps ? asset.fps + ' fps' : 'N/A';
elements.detailsDuration.textContent = asset.duration_ms elements.detailsDuration.textContent = asset.duration_ms
? formatDuration(asset.duration_ms / 1000) ? formatDuration(asset.duration_ms / 1000)
: 'N/A'; : 'N/A';
elements.detailsSize.textContent = meta.file_size elements.detailsSize.textContent = asset.file_size
? formatFileSize(meta.file_size) ? formatFileSize(asset.file_size)
: 'N/A'; : 'N/A';
elements.detailsTags.innerHTML = ''; elements.detailsTags.innerHTML = '';
const tags = asset.tags || []; const tags = asset.tags || [];
if (tags.length > 0) { if (tags.length > 0) {
tags.forEach((tag) => { tags.forEach((tag) => {
const el = document.createElement('span'); const el = document.createElement('span');
el.className = 'tag'; el.className = 'tag';
el.textContent = tag; el.textContent = tag;
elements.detailsTags.appendChild(el); elements.detailsTags.appendChild(el);
}); });
@ -397,7 +391,7 @@ function handleSearch(e) {
function handleProjectFilter(e) { function handleProjectFilter(e) {
state.selectedProject = e.target.value; state.selectedProject = e.target.value;
state.currentPage = 0; state.currentPage = 0;
fetchAssets(); fetchAssets();
} }
@ -428,18 +422,18 @@ async function importAsset(asset) {
try { try {
elements.importBtn.disabled = true; elements.importBtn.disabled = true;
// Step 1: get a presigned proxy URL from the MAM API // 1. Get a presigned proxy URL
showProgress('Getting download link...', 5); showProgress('Getting download link...', 5);
const url = await getSignedDownloadUrl(asset.id); const url = await getSignedDownloadUrl(asset.id);
// Step 2: download the proxy (H.264) to the OS temp directory // 2. Download proxy to OS temp dir via Node.js
const safeName = sanitizeFilename( const safeName = sanitizeFilename(
(asset.display_name || asset.filename || asset.id) + '.mp4' (asset.display_name || asset.filename || asset.id) + '.mp4'
); );
showProgress('Downloading ' + safeName + '...', 10); showProgress('Downloading ' + safeName + '...', 10);
const filePath = await downloadFile(url, safeName); const filePath = await downloadFile(url, safeName);
// Step 3: hand the local path to Premiere Pro via ExtendScript // 3. Hand the local path to Premiere Pro
showProgress('Importing into Premiere Pro...', 85); showProgress('Importing into Premiere Pro...', 85);
await importFileToPremiereProject(filePath); await importFileToPremiereProject(filePath);
@ -456,22 +450,16 @@ async function importAsset(asset) {
/** /**
* Downloads a remote URL to a local temp file using Node.js http/https. * Downloads a remote URL to a local temp file using Node.js http/https.
* * Requires --enable-nodejs in the CEP manifest.
* Node.js is available via require() in CEP when the manifest contains:
* <CEFCommandLine><Parameter>--enable-nodejs</Parameter></CEFCommandLine>
*
* @param {string} url - Presigned download URL (http or https)
* @param {string} filename - Desired local filename (sanitized)
* @returns {Promise<string>} Resolved with the absolute path to the saved file
*/ */
function downloadFile(url, filename) { function downloadFile(url, filename) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
try { try {
var https = require('https'); var https = require('https');
var http = require('http'); var http = require('http');
var fs = require('fs'); var fs = require('fs');
var path = require('path'); var path = require('path');
var os = require('os'); var os = require('os');
var tempPath = path.join(os.tmpdir(), filename); var tempPath = path.join(os.tmpdir(), filename);
var file = fs.createWriteStream(tempPath); var file = fs.createWriteStream(tempPath);
@ -517,15 +505,10 @@ function downloadFile(url, filename) {
} }
/** /**
* Uses csInterface.evalScript to call the Premiere Pro ExtendScript layer * Calls csInterface.evalScript to import a local file into the open Premiere project.
* and import the given local file into the active project.
*
* @param {string} filePath - Absolute local path to the downloaded proxy file
* @returns {Promise<object>}
*/ */
function importFileToPremiereProject(filePath) { function importFileToPremiereProject(filePath) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
// Escape backslashes for Windows paths inside the script string literal
var safePath = filePath.replace(/\\/g, '\\\\'); var safePath = filePath.replace(/\\/g, '\\\\');
var script = [ var script = [
@ -577,7 +560,7 @@ function handleAssetClick(e) {
card.classList.add('selected'); card.classList.add('selected');
var assetId = card.dataset.assetId; var assetId = card.dataset.assetId;
var asset = state.assets.find(function (a) { return a.id === assetId; }); var asset = state.assets.find(function (a) { return a.id === assetId; });
if (asset) showAssetDetails(asset); if (asset) showAssetDetails(asset);
} }
@ -607,10 +590,10 @@ function showSuccessMessage(message) {
} }
function _showFlash(message, className) { function _showFlash(message, className) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = className; el.className = className;
el.textContent = message; el.textContent = message;
var anchor = document.querySelector('.search-filter-area'); var anchor = document.querySelector('.search-filter-area');
anchor.insertBefore(el, anchor.firstChild); anchor.insertBefore(el, anchor.firstChild);
setTimeout(function () { el.remove(); }, 5000); setTimeout(function () { el.remove(); }, 5000);
} }
@ -641,9 +624,6 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/**
* Strips characters illegal in file names on Windows/macOS.
*/
function sanitizeFilename(name) { function sanitizeFilename(name) {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
} }
@ -653,9 +633,7 @@ function formatDuration(seconds) {
var h = Math.floor(seconds / 3600); var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60); var m = Math.floor((seconds % 3600) / 60);
var s = Math.floor(seconds % 60); var s = Math.floor(seconds % 60);
if (h > 0) { if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
return m + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0');
} }