1	import express from 'express';
2	import pool from '../db/pool.js';
3	import { requireAuth } from '../middleware/auth.js';
4	import { getAmppConfig } from '../ampp/client.js';
5	import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
6
7	const router = express.Router();
8	router.use(requireAuth);
9
10	// ── S3 / Object Storage ───────────────────────────────────────────────────────
11
12	router.get('/s3', async (req, res, next) => {
13	  try {
14	    const result = await pool.query(
15	      `SELECT key, value FROM settings
16	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
17	    );
18	    const out = {
19	      s3_endpoint:   '',
20	      s3_bucket:     getS3Bucket(),
21	      s3_access_key: '',
22	      s3_secret_key_exists: false,
23	      s3_region:     'us-east-1',
24	    };
25	    for (const { key, value } of result.rows) {
26	      if (key === 's3_secret_key') {
27	        out.s3_secret_key_exists = !!value;
28	      } else {
29	        out[key] = value || '';
30	      }
31	    }
32	    res.json(out);
33	  } catch (err) {
34	    next(err);
35	  }
36	});
37
38	router.put('/s3', async (req, res, next) => {
39	  try {
40	    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
41
42	    if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
43	    if (!s3_bucket)   return res.status(400).json({ error: 's3_bucket is required' });
44
45	    const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
46	    if (s3_access_key) keys.s3_access_key = s3_access_key;
47	    if (s3_secret_key) keys.s3_secret_key = s3_secret_key;
48
49	    for (const [key, value] of Object.entries(keys)) {
50	      await pool.query(
51	        `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
52	         ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
53	        [key, value.trim()]
54	      );
55	    }
56
57	    // Fetch the full current config (including any previously saved secret)
58	    const saved = await pool.query(
59	      `SELECT key, value FROM settings
60	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
61	         AND value IS NOT NULL AND value <> ''`
62	    );
63	    const cfg = {};
64	    for (const { key, value } of saved.rows) {
65	      switch (key) {
66	        case 's3_endpoint':   cfg.endpoint  = value; break;
67	        case 's3_bucket':     cfg.bucket    = value; break;
68	        case 's3_access_key': cfg.accessKey = value; break;
69	        case 's3_secret_key': cfg.secretKey = value; break;
70	        case 's3_region':     cfg.region    = value; break;
71	      }
72	    }
73	    rebuildS3Client(cfg);
74
75	    res.json({ message: 'S3 settings saved and applied' });
76	  } catch (err) {
77	    next(err);
78	  }
79	});
80
81	// Test with the values the browser just typed (before saving), or with saved
82	// creds if the fields are left blank. Secret is sent only if user typed it.
83	router.post('/s3/test', async (req, res, next) => {
84	  try {
85	    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
86
87	    // Merge submitted values with anything already saved in the DB
88	    const saved = await pool.query(
89	      `SELECT key, value FROM settings
90	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
91	         AND value IS NOT NULL AND value <> ''`
92	    );
93	    const fromDb = {};
94	    for (const { key, value } of saved.rows) fromDb[key] = value;
95
96	    const cfg = {
97	      endpoint:  (s3_endpoint  || fromDb.s3_endpoint  || '').trim(),
98	      bucket:    (s3_bucket    || fromDb.s3_bucket    || getS3Bucket()).trim(),
99	      accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
100	      secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
101	      region:    (s3_region    || fromDb.s3_region    || 'us-east-1').trim(),
102	    };
103
104	    if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
105	    if (!cfg.bucket)   return res.status(400).json({ error: 'S3 bucket is required' });
106
107	    const client = buildTestClient(cfg);
108	    const result = await testS3Connection(client, cfg.bucket);
109	    res.json(result);
110	  } catch (err) {
111	    res.status(400).json({ ok: false, error: err.message });
112	  }
113	});
114
115	// ── AMPP integration ───────────────────────────────────────────────────────────
116
117	router.get('/ampp', async (req, res, next) => {
118	  try {
119	    const result = await pool.query(
120	      "SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
121	    );
122	    const out = {};
123	    for (const row of result.rows) {
124	      if (row.key === 'ampp_token') {
125	        out.ampp_token_exists = true;
126	      } else {
127	        out[row.key] = row.value;
128	      }
129	    }
130	    res.json(out);
131	  } catch (err) {
132	    next(err);
133	  }
134	});
135
136	router.put('/ampp', async (req, res, next) => {
137	  try {
138	    const { ampp_base_url, ampp_token } = req.body;
139	    if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
140	    const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
141	    await pool.query(
142	      `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
143	       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
144	      [baseUrl]
145	    );
146	    if (ampp_token) {
147	      await pool.query(
148	        `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
149	         ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
150	        [ampp_token.trim()]
151	      );
152	    }
153	    res.json({ message: 'AMPP settings saved' });
154	  } catch (err) {
155	    next(err);
156	  }
157	});
158
159	router.post('/ampp/test', async (req, res, next) => {
160	  try {
161	    const config = await getAmppConfig();
162	    if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
163	    const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
164	    const testRes = await fetch(testUrl, {
165	      headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
166	    });
167	    if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
168	    res.json({ message: 'AMPP connection successful' });
169	  } catch (err) {
170	    res.status(400).json({ error: `Connection failed: ${err.message}` });
171	  }
172	});
173
174	// ── Hardware inventory ────────────────────────────────────────────────────────
175
176	router.get('/hardware', async (req, res, next) => {
177	  try {
178	    const result = await pool.query(
179	      `SELECT id, hostname, role, ip_address, capabilities,
180	              EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
181	       FROM cluster_nodes
182	       ORDER BY role, hostname`
183	    );
184	    const nodes = result.rows.map(n => ({
185	      id:           n.id,
186	      hostname:     n.hostname,
187	      role:         n.role,
188	      ip_address:   n.ip_address,
189	      online:       n.stale_seconds < 120,
190	      capabilities: n.capabilities || {},
191	    }));
192	    res.json({ nodes });
193	  } catch (err) {
194	    next(err);
195	  }
196	});
197
198	// ── GPU / Transcoding ─────────────────────────────────────────────────────────
199
200	router.get('/transcoding', async (req, res, next) => {
201	  try {
202	    const result = await pool.query(
203	      `SELECT key, value FROM settings
204	       WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
205	                     'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
206	    );
207	    const out = {
208	      gpu_transcode_enabled:  'false',
209	      gpu_codec:              'h264_nvenc',
210	      gpu_preset:             'p4',
211	      gpu_bitrate_mbps:       '8',
212	      gpu_node:               '',
213	      gpu_extension:          'mp4',
214	      gpu_framerate:          'passthrough',
215	      gpu_rc_mode:            'cbr',
216	      gpu_audio_codec:        'aac',
217	      gpu_audio_bitrate_kbps: '192',
218	    };
219	    for (const { key, value } of result.rows) out[key] = value;
220	    res.json(out);
221	  } catch (err) {
222	    next(err);
223	  }
224	});
225
226	router.put('/transcoding', async (req, res, next) => {
227	  try {
228	    const allowed = [
229	      'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
230	      'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
231	    ];
232	    for (const key of allowed) {
233	      if (req.body[key] !== undefined) {
234	        await pool.query(
235	          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
236	           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
237	          [key, String(req.body[key])]
238	        );
239	      }
240	    }
241	    res.json({ message: 'Transcoding settings saved' });
242	  } catch (err) {
243	    next(err);
244	  }
245	});
246
247	// ── Growing files (SMB landing zone) ─────────────────────────────────────────
248	// Lets capture write its master output to a fast local SMB share instead of
249	// streaming directly to S3. Premiere can mount the share and edit the file
250	// while it's still being written; the promotion worker later moves the
251	// finalized file to S3 and flips the asset to status='ready'.
252
253	const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
254
255	router.get('/growing', async (req, res, next) => {
256	  try {
257	    const result = await pool.query(
258	      `SELECT key, value FROM settings WHERE key = ANY($1)`,
259	      [GROWING_KEYS]
260	    );
261	    const out = {
262	      growing_enabled: 'false',
263	      growing_path: '/growing',
264	      growing_smb_url: '',
265	      growing_promote_after_seconds: '8',
266	    };
267	    for (const { key, value } of result.rows) out[key] = value;
268	    res.json(out);
269	  } catch (err) {
270	    next(err);
271	  }
272	});
273
274	router.put('/growing', async (req, res, next) => {
275	  try {
276	    for (const key of GROWING_KEYS) {
277	      if (req.body[key] !== undefined) {
278	        await pool.query(
279	          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
280	           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
281	          [key, String(req.body[key])]
282	        );
283	      }
284	    }
285	    res.json({ message: 'Growing-files settings saved' });
286	  } catch (err) {
287	    next(err);
288	  }
289	});
290
291	// ── Capture service routing ───────────────────────────────────────────────────
292
293	router.get('/capture-service', async (req, res, next) => {
294	  try {
295	    const result = await pool.query(
296	      "SELECT value FROM settings WHERE key = 'capture_service_url'"
297	    );
298	    res.json({ capture_service_url: result.rows[0]?.value || '' });
299	  } catch (err) {
300	    next(err);
301	  }
302	});
303
304	router.put('/capture-service', async (req, res, next) => {
305	  try {
306	    const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
307	    await pool.query(
308	      `INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
309	       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
310	      [url]
311	    );
312	    res.json({ message: 'Capture service URL saved' });
313	  } catch (err) {
314	    next(err);
315	  }
316	});
317
318	export default router;
319

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { getAmppConfig } from '../ampp/client.js';
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';

