diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index c2bdfab..fef867c 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -26,7 +26,7 @@ jobs: path-to-signatures: 'signatures/version1/cla.json' path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/tree/main/.github/CLA.md' branch: 'main' - allowlist: '' + allowlist: 'wayneshn' remote-organization-name: 'LogicLabs-OU' remote-repository-name: 'cla-db' diff --git a/packages/backend/src/api/controllers/ingestion.controller.ts b/packages/backend/src/api/controllers/ingestion.controller.ts index aa719cc..df8ae83 100644 --- a/packages/backend/src/api/controllers/ingestion.controller.ts +++ b/packages/backend/src/api/controllers/ingestion.controller.ts @@ -1,14 +1,35 @@ import { Request, Response } from 'express'; import { IngestionService } from '../../services/IngestionService'; -import { CreateIngestionSourceDto, UpdateIngestionSourceDto } from '@open-archiver/types'; +import { + CreateIngestionSourceDto, + UpdateIngestionSourceDto, + IngestionSource, + SafeIngestionSource +} from '@open-archiver/types'; import { logger } from '../../config/logger'; +import { config } from '../../config'; export class IngestionController { + /** + * Converts an IngestionSource object to a safe version for client-side consumption + * by removing the credentials. + * @param source The full IngestionSource object. + * @returns An object conforming to the SafeIngestionSource type. + */ + private toSafeIngestionSource(source: IngestionSource): SafeIngestionSource { + const { credentials, ...safeSource } = source; + return safeSource; + } + public create = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const dto: CreateIngestionSourceDto = req.body; const newSource = await IngestionService.create(dto); - return res.status(201).json(newSource); + const safeSource = this.toSafeIngestionSource(newSource); + return res.status(201).json(safeSource); } catch (error: any) { logger.error({ err: error }, 'Create ingestion source error'); // Return a 400 Bad Request for connection errors @@ -19,7 +40,8 @@ export class IngestionController { public findAll = async (req: Request, res: Response): Promise => { try { const sources = await IngestionService.findAll(); - return res.status(200).json(sources); + const safeSources = sources.map(this.toSafeIngestionSource); + return res.status(200).json(safeSources); } catch (error) { console.error('Find all ingestion sources error:', error); return res.status(500).json({ message: 'An internal server error occurred' }); @@ -30,7 +52,8 @@ export class IngestionController { try { const { id } = req.params; const source = await IngestionService.findById(id); - return res.status(200).json(source); + const safeSource = this.toSafeIngestionSource(source); + return res.status(200).json(safeSource); } catch (error) { console.error(`Find ingestion source by id ${req.params.id} error:`, error); if (error instanceof Error && error.message === 'Ingestion source not found') { @@ -41,11 +64,15 @@ export class IngestionController { }; public update = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const { id } = req.params; const dto: UpdateIngestionSourceDto = req.body; const updatedSource = await IngestionService.update(id, dto); - return res.status(200).json(updatedSource); + const safeSource = this.toSafeIngestionSource(updatedSource); + return res.status(200).json(safeSource); } catch (error) { console.error(`Update ingestion source ${req.params.id} error:`, error); if (error instanceof Error && error.message === 'Ingestion source not found') { @@ -56,6 +83,9 @@ export class IngestionController { }; public delete = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const { id } = req.params; await IngestionService.delete(id); @@ -70,6 +100,9 @@ export class IngestionController { }; public triggerInitialImport = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const { id } = req.params; await IngestionService.triggerInitialImport(id); @@ -84,10 +117,14 @@ export class IngestionController { }; public pause = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const { id } = req.params; const updatedSource = await IngestionService.update(id, { status: 'paused' }); - return res.status(200).json(updatedSource); + const safeSource = this.toSafeIngestionSource(updatedSource); + return res.status(200).json(safeSource); } catch (error) { console.error(`Pause ingestion source ${req.params.id} error:`, error); if (error instanceof Error && error.message === 'Ingestion source not found') { @@ -98,6 +135,9 @@ export class IngestionController { }; public triggerForceSync = async (req: Request, res: Response): Promise => { + if (config.app.isDemo) { + return res.status(403).json({ message: 'This operation is not allowed in demo mode.' }); + } try { const { id } = req.params; await IngestionService.triggerForceSync(id); diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 3deafdc..774a4bf 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -4,4 +4,5 @@ export const app = { nodeEnv: process.env.NODE_ENV || 'development', port: process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND, 10) : 4000, encryptionKey: process.env.ENCRYPTION_KEY, + isDemo: process.env.IS_DEMO === 'true', }; diff --git a/packages/frontend/src/routes/+layout.server.ts b/packages/frontend/src/routes/+layout.server.ts index 04bdd4c..82f5bf6 100644 --- a/packages/frontend/src/routes/+layout.server.ts +++ b/packages/frontend/src/routes/+layout.server.ts @@ -1,9 +1,12 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; +import 'dotenv/config'; + export const load: LayoutServerLoad = async ({ locals }) => { return { user: locals.user, - accessToken: locals.accessToken + accessToken: locals.accessToken, + isDemo: process.env.IS_DEMO === 'true' }; }; diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte index 97019be..3a2568e 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte @@ -27,6 +27,16 @@ }; const openEditDialog = (source: IngestionSource) => { + if (data.isDemo) { + setAlert({ + type: 'warning', + title: 'Demo mode', + message: 'Editing is now allowed in demo mode.', + duration: 5000, + show: true + }); + return; + } selectedSource = source; isDialogOpen = true; }; @@ -40,7 +50,18 @@ if (!sourceToDelete) return; isDeleting = true; try { - await api(`/ingestion-sources/${sourceToDelete.id}`, { method: 'DELETE' }); + const res = await api(`/ingestion-sources/${sourceToDelete.id}`, { method: 'DELETE' }); + if (!res.ok) { + const errorBody = await res.json(); + setAlert({ + type: 'error', + title: 'Failed to delete ingestion', + message: errorBody.message || JSON.stringify(errorBody), + duration: 5000, + show: true + }); + return; + } ingestionSources = ingestionSources.filter((s) => s.id !== sourceToDelete!.id); isDeleteDialogOpen = false; sourceToDelete = null; @@ -50,7 +71,18 @@ }; const handleSync = async (id: string) => { - await api(`/ingestion-sources/${id}/sync`, { method: 'POST' }); + const res = await api(`/ingestion-sources/${id}/sync`, { method: 'POST' }); + if (!res.ok) { + const errorBody = await res.json(); + setAlert({ + type: 'error', + title: 'Failed to trigger force sync ingestion', + message: errorBody.message || JSON.stringify(errorBody), + duration: 5000, + show: true + }); + return; + } const updatedSources = ingestionSources.map((s) => { if (s.id === id) { return { ...s, status: 'syncing' as const }; @@ -61,24 +93,36 @@ }; const handleToggle = async (source: IngestionSource) => { - const isPaused = source.status === 'paused'; - const newStatus = isPaused ? 'active' : 'paused'; + try { + const isPaused = source.status === 'paused'; + const newStatus = isPaused ? 'active' : 'paused'; + if (data.isDemo) { + throw Error('This operation is not allowed in demo mode.'); + } + if (newStatus === 'paused') { + await api(`/ingestion-sources/${source.id}/pause`, { method: 'POST' }); + } else { + await api(`/ingestion-sources/${source.id}`, { + method: 'PUT', + body: JSON.stringify({ status: 'active' }) + }); + } - if (newStatus === 'paused') { - await api(`/ingestion-sources/${source.id}/pause`, { method: 'POST' }); - } else { - await api(`/ingestion-sources/${source.id}`, { - method: 'PUT', - body: JSON.stringify({ status: 'active' }) + ingestionSources = ingestionSources.map((s) => { + if (s.id === source.id) { + return { ...s, status: newStatus }; + } + return s; + }); + } catch (e) { + setAlert({ + type: 'error', + title: 'Failed to trigger force sync ingestion', + message: e instanceof Error ? e.message : JSON.stringify(e), + duration: 5000, + show: true }); } - - ingestionSources = ingestionSources.map((s) => { - if (s.id === source.id) { - return { ...s, status: newStatus }; - } - return s; - }); }; const handleFormSubmit = async (formData: CreateIngestionSourceDto) => { @@ -155,7 +199,7 @@

Ingestion Sources

- +
diff --git a/packages/types/src/ingestion.types.ts b/packages/types/src/ingestion.types.ts index 4b1dc7a..88a53c4 100644 --- a/packages/types/src/ingestion.types.ts +++ b/packages/types/src/ingestion.types.ts @@ -81,6 +81,13 @@ export interface IngestionSource { syncState?: SyncState | null; } +/** + * Represents an ingestion source with sensitive credential information removed. + * This type is safe to use in client-side applications or API responses + * where exposing credentials would be a security risk. + */ +export type SafeIngestionSource = Omit; + export interface CreateIngestionSourceDto { name: string; provider: IngestionProvider; @@ -121,4 +128,4 @@ export type MailboxUser = { export type ProcessMailboxError = { error: boolean; message: string; -}; \ No newline at end of file +};