Add an ephemeral, process-local blob store so the in-app agent (and the
embedded MCP) can hand a large page document and its images to an external
consumer WITHOUT routing the bytes through the model context or Docmost auth.
- SandboxStore (@Injectable singleton): Map<uuid,{buf,mime,sha256,expiresAt}>
in RAM only. put() picks a per-blob cap by mime (image vs doc), enforces a
total-bytes RAM guard with oldest-first eviction, and stamps a TTL; get()
lazily expires. sha256 computed at put() doubles as the strong ETag. An
unref'd sweep interval clears expired entries and is cleared on destroy.
- GET /api/sb/:uuid anonymous controller: serves raw bytes with Content-Type,
Content-Length and ETag=sha256; 404 on missing/expired/non-UUID (anti-
traversal), 304 on a matching If-None-Match. No tokens, no 401 — the
capability is the unguessable UUID + short TTL + TLS. Auth-exempt the same
way as /api/files/public (no JwtAuthGuard) plus an /api/sb entry in main.ts's
workspace-resolution preHandler so a remote consumer with no workspace host
is not rejected.
- stash_page tool in both layers (MCP resource_link + in-app {uri,size,sha256,
images}). client.stashPage serializes the get_page_json shape, mirrors every
INTERNAL file/image src (type-agnostic, covers drawio/excalidraw/video/file)
into the sandbox under Docmost auth and rewrites src to the sandbox URL;
external http(s) srcs are left untouched; dedup by src; a failed image fetch
is counted, never aborts the doc.
- SANDBOX_PUBLIC_URL / SANDBOX_TTL_MS / SANDBOX_MAX_BYTES /
SANDBOX_MAX_IMAGE_BYTES / SANDBOX_MAX_TOTAL_BYTES wired through the
environment service + validation + .env.example.
- SandboxModule (@Global) provides the shared store to the controller,
McpService and AiChatToolsService (same instance for put and get).
Tests: SandboxStore (round-trip, sha256, TTL lazy + sweep, caps, eviction),
SandboxController (200+ETag+CT+CL, 404 missing/expired/non-UUID, 304), and a
mock-HTTP stashPage test (mirror+rewrite internal, keep external, dedup, failed
image counted, returns only a link). Interoperates with the vvzvlad/habr-mcp
consumer's anonymous-GET + sha256-ETag + resource_link contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
227 lines
5.4 KiB
TypeScript
227 lines
5.4 KiB
TypeScript
import {
|
|
IsIn,
|
|
IsNotEmpty,
|
|
IsNotIn,
|
|
IsNumberString,
|
|
IsOptional,
|
|
IsString,
|
|
IsUrl,
|
|
MinLength,
|
|
ValidateIf,
|
|
validateSync,
|
|
} from 'class-validator';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { IsISO6391 } from '../../common/validators/is-iso6391';
|
|
|
|
export class EnvironmentVariables {
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['postgres', 'postgresql'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{ message: 'DATABASE_URL must be a valid postgres connection string' },
|
|
)
|
|
DATABASE_URL: string;
|
|
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['redis', 'rediss'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{ message: 'REDIS_URL must be a valid redis connection string' },
|
|
)
|
|
REDIS_URL: string;
|
|
|
|
@IsOptional()
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
APP_URL: string;
|
|
|
|
@IsNotEmpty()
|
|
@MinLength(32)
|
|
@IsNotIn(['REPLACE_WITH_LONG_SECRET'])
|
|
APP_SECRET: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['smtp', 'postmark'])
|
|
MAIL_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['local', 's3', 'azure'])
|
|
STORAGE_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.COLLAB_URL != '' && obj.COLLAB_URL != null)
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
COLLAB_URL: string;
|
|
|
|
@IsOptional()
|
|
CLOUD: boolean;
|
|
|
|
@IsOptional()
|
|
@IsUrl(
|
|
{ protocols: [], require_tld: true },
|
|
{
|
|
message:
|
|
'SUBDOMAIN_HOST must be a valid FQDN domain without the http protocol. e.g example.com',
|
|
},
|
|
)
|
|
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
|
|
SUBDOMAIN_HOST: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['database', 'typesense'])
|
|
@IsString()
|
|
SEARCH_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['http', 'https'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{
|
|
message:
|
|
'TYPESENSE_URL must be a valid typesense url e.g http://localhost:8108',
|
|
},
|
|
)
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
TYPESENSE_URL: string;
|
|
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
@IsNotEmpty()
|
|
@IsString()
|
|
TYPESENSE_API_KEY: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
@IsISO6391()
|
|
@IsString()
|
|
TYPESENSE_LOCALE: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.AI_DRIVER)
|
|
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
|
|
@IsString()
|
|
AI_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
AI_EMBEDDING_MODEL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
|
|
@IsIn(['768', '1024', '1536', '2000', '3072'])
|
|
@IsString()
|
|
AI_EMBEDDING_DIMENSION: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
|
|
@IsIn(['true', 'false'])
|
|
@IsString()
|
|
AI_EMBEDDING_SUPPORTS_MRL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER)
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
AI_COMPLETION_MODEL: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf(
|
|
(obj) =>
|
|
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
|
|
)
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
OPENAI_API_KEY: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf(
|
|
(obj) =>
|
|
obj.AI_DRIVER === 'openai-compatible' ||
|
|
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
|
|
)
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
OPENAI_API_URL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
GEMINI_API_KEY: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
OLLAMA_API_URL: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['postgres', 'clickhouse'])
|
|
@IsString()
|
|
EVENT_STORE_DRIVER: string;
|
|
|
|
@ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{ protocols: ['http', 'https'], require_tld: false },
|
|
{
|
|
message:
|
|
'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
|
|
},
|
|
)
|
|
CLICKHOUSE_URL: string;
|
|
|
|
// --- Blob sandbox (in-RAM ephemeral blob transfer; see SandboxModule) ---
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.SANDBOX_PUBLIC_URL != '' && obj.SANDBOX_PUBLIC_URL != null)
|
|
@IsUrl(
|
|
{ protocols: ['http', 'https'], require_tld: false },
|
|
{
|
|
message:
|
|
'SANDBOX_PUBLIC_URL must be a valid http(s) URL reachable by the external blob consumer',
|
|
},
|
|
)
|
|
SANDBOX_PUBLIC_URL: string;
|
|
|
|
@IsOptional()
|
|
@IsNumberString({}, { message: 'SANDBOX_TTL_MS must be an integer (milliseconds)' })
|
|
SANDBOX_TTL_MS: string;
|
|
|
|
@IsOptional()
|
|
@IsNumberString({}, { message: 'SANDBOX_MAX_BYTES must be an integer (bytes)' })
|
|
SANDBOX_MAX_BYTES: string;
|
|
|
|
@IsOptional()
|
|
@IsNumberString({}, { message: 'SANDBOX_MAX_IMAGE_BYTES must be an integer (bytes)' })
|
|
SANDBOX_MAX_IMAGE_BYTES: string;
|
|
|
|
@IsOptional()
|
|
@IsNumberString({}, { message: 'SANDBOX_MAX_TOTAL_BYTES must be an integer (bytes)' })
|
|
SANDBOX_MAX_TOTAL_BYTES: string;
|
|
}
|
|
|
|
export function validate(config: Record<string, any>) {
|
|
const validatedConfig = plainToInstance(EnvironmentVariables, config);
|
|
|
|
const errors = validateSync(validatedConfig);
|
|
|
|
if (errors.length > 0) {
|
|
console.error(
|
|
'The Environment variables has failed the following validations:',
|
|
);
|
|
|
|
errors.map((error) => {
|
|
console.error(JSON.stringify(error.constraints));
|
|
});
|
|
|
|
console.error(
|
|
'Please fix the environment variables and try again. Exiting program...',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
return validatedConfig;
|
|
}
|