const router = express.Router();
router.use(requireAuth);

// ── S3 / Object Storage ───────────────────────────────────────────────────────

router.get('/s3', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
    );
    const out = {
      s3_endpoint:   '',
      s3_bucket:     getS3Bucket(),
      s3_access_key: '',
      s3_secret_key_exists: false,
      s3_region:     'us-east-1',
    };
    for (const { key, value } of result.rows) {
      if (key === 's3_secret_key') {
        out.s3_secret_key_exists = !!value;
      } else {
        out[key] = value || '';
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/s3', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
    if (!s3_bucket)   return res.status(400).json({ error: 's3_bucket is required' });

    const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
    if (s3_access_key) keys.s3_access_key = s3_access_key;
    if (s3_secret_key) keys.s3_secret_key = s3_secret_key;

    for (const [key, value] of Object.entries(keys)) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
        [key, value.trim()]
      );
    }

    // Fetch the full current config (including any previously saved secret)
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const cfg = {};
    for (const { key, value } of saved.rows) {
      switch (key) {
        case 's3_endpoint':   cfg.endpoint  = value; break;
        case 's3_bucket':     cfg.bucket    = value; break;
        case 's3_access_key': cfg.accessKey = value; break;
        case 's3_secret_key': cfg.secretKey = value; break;
        case 's3_region':     cfg.region    = value; break;
      }
    }
    rebuildS3Client(cfg);

    res.json({ message: 'S3 settings saved and applied' });
  } catch (err) {
    next(err);
  }
});

// Test with the values the browser just typed (before saving), or with saved
// creds if the fields are left blank. Secret is sent only if user typed it.
router.post('/s3/test', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    // Merge submitted values with anything already saved in the DB
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const fromDb = {};
    for (const { key, value } of saved.rows) fromDb[key] = value;

    const cfg = {
      endpoint:  (s3_endpoint  || fromDb.s3_endpoint  || '').trim(),
      bucket:    (s3_bucket    || fromDb.s3_bucket    || getS3Bucket()).trim(),
      accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
      secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
      region:    (s3_region    || fromDb.s3_region    || 'us-east-1').trim(),
    };

    if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
    if (!cfg.bucket)   return res.status(400).json({ error: 'S3 bucket is required' });

    const client = buildTestClient(cfg);
    const result = await testS3Connection(client, cfg.bucket);
    res.json(result);
  } catch (err) {
    res.status(400).json({ ok: false, error: err.message });
  }
});

// ── AMPP integration ───────────────────────────────────────────────────────────

router.get('/ampp', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
    );
    const out = {};
    for (const row of result.rows) {
      if (row.key === 'ampp_token') {
        out.ampp_token_exists = true;
      } else {
        out[row.key] = row.value;
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/ampp', async (req, res, next) => {
  try {
    const { ampp_base_url, ampp_token } = req.body;
    if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
    const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [baseUrl]
    );
    if (ampp_token) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
        [ampp_token.trim()]
      );
    }
    res.json({ message: 'AMPP settings saved' });
  } catch (err) {
    next(err);
  }
});

router.post('/ampp/test', async (req, res, next) => {
  try {
    const config = await getAmppConfig();
    if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
    const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
    const testRes = await fetch(testUrl, {
      headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
    });
    if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
    res.json({ message: 'AMPP connection successful' });
  } catch (err) {
    res.status(400).json({ error: `Connection failed: ${err.message}` });
  }
});

// ── Hardware inventory ────────────────────────────────────────────────────────

router.get('/hardware', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT id, hostname, role, ip_address, capabilities,
              EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
       FROM cluster_nodes
       ORDER BY role, hostname`
    );
    const nodes = result.rows.map(n => ({
      id:           n.id,
      hostname:     n.hostname,
      role:         n.role,
      ip_address:   n.ip_address,
      online:       n.stale_seconds < 120,
      capabilities: n.capabilities || {},
    }));
    res.json({ nodes });
  } catch (err) {
    next(err);
  }
});

// ── GPU / Transcoding ─────────────────────────────────────────────────────────

router.get('/transcoding', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
                     'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
    );
    const out = {
      gpu_transcode_enabled:  'false',
      gpu_codec:              'h264_nvenc',
      gpu_preset:             'p4',
      gpu_bitrate_mbps:       '8',
      gpu_node:               '',
      gpu_extension:          'mp4',
      gpu_framerate:          'passthrough',
      gpu_rc_mode:            'cbr',
      gpu_audio_codec:        'aac',
      gpu_audio_bitrate_kbps: '192',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/transcoding', async (req, res, next) => {
  try {
    const allowed = [
      'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
      'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
    ];
    for (const key of allowed) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Transcoding settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Growing files (SMB landing zone) ─────────────────────────────────────────
// Lets capture write its master output to a fast local SMB share instead of
// streaming directly to S3. Premiere can mount the share and edit the file
// while it's still being written; the promotion worker later moves the
// finalized file to S3 and flips the asset to status='ready'.

const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];

router.get('/growing', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings WHERE key = ANY($1)`,
      [GROWING_KEYS]
    );
    const out = {
      growing_enabled: 'false',
      growing_path: '/growing',
      growing_smb_url: '',
      growing_promote_after_seconds: '8',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/growing', async (req, res, next) => {
  try {
    for (const key of GROWING_KEYS) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Growing-files settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Capture service routing ───────────────────────────────────────────────────

router.get('/capture-service', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT value FROM settings WHERE key = 'capture_service_url'"
    );
    res.json({ capture_service_url: result.rows[0]?.value || '' });
  } catch (err) {
    next(err);
  }
});

router.put('/capture-service', async (req, res, next) => {
  try {
    const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [url]
    );
    res.json({ message: 'Capture service URL saved' });
  } catch (err) {
    next(err);
  }
});

export default router;


1	import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
2	import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3	import { Upload } from '@aws-sdk/lib-storage';
4	import pool from '../db/pool.js';
5
6	// ── Mutable config ────────────────────────────────────────────────────────────
7	let _cfg = {
8	  endpoint:  process.env.S3_ENDPOINT   || '',
9	  bucket:    process.env.S3_BUCKET     || 'mam',
10	  accessKey: process.env.S3_ACCESS_KEY || '',
11	  secretKey: process.env.S3_SECRET_KEY || '',
12	  region:    process.env.S3_REGION     || 'us-east-1',
13	};
14
15	// ── Client factory ────────────────────────────────────────────────────────────
16	function buildClient(cfg) {
17	  return new S3Client({
18	    region:   cfg.region,
19	    endpoint: cfg.endpoint || undefined,
20	    credentials: {
21	      accessKeyId:     cfg.accessKey,
22	      secretAccessKey: cfg.secretKey,
23	    },
24	    forcePathStyle: true,
25	    requestChecksumCalculation: 'WHEN_REQUIRED',
26	    responseChecksumValidation: 'WHEN_REQUIRED',
27	  });
28	}
29
30	let _client = buildClient(_cfg);
31
32	// ── Proxy export — always delegates to the live _client ───────────────────────
33	// Existing code that does `s3Client.send(cmd)` continues to work even after
34	// rebuildS3Client() replaces the underlying instance.
35	export const s3Client = new Proxy(
36	  {},
37	  { get(_target, prop) { return Reflect.get(_client, prop, _client); } }
38	);
39
40	// ── Bucket getter — always returns the current bucket name ───────────────────
41	export function getS3Bucket() { return _cfg.bucket; }
42
43	// Legacy named export kept for any code that captured it at import time.
44	// Prefer getS3Bucket() in new code.
45	export let S3_BUCKET = _cfg.bucket;
46
47	// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
48	export function rebuildS3Client(overrides = {}) {
49	  _cfg      = { ..._cfg, ...overrides };
50	  S3_BUCKET = _cfg.bucket;
51	  _client   = buildClient(_cfg);
52	  console.log('[s3] client rebuilt — endpoint:', _cfg.endpoint || '(AWS)', '  bucket:', _cfg.bucket);
53	}
54
55	// ── Load config from DB on startup ───────────────────────────────────────────
56	// Called in index.js after migrations. DB values override env-var defaults.
57	export async function loadS3ConfigFromDb() {
58	  try {
59	    const result = await pool.query(
60	      `SELECT key, value FROM settings
61	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
62	         AND value IS NOT NULL AND value <> ''`
63	    );
64	    if (result.rows.length === 0) return;
65	    const overrides = {};
66	    for (const { key, value } of result.rows) {
67	      switch (key) {
68	        case 's3_endpoint':   overrides.endpoint  = value; break;
69	        case 's3_bucket':     overrides.bucket    = value; break;
70	        case 's3_access_key': overrides.accessKey = value; break;
71	        case 's3_secret_key': overrides.secretKey = value; break;
72	        case 's3_region':     overrides.region    = value; break;
73	      }
74	    }
75	    rebuildS3Client(overrides);
76	    console.log('[s3] config loaded from DB settings');
77	  } catch (err) {
78	    console.error('[s3] failed to load config from DB:', err.message);
79	  }
80	}
81
82	// ── One-shot test client (does not replace _client) ──────────────────────────
83	export function buildTestClient(cfg) {
84	  return buildClient({
85	    endpoint:  cfg.endpoint  ?? _cfg.endpoint,
86	    bucket:    cfg.bucket    ?? _cfg.bucket,
87	    accessKey: cfg.accessKey ?? _cfg.accessKey,
88	    secretKey: cfg.secretKey ?? _cfg.secretKey,
89	    region:    cfg.region    ?? _cfg.region,
90	  });
91	}
92
93	// ── Connectivity test (used by /settings/s3/test) ────────────────────────────
94	export async function testS3Connection(client, bucket) {
95	  try {
96	    await client.send(new HeadBucketCommand({ Bucket: bucket }));
97	    return { ok: true, message: `Bucket "${bucket}" is accessible` };
98	  } catch (headErr) {
99	    // Some S3-compatible stores don't implement HeadBucket — try a list
100	    try {
101	      await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
102	      return { ok: true, message: `Bucket "${bucket}" is accessible` };
103	    } catch (listErr) {
104	      throw new Error(listErr.message || headErr.message);
105	    }
106	  }
107	}
108
109	// ── Utility helpers (all reference the live _client / _cfg) ──────────────────
110	export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => {
111	  const params = { Bucket: _cfg.bucket, Key: key };
112	  if (contentType) params.ResponseContentType = contentType;
113	  return getSignedUrl(_client, new GetObjectCommand(params), { expiresIn });
114	};
115
116	export const uploadStream = async (key, stream, contentType) => {
117	  const upload = new Upload({
118	    client: _client,
119	    params: { Bucket: _cfg.bucket, Key: key, Body: stream, ContentType: contentType },
120	  });
121	  return upload.done();
122	};
123
124	export const deleteObject = async (key) => {
125	  return _client.send(new DeleteObjectCommand({ Bucket: _cfg.bucket, Key: key }));
126	};
127

import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import pool from '../db/pool.js';

// ── Mutable config ────────────────────────────────────────────────────────────
let _cfg = {
  endpoint:  process.env.S3_ENDPOINT   || '',
  bucket:    process.env.S3_BUCKET     || 'mam',
  accessKey: process.env.S3_ACCESS_KEY || '',
  secretKey: process.env.S3_SECRET_KEY || '',
  region:    process.env.S3_REGION     || 'us-east-1',
};

// ── Client factory ────────────────────────────────────────────────────────────
function buildClient(cfg) {
  return new S3Client({
    region:   cfg.region,
    endpoint: cfg.endpoint || undefined,
    credentials: {
      accessKeyId:     cfg.accessKey,
      secretAccessKey: cfg.secretKey,
    },
    forcePathStyle: true,
    requestChecksumCalculation: 'WHEN_REQUIRED',
    responseChecksumValidation: 'WHEN_REQUIRED',
  });
}

let _client = buildClient(_cfg);

// ── Proxy export — always delegates to the live _client ───────────────────────
// Existing code that does `s3Client.send(cmd)` continues to work even after
// rebuildS3Client() replaces the underlying instance.
export const s3Client = new Proxy(
  {},
  { get(_target, prop) { return Reflect.get(_client, prop, _client); } }
);

// ── Bucket getter — always returns the current bucket name ───────────────────
export function getS3Bucket() { return _cfg.bucket; }

// Legacy named export kept for any code that captured it at import time.
// Prefer getS3Bucket() in new code.
export let S3_BUCKET = _cfg.bucket;

// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
export function rebuildS3Client(overrides = {}) {
  _cfg      = { ..._cfg, ...overrides };
  S3_BUCKET = _cfg.bucket;
  _client   = buildClient(_cfg);
  console.log('[s3] client rebuilt — endpoint:', _cfg.endpoint || '(AWS)', '  bucket:', _cfg.bucket);
}

// ── Load config from DB on startup ───────────────────────────────────────────
// Called in index.js after migrations. DB values override env-var defaults.
export async function loadS3ConfigFromDb() {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    if (result.rows.length === 0) return;
    const overrides = {};
    for (const { key, value } of result.rows) {
      switch (key) {
        case 's3_endpoint':   overrides.endpoint  = value; break;
        case 's3_bucket':     overrides.bucket    = value; break;
        case 's3_access_key': overrides.accessKey = value; break;
        case 's3_secret_key': overrides.secretKey = value; break;
        case 's3_region':     overrides.region    = value; break;
      }
    }
    rebuildS3Client(overrides);
    console.log('[s3] config loaded from DB settings');
  } catch (err) {
    console.error('[s3] failed to load config from DB:', err.message);
  }
}

// ── One-shot test client (does not replace _client) ──────────────────────────
export function buildTestClient(cfg) {
  return buildClient({
    endpoint:  cfg.endpoint  ?? _cfg.endpoint,
    bucket:    cfg.bucket    ?? _cfg.bucket,
    accessKey: cfg.accessKey ?? _cfg.accessKey,
    secretKey: cfg.secretKey ?? _cfg.secretKey,
    region:    cfg.region    ?? _cfg.region,
  });
}

// ── Connectivity test (used by /settings/s3/test) ────────────────────────────
export async function testS3Connection(client, bucket) {
  try {
    await client.send(new HeadBucketCommand({ Bucket: bucket }));
    return { ok: true, message: `Bucket "${bucket}" is accessible` };
  } catch (headErr) {
    // Some S3-compatible stores don't implement HeadBucket — try a list
    try {
      await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
      return { ok: true, message: `Bucket "${bucket}" is accessible` };
    } catch (listErr) {
      throw new Error(listErr.message || headErr.message);
    }
  }
}

// ── Utility helpers (all reference the live _client / _cfg) ──────────────────
export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => {
  const params = { Bucket: _cfg.bucket, Key: key };
  if (contentType) params.ResponseContentType = contentType;
  return getSignedUrl(_client, new GetObjectCommand(params), { expiresIn });
};

export const uploadStream = async (key, stream, contentType) => {
  const upload = new Upload({
    client: _client,
    params: { Bucket: _cfg.bucket, Key: key, Body: stream, ContentType: contentType },
  });
  return upload.done();
};

export const deleteObject = async (key) => {
  return _client.send(new DeleteObjectCommand({ Bucket: _cfg.bucket, Key: key }));
};


Ok - another push here.  The GUI needs the ability to deploy the Blackmagic SDK , Deltacast SDK, or AJA SDK ( I want to support those 3 capture cards for now). Can we wire that in?  Also, the settings…


Debug ingest, monitors, buttons and prototype file editing