mirror of
https://github.com/papra-hq/papra.git
synced 2026-05-01 19:59:52 -05:00
feat(config): added support for file based configuration (#177)
This commit is contained in:
committed by
GitHub
parent
fc973d20fe
commit
faca409604
@@ -20,7 +20,8 @@
|
||||
"@astrojs/starlight": "^0.31.0",
|
||||
"astro": "^5.1.5",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-theme-rapide": "^0.3.0"
|
||||
"starlight-theme-rapide": "^0.3.0",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
@@ -29,7 +30,7 @@
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^2.2.0",
|
||||
"figue": "^2.2.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -6,9 +6,73 @@ slug: self-hosting/configuration
|
||||
|
||||
import { mdSections } from '../../../config.data.ts';
|
||||
import { marked } from 'marked';
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Configuring your self hosted Papra allows you to customize the application to better suit your environment and requirements. This guide covers the key environment variables you can set to control various aspects of the application, including port settings, security options, and storage configurations.
|
||||
|
||||
## Configuration files
|
||||
|
||||
You can configure Papra using standard environment variables or use some configuration files.
|
||||
Papra uses [c12](https://github.com/unjs/c12) to load configuration files and [figue](https://github.com/CorentinTh/figue) to validate and merge environment variables and configuration files.
|
||||
|
||||
The [c12](https://github.com/unjs/c12) allows you to use the file format you want. The configuration file should be named `papra.config.[ext]` and should be located in the root of the project or in `/app/app-data/` directory in docker container (it can be changed using `PAPRA_CONFIG_DIR` environment variable).
|
||||
|
||||
The supported formats are: `json`, `jsonc`, `json5`, `yaml`, `yml`, `toml`, `js`, `ts`, `cjs`, `mjs`.
|
||||
|
||||
Example of configuration files:
|
||||
<Tabs>
|
||||
<TabItem label="papra.config.yaml">
|
||||
```yaml
|
||||
server:
|
||||
baseUrl: https://papra.example.com
|
||||
corsOrigins: *
|
||||
|
||||
client:
|
||||
baseUrl: https://papra.example.com
|
||||
|
||||
auth:
|
||||
secret: your-secret-key
|
||||
isRegistrationEnabled: true
|
||||
# ...
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="papra.config.json">
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.papra.com/papra-config-schema.json",
|
||||
"server": {
|
||||
"baseUrl": "https://papra.example.com"
|
||||
},
|
||||
"client": {
|
||||
"baseUrl": "https://papra.example.com"
|
||||
},
|
||||
"auth": {
|
||||
"secret": "your-secret-key",
|
||||
"isRegistrationEnabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
When using an IDE, you can use the [papra-config-schema.json](/papra-config-schema.json) file to get autocompletion for the configuration file. Just add a `$schema` property to your configuration file and point it to the schema file.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.papra.com/papra-config-schema.json",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
</Aside>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
You'll find the complete list of configuration variables with their environment variables equivalents and path for files in the next section.
|
||||
|
||||
## Configuration variables
|
||||
|
||||
Here is the complete list of configuration variables that you can use to configure Papra. You can set these variables in the `.env` file or pass them as environment variables when running the Docker container.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) {
|
||||
const schema: any = mapValues(configDefinition, (config) => {
|
||||
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
|
||||
return config.schema;
|
||||
} else {
|
||||
return buildConfigSchema({
|
||||
configDefinition: config as ConfigDefinition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return z.object(schema);
|
||||
}
|
||||
|
||||
function stripRequired(schema: any) {
|
||||
if (schema.type === 'object') {
|
||||
schema.required = [];
|
||||
for (const key in schema.properties) {
|
||||
stripRequired(schema.properties[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSchema(schema: any) {
|
||||
schema.properties.$schema = {
|
||||
type: 'string',
|
||||
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
|
||||
};
|
||||
}
|
||||
|
||||
function getConfigSchema() {
|
||||
const schema = buildConfigSchema({ configDefinition });
|
||||
const jsonSchema = zodToJsonSchema(schema, { pipeStrategy: 'output' });
|
||||
|
||||
stripRequired(jsonSchema);
|
||||
addSchema(jsonSchema);
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return new Response(JSON.stringify(getConfigSchema()));
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
function getRobotsTxt(sitemapURL: URL) {
|
||||
return `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site);
|
||||
return new Response(getRobotsTxt(sitemapURL));
|
||||
};
|
||||
@@ -42,10 +42,11 @@
|
||||
"@papra/lecture": "^0.0.4",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-auth": "catalog:",
|
||||
"c12": "^3.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"figue": "^2.2.0",
|
||||
"figue": "^2.2.3",
|
||||
"hono": "^4.6.15",
|
||||
"lodash-es": "^4.17.21",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable antfu/no-top-level-await */
|
||||
import process, { env } from 'node:process';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { setupDatabase } from './modules/app/database/database';
|
||||
@@ -9,10 +10,10 @@ import { taskDefinitions } from './modules/tasks/tasks.defiitions';
|
||||
|
||||
const logger = createLogger({ namespace: 'app-server' });
|
||||
|
||||
const { config } = parseConfig({ env });
|
||||
const { config } = await parseConfig({ env });
|
||||
const { db, client } = setupDatabase(config.database);
|
||||
|
||||
const { app } = createServer({ config, db });
|
||||
const { app } = await createServer({ config, db });
|
||||
const { taskScheduler } = createTaskScheduler({ config, taskDefinitions, tasksArgs: { db } });
|
||||
|
||||
const server = serve(
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('health check routes e2e', () => {
|
||||
describe('the /api/health is a publicly accessible route that provides health information about the server', () => {
|
||||
test('when the database is healthy, the /api/health returns 200', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { app } = createServer({ db });
|
||||
const { app } = await createServer({ db });
|
||||
|
||||
const response = await app.request('/api/health');
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('health check routes e2e', () => {
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
const { app } = createServer({ db });
|
||||
const { app } = await createServer({ db });
|
||||
|
||||
const response = await app.request('/api/health');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createServer } from '../../server';
|
||||
describe('ping routes e2e', () => {
|
||||
test('the /api/ping is a publicly accessible route that always returns a 200 with a status ok', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { app } = createServer({ db });
|
||||
const { app } = await createServer({ db });
|
||||
|
||||
const response = await app.request('/api/ping');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createTimeoutMiddleware } from './timeout.middleware';
|
||||
describe('middlewares', () => {
|
||||
describe('timeoutMiddleware', () => {
|
||||
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
||||
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
|
||||
const config = await overrideConfig({ server: { routeTimeoutMs: 50 } });
|
||||
|
||||
const app = new Hono<{ Variables: { config: any } }>();
|
||||
registerErrorMiddleware({ app: app as any });
|
||||
|
||||
@@ -14,8 +14,8 @@ import { createTimeoutMiddleware } from './middlewares/timeout.middleware';
|
||||
import { registerRoutes } from './server.routes';
|
||||
import { registerStaticAssetsRoutes } from './static-assets/static-assets.routes';
|
||||
|
||||
function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>): GlobalDependencies {
|
||||
const config = partialDeps.config ?? parseConfig().config;
|
||||
async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>): Promise<GlobalDependencies> {
|
||||
const config = partialDeps.config ?? (await parseConfig()).config;
|
||||
const db = partialDeps.db ?? setupDatabase(config.database).db;
|
||||
const emailsServices = createEmailsServices({ config });
|
||||
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }) }).auth;
|
||||
@@ -30,8 +30,8 @@ function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>): Glo
|
||||
};
|
||||
}
|
||||
|
||||
export function createServer(initialDeps: Partial<GlobalDependencies>) {
|
||||
const dependencies = createGlobalDependencies(initialDeps);
|
||||
export async function createServer(initialDeps: Partial<GlobalDependencies>) {
|
||||
const dependencies = await createGlobalDependencies(initialDeps);
|
||||
const { config } = dependencies;
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>({ strict: true });
|
||||
|
||||
@@ -5,8 +5,8 @@ import { parseConfig } from './config';
|
||||
|
||||
export { overrideConfig };
|
||||
|
||||
function overrideConfig(config: DeepPartial<Config>) {
|
||||
const { config: defaultConfig } = parseConfig();
|
||||
async function overrideConfig(config: DeepPartial<Config>) {
|
||||
const { config: defaultConfig } = await parseConfig();
|
||||
|
||||
return merge({}, defaultConfig, config);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import process from 'node:process';
|
||||
import { safelySync } from '@corentinth/chisels';
|
||||
import { loadConfig } from 'c12';
|
||||
import { defineConfig } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { authConfig } from '../app/auth/auth.config';
|
||||
@@ -90,13 +91,18 @@ export const configDefinition = {
|
||||
|
||||
const logger = createLogger({ namespace: 'config' });
|
||||
|
||||
export function parseConfig({ env = process.env }: { env?: Record<string, string | undefined> } = {}) {
|
||||
const [configResult, configError] = safelySync(() => defineConfig(
|
||||
configDefinition,
|
||||
{
|
||||
envSource: env,
|
||||
},
|
||||
));
|
||||
export async function parseConfig({ env = process.env }: { env?: Record<string, string | undefined> } = {}) {
|
||||
const { config: configFromFile } = await loadConfig({
|
||||
name: 'papra',
|
||||
rcFile: false,
|
||||
globalRc: false,
|
||||
dotenv: false,
|
||||
packageJson: false,
|
||||
envName: false,
|
||||
cwd: env.PAPRA_CONFIG_DIR ?? process.cwd(),
|
||||
});
|
||||
|
||||
const [configResult, configError] = safelySync(() => defineConfig(configDefinition, { envSource: env, defaults: configFromFile }));
|
||||
|
||||
if (configError) {
|
||||
logger.error({ error: configError }, `Invalid config: ${configError.message}`);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { parseConfig } from './config';
|
||||
|
||||
export type Config = ReturnType<typeof parseConfig>['config'];
|
||||
export type Config = Awaited<ReturnType<typeof parseConfig>>['config'];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { FS_STORAGE_DRIVER_NAME } from '../../documents/storage/drivers/fs/fs.storage-driver';
|
||||
import { IN_MEMORY_STORAGE_DRIVER_NAME } from '../../documents/storage/drivers/memory/memory.storage-driver';
|
||||
import { S3_STORAGE_DRIVER_NAME } from '../../documents/storage/drivers/s3/s3.storage-driver';
|
||||
import { FS_STORAGE_DRIVER_NAME } from './drivers/fs/fs.storage-driver';
|
||||
import { IN_MEMORY_STORAGE_DRIVER_NAME } from './drivers/memory/memory.storage-driver';
|
||||
import { S3_STORAGE_DRIVER_NAME } from './drivers/s3/s3.storage-driver';
|
||||
|
||||
export const documentStorageConfig = {
|
||||
maxUploadSize: {
|
||||
|
||||
@@ -11,9 +11,9 @@ describe('intake-emails e2e', () => {
|
||||
describe('ingest an intake email', () => {
|
||||
test('when intake email ingestion is disabled, a 403 is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { app } = createServer({
|
||||
const { app } = await createServer({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
config: await overrideConfig({
|
||||
intakeEmails: {
|
||||
isEnabled: false,
|
||||
},
|
||||
@@ -42,9 +42,9 @@ describe('intake-emails e2e', () => {
|
||||
describe('when ingesting an email, the request must have an X-Signature header with the hmac signature of the body', async () => {
|
||||
test('when the header is missing, a 400 is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { app } = createServer({
|
||||
const { app } = await createServer({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
config: await overrideConfig({
|
||||
intakeEmails: {
|
||||
isEnabled: true,
|
||||
webhookSecret: 'super-secret',
|
||||
@@ -73,9 +73,9 @@ describe('intake-emails e2e', () => {
|
||||
|
||||
test('when the header is invalid, a 401 is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { app } = createServer({
|
||||
const { app } = await createServer({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
config: await overrideConfig({
|
||||
intakeEmails: {
|
||||
isEnabled: true,
|
||||
webhookSecret: 'super-secret',
|
||||
@@ -114,9 +114,9 @@ describe('intake-emails e2e', () => {
|
||||
intakeEmails: [{ id: 'ie_1', organizationId: 'org_1', emailAddress: 'email-1@papra.email', allowedOrigins: ['foo@example.fr'] }],
|
||||
});
|
||||
|
||||
const { app } = createServer({
|
||||
const { app } = await createServer({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
config: await overrideConfig({
|
||||
intakeEmails: { isEnabled: true, webhookSecret: 'super-secret' },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('organizations usecases', () => {
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const config = overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
const config = await overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
|
||||
// no throw
|
||||
await checkIfUserCanCreateNewOrganization({
|
||||
@@ -128,7 +128,7 @@ describe('organizations usecases', () => {
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const config = overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
const config = await overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
|
||||
// no throw
|
||||
await checkIfUserCanCreateNewOrganization({
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getOrganizationPlansRecords } from './plans.repository';
|
||||
describe('plans repository', () => {
|
||||
describe('getOrganizationPlansRecords', () => {
|
||||
describe('generates a map of organization plans, used in the organization plan repository', () => {
|
||||
test('the key indexing the plans is the plan id', () => {
|
||||
const config = overrideConfig({});
|
||||
test('the key indexing the plans is the plan id', async () => {
|
||||
const config = await overrideConfig({});
|
||||
|
||||
const { organizationPlans } = getOrganizationPlansRecords({ config });
|
||||
const organizationPlanEntries = Object.entries(organizationPlans);
|
||||
@@ -20,8 +20,8 @@ describe('plans repository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('for self-hosted instances, it make no sense to have a limited free plan, so admin can set the free plan to unlimited using the isFreePlanUnlimited config', () => {
|
||||
const config = overrideConfig({
|
||||
test('for self-hosted instances, it make no sense to have a limited free plan, so admin can set the free plan to unlimited using the isFreePlanUnlimited config', async () => {
|
||||
const config = await overrideConfig({
|
||||
organizationPlans: {
|
||||
isFreePlanUnlimited: true,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('plans usecases', () => {
|
||||
}],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
const config = await overrideConfig({
|
||||
organizationPlans: {
|
||||
plusPlanPriceId: 'price_123',
|
||||
},
|
||||
@@ -48,7 +48,7 @@ describe('plans usecases', () => {
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
const config = await overrideConfig({
|
||||
organizationPlans: {
|
||||
plusPlanPriceId: 'price_123',
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ async function runScript(
|
||||
async () => {
|
||||
const logger = createLogger({ namespace: 'scripts' });
|
||||
|
||||
const { config } = parseConfig({ env: process.env });
|
||||
const { config } = await parseConfig({ env: process.env });
|
||||
const { db, client } = setupDatabase({ ...config.database });
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
@@ -35,6 +37,7 @@ EXPOSE 1221
|
||||
ENV SERVER_SERVE_PUBLIC_DIR=true
|
||||
ENV DATABASE_URL=file:./app-data/db/db.sqlite
|
||||
ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents
|
||||
ENV PAPRA_CONFIG_DIR=./app-data
|
||||
|
||||
RUN mkdir -p ./app-data/db ./app-data/documents
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
@@ -45,6 +47,6 @@ EXPOSE 1221
|
||||
ENV SERVER_SERVE_PUBLIC_DIR=true
|
||||
ENV DATABASE_URL=file:./app-data/db/db.sqlite
|
||||
ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents
|
||||
|
||||
ENV PAPRA_CONFIG_DIR=./app-data
|
||||
|
||||
CMD ["pnpm", "start:with-migrations"]
|
||||
Generated
+679
-978
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user