From cc2edc59b0ee92c740a8bef73cbd4c37c68fce9e Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Thu, 24 Apr 2025 21:13:56 +0200 Subject: [PATCH] feat(server): added api-keys (#248) --- apps/papra-client/src/locales/en.yml | 56 + .../modules/api-keys/api-keys.constants.ts | 28 + .../src/modules/api-keys/api-keys.services.ts | 56 + .../src/modules/api-keys/api-keys.types.ts | 12 + .../api-key-permissions-picker.component.tsx | 74 + .../modules/api-keys/pages/api-keys.page.tsx | 139 ++ .../api-keys/pages/create-api-key.page.tsx | 117 ++ .../src/modules/demo/demo-api-mock.ts | 73 +- .../src/modules/demo/demo.storage.ts | 2 + .../src/modules/i18n/locales.types.ts | 2 +- .../shared/http/http-client.models.test.ts | 4 + .../modules/shared/http/http-client.models.ts | 2 + .../modules/ui/layouts/settings.layout.tsx | 47 +- .../src/modules/ui/layouts/sidenav.layout.tsx | 5 + .../users/pages/user-settings.page.tsx | 15 +- apps/papra-client/src/routes.tsx | 21 +- apps/papra-client/vite.config.ts | 2 +- .../papra-server/migrations/0003_api-keys.sql | 24 + .../migrations/meta/0003_snapshot.json | 1612 +++++++++++++++++ .../migrations/meta/_journal.json | 7 + apps/papra-server/package.json | 1 + .../modules/api-keys/api-keys.constants.ts | 19 + .../modules/api-keys/api-keys.middlewares.ts | 43 + .../modules/api-keys/api-keys.models.test.ts | 14 + .../src/modules/api-keys/api-keys.models.ts | 14 + .../api-keys/api-keys.repository.test.ts | 55 + .../modules/api-keys/api-keys.repository.ts | 149 ++ .../src/modules/api-keys/api-keys.routes.ts | 99 + .../api-keys/api-keys.services.test.ts | 12 + .../src/modules/api-keys/api-keys.services.ts | 8 + .../src/modules/api-keys/api-keys.tables.ts | 42 + .../src/modules/api-keys/api-keys.types.ts | 6 + .../api-keys/api-keys.usecases.test.ts | 54 + .../src/modules/api-keys/api-keys.usecases.ts | 53 + .../create-document-with-api-key.e2e.test.ts | 88 + .../src/modules/app/auth/auth.middleware.ts | 22 + .../src/modules/app/auth/auth.models.test.ts | 108 +- .../src/modules/app/auth/auth.models.ts | 55 +- .../src/modules/app/auth/auth.routes.ts | 32 +- .../src/modules/app/auth/auth.types.ts | 3 + .../app/database/database.test-utils.ts | 3 + .../src/modules/app/server.routes.test.ts | 69 + .../src/modules/app/server.routes.ts | 49 +- apps/papra-server/src/modules/app/server.ts | 7 +- .../src/modules/app/server.types.ts | 8 +- .../src/modules/config/config.routes.ts | 2 +- .../src/modules/documents/documents.routes.ts | 15 +- .../intake-emails/intake-emails.routes.ts | 12 +- .../organizations/organizations.routes.ts | 27 +- .../src/modules/shared/crypto/hash.test.ts | 20 + .../src/modules/shared/crypto/hash.ts | 5 + .../shared/random/random.services.test.ts | 17 + .../modules/shared/random/random.services.ts | 10 + .../subscriptions/subscriptions.routes.ts | 11 +- .../tagging-rules/tagging-rules.routes.ts | 6 + .../src/modules/tags/tags.routes.ts | 13 +- .../src/modules/users/users.routes.ts | 64 +- docker/Dockerfile | 1 + docker/Dockerfile.rootless | 1 + pnpm-lock.yaml | 10 + 60 files changed, 3380 insertions(+), 145 deletions(-) create mode 100644 apps/papra-client/src/modules/api-keys/api-keys.constants.ts create mode 100644 apps/papra-client/src/modules/api-keys/api-keys.services.ts create mode 100644 apps/papra-client/src/modules/api-keys/api-keys.types.ts create mode 100644 apps/papra-client/src/modules/api-keys/components/api-key-permissions-picker.component.tsx create mode 100644 apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx create mode 100644 apps/papra-client/src/modules/api-keys/pages/create-api-key.page.tsx create mode 100644 apps/papra-server/migrations/0003_api-keys.sql create mode 100644 apps/papra-server/migrations/meta/0003_snapshot.json create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.constants.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.middlewares.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.models.test.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.models.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.repository.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.routes.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.services.test.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.services.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.tables.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.types.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.usecases.test.ts create mode 100644 apps/papra-server/src/modules/api-keys/api-keys.usecases.ts create mode 100644 apps/papra-server/src/modules/api-keys/e2e/create-document-with-api-key.e2e.test.ts create mode 100644 apps/papra-server/src/modules/app/auth/auth.middleware.ts create mode 100644 apps/papra-server/src/modules/app/auth/auth.types.ts create mode 100644 apps/papra-server/src/modules/app/server.routes.test.ts create mode 100644 apps/papra-server/src/modules/shared/crypto/hash.test.ts create mode 100644 apps/papra-server/src/modules/shared/crypto/hash.ts create mode 100644 apps/papra-server/src/modules/shared/random/random.services.test.ts create mode 100644 apps/papra-server/src/modules/shared/random/random.services.ts diff --git a/apps/papra-client/src/locales/en.yml b/apps/papra-client/src/locales/en.yml index 4aadac3..d07c939 100644 --- a/apps/papra-client/src/locales/en.yml +++ b/apps/papra-client/src/locales/en.yml @@ -105,6 +105,9 @@ layout: integrations: Integrations deleted-documents: Deleted documents organization-settings: Organization settings + api-keys: API keys + settings: Settings + account: Account tagging-rules: field: @@ -217,3 +220,56 @@ api-errors: intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails. user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support. default: An error occurred while processing your request. + +api-keys: + permissions: + documents: + title: Documents + documents:create: Create documents + documents:read: Read documents + documents:update: Update documents + documents:delete: Delete documents + tags: + title: Tags + tags:create: Create tags + tags:read: Read tags + tags:update: Update tags + tags:delete: Delete tags + create: + title: Create API key + description: Create a new API key to access the Papra API. + success: The API key has been created successfully. + back: Back to API keys + form: + name: + label: Name + placeholder: 'Example: My API key' + required: Please enter a name for the API key + permissions: + label: Permissions + description: Select the permissions for the API key. + required: Please select at least one permission + submit: Create API key + created: + title: API key created + description: The API key has been created successfully. Save it in a secure location as it will not be displayed again. + list: + title: API keys + description: Manage your API keys here. + delete: Delete + create: Create API key + empty: + title: No API keys + description: Create an API key to access the Papra API. + card: + last-used: Last used + never: Never + created: Created + delete: Delete + delete: + success: The API key has been deleted successfully + confirm: + title: Delete API key + message: Are you sure you want to delete this API key? This action cannot be undone. + confirm-button: Delete + cancel-button: Cancel diff --git a/apps/papra-client/src/modules/api-keys/api-keys.constants.ts b/apps/papra-client/src/modules/api-keys/api-keys.constants.ts new file mode 100644 index 0000000..fb9390a --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/api-keys.constants.ts @@ -0,0 +1,28 @@ +// export const API_KEY_PERMISSIONS = { +// documents: { +// create: 'documents:create', +// }, +// } as const; + +export const API_KEY_PERMISSIONS = [ + { + section: 'documents', + permissions: [ + 'documents:create', + 'documents:read', + 'documents:update', + 'documents:delete', + ], + }, + { + section: 'tags', + permissions: [ + 'tags:create', + 'tags:read', + 'tags:update', + 'tags:delete', + ], + }, +] as const; + +export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions); diff --git a/apps/papra-client/src/modules/api-keys/api-keys.services.ts b/apps/papra-client/src/modules/api-keys/api-keys.services.ts new file mode 100644 index 0000000..1bcaea9 --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/api-keys.services.ts @@ -0,0 +1,56 @@ +import type { ApiKey } from './api-keys.types'; +import { apiClient } from '../shared/http/api-client'; +import { coerceDates } from '../shared/http/http-client.models'; + +export async function createApiKey({ + name, + permissions, + organizationIds, + allOrganizations, + expiresAt, +}: { + name: string; + permissions: string[]; + organizationIds: string[]; + allOrganizations: boolean; + expiresAt?: Date; +}) { + const { apiKey, token } = await apiClient<{ + apiKey: ApiKey; + token: string; + }>({ + path: '/api/api-keys', + method: 'POST', + body: { + name, + permissions, + organizationIds, + allOrganizations, + expiresAt, + }, + }); + + return { + apiKey: coerceDates(apiKey), + token, + }; +} + +export async function fetchApiKeys() { + const { apiKeys } = await apiClient<{ + apiKeys: ApiKey[]; + }>({ + path: '/api/api-keys', + }); + + return { + apiKeys: apiKeys.map(coerceDates), + }; +} + +export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) { + await apiClient({ + path: `/api/api-keys/${apiKeyId}`, + method: 'DELETE', + }); +} diff --git a/apps/papra-client/src/modules/api-keys/api-keys.types.ts b/apps/papra-client/src/modules/api-keys/api-keys.types.ts new file mode 100644 index 0000000..94eb4b3 --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/api-keys.types.ts @@ -0,0 +1,12 @@ +export type ApiKey = { + id: string; + name: string; + permissions: string[]; + organizationIds: string[]; + allOrganizations: boolean; + expiresAt?: Date; + prefix: string; + lastUsedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; diff --git a/apps/papra-client/src/modules/api-keys/components/api-key-permissions-picker.component.tsx b/apps/papra-client/src/modules/api-keys/components/api-key-permissions-picker.component.tsx new file mode 100644 index 0000000..287552b --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/components/api-key-permissions-picker.component.tsx @@ -0,0 +1,74 @@ +import type { LocaleKeys } from '@/modules/i18n/locales.types'; +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox'; +import { type Component, createSignal, For } from 'solid-js'; +import { API_KEY_PERMISSIONS } from '../api-keys.constants'; + +export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => { + const [permissions, setPermissions] = createSignal(props.permissions); + const { t } = useI18n(); + + const getPermissionsSections = () => { + return API_KEY_PERMISSIONS.map(section => ({ + ...section, + title: t(`api-keys.permissions.${section.section}.title`), + permissions: section.permissions.map((permission) => { + const [prefix, suffix] = permission.split(':'); + + return { + name: permission, + prefix, + suffix, + description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys), + }; + }), + })); + }; + + const isPermissionSelected = (permission: string) => { + return permissions().includes(permission); + }; + + const togglePermission = (permission: string) => { + setPermissions((prev) => { + if (prev.includes(permission)) { + return prev.filter(p => p !== permission); + } + + return [...prev, permission]; + }); + + props.onChange(permissions()); + }; + + return ( +
+ + {section => ( +
+

{section.title}

+ +
+ + {permission => ( + togglePermission(permission.name)} + > + +
+ + {permission.description} + +
+
+ )} +
+
+
+ )} +
+
+ ); +}; diff --git a/apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx b/apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx new file mode 100644 index 0000000..5f571b0 --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx @@ -0,0 +1,139 @@ +import type { ApiKey } from '../api-keys.types'; +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { useConfirmModal } from '@/modules/shared/confirm'; +import { queryClient } from '@/modules/shared/query/query-client'; +import { Button } from '@/modules/ui/components/button'; +import { EmptyState } from '@/modules/ui/components/empty'; +import { createToast } from '@/modules/ui/components/sonner'; +import { A } from '@solidjs/router'; +import { createMutation, createQuery } from '@tanstack/solid-query'; +import { format } from 'date-fns'; +import { type Component, For, Match, Show, Suspense, Switch } from 'solid-js'; +import { deleteApiKey, fetchApiKeys } from '../api-keys.services'; + +export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => { + const { t } = useI18n(); + const { confirm } = useConfirmModal(); + + const deleteApiKeyMutation = createMutation(() => ({ + mutationFn: deleteApiKey, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + createToast({ + message: t('api-keys.delete.success'), + }); + }, + })); + + const handleDelete = async () => { + const confirmed = await confirm({ + title: t('api-keys.delete.confirm.title'), + message: t('api-keys.delete.confirm.message'), + confirmButton: { + text: t('api-keys.delete.confirm.confirm-button'), + variant: 'destructive', + }, + cancelButton: { + text: t('api-keys.delete.confirm.cancel-button'), + }, + }); + + if (!confirmed) { + return; + } + + deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id }); + }; + + return ( +
+
+
+
+
+

{apiKey.name}

+

{`${apiKey.prefix}...`}

+
+ +
+

+ {t('api-keys.list.card.last-used')} + {' '} + {apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')} +

+

+ {t('api-keys.list.card.created')} + {' '} + {format(apiKey.createdAt, 'MMM d, yyyy')} +

+
+ +
+ +
+
+ ); +}; + +export const ApiKeysPage: Component = () => { + const { t } = useI18n(); + const query = createQuery(() => ({ + queryKey: ['api-keys'], + queryFn: () => fetchApiKeys(), + })); + + return ( +
+
+
+

{t('api-keys.list.title')}

+

{t('api-keys.list.description')}

+
+
+ + + +
+
+ + + + + +
+ {t('api-keys.list.create')} + + )} + /> + + + + +
+ + {apiKey => ( + + )} + +
+
+ + +
+ ); +}; diff --git a/apps/papra-client/src/modules/api-keys/pages/create-api-key.page.tsx b/apps/papra-client/src/modules/api-keys/pages/create-api-key.page.tsx new file mode 100644 index 0000000..19e9a41 --- /dev/null +++ b/apps/papra-client/src/modules/api-keys/pages/create-api-key.page.tsx @@ -0,0 +1,117 @@ +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { createForm } from '@/modules/shared/form/form'; +import { queryClient } from '@/modules/shared/query/query-client'; +import { CopyButton } from '@/modules/shared/utils/copy'; +import { Button } from '@/modules/ui/components/button'; +import { createToast } from '@/modules/ui/components/sonner'; +import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; +import { setValue } from '@modular-forms/solid'; +import { A } from '@solidjs/router'; +import { type Component, createSignal, Show } from 'solid-js'; +import * as v from 'valibot'; +import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants'; +import { createApiKey } from '../api-keys.services'; +import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component'; + +export const CreateApiKeyPage: Component = () => { + const { t } = useI18n(); + const [getToken, setToken] = createSignal(null); + + const { form, Form, Field } = createForm({ + onSubmit: async ({ name, permissions }) => { + const { token } = await createApiKey({ + name, + permissions, + organizationIds: [], + allOrganizations: false, + }); + + await queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + + setToken(token); + + createToast({ + type: 'success', + message: t('api-keys.create.success'), + }); + }, + schema: v.object({ + name: v.pipe( + v.string(), + v.nonEmpty(t('api-keys.create.form.name.required')), + ), + permissions: v.pipe( + v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])), + v.nonEmpty(t('api-keys.create.form.permissions.required')), + ), + }), + initialValues: { + name: '', + permissions: API_KEY_PERMISSIONS_LIST, + }, + }); + + return ( +
+
+

{t('api-keys.create.title')}

+

{t('api-keys.create.description')}

+
+ + +
+

{t('api-keys.create.created.title')}

+

{t('api-keys.create.created.description')}

+ + + + + +
+ +
+ +
+
+ + +
+ + {(field, inputProps) => ( + + + {t('api-keys.create.form.name.label')} + + {field.error &&
{field.error}
} +
+ + )} +
+ + + {field => ( +
+

{t('api-keys.create.form.permissions.label')}

+ +
+ setValue(form, 'permissions', permissions)} /> +
+ + {field.error &&
{field.error}
} +
+ + )} +
+ +
+ +
+
+
+
+ ); +}; diff --git a/apps/papra-client/src/modules/demo/demo-api-mock.ts b/apps/papra-client/src/modules/demo/demo-api-mock.ts index a6c691d..b123041 100644 --- a/apps/papra-client/src/modules/demo/demo-api-mock.ts +++ b/apps/papra-client/src/modules/demo/demo-api-mock.ts @@ -1,10 +1,29 @@ +import type { ApiKey } from '../api-keys/api-keys.types'; import { get } from 'lodash-es'; import { FetchError } from 'ofetch'; import { createRouter } from 'radix3'; import { defineHandler } from './demo-api-mock.models'; -import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, taggingRuleStorage, tagStorage } from './demo.storage'; +import { + apiKeyStorage, + documentFileStorage, + documentStorage, + organizationStorage, + tagDocumentStorage, + taggingRuleStorage, + tagStorage, +} from './demo.storage'; import { findMany, getValues } from './demo.storage.models'; +const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function randomString({ length = 10 }: { length?: number } = {}) { + return Array.from({ length }, () => corpus[Math.floor(Math.random() * corpus.length)]).join(''); +} + +function createId({ prefix }: { prefix: string }) { + return `${prefix}_${randomString({ length: 24 })}`; +} + function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition { if (!condition) { throw Object.assign(new FetchError(message), { status }); @@ -114,7 +133,7 @@ const inMemoryApiMock: Record = { assert(file, { status: 400 }); const document = { - id: `doc_${Math.random().toString(36).slice(2)}`, + id: createId({ prefix: 'doc' }), organizationId, name: file.name, originalName: file.name, @@ -311,7 +330,7 @@ const inMemoryApiMock: Record = { assert(organization, { status: 403 }); const tag = { - id: `tag_${Math.random().toString(36).slice(2)}`, + id: createId({ prefix: 'tag' }), organizationId, name: get(body, 'name'), color: get(body, 'color'), @@ -373,7 +392,7 @@ const inMemoryApiMock: Record = { assert(tagId, { status: 400 }); const tagDocument = { - id: `tagDoc_${Math.random().toString(36).slice(2)}`, + id: createId({ prefix: 'tagDoc' }), tagId, documentId, createdAt: new Date(), @@ -412,7 +431,7 @@ const inMemoryApiMock: Record = { method: 'POST', handler: async ({ body }) => { const organization = { - id: `org_${Math.random().toString(36).slice(2)}`, + id: createId({ prefix: 'org' }), name: get(body, 'name'), createdAt: new Date(), updatedAt: new Date(), @@ -476,7 +495,7 @@ const inMemoryApiMock: Record = { method: 'POST', handler: async ({ params: { organizationId }, body }) => { const taggingRule = { - id: `tr_${Math.random().toString(36).slice(2)}`, + id: createId({ prefix: 'tr' }), organizationId, name: get(body, 'name'), description: get(body, 'description'), @@ -545,6 +564,48 @@ const inMemoryApiMock: Record = { await documentStorage.removeItem(key); }, }), + + ...defineHandler({ + path: '/api/api-keys', + method: 'GET', + handler: async () => { + const apiKeys = await getValues(apiKeyStorage); + + return { apiKeys }; + }, + }), + + ...defineHandler({ + path: '/api/api-keys', + method: 'POST', + handler: async ({ body }) => { + const token = `ppapi_${randomString({ length: 64 })}`; + + const apiKey = { + id: createId({ prefix: 'apiKey' }), + name: get(body, 'name'), + permissions: get(body, 'permissions'), + organizationIds: get(body, 'organizationIds'), + allOrganizations: get(body, 'allOrganizations'), + expiresAt: get(body, 'expiresAt'), + createdAt: new Date(), + updatedAt: new Date(), + prefix: token.slice(0, 11), + } as ApiKey; + + await apiKeyStorage.setItem(apiKey.id, apiKey); + + return { apiKey, token }; + }, + }), + + ...defineHandler({ + path: '/api/api-keys/:apiKeyId', + method: 'DELETE', + handler: async ({ params: { apiKeyId } }) => { + await apiKeyStorage.removeItem(apiKeyId); + }, + }), }; export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false }); diff --git a/apps/papra-client/src/modules/demo/demo.storage.ts b/apps/papra-client/src/modules/demo/demo.storage.ts index fd361b2..8e2b536 100644 --- a/apps/papra-client/src/modules/demo/demo.storage.ts +++ b/apps/papra-client/src/modules/demo/demo.storage.ts @@ -1,3 +1,4 @@ +import type { ApiKey } from '../api-keys/api-keys.types'; import type { Document } from '../documents/documents.types'; import type { Organization } from '../organizations/organizations.types'; import type { TaggingRule } from '../tagging-rules/tagging-rules.types'; @@ -16,6 +17,7 @@ export const documentFileStorage = prefixStorage(storage, 'documentFiles'); export const tagStorage = prefixStorage>(storage, 'tags'); export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments'); export const taggingRuleStorage = prefixStorage(storage, 'taggingRules'); +export const apiKeyStorage = prefixStorage(storage, 'apiKeys'); export async function clearDemoStorage() { await storage.clear(); diff --git a/apps/papra-client/src/modules/i18n/locales.types.ts b/apps/papra-client/src/modules/i18n/locales.types.ts index d3beabd..602b740 100644 --- a/apps/papra-client/src/modules/i18n/locales.types.ts +++ b/apps/papra-client/src/modules/i18n/locales.types.ts @@ -1,2 +1,2 @@ // Dynamically generated file. Use "pnpm script:generate-i18n-types" to update. -export type LocaleKeys = 'auth.request-password-reset.title' | 'auth.request-password-reset.description' | 'auth.request-password-reset.requested' | 'auth.request-password-reset.back-to-login' | 'auth.request-password-reset.form.email.label' | 'auth.request-password-reset.form.email.placeholder' | 'auth.request-password-reset.form.email.required' | 'auth.request-password-reset.form.email.invalid' | 'auth.request-password-reset.form.submit' | 'auth.reset-password.title' | 'auth.reset-password.description' | 'auth.reset-password.reset' | 'auth.reset-password.back-to-login' | 'auth.reset-password.form.new-password.label' | 'auth.reset-password.form.new-password.placeholder' | 'auth.reset-password.form.new-password.required' | 'auth.reset-password.form.new-password.min-length' | 'auth.reset-password.form.new-password.max-length' | 'auth.reset-password.form.submit' | 'auth.email-provider.open' | 'auth.login.title' | 'auth.login.description' | 'auth.login.login-with-provider' | 'auth.login.no-account' | 'auth.login.register' | 'auth.login.form.email.label' | 'auth.login.form.email.placeholder' | 'auth.login.form.email.required' | 'auth.login.form.email.invalid' | 'auth.login.form.password.label' | 'auth.login.form.password.placeholder' | 'auth.login.form.password.required' | 'auth.login.form.remember-me.label' | 'auth.login.form.forgot-password.label' | 'auth.login.form.submit' | 'auth.register.title' | 'auth.register.description' | 'auth.register.register-with-email' | 'auth.register.register-with-provider' | 'auth.register.providers.google' | 'auth.register.providers.github' | 'auth.register.have-account' | 'auth.register.login' | 'auth.register.registration-disabled.title' | 'auth.register.registration-disabled.description' | 'auth.register.form.email.label' | 'auth.register.form.email.placeholder' | 'auth.register.form.email.required' | 'auth.register.form.email.invalid' | 'auth.register.form.password.label' | 'auth.register.form.password.placeholder' | 'auth.register.form.password.required' | 'auth.register.form.password.min-length' | 'auth.register.form.password.max-length' | 'auth.register.form.name.label' | 'auth.register.form.name.placeholder' | 'auth.register.form.name.required' | 'auth.register.form.name.max-length' | 'auth.register.form.submit' | 'auth.email-validation-required.title' | 'auth.email-validation-required.description' | 'auth.legal-links.description' | 'auth.legal-links.terms' | 'auth.legal-links.privacy' | 'tags.no-tags.title' | 'tags.no-tags.description' | 'tags.no-tags.create-tag' | 'layout.menu.home' | 'layout.menu.documents' | 'layout.menu.tags' | 'layout.menu.tagging-rules' | 'layout.menu.integrations' | 'layout.menu.deleted-documents' | 'layout.menu.organization-settings' | 'tagging-rules.field.name' | 'tagging-rules.field.content' | 'tagging-rules.operator.equals' | 'tagging-rules.operator.not-equals' | 'tagging-rules.operator.contains' | 'tagging-rules.operator.not-contains' | 'tagging-rules.operator.starts-with' | 'tagging-rules.operator.ends-with' | 'tagging-rules.list.title' | 'tagging-rules.list.description' | 'tagging-rules.list.demo-warning' | 'tagging-rules.list.no-tagging-rules.title' | 'tagging-rules.list.no-tagging-rules.description' | 'tagging-rules.list.no-tagging-rules.create-tagging-rule' | 'tagging-rules.list.card.no-conditions' | 'tagging-rules.list.card.one-condition' | 'tagging-rules.list.card.conditions' | 'tagging-rules.list.card.delete' | 'tagging-rules.list.card.edit' | 'tagging-rules.create.title' | 'tagging-rules.create.success' | 'tagging-rules.create.error' | 'tagging-rules.create.submit' | 'tagging-rules.form.name.label' | 'tagging-rules.form.name.placeholder' | 'tagging-rules.form.name.min-length' | 'tagging-rules.form.name.max-length' | 'tagging-rules.form.description.label' | 'tagging-rules.form.description.placeholder' | 'tagging-rules.form.description.max-length' | 'tagging-rules.form.conditions.label' | 'tagging-rules.form.conditions.description' | 'tagging-rules.form.conditions.add-condition' | 'tagging-rules.form.conditions.no-conditions.title' | 'tagging-rules.form.conditions.no-conditions.description' | 'tagging-rules.form.conditions.no-conditions.confirm' | 'tagging-rules.form.conditions.no-conditions.cancel' | 'tagging-rules.form.conditions.field.label' | 'tagging-rules.form.conditions.operator.label' | 'tagging-rules.form.conditions.value.label' | 'tagging-rules.form.conditions.value.placeholder' | 'tagging-rules.form.conditions.value.min-length' | 'tagging-rules.form.tags.label' | 'tagging-rules.form.tags.description' | 'tagging-rules.form.tags.min-length' | 'tagging-rules.form.tags.add-tag' | 'tagging-rules.form.submit' | 'tagging-rules.update.title' | 'tagging-rules.update.success' | 'tagging-rules.update.error' | 'tagging-rules.update.submit' | 'tagging-rules.update.cancel' | 'demo.popup.description' | 'demo.popup.discord' | 'demo.popup.discord-link-label' | 'demo.popup.reset' | 'demo.popup.hide' | 'trash.delete-all.button' | 'trash.delete-all.confirm.title' | 'trash.delete-all.confirm.description' | 'trash.delete-all.confirm.label' | 'trash.delete-all.confirm.cancel' | 'trash.delete.button' | 'trash.delete.confirm.title' | 'trash.delete.confirm.description' | 'trash.delete.confirm.label' | 'trash.delete.confirm.cancel' | 'trash.deleted.success.title' | 'trash.deleted.success.description' | 'import-documents.title.error' | 'import-documents.title.success' | 'import-documents.title.pending' | 'import-documents.title.none' | 'import-documents.no-import-in-progress' | 'api-errors.document.already_exists' | 'api-errors.document.file_too_big' | 'api-errors.intake_email.limit_reached' | 'api-errors.user.max_organization_count_reached' | 'api-errors.default'; +export type LocaleKeys = 'auth.request-password-reset.title' | 'auth.request-password-reset.description' | 'auth.request-password-reset.requested' | 'auth.request-password-reset.back-to-login' | 'auth.request-password-reset.form.email.label' | 'auth.request-password-reset.form.email.placeholder' | 'auth.request-password-reset.form.email.required' | 'auth.request-password-reset.form.email.invalid' | 'auth.request-password-reset.form.submit' | 'auth.reset-password.title' | 'auth.reset-password.description' | 'auth.reset-password.reset' | 'auth.reset-password.back-to-login' | 'auth.reset-password.form.new-password.label' | 'auth.reset-password.form.new-password.placeholder' | 'auth.reset-password.form.new-password.required' | 'auth.reset-password.form.new-password.min-length' | 'auth.reset-password.form.new-password.max-length' | 'auth.reset-password.form.submit' | 'auth.email-provider.open' | 'auth.login.title' | 'auth.login.description' | 'auth.login.login-with-provider' | 'auth.login.no-account' | 'auth.login.register' | 'auth.login.form.email.label' | 'auth.login.form.email.placeholder' | 'auth.login.form.email.required' | 'auth.login.form.email.invalid' | 'auth.login.form.password.label' | 'auth.login.form.password.placeholder' | 'auth.login.form.password.required' | 'auth.login.form.remember-me.label' | 'auth.login.form.forgot-password.label' | 'auth.login.form.submit' | 'auth.register.title' | 'auth.register.description' | 'auth.register.register-with-email' | 'auth.register.register-with-provider' | 'auth.register.providers.google' | 'auth.register.providers.github' | 'auth.register.have-account' | 'auth.register.login' | 'auth.register.registration-disabled.title' | 'auth.register.registration-disabled.description' | 'auth.register.form.email.label' | 'auth.register.form.email.placeholder' | 'auth.register.form.email.required' | 'auth.register.form.email.invalid' | 'auth.register.form.password.label' | 'auth.register.form.password.placeholder' | 'auth.register.form.password.required' | 'auth.register.form.password.min-length' | 'auth.register.form.password.max-length' | 'auth.register.form.name.label' | 'auth.register.form.name.placeholder' | 'auth.register.form.name.required' | 'auth.register.form.name.max-length' | 'auth.register.form.submit' | 'auth.email-validation-required.title' | 'auth.email-validation-required.description' | 'auth.legal-links.description' | 'auth.legal-links.terms' | 'auth.legal-links.privacy' | 'tags.no-tags.title' | 'tags.no-tags.description' | 'tags.no-tags.create-tag' | 'layout.menu.home' | 'layout.menu.documents' | 'layout.menu.tags' | 'layout.menu.tagging-rules' | 'layout.menu.integrations' | 'layout.menu.deleted-documents' | 'layout.menu.organization-settings' | 'layout.menu.api-keys' | 'layout.menu.settings' | 'layout.menu.account' | 'tagging-rules.field.name' | 'tagging-rules.field.content' | 'tagging-rules.operator.equals' | 'tagging-rules.operator.not-equals' | 'tagging-rules.operator.contains' | 'tagging-rules.operator.not-contains' | 'tagging-rules.operator.starts-with' | 'tagging-rules.operator.ends-with' | 'tagging-rules.list.title' | 'tagging-rules.list.description' | 'tagging-rules.list.demo-warning' | 'tagging-rules.list.no-tagging-rules.title' | 'tagging-rules.list.no-tagging-rules.description' | 'tagging-rules.list.no-tagging-rules.create-tagging-rule' | 'tagging-rules.list.card.no-conditions' | 'tagging-rules.list.card.one-condition' | 'tagging-rules.list.card.conditions' | 'tagging-rules.list.card.delete' | 'tagging-rules.list.card.edit' | 'tagging-rules.create.title' | 'tagging-rules.create.success' | 'tagging-rules.create.error' | 'tagging-rules.create.submit' | 'tagging-rules.form.name.label' | 'tagging-rules.form.name.placeholder' | 'tagging-rules.form.name.min-length' | 'tagging-rules.form.name.max-length' | 'tagging-rules.form.description.label' | 'tagging-rules.form.description.placeholder' | 'tagging-rules.form.description.max-length' | 'tagging-rules.form.conditions.label' | 'tagging-rules.form.conditions.description' | 'tagging-rules.form.conditions.add-condition' | 'tagging-rules.form.conditions.no-conditions.title' | 'tagging-rules.form.conditions.no-conditions.description' | 'tagging-rules.form.conditions.no-conditions.confirm' | 'tagging-rules.form.conditions.no-conditions.cancel' | 'tagging-rules.form.conditions.field.label' | 'tagging-rules.form.conditions.operator.label' | 'tagging-rules.form.conditions.value.label' | 'tagging-rules.form.conditions.value.placeholder' | 'tagging-rules.form.conditions.value.min-length' | 'tagging-rules.form.tags.label' | 'tagging-rules.form.tags.description' | 'tagging-rules.form.tags.min-length' | 'tagging-rules.form.tags.add-tag' | 'tagging-rules.form.submit' | 'tagging-rules.update.title' | 'tagging-rules.update.success' | 'tagging-rules.update.error' | 'tagging-rules.update.submit' | 'tagging-rules.update.cancel' | 'demo.popup.description' | 'demo.popup.discord' | 'demo.popup.discord-link-label' | 'demo.popup.reset' | 'demo.popup.hide' | 'trash.delete-all.button' | 'trash.delete-all.confirm.title' | 'trash.delete-all.confirm.description' | 'trash.delete-all.confirm.label' | 'trash.delete-all.confirm.cancel' | 'trash.delete.button' | 'trash.delete.confirm.title' | 'trash.delete.confirm.description' | 'trash.delete.confirm.label' | 'trash.delete.confirm.cancel' | 'trash.deleted.success.title' | 'trash.deleted.success.description' | 'import-documents.title.error' | 'import-documents.title.success' | 'import-documents.title.pending' | 'import-documents.title.none' | 'import-documents.no-import-in-progress' | 'api-errors.document.already_exists' | 'api-errors.document.file_too_big' | 'api-errors.intake_email.limit_reached' | 'api-errors.user.max_organization_count_reached' | 'api-errors.default' | 'api-keys.permissions.documents.title' | 'api-keys.permissions.documents.documents:create' | 'api-keys.permissions.documents.documents:read' | 'api-keys.permissions.documents.documents:update' | 'api-keys.permissions.documents.documents:delete' | 'api-keys.permissions.tags.title' | 'api-keys.permissions.tags.tags:create' | 'api-keys.permissions.tags.tags:read' | 'api-keys.permissions.tags.tags:update' | 'api-keys.permissions.tags.tags:delete' | 'api-keys.create.title' | 'api-keys.create.description' | 'api-keys.create.success' | 'api-keys.create.back' | 'api-keys.create.form.name.label' | 'api-keys.create.form.name.placeholder' | 'api-keys.create.form.name.required' | 'api-keys.create.form.permissions.label' | 'api-keys.create.form.permissions.description' | 'api-keys.create.form.permissions.required' | 'api-keys.create.form.submit' | 'api-keys.create.created.title' | 'api-keys.create.created.description' | 'api-keys.list.title' | 'api-keys.list.description' | 'api-keys.list.delete' | 'api-keys.list.create' | 'api-keys.list.empty.title' | 'api-keys.list.empty.description' | 'api-keys.list.card.last-used' | 'api-keys.list.card.never' | 'api-keys.list.card.created' | 'api-keys.list.card.delete' | 'api-keys.delete.success' | 'api-keys.delete.confirm.title' | 'api-keys.delete.confirm.message' | 'api-keys.delete.confirm.confirm-button' | 'api-keys.delete.confirm.cancel-button'; diff --git a/apps/papra-client/src/modules/shared/http/http-client.models.test.ts b/apps/papra-client/src/modules/shared/http/http-client.models.test.ts index 2534dc8..75c51ba 100644 --- a/apps/papra-client/src/modules/shared/http/http-client.models.test.ts +++ b/apps/papra-client/src/modules/shared/http/http-client.models.test.ts @@ -24,6 +24,8 @@ describe('http-client models', () => { createdAt: '2021-01-01T00:00:00.000Z', updatedAt: '2021-01-02T00:00:00.000Z', deletedAt: '2021-01-03T00:00:00.000Z', + expiresAt: '2021-01-04T00:00:00.000Z', + lastUsedAt: '2021-01-05T00:00:00.000Z', foo: 'bar', baz: 'qux', }; @@ -34,6 +36,8 @@ describe('http-client models', () => { createdAt: new Date('2021-01-01T00:00:00.000Z'), updatedAt: new Date('2021-01-02T00:00:00.000Z'), deletedAt: new Date('2021-01-03T00:00:00.000Z'), + expiresAt: new Date('2021-01-04T00:00:00.000Z'), + lastUsedAt: new Date('2021-01-05T00:00:00.000Z'), foo: 'bar', baz: 'qux', }); diff --git a/apps/papra-client/src/modules/shared/http/http-client.models.ts b/apps/papra-client/src/modules/shared/http/http-client.models.ts index 72bea33..92ee3d9 100644 --- a/apps/papra-client/src/modules/shared/http/http-client.models.ts +++ b/apps/papra-client/src/modules/shared/http/http-client.models.ts @@ -24,5 +24,7 @@ export function coerceDates>(obj: T): CoerceDates< ...('createdAt' in obj ? { createdAt: toDate(obj.createdAt) } : {}), ...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}), ...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}), + ...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}), + ...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}), }; } diff --git a/apps/papra-client/src/modules/ui/layouts/settings.layout.tsx b/apps/papra-client/src/modules/ui/layouts/settings.layout.tsx index b6395fd..bcc1f44 100644 --- a/apps/papra-client/src/modules/ui/layouts/settings.layout.tsx +++ b/apps/papra-client/src/modules/ui/layouts/settings.layout.tsx @@ -1,34 +1,47 @@ import type { ParentComponent } from 'solid-js'; +import { useI18n } from '@/modules/i18n/i18n.provider'; import { A } from '@solidjs/router'; import { Button } from '../components/button'; +import { SideNav } from './sidenav.layout'; export const SettingsLayout: ParentComponent = (props) => { - const navigation = [ + const { t } = useI18n(); + + const getMainMenuItems = () => [ { - name: 'Account', - href: '/settings/account', + label: t('layout.menu.account'), + icon: 'i-tabler-user', + href: '/settings', }, { - name: 'Billing & Plan', - href: '/settings/billing', + label: t('layout.menu.api-keys'), + icon: 'i-tabler-key', + href: '/api-keys', }, ]; return ( -
-
-

Settings

-
+
+ +
+ {props.children} +
); }; diff --git a/apps/papra-client/src/modules/ui/layouts/sidenav.layout.tsx b/apps/papra-client/src/modules/ui/layouts/sidenav.layout.tsx index 9a329df..adf89b1 100644 --- a/apps/papra-client/src/modules/ui/layouts/sidenav.layout.tsx +++ b/apps/papra-client/src/modules/ui/layouts/sidenav.layout.tsx @@ -242,6 +242,11 @@ export const SidenavLayout: ParentComponent<{ Account settings + +
+ API keys +
+
diff --git a/apps/papra-client/src/modules/users/pages/user-settings.page.tsx b/apps/papra-client/src/modules/users/pages/user-settings.page.tsx index 5201f97..4d6d966 100644 --- a/apps/papra-client/src/modules/users/pages/user-settings.page.tsx +++ b/apps/papra-client/src/modules/users/pages/user-settings.page.tsx @@ -4,7 +4,7 @@ import { Button } from '@/modules/ui/components/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card'; import { createToast } from '@/modules/ui/components/sonner'; import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; -import { A, useNavigate } from '@solidjs/router'; +import { useNavigate } from '@solidjs/router'; import { createQuery } from '@tanstack/solid-query'; import { type Component, createSignal, Show, Suspense } from 'solid-js'; import * as v from 'valibot'; @@ -127,18 +127,15 @@ export const UserSettingsPage: Component = () => { })); return ( -
+
{getUser => ( <> - - -

User settings

-

Manage your account settings here.

+
+

User settings

+

Manage your account settings here.

+
diff --git a/apps/papra-client/src/routes.tsx b/apps/papra-client/src/routes.tsx index 7f69613..57f9954 100644 --- a/apps/papra-client/src/routes.tsx +++ b/apps/papra-client/src/routes.tsx @@ -1,6 +1,8 @@ import { Navigate, type RouteDefinition, useParams } from '@solidjs/router'; import { createQuery } from '@tanstack/solid-query'; import { Match, Show, Suspense, Switch } from 'solid-js'; +import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page'; +import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page'; import { createProtectedPage } from './modules/auth/middleware/protected-page.middleware'; import { EmailValidationRequiredPage } from './modules/auth/pages/email-validation-required.page'; import { LoginPage } from './modules/auth/pages/login.page'; @@ -25,6 +27,7 @@ import { UpdateTaggingRulePage } from './modules/tagging-rules/pages/update-tagg import { TagsPage } from './modules/tags/pages/tags.page'; import { IntegrationsLayout } from './modules/ui/layouts/integrations.layout'; import { OrganizationLayout } from './modules/ui/layouts/organization.layout'; +import { SettingsLayout } from './modules/ui/layouts/settings.layout'; import { CurrentUserProvider, useCurrentUser } from './modules/users/composables/useCurrentUser'; import { UserSettingsPage } from './modules/users/pages/user-settings.page'; @@ -155,8 +158,22 @@ export const routes: RouteDefinition[] = [ ], }, { - path: '/settings', - component: UserSettingsPage, + path: '/', + component: SettingsLayout, + children: [ + { + path: '/settings', + component: UserSettingsPage, + }, + { + path: '/api-keys', + component: ApiKeysPage, + }, + { + path: '/api-keys/create', + component: CreateApiKeyPage, + }, + ], }, { path: '/login', diff --git a/apps/papra-client/vite.config.ts b/apps/papra-client/vite.config.ts index fd7eb37..cd69515 100644 --- a/apps/papra-client/vite.config.ts +++ b/apps/papra-client/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ server: { port: 3000, proxy: { - '/api': { + '/api/': { target: 'http://localhost:1221', }, }, diff --git a/apps/papra-server/migrations/0003_api-keys.sql b/apps/papra-server/migrations/0003_api-keys.sql new file mode 100644 index 0000000..0aaadbd --- /dev/null +++ b/apps/papra-server/migrations/0003_api-keys.sql @@ -0,0 +1,24 @@ +CREATE TABLE `api_key_organizations` ( + `api_key_id` text NOT NULL, + `organization_member_id` text NOT NULL, + FOREIGN KEY (`api_key_id`) REFERENCES `api_keys`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`organization_member_id`) REFERENCES `organization_members`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `name` text NOT NULL, + `key_hash` text NOT NULL, + `prefix` text NOT NULL, + `user_id` text NOT NULL, + `last_used_at` integer, + `expires_at` integer, + `permissions` text DEFAULT '[]' NOT NULL, + `all_organizations` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint +CREATE INDEX `key_hash_index` ON `api_keys` (`key_hash`); \ No newline at end of file diff --git a/apps/papra-server/migrations/meta/0003_snapshot.json b/apps/papra-server/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..d918442 --- /dev/null +++ b/apps/papra-server/migrations/meta/0003_snapshot.json @@ -0,0 +1,1612 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5986ec37-a9d8-46a4-8669-e0650dac4726", + "prevId": "6d328e8d-0cb2-440d-beb4-ebb9b92370e6", + "tables": { + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_size": { + "name": "original_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "original_storage_key": { + "name": "original_storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_sha256_hash": { + "name": "original_sha256_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": { + "documents_organization_id_is_deleted_created_at_index": { + "name": "documents_organization_id_is_deleted_created_at_index", + "columns": [ + "organization_id", + "is_deleted", + "created_at" + ], + "isUnique": false + }, + "documents_organization_id_is_deleted_index": { + "name": "documents_organization_id_is_deleted_index", + "columns": [ + "organization_id", + "is_deleted" + ], + "isUnique": false + }, + "documents_organization_id_original_sha256_hash_unique": { + "name": "documents_organization_id_original_sha256_hash_unique", + "columns": [ + "organization_id", + "original_sha256_hash" + ], + "isUnique": true + }, + "documents_original_sha256_hash_index": { + "name": "documents_original_sha256_hash_index", + "columns": [ + "original_sha256_hash" + ], + "isUnique": false + }, + "documents_organization_id_size_index": { + "name": "documents_organization_id_size_index", + "columns": [ + "organization_id", + "original_size" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_organization_id_organizations_id_fk": { + "name": "documents_organization_id_organizations_id_fk", + "tableFrom": "documents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "documents_created_by_users_id_fk": { + "name": "documents_created_by_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "documents_deleted_by_users_id_fk": { + "name": "documents_deleted_by_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "deleted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_invitations": { + "name": "organization_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_invitations_organization_id_organizations_id_fk": { + "name": "organization_invitations_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "organization_invitations_inviter_id_users_id_fk": { + "name": "organization_invitations_inviter_id_users_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_user_organization_unique": { + "name": "organization_members_user_organization_unique", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_roles_role_index": { + "name": "user_roles_role_index", + "columns": [ + "role" + ], + "isUnique": false + }, + "user_roles_user_id_role_unique_index": { + "name": "user_roles_user_id_role_unique_index", + "columns": [ + "user_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents_tags": { + "name": "documents_tags", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_tags_document_id_documents_id_fk": { + "name": "documents_tags_document_id_documents_id_fk", + "tableFrom": "documents_tags", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "documents_tags_tag_id_tags_id_fk": { + "name": "documents_tags_tag_id_tags_id_fk", + "tableFrom": "documents_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "documents_tags_pkey": { + "columns": [ + "document_id", + "tag_id" + ], + "name": "documents_tags_pkey" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tags_organization_id_name_unique": { + "name": "tags_organization_id_name_unique", + "columns": [ + "organization_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tags_organization_id_organizations_id_fk": { + "name": "tags_organization_id_organizations_id_fk", + "tableFrom": "tags", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_organization_count": { + "name": "max_organization_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key_organizations": { + "name": "api_key_organizations", + "columns": { + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_member_id": { + "name": "organization_member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_organizations_api_key_id_api_keys_id_fk": { + "name": "api_key_organizations_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_organizations", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "api_key_organizations_organization_member_id_organization_members_id_fk": { + "name": "api_key_organizations_organization_member_id_organization_members_id_fk", + "tableFrom": "api_key_organizations", + "tableTo": "organization_members", + "columnsFrom": [ + "organization_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "all_organizations": { + "name": "all_organizations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "key_hash_index": { + "name": "key_hash_index", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_accounts": { + "name": "auth_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_users_id_fk": { + "name": "auth_accounts_user_id_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "auth_sessions_token_index": { + "name": "auth_sessions_token_index", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auth_sessions_active_organization_id_organizations_id_fk": { + "name": "auth_sessions_active_organization_id_organizations_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_verifications": { + "name": "auth_verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_verifications_identifier_index": { + "name": "auth_verifications_identifier_index", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "intake_emails": { + "name": "intake_emails", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_origins": { + "name": "allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "intake_emails_email_address_unique": { + "name": "intake_emails_email_address_unique", + "columns": [ + "email_address" + ], + "isUnique": true + } + }, + "foreignKeys": { + "intake_emails_organization_id_organizations_id_fk": { + "name": "intake_emails_organization_id_organizations_id_fk", + "tableFrom": "intake_emails", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_subscriptions": { + "name": "organization_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seats_count": { + "name": "seats_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_subscriptions_organization_id_organizations_id_fk": { + "name": "organization_subscriptions_organization_id_organizations_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rule_actions": { + "name": "tagging_rule_actions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagging_rule_id": { + "name": "tagging_rule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rule_actions_tagging_rule_id_tagging_rules_id_fk": { + "name": "tagging_rule_actions_tagging_rule_id_tagging_rules_id_fk", + "tableFrom": "tagging_rule_actions", + "tableTo": "tagging_rules", + "columnsFrom": [ + "tagging_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "tagging_rule_actions_tag_id_tags_id_fk": { + "name": "tagging_rule_actions_tag_id_tags_id_fk", + "tableFrom": "tagging_rule_actions", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rule_conditions": { + "name": "tagging_rule_conditions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagging_rule_id": { + "name": "tagging_rule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_case_sensitive": { + "name": "is_case_sensitive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rule_conditions_tagging_rule_id_tagging_rules_id_fk": { + "name": "tagging_rule_conditions_tagging_rule_id_tagging_rules_id_fk", + "tableFrom": "tagging_rule_conditions", + "tableTo": "tagging_rules", + "columnsFrom": [ + "tagging_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rules": { + "name": "tagging_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rules_organization_id_organizations_id_fk": { + "name": "tagging_rules_organization_id_organizations_id_fk", + "tableFrom": "tagging_rules", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/papra-server/migrations/meta/_journal.json b/apps/papra-server/migrations/meta/_journal.json index 49a9d8c..b598a1c 100644 --- a/apps/papra-server/migrations/meta/_journal.json +++ b/apps/papra-server/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1743938048080, "tag": "0002_tagging_rules", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1745131802627, + "tag": "0003_api-keys", + "breakpoints": true } ] } diff --git a/apps/papra-server/package.json b/apps/papra-server/package.json index 27fc816..46ab260 100644 --- a/apps/papra-server/package.json +++ b/apps/papra-server/package.json @@ -51,6 +51,7 @@ "hono": "^4.6.15", "lodash-es": "^4.17.21", "mime-types": "^3.0.1", + "nanoid": "^5.1.5", "node-cron": "^3.0.3", "p-limit": "^6.2.0", "p-queue": "^8.1.0", diff --git a/apps/papra-server/src/modules/api-keys/api-keys.constants.ts b/apps/papra-server/src/modules/api-keys/api-keys.constants.ts new file mode 100644 index 0000000..dbce4d7 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.constants.ts @@ -0,0 +1,19 @@ +export const API_KEY_PREFIX = 'ppapi'; +export const API_KEY_TOKEN_LENGTH = 64; + +export const API_KEY_PERMISSIONS = { + DOCUMENTS: { + CREATE: 'documents:create', + READ: 'documents:read', + UPDATE: 'documents:update', + DELETE: 'documents:delete', + }, + TAGS: { + CREATE: 'tags:create', + READ: 'tags:read', + UPDATE: 'tags:update', + DELETE: 'tags:delete', + }, +} as const; + +export const API_KEY_PERMISSIONS_VALUES = Object.values(API_KEY_PERMISSIONS).flatMap(permissions => Object.values(permissions)); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.middlewares.ts b/apps/papra-server/src/modules/api-keys/api-keys.middlewares.ts new file mode 100644 index 0000000..3d89142 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.middlewares.ts @@ -0,0 +1,43 @@ +import type { Database } from '../app/database/database.types'; +import type { Context } from '../app/server.types'; +import { createMiddleware } from 'hono/factory'; +import { createUnauthorizedError } from '../app/auth/auth.errors'; +import { getAuthorizationHeader } from '../shared/headers/headers.models'; +import { createApiKeysRepository } from './api-keys.repository'; +import { getApiKey } from './api-keys.usecases'; + +// The role of this middleware is to extract the api key from the authorization header if present +// and set it on the context, no auth enforcement is done here +export function createApiKeyMiddleware({ db }: { db: Database }) { + const apiKeyRepository = createApiKeysRepository({ db }); + + return createMiddleware(async (context: Context, next) => { + const { authorizationHeader } = getAuthorizationHeader({ context }); + + if (!authorizationHeader) { + return next(); + } + + const parts = authorizationHeader.split(' '); + + if (parts.length !== 2) { + throw createUnauthorizedError(); + } + + const [maybeBearer, token] = parts; + + if (maybeBearer !== 'Bearer') { + throw createUnauthorizedError(); + } + + const { apiKey } = await getApiKey({ token, apiKeyRepository }); + + if (apiKey) { + context.set('apiKey', apiKey); + context.set('userId', apiKey.userId); + context.set('authType', 'api-key'); + } + + await next(); + }); +} diff --git a/apps/papra-server/src/modules/api-keys/api-keys.models.test.ts b/apps/papra-server/src/modules/api-keys/api-keys.models.test.ts new file mode 100644 index 0000000..c6958c3 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.models.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest'; +import { getApiKeyUiPrefix } from './api-keys.models'; + +describe('api-keys models', () => { + describe('getApiKeyUiPrefix', () => { + test('the prefix is what the user will see in the ui in order to identify the api key, it is the first 5 characters of the token regardless of the token prefix', () => { + expect( + getApiKeyUiPrefix({ token: 'ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD' }), + ).to.eql( + { prefix: 'ppapi_29qxv' }, + ); + }); + }); +}); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.models.ts b/apps/papra-server/src/modules/api-keys/api-keys.models.ts new file mode 100644 index 0000000..d37bf6d --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.models.ts @@ -0,0 +1,14 @@ +import { sha256 } from '../shared/crypto/hash'; +import { API_KEY_PREFIX } from './api-keys.constants'; + +export function getApiKeyUiPrefix({ token }: { token: string }) { + return { + prefix: token.slice(0, 5 + API_KEY_PREFIX.length + 1), + }; +} + +export function getApiKeyHash({ token }: { token: string }) { + return { + keyHash: sha256(token, { digest: 'base64url' }), + }; +} diff --git a/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts b/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts new file mode 100644 index 0000000..07ef66a --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabase } from '../app/database/database.test-utils'; +import { ORGANIZATION_ROLES } from '../organizations/organizations.constants'; +import { createApiKeysRepository } from './api-keys.repository'; + +describe('api-keys repository', () => { + describe('getUserApiKeys', () => { + test('when retrieving api keys, it returns the api keys with the organizations they are linked to', async () => { + const { db } = await createInMemoryDatabase({ + users: [ + { id: 'user-1', email: 'user-1@example.com' }, + ], + organizations: [ + { id: 'organization-1', name: 'Organization 1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-02') }, + { id: 'organization-2', name: 'Organization 2', createdAt: new Date('2021-02-01'), updatedAt: new Date('2021-02-02') }, + ], + organizationMembers: [ + { id: 'om-1', organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }, + ], + apiKeys: [ + { id: 'api-key-1', userId: 'user-1', name: 'API Key 1', prefix: 'ak', keyHash: 'hash1', createdAt: new Date('2021-03-01'), updatedAt: new Date('2021-03-02') }, + ], + apiKeyOrganizations: [ + { apiKeyId: 'api-key-1', organizationMemberId: 'om-1' }, + ], + }); + + const repository = createApiKeysRepository({ db }); + + const { apiKeys } = await repository.getUserApiKeys({ userId: 'user-1' }); + + expect(apiKeys).to.eql([ + { + id: 'api-key-1', + userId: 'user-1', + name: 'API Key 1', + allOrganizations: false, + prefix: 'ak', + lastUsedAt: null, + expiresAt: null, + organizations: [{ + id: 'organization-1', + name: 'Organization 1', + customerId: null, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-02'), + }], + createdAt: new Date('2021-03-01'), + updatedAt: new Date('2021-03-02'), + permissions: [], + }, + ]); + }); + }); +}); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.repository.ts b/apps/papra-server/src/modules/api-keys/api-keys.repository.ts new file mode 100644 index 0000000..68c3e57 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.repository.ts @@ -0,0 +1,149 @@ +import type { Database } from '../app/database/database.types'; +import type { Logger } from '../shared/logger/logger'; +import type { ApiKeyPermissions } from './api-keys.types'; +import { injectArguments } from '@corentinth/chisels'; +import { and, eq, getTableColumns, inArray } from 'drizzle-orm'; +import { omit, pick } from 'lodash-es'; +import { organizationMembersTable, organizationsTable } from '../organizations/organizations.table'; +import { createLogger } from '../shared/logger/logger'; +import { apiKeyOrganizationsTable, apiKeysTable } from './api-keys.tables'; + +export type ApiKeysRepository = ReturnType; + +export function createApiKeysRepository({ db, logger = createLogger({ namespace: 'api-keys.repository' }) }: { db: Database; logger?: Logger }) { + return injectArguments( + { + saveApiKey, + getUserApiKeys, + deleteUserApiKey, + getApiKeyByHash, + }, + { db, logger }, + ); +} + +async function saveApiKey({ + db, + logger, + name, + keyHash, + prefix, + permissions, + allOrganizations, + userId, + organizationIds, + expiresAt, +}: { + db: Database; + logger: Logger; + name: string; + keyHash: string; + prefix: string; + permissions: ApiKeyPermissions[]; + allOrganizations: boolean; + organizationIds: string[] | undefined; + expiresAt?: Date; + userId: string; +}) { + const [apiKey] = await db + .insert(apiKeysTable) + .values({ + name, + keyHash, + prefix, + permissions, + allOrganizations, + userId, + expiresAt, + }) + .returning(); + + if (organizationIds && organizationIds.length > 0) { + const apiKeyId = apiKey.id; + + const organizationMembers = await db + .select() + .from(organizationMembersTable) + .where( + and( + inArray(organizationMembersTable.organizationId, organizationIds), + eq(organizationMembersTable.userId, userId), + ), + ); + + if (!organizationIds.every(id => organizationMembers.some(om => om.organizationId === id))) { + logger.warn({ + userId, + organizationIds, + organizationMembers: organizationMembers.map(om => pick(om, ['id', 'organizationId', 'userId', 'role'])), + }, 'Api key created for organization that the user is not part of'); + } + + await db + .insert(apiKeyOrganizationsTable) + .values( + organizationMembers.map(({ id: organizationMemberId }) => ({ apiKeyId, organizationMemberId })), + ); + } + + return { apiKey }; +} + +async function getUserApiKeys({ userId, db }: { userId: string; db: Database }) { + const apiKeys = await db + .select({ + ...omit(getTableColumns(apiKeysTable), 'keyHash'), + }) + .from(apiKeysTable) + .where( + eq(apiKeysTable.userId, userId), + ); + + const relatedOrganizations = await db + .select({ + ...getTableColumns(organizationsTable), + apiKeyId: apiKeyOrganizationsTable.apiKeyId, + }) + .from(apiKeyOrganizationsTable) + .leftJoin(organizationMembersTable, eq(apiKeyOrganizationsTable.organizationMemberId, organizationMembersTable.id)) + .leftJoin(organizationsTable, eq(organizationMembersTable.organizationId, organizationsTable.id)) + .where( + and( + inArray(apiKeyOrganizationsTable.apiKeyId, apiKeys.map(apiKey => apiKey.id)), + eq(organizationMembersTable.userId, userId), + ), + ); + + const apiKeysWithOrganizations = apiKeys.map(apiKey => ({ + ...apiKey, + organizations: relatedOrganizations + .filter(organization => organization.apiKeyId === apiKey.id) + .map(({ apiKeyId: _, ...organization }) => organization), + })); + + return { + apiKeys: apiKeysWithOrganizations, + }; +} + +async function deleteUserApiKey({ apiKeyId, userId, db }: { apiKeyId: string; userId: string; db: Database }) { + await db + .delete(apiKeysTable) + .where( + and( + eq(apiKeysTable.id, apiKeyId), + eq(apiKeysTable.userId, userId), + ), + ); +} + +async function getApiKeyByHash({ keyHash, db }: { keyHash: string; db: Database }) { + const [apiKey] = await db + .select() + .from(apiKeysTable) + .where( + eq(apiKeysTable.keyHash, keyHash), + ); + + return { apiKey }; +} diff --git a/apps/papra-server/src/modules/api-keys/api-keys.routes.ts b/apps/papra-server/src/modules/api-keys/api-keys.routes.ts new file mode 100644 index 0000000..2ea0df8 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.routes.ts @@ -0,0 +1,99 @@ +import type { RouteDefinitionContext } from '../app/server.types'; +import type { ApiKeyPermissions } from './api-keys.types'; +import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; +import { getUser } from '../app/auth/auth.models'; +import { createError } from '../shared/errors/errors'; +import { validateJsonBody } from '../shared/validation/validation'; +import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants'; +import { createApiKeysRepository } from './api-keys.repository'; +import { createApiKey } from './api-keys.usecases'; + +export function registerApiKeysRoutes(context: RouteDefinitionContext) { + setupCreateApiKeyRoute(context); + setupGetApiKeysRoute(context); + setupDeleteApiKeyRoute(context); +} + +function setupCreateApiKeyRoute({ app, db }: RouteDefinitionContext) { + app.post( + '/api/api-keys', + requireAuthentication(), + validateJsonBody( + z.object({ + name: z.string(), + permissions: z.array(z.enum(API_KEY_PERMISSIONS_VALUES as [ApiKeyPermissions, ...ApiKeyPermissions[]])).min(1), + organizationIds: z.array(z.string()).default([]), + allOrganizations: z.boolean().default(false), + expiresAt: z.date().optional(), + }), + ), + async (context) => { + const { userId } = getUser({ context }); + const apiKeyRepository = createApiKeysRepository({ db }); + + const { + name, + permissions, + organizationIds, + allOrganizations, + expiresAt, + } = context.req.valid('json'); + + if (allOrganizations && organizationIds.length > 0) { + throw createError({ + code: 'api_keys.invalid_organization_ids', + message: 'No organizationIds should be provided if allOrganizations is true', + statusCode: 400, + }); + } + + const { apiKey, token } = await createApiKey({ + name, + permissions, + organizationIds, + allOrganizations, + expiresAt, + userId, + apiKeyRepository, + }); + + return context.json({ + apiKey, + token, + }); + }, + ); +} + +function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) { + app.get( + '/api/api-keys', + requireAuthentication(), + async (context) => { + const { userId } = getUser({ context }); + const apiKeyRepository = createApiKeysRepository({ db }); + + const { apiKeys } = await apiKeyRepository.getUserApiKeys({ userId }); + + return context.json({ apiKeys }); + }, + ); +} + +function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) { + app.delete( + '/api/api-keys/:apiKeyId', + requireAuthentication(), + async (context) => { + const { userId } = getUser({ context }); + const apiKeyRepository = createApiKeysRepository({ db }); + + const { apiKeyId } = context.req.param(); + + await apiKeyRepository.deleteUserApiKey({ apiKeyId, userId }); + + return context.body(null, 204); + }, + ); +} diff --git a/apps/papra-server/src/modules/api-keys/api-keys.services.test.ts b/apps/papra-server/src/modules/api-keys/api-keys.services.test.ts new file mode 100644 index 0000000..7e2f58f --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.services.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'vitest'; +import { generateApiToken } from './api-keys.services'; + +describe('api-keys services', () => { + describe('generateApiToken', () => { + test('api token is a 64 random alphanumeric characters string prefixed with "ppapi_",', () => { + const { token } = generateApiToken(); + + expect(token).toMatch(/^ppapi_[a-zA-Z0-9]{64}$/); + }); + }); +}); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.services.ts b/apps/papra-server/src/modules/api-keys/api-keys.services.ts new file mode 100644 index 0000000..419d34d --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.services.ts @@ -0,0 +1,8 @@ +import { generateToken } from '../shared/random/random.services'; +import { API_KEY_PREFIX, API_KEY_TOKEN_LENGTH } from './api-keys.constants'; + +export function generateApiToken() { + const { token } = generateToken({ length: API_KEY_TOKEN_LENGTH }); + + return { token: `${API_KEY_PREFIX}_${token}` }; +} diff --git a/apps/papra-server/src/modules/api-keys/api-keys.tables.ts b/apps/papra-server/src/modules/api-keys/api-keys.tables.ts new file mode 100644 index 0000000..8e8b97b --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.tables.ts @@ -0,0 +1,42 @@ +import type { ApiKeyPermissions } from './api-keys.types'; + +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { organizationMembersTable } from '../organizations/organizations.table'; +import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers'; +import { usersTable } from '../users/users.table'; + +export const apiKeysTable = sqliteTable( + 'api_keys', + { + ...createPrimaryKeyField({ prefix: 'ak' }), + ...createTimestampColumns(), + + name: text('name').notNull(), + keyHash: text('key_hash').notNull().unique(), + // the prefix is used to identify the key, it is the + prefix: text('prefix').notNull(), + userId: text('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }), + permissions: text('permissions', { mode: 'json' }).notNull().$type().default([]), + allOrganizations: integer('all_organizations', { mode: 'boolean' }).notNull().default(false), + }, + table => [ + // To get an API key by its token + index('key_hash_index').on(table.keyHash), + ], +); + +// We use an intermediate table (instead of a json array) to link API keys to organization members, so the relationship between +// the api key and the organization is deleted on cascade when the organization is deleted or the member is removed from the organization. +export const apiKeyOrganizationsTable = sqliteTable('api_key_organizations', { + apiKeyId: text('api_key_id') + .notNull() + .references(() => apiKeysTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + + organizationMemberId: text('organization_member_id') + .notNull() + .references(() => organizationMembersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), +}); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.types.ts b/apps/papra-server/src/modules/api-keys/api-keys.types.ts new file mode 100644 index 0000000..ff4b197 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.types.ts @@ -0,0 +1,6 @@ +import type { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants'; +import type { apiKeysTable } from './api-keys.tables'; + +export type ApiKeyPermissions = (typeof API_KEY_PERMISSIONS_VALUES)[number]; + +export type ApiKey = typeof apiKeysTable.$inferSelect; diff --git a/apps/papra-server/src/modules/api-keys/api-keys.usecases.test.ts b/apps/papra-server/src/modules/api-keys/api-keys.usecases.test.ts new file mode 100644 index 0000000..deab0c2 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.usecases.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabase } from '../app/database/database.test-utils'; +import { createApiKeysRepository } from './api-keys.repository'; +import { createApiKey, getApiKey } from './api-keys.usecases'; + +describe('api-keys usecases', () => { + describe('createApiKey', () => { + test('the api key is created with the correct hash', async () => { + const { db } = await createInMemoryDatabase({ + users: [ + { id: 'user_1', email: 'test@test.com' }, + ], + }); + const apiKeyRepository = createApiKeysRepository({ db }); + + const { apiKey, token } = await createApiKey({ + name: 'test', + userId: 'user_1', + permissions: ['documents:create'], + organizationIds: [], + allOrganizations: false, + apiKeyRepository, + generateApiToken: () => ({ token: 'ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0' }), + }); + + expect(apiKey).to.deep.include({ + name: 'test', + permissions: ['documents:create'], + keyHash: 'ExkPP3tmeg55u7ObhGuMOywnfkbLVGYE2VBxMj8koB4', + userId: 'user_1', + prefix: 'ppapi_HT2Hj', + }); + expect(token).to.eql('ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0'); + }); + }); + + describe('getApiKey', () => { + test('an api key can be retrieved by its token', async () => { + const { db } = await createInMemoryDatabase({ + users: [ + { id: 'user_1', email: 'test@test.com' }, + ], + apiKeys: [ + { id: 'api_key_1', keyHash: 'ExkPP3tmeg55u7ObhGuMOywnfkbLVGYE2VBxMj8koB4', userId: 'user_1', prefix: 'ppapi_HT2Hj', name: 'test' }, + ], + }); + const apiKeyRepository = createApiKeysRepository({ db }); + + const { apiKey } = await getApiKey({ token: 'ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0', apiKeyRepository }); + + expect(apiKey.id).to.eql('api_key_1'); + }); + }); +}); diff --git a/apps/papra-server/src/modules/api-keys/api-keys.usecases.ts b/apps/papra-server/src/modules/api-keys/api-keys.usecases.ts new file mode 100644 index 0000000..786e06c --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/api-keys.usecases.ts @@ -0,0 +1,53 @@ +import type { ApiKeysRepository } from './api-keys.repository'; +import type { ApiKeyPermissions } from './api-keys.types'; +import { getApiKeyHash, getApiKeyUiPrefix } from './api-keys.models'; +import { generateApiToken as generateApiTokenImpl } from './api-keys.services'; + +export async function createApiKey({ + name, + userId, + permissions, + organizationIds, + allOrganizations, + expiresAt, + apiKeyRepository, + generateApiToken = generateApiTokenImpl, +}: { + name: string; + userId: string; + permissions: ApiKeyPermissions[]; + organizationIds: string[]; + allOrganizations: boolean; + expiresAt?: Date; + apiKeyRepository: ApiKeysRepository; + generateApiToken?: () => { token: string }; +}) { + const { token } = generateApiToken(); + + const { prefix } = getApiKeyUiPrefix({ token }); + const { keyHash } = getApiKeyHash({ token }); + + const { apiKey } = await apiKeyRepository.saveApiKey({ + name, + permissions, + keyHash, + organizationIds, + allOrganizations, + expiresAt, + userId, + prefix, + }); + + return { + apiKey, + token, + }; +} + +export async function getApiKey({ token, apiKeyRepository }: { token: string; apiKeyRepository: ApiKeysRepository }) { + const { keyHash } = getApiKeyHash({ token }); + + const apiKey = await apiKeyRepository.getApiKeyByHash({ keyHash }); + + return apiKey; +} diff --git a/apps/papra-server/src/modules/api-keys/e2e/create-document-with-api-key.e2e.test.ts b/apps/papra-server/src/modules/api-keys/e2e/create-document-with-api-key.e2e.test.ts new file mode 100644 index 0000000..e44bdc3 --- /dev/null +++ b/apps/papra-server/src/modules/api-keys/e2e/create-document-with-api-key.e2e.test.ts @@ -0,0 +1,88 @@ +import type { Document } from '../../documents/documents.types'; +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabase } from '../../app/database/database.test-utils'; +import { createServer } from '../../app/server'; +import { overrideConfig } from '../../config/config.test-utils'; +import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants'; + +describe('api-key e2e', () => { + test('one can create a document using an api key', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }], + organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }], + organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }], + }); + + const { app } = await createServer({ + db, + config: overrideConfig({ + env: 'test', + documentsStorage: { + driver: 'in-memory', + }, + }), + }); + + const createApiKeyResponse = await app.request( + '/api/api-keys', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Test API Key', + permissions: ['documents:create'], + }), + }, + { loggedInUserId: 'usr_111111111111111111111111' }, + ); + + expect(createApiKeyResponse.status).toBe(200); + const { token } = await createApiKeyResponse.json() as { token: string }; + + const formData = new FormData(); + formData.append('file', new File(['test'], 'invoice.txt', { type: 'text/plain' })); + const bodyResponse = new Response(formData); + + const createDocumentResponse = await app.request('/api/organizations/org_222222222222222222222222/documents', { + method: 'POST', + headers: { + ...Object.fromEntries(bodyResponse.headers.entries()), + Authorization: `Bearer ${token}`, + }, + body: await bodyResponse.arrayBuffer(), + }); + + expect(createDocumentResponse.status).toBe(200); + const { document } = await createDocumentResponse.json() as { document: Document }; + + expect(document).to.deep.include({ + isDeleted: false, + deletedAt: null, + organizationId: 'org_222222222222222222222222', + createdBy: 'usr_111111111111111111111111', + deletedBy: null, + originalName: 'invoice.txt', + originalSize: 4, + originalSha256Hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + name: 'invoice.txt', + mimeType: 'text/plain', + content: 'test', + }); + + const fetchDocumentResponse = await app.request(`/api/organizations/org_222222222222222222222222/documents/${document.id}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // The api key is not authorized to read the document, only documents:create is granted + expect(fetchDocumentResponse.status).toBe(401); + expect(await fetchDocumentResponse.json()).to.eql({ + error: { + code: 'auth.unauthorized', + message: 'Unauthorized', + }, + }); + }); +}); diff --git a/apps/papra-server/src/modules/app/auth/auth.middleware.ts b/apps/papra-server/src/modules/app/auth/auth.middleware.ts new file mode 100644 index 0000000..d81e306 --- /dev/null +++ b/apps/papra-server/src/modules/app/auth/auth.middleware.ts @@ -0,0 +1,22 @@ +import type { ApiKeyPermissions } from '../../api-keys/api-keys.types'; +import type { Context } from '../server.types'; +import { createMiddleware } from 'hono/factory'; +import { createUnauthorizedError } from './auth.errors'; +import { isAuthenticationValid } from './auth.models'; + +export function requireAuthentication({ apiKeyPermissions }: { apiKeyPermissions?: ApiKeyPermissions[] } = {}) { + return createMiddleware(async (context: Context, next) => { + const isAuthenticated = isAuthenticationValid({ + authType: context.get('authType'), + session: context.get('session'), + apiKey: context.get('apiKey'), + requiredApiKeyPermissions: apiKeyPermissions, + }); + + if (!isAuthenticated) { + throw createUnauthorizedError(); + } + + await next(); + }); +} diff --git a/apps/papra-server/src/modules/app/auth/auth.models.test.ts b/apps/papra-server/src/modules/app/auth/auth.models.test.ts index 19672d5..595eb02 100644 --- a/apps/papra-server/src/modules/app/auth/auth.models.test.ts +++ b/apps/papra-server/src/modules/app/auth/auth.models.test.ts @@ -1,6 +1,8 @@ +import type { ApiKey, ApiKeyPermissions } from '../../api-keys/api-keys.types'; import type { Config } from '../../config/config.types'; +import type { Session } from './auth.types'; import { describe, expect, test } from 'vitest'; -import { getTrustedOrigins } from './auth.models'; +import { getTrustedOrigins, isAuthenticationValid } from './auth.models'; describe('auth models', () => { describe('getTrustedOrigins', () => { @@ -45,4 +47,108 @@ describe('auth models', () => { ]); }); }); + + describe('checkAuthentication', () => { + describe('coherence checks', () => { + test('when the auth type is null, the authentication is invalid', () => { + expect(isAuthenticationValid({ + authType: null, + })).to.eql(false); + }); + + test('when the auth type is api-key, the apiKey is required', () => { + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: null, + session: null, + })).to.eql(false); + }); + + test('when the auth type is session, the session is required', () => { + expect(isAuthenticationValid({ + authType: 'session', + apiKey: null, + session: null, + })).to.eql(false); + }); + + test('when the auth type is api-key, the session is not allowed', () => { + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: {} as ApiKey, + session: {} as Session, + })).to.eql(false); + }); + + test('when the auth type is session, the apiKey is not allowed', () => { + expect(isAuthenticationValid({ + authType: 'session', + apiKey: {} as ApiKey, + session: {} as Session, + })).to.eql(false); + }); + + test('when the auth type is api-key, the requiredApiKeyPermissions are required', () => { + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: {} as ApiKey, + session: null, + })).to.eql(false); + }); + + test('when both the apiKey and the session are provided, the authentication is invalid', () => { + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: {} as ApiKey, + session: {} as Session, + })).to.eql(false); + }); + }); + + test('when the auth type is api-key, at least one permission must match', () => { + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: { + permissions: [] as ApiKeyPermissions[], + } as ApiKey, + requiredApiKeyPermissions: ['documents:create'], + })).to.eql(false); + + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: { + permissions: ['documents:create'], + } as ApiKey, + requiredApiKeyPermissions: ['documents:create'], + })).to.eql(true); + + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: { + permissions: ['documents:create'], + } as ApiKey, + requiredApiKeyPermissions: ['documents:read'], + })).to.eql(false); + + expect(isAuthenticationValid({ + authType: 'api-key', + apiKey: { + permissions: ['documents:create'], + } as ApiKey, + requiredApiKeyPermissions: ['documents:create', 'documents:read'], + })).to.eql(true); + }); + + test('when the auth type is session, the session should exist', () => { + expect(isAuthenticationValid({ + authType: 'session', + session: null, + })).to.eql(false); + + expect(isAuthenticationValid({ + authType: 'session', + session: {} as Session, + })).to.eql(true); + }); + }); }); diff --git a/apps/papra-server/src/modules/app/auth/auth.models.ts b/apps/papra-server/src/modules/app/auth/auth.models.ts index ab6edb4..2071350 100644 --- a/apps/papra-server/src/modules/app/auth/auth.models.ts +++ b/apps/papra-server/src/modules/app/auth/auth.models.ts @@ -1,12 +1,16 @@ +import type { ApiKey, ApiKeyPermissions } from '../../api-keys/api-keys.types'; import type { Config } from '../../config/config.types'; import type { Context } from '../server.types'; +import type { Session } from './auth.types'; import { uniq } from 'lodash-es'; import { createError } from '../../shared/errors/errors'; export function getUser({ context }: { context: Context }) { - const user = context.get('user'); + const userId = context.get('userId'); - if (!user) { + if (!userId) { + // This should never happen as getUser is called in authenticated routes + // just for proper type safety throw createError({ message: 'User not found in context', code: 'users.not_found', @@ -16,8 +20,7 @@ export function getUser({ context }: { context: Context }) { } return { - user, - userId: user.id, + userId, }; } @@ -35,3 +38,47 @@ export function getTrustedOrigins({ config }: { config: Config }) { trustedOrigins: uniq([baseUrl, ...trustedOrigins]), }; } + +export function isAuthenticationValid({ + session, + apiKey, + requiredApiKeyPermissions, + authType, +}: { + session?: Session | null | undefined; + apiKey?: ApiKey | null | undefined; + requiredApiKeyPermissions?: ApiKeyPermissions[]; + authType: 'api-key' | 'session' | null; +}): boolean { + if (!authType) { + return false; + } + + if (session && authType !== 'session') { + return false; + } + + if (apiKey && authType !== 'api-key') { + return false; + } + + if (authType === 'api-key') { + if (!apiKey) { + return false; + } + + if (!requiredApiKeyPermissions) { + return false; + } + + const atLeastOnePermissionMatches = apiKey.permissions.some(permission => requiredApiKeyPermissions.includes(permission)); + + return atLeastOnePermissionMatches; + } + + if (authType === 'session' && session) { + return true; + } + + return false; +} diff --git a/apps/papra-server/src/modules/app/auth/auth.routes.ts b/apps/papra-server/src/modules/app/auth/auth.routes.ts index 783b271..3503a9b 100644 --- a/apps/papra-server/src/modules/app/auth/auth.routes.ts +++ b/apps/papra-server/src/modules/app/auth/auth.routes.ts @@ -1,23 +1,37 @@ -import type { RouteDefinitionContext } from '../server.types'; +import type { Context, RouteDefinitionContext } from '../server.types'; +import type { Session } from './auth.types'; +import { get } from 'lodash-es'; -export function registerAuthRoutes({ app, auth }: RouteDefinitionContext) { +export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) { app.on( ['POST', 'GET'], '/api/auth/*', context => auth.handler(context.req.raw), ); - app.use('*', async (context, next) => { + app.use('*', async (context: Context, next) => { const session = await auth.api.getSession({ headers: context.req.raw.headers }); - if (!session) { - context.set('user', null); - context.set('session', null); - return next(); + if (session) { + context.set('userId', session.user.id); + context.set('session', session.session); + context.set('authType', 'session'); } - context.set('user', session.user); - context.set('session', session.session); return next(); }); + + if (config.env === 'test') { + app.use('*', async (context: Context, next) => { + const overrideUserId = get(context.env, 'loggedInUserId') as string | undefined; + + if (overrideUserId) { + context.set('userId', overrideUserId); + context.set('session', {} as Session); + context.set('authType', 'session'); + } + + return next(); + }); + } } diff --git a/apps/papra-server/src/modules/app/auth/auth.types.ts b/apps/papra-server/src/modules/app/auth/auth.types.ts new file mode 100644 index 0000000..739e7b8 --- /dev/null +++ b/apps/papra-server/src/modules/app/auth/auth.types.ts @@ -0,0 +1,3 @@ +import type { Auth } from './auth.services'; + +export type Session = Auth['$Infer']['Session']['session']; diff --git a/apps/papra-server/src/modules/app/database/database.test-utils.ts b/apps/papra-server/src/modules/app/database/database.test-utils.ts index f457d62..1d8517b 100644 --- a/apps/papra-server/src/modules/app/database/database.test-utils.ts +++ b/apps/papra-server/src/modules/app/database/database.test-utils.ts @@ -1,4 +1,5 @@ import type { Database } from './database.types'; +import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables'; import { documentsTable } from '../../documents/documents.table'; import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables'; import { organizationMembersTable, organizationsTable } from '../../organizations/organizations.table'; @@ -35,6 +36,8 @@ const seedTables = { taggingRules: taggingRulesTable, taggingRuleConditions: taggingRuleConditionsTable, taggingRuleActions: taggingRuleActionsTable, + apiKeys: apiKeysTable, + apiKeyOrganizations: apiKeyOrganizationsTable, } as const; type SeedTablesRows = { diff --git a/apps/papra-server/src/modules/app/server.routes.test.ts b/apps/papra-server/src/modules/app/server.routes.test.ts new file mode 100644 index 0000000..eee2153 --- /dev/null +++ b/apps/papra-server/src/modules/app/server.routes.test.ts @@ -0,0 +1,69 @@ +import { inspectRoutes } from 'hono/dev'; +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabase } from './database/database.test-utils'; +import { createServer } from './server'; + +function setValidParams(path: string) { + const newPath = path + .replaceAll(':organizationId', 'org_111111111111111111111111') + .replaceAll(':userId', 'user_222222222222222222222222') + .replaceAll(':documentId', 'doc_333333333333333333333333') + .replaceAll(':tagId', 'tag_444444444444444444444444') + .replaceAll(':taggingRuleId', 'rule_555555555555555555555555') + .replaceAll(':intakeEmailId', 'email_666666666666666666666666') + .replaceAll(':apiKeyId', 'api_key_777777777777777777777777'); + + // throw if there are any remaining params + if (newPath.match(/:(\w+)/g)) { + throw new Error(`Add a dummy value for the params in ${path}`); + } + + return newPath; +} + +describe('server routes', () => { + test('all routes should respond with a 401 when non-authenticated, except for public and auth-related routes', async () => { + const { db } = await createInMemoryDatabase(); + const { app } = await createServer({ db }); + + const publicRoutes = [ + 'GET /api/ping', + 'GET /api/health', + 'GET /api/config', + + // Authentication is handled by payload signature verification + 'POST /api/intake-emails/ingest', + + // Stripe stuff + 'POST /api/stripe/webhook', + ]; + + // Excluding auth routes that are managed by better-auth + const authRoutesPrefix = '/api/auth'; + + const routes = inspectRoutes(app) + .filter(route => !route.isMiddleware) + .filter(route => !publicRoutes.includes(`${route.method} ${route.path}`)) + .filter(route => !route.path.startsWith(authRoutesPrefix)); + + for (const route of routes) { + const response = await app.request( + setValidParams(route.path), + { + method: route.method, + }, + ); + + expect(response.status).to.eql( + 401, + `Route ${route.method} ${route.path} did not return 401 when not authenticated`, + ); + expect(await response.json()).to.eql({ + error: { + message: 'Unauthorized', + code: 'auth.unauthorized', + }, + }); + } + }); +}); diff --git a/apps/papra-server/src/modules/app/server.routes.ts b/apps/papra-server/src/modules/app/server.routes.ts index fac6d2c..c6911e8 100644 --- a/apps/papra-server/src/modules/app/server.routes.ts +++ b/apps/papra-server/src/modules/app/server.routes.ts @@ -1,47 +1,26 @@ import type { RouteDefinitionContext } from './server.types'; -import { registerConfigPublicRoutes } from '../config/config.routes'; -import { registerDocumentsPrivateRoutes } from '../documents/documents.routes'; -import { registerIntakeEmailsPrivateRoutes, registerIntakeEmailsPublicRoutes } from '../intake-emails/intake-emails.routes'; -import { registerOrganizationsPrivateRoutes } from '../organizations/organizations.routes'; -import { registerSubscriptionsPrivateRoutes, registerSubscriptionsPublicRoutes } from '../subscriptions/subscriptions.routes'; +import { registerApiKeysRoutes } from '../api-keys/api-keys.routes'; +import { registerConfigRoutes } from '../config/config.routes'; +import { registerDocumentsRoutes } from '../documents/documents.routes'; +import { registerIntakeEmailsRoutes } from '../intake-emails/intake-emails.routes'; +import { registerOrganizationsRoutes } from '../organizations/organizations.routes'; +import { registerSubscriptionsRoutes } from '../subscriptions/subscriptions.routes'; import { registerTaggingRulesRoutes } from '../tagging-rules/tagging-rules.routes'; import { registerTagsRoutes } from '../tags/tags.routes'; -import { registerUsersPrivateRoutes } from '../users/users.routes'; -import { createUnauthorizedError } from './auth/auth.errors'; -import { getSession } from './auth/auth.models'; +import { registerUsersRoutes } from '../users/users.routes'; import { registerAuthRoutes } from './auth/auth.routes'; import { registerHealthCheckRoutes } from './health-check/health-check.routes'; export function registerRoutes(context: RouteDefinitionContext) { registerAuthRoutes(context); - - registerPublicRoutes(context); - registerPrivateRoutes(context); -} - -function registerPublicRoutes(context: RouteDefinitionContext) { - registerConfigPublicRoutes(context); + registerConfigRoutes(context); registerHealthCheckRoutes(context); - registerIntakeEmailsPublicRoutes(context); - registerSubscriptionsPublicRoutes(context); -} - -function registerPrivateRoutes(context: RouteDefinitionContext) { - context.app.use(async (handlerContext, next) => { - const { session } = getSession({ context: handlerContext }); - - if (!session) { - throw createUnauthorizedError(); - } - - await next(); - }); - - registerUsersPrivateRoutes(context); - registerOrganizationsPrivateRoutes(context); - registerDocumentsPrivateRoutes(context); + registerIntakeEmailsRoutes(context); + registerSubscriptionsRoutes(context); + registerUsersRoutes(context); + registerOrganizationsRoutes(context); + registerDocumentsRoutes(context); registerTagsRoutes(context); - registerIntakeEmailsPrivateRoutes(context); - registerSubscriptionsPrivateRoutes(context); registerTaggingRulesRoutes(context); + registerApiKeysRoutes(context); } diff --git a/apps/papra-server/src/modules/app/server.ts b/apps/papra-server/src/modules/app/server.ts index 53163d0..5b79acb 100644 --- a/apps/papra-server/src/modules/app/server.ts +++ b/apps/papra-server/src/modules/app/server.ts @@ -1,6 +1,7 @@ import type { GlobalDependencies, ServerInstanceGenerics } from './server.types'; import { Hono } from 'hono'; import { secureHeaders } from 'hono/secure-headers'; +import { createApiKeyMiddleware } from '../api-keys/api-keys.middlewares'; import { parseConfig } from '../config/config'; import { createEmailsServices } from '../emails/emails.services'; import { createLoggerMiddleware } from '../shared/logger/logger.middleware'; @@ -33,9 +34,9 @@ async function createGlobalDependencies(partialDeps: Partial }; } -export async function createServer(initialDeps: Partial) { +export async function createServer(initialDeps: Partial = {}) { const dependencies = await createGlobalDependencies(initialDeps); - const { config, trackingServices } = dependencies; + const { config, trackingServices, db } = dependencies; const app = new Hono({ strict: true }); @@ -47,6 +48,8 @@ export async function createServer(initialDeps: Partial) { registerErrorMiddleware({ app }); registerStaticAssetsRoutes({ app, config }); + app.use(createApiKeyMiddleware({ db })); + registerRoutes({ app, ...dependencies }); return { diff --git a/apps/papra-server/src/modules/app/server.types.ts b/apps/papra-server/src/modules/app/server.types.ts index e821125..fa8b887 100644 --- a/apps/papra-server/src/modules/app/server.types.ts +++ b/apps/papra-server/src/modules/app/server.types.ts @@ -1,15 +1,19 @@ import type { Context as BaseContext, Hono } from 'hono'; +import type { ApiKey } from '../api-keys/api-keys.types'; import type { Config } from '../config/config.types'; import type { EmailsServices } from '../emails/emails.services'; import type { SubscriptionsServices } from '../subscriptions/subscriptions.services'; import type { TrackingServices } from '../tracking/tracking.services'; import type { Auth } from './auth/auth.services'; +import type { Session } from './auth/auth.types'; import type { Database } from './database/database.types'; export type ServerInstanceGenerics = { Variables: { - user: Auth['$Infer']['Session']['user'] | null; - session: Auth['$Infer']['Session']['session'] | null; + userId: string | null; + session: Session | null; + apiKey: ApiKey | null; + authType: 'session' | 'api-key' | null; }; }; diff --git a/apps/papra-server/src/modules/config/config.routes.ts b/apps/papra-server/src/modules/config/config.routes.ts index 72f4101..770307e 100644 --- a/apps/papra-server/src/modules/config/config.routes.ts +++ b/apps/papra-server/src/modules/config/config.routes.ts @@ -1,7 +1,7 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { getPublicConfig } from './config.models'; -export async function registerConfigPublicRoutes(context: RouteDefinitionContext) { +export async function registerConfigRoutes(context: RouteDefinitionContext) { setupGetPublicConfigRoute(context); } diff --git a/apps/papra-server/src/modules/documents/documents.routes.ts b/apps/papra-server/src/modules/documents/documents.routes.ts index 0650db1..142764b 100644 --- a/apps/papra-server/src/modules/documents/documents.routes.ts +++ b/apps/papra-server/src/modules/documents/documents.routes.ts @@ -1,6 +1,7 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { organizationIdSchema } from '../organizations/organization.schemas'; import { createOrganizationsRepository } from '../organizations/organizations.repository'; @@ -18,7 +19,7 @@ import { documentIdSchema } from './documents.schemas'; import { createDocument, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases'; import { createDocumentStorageService } from './storage/documents.storage.services'; -export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext) { +export function registerDocumentsRoutes(context: RouteDefinitionContext) { setupCreateDocumentRoute(context); setupGetDocumentsRoute(context); setupSearchDocumentsRoute(context); @@ -36,6 +37,7 @@ export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext) function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/documents', + requireAuthentication({ apiKeyPermissions: ['documents:create'] }), (context, next) => { const { maxUploadSize } = config.documentsStorage; @@ -115,6 +117,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe function setupGetDocumentsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -158,6 +161,7 @@ function setupGetDocumentsRoute({ app, db }: RouteDefinitionContext) { function setupGetDeletedDocumentsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents/deleted', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -197,6 +201,7 @@ function setupGetDeletedDocumentsRoute({ app, db }: RouteDefinitionContext) { function setupGetDocumentRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents/:documentId', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -223,6 +228,7 @@ function setupGetDocumentRoute({ app, db }: RouteDefinitionContext) { function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/documents/:documentId', + requireAuthentication({ apiKeyPermissions: ['documents:delete'] }), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -250,6 +256,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) { function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/documents/:documentId/restore', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -280,6 +287,7 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) { function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents/:documentId/file', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -316,6 +324,7 @@ function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext) function setupSearchDocumentsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents/search', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -349,6 +358,7 @@ function setupSearchDocumentsRoute({ app, db }: RouteDefinitionContext) { function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/documents/statistics', + requireAuthentication({ apiKeyPermissions: ['documents:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -377,6 +387,7 @@ function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionCon function setupDeleteTrashDocumentRoute({ app, config, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/documents/trash/:documentId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -404,6 +415,7 @@ function setupDeleteTrashDocumentRoute({ app, config, db }: RouteDefinitionConte function setupDeleteAllTrashDocumentsRoute({ app, config, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/documents/trash', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -428,6 +440,7 @@ function setupDeleteAllTrashDocumentsRoute({ app, config, db }: RouteDefinitionC function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) { app.patch( '/api/organizations/:organizationId/documents/:documentId', + requireAuthentication({ apiKeyPermissions: ['documents:update'] }), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, diff --git a/apps/papra-server/src/modules/intake-emails/intake-emails.routes.ts b/apps/papra-server/src/modules/intake-emails/intake-emails.routes.ts index 5cca9e7..dabd830 100644 --- a/apps/papra-server/src/modules/intake-emails/intake-emails.routes.ts +++ b/apps/papra-server/src/modules/intake-emails/intake-emails.routes.ts @@ -2,6 +2,7 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { verifySignature } from '@owlrelay/webhook'; import { z } from 'zod'; import { createUnauthorizedError } from '../app/auth/auth.errors'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { createDocumentsRepository } from '../documents/documents.repository'; import { createDocumentStorageService } from '../documents/storage/documents.storage.services'; @@ -24,20 +25,18 @@ import { createIntakeEmail, deleteIntakeEmail, processIntakeEmailIngestion } fro const logger = createLogger({ namespace: 'intake-emails.routes' }); -export function registerIntakeEmailsPrivateRoutes(context: RouteDefinitionContext) { +export function registerIntakeEmailsRoutes(context: RouteDefinitionContext) { + setupIngestIntakeEmailRoute(context); setupGetOrganizationIntakeEmailsRoute(context); setupCreateIntakeEmailRoute(context); setupDeleteIntakeEmailRoute(context); setupUpdateIntakeEmailRoute(context); } -export function registerIntakeEmailsPublicRoutes(context: RouteDefinitionContext) { - setupIngestIntakeEmailRoute(context); -} - function setupGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/intake-emails', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -60,6 +59,7 @@ function setupGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionConte function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/intake-emails', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -91,6 +91,7 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext function setupDeleteIntakeEmailRoute({ app, db, config }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/intake-emails/:intakeEmailId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, intakeEmailId: intakeEmailIdSchema, @@ -115,6 +116,7 @@ function setupDeleteIntakeEmailRoute({ app, db, config }: RouteDefinitionContext function setupUpdateIntakeEmailRoute({ app, db }: RouteDefinitionContext) { app.put( '/api/organizations/:organizationId/intake-emails/:intakeEmailId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, intakeEmailId: intakeEmailIdSchema, diff --git a/apps/papra-server/src/modules/organizations/organizations.routes.ts b/apps/papra-server/src/modules/organizations/organizations.routes.ts index 9891b3c..b771422 100644 --- a/apps/papra-server/src/modules/organizations/organizations.routes.ts +++ b/apps/papra-server/src/modules/organizations/organizations.routes.ts @@ -1,5 +1,6 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { validateJsonBody, validateParams } from '../shared/validation/validation'; import { createUsersRepository } from '../users/users.repository'; @@ -7,7 +8,7 @@ import { organizationIdSchema } from './organization.schemas'; import { createOrganizationsRepository } from './organizations.repository'; import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization } from './organizations.usecases'; -export async function registerOrganizationsPrivateRoutes(context: RouteDefinitionContext) { +export async function registerOrganizationsRoutes(context: RouteDefinitionContext) { setupGetOrganizationsRoute(context); setupCreateOrganizationRoute(context); setupGetOrganizationRoute(context); @@ -16,22 +17,27 @@ export async function registerOrganizationsPrivateRoutes(context: RouteDefinitio } function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) { - app.get('/api/organizations', async (context) => { - const { userId } = getUser({ context }); + app.get( + '/api/organizations', + requireAuthentication(), + async (context) => { + const { userId } = getUser({ context }); - const organizationsRepository = createOrganizationsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); - const { organizations } = await organizationsRepository.getUserOrganizations({ userId }); + const { organizations } = await organizationsRepository.getUserOrganizations({ userId }); - return context.json({ - organizations, - }); - }); + return context.json({ + organizations, + }); + }, + ); } function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) { app.post( '/api/organizations', + requireAuthentication(), validateJsonBody(z.object({ name: z.string().min(3).max(50), })), @@ -56,6 +62,7 @@ function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContex function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -77,6 +84,7 @@ function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) { function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) { app.put( '/api/organizations/:organizationId', + requireAuthentication(), validateJsonBody(z.object({ name: z.string().min(3).max(50), })), @@ -104,6 +112,7 @@ function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) { function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), diff --git a/apps/papra-server/src/modules/shared/crypto/hash.test.ts b/apps/papra-server/src/modules/shared/crypto/hash.test.ts new file mode 100644 index 0000000..bb48c56 --- /dev/null +++ b/apps/papra-server/src/modules/shared/crypto/hash.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest'; +import { sha256 } from './hash'; + +describe('hash', () => { + describe('sha256', () => { + test('hashes a string using sha256', () => { + expect(sha256('test')).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + }); + + test('the output format can be specified, default is hex', () => { + expect(sha256('test', { digest: 'base64' })).to.eql('n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg='); + expect(sha256('test', { digest: 'base64url' })).to.eql('n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg'); + expect(sha256('test', { digest: 'hex' })).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + + expect(sha256('test', { })).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + expect(sha256('test', undefined)).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + expect(sha256('test')).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + }); + }); +}); diff --git a/apps/papra-server/src/modules/shared/crypto/hash.ts b/apps/papra-server/src/modules/shared/crypto/hash.ts new file mode 100644 index 0000000..9b5c7b5 --- /dev/null +++ b/apps/papra-server/src/modules/shared/crypto/hash.ts @@ -0,0 +1,5 @@ +import { createHash } from 'node:crypto'; + +export function sha256(value: string, { digest = 'hex' }: { digest?: 'hex' | 'base64' | 'base64url' } = {}) { + return createHash('sha256').update(value).digest(digest); +} diff --git a/apps/papra-server/src/modules/shared/random/random.services.test.ts b/apps/papra-server/src/modules/shared/random/random.services.test.ts new file mode 100644 index 0000000..cce6eec --- /dev/null +++ b/apps/papra-server/src/modules/shared/random/random.services.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest'; +import { generateToken } from './random.services'; + +describe('random', () => { + describe('generateToken', () => { + test('create random token of 32 characters by default', () => { + const { token } = generateToken(); + + expect(token.length).toBe(32); + }); + + test('the length can be customized', () => { + expect(generateToken({ length: 64 }).token.length).toBe(64); + expect(generateToken({ length: 31 }).token.length).toBe(31); + }); + }); +}); diff --git a/apps/papra-server/src/modules/shared/random/random.services.ts b/apps/papra-server/src/modules/shared/random/random.services.ts new file mode 100644 index 0000000..5454ad3 --- /dev/null +++ b/apps/papra-server/src/modules/shared/random/random.services.ts @@ -0,0 +1,10 @@ +import { customAlphabet } from 'nanoid'; + +const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const nanoid = customAlphabet(corpus); + +export function generateToken({ length = 32 }: { length?: number } = {}) { + const token = nanoid(length); + + return { token }; +} diff --git a/apps/papra-server/src/modules/subscriptions/subscriptions.routes.ts b/apps/papra-server/src/modules/subscriptions/subscriptions.routes.ts index 2bf2d5f..cf2c3dd 100644 --- a/apps/papra-server/src/modules/subscriptions/subscriptions.routes.ts +++ b/apps/papra-server/src/modules/subscriptions/subscriptions.routes.ts @@ -1,6 +1,7 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { get, pick } from 'lodash-es'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { organizationIdSchema } from '../organizations/organization.schemas'; import { createOrganizationNotFoundError } from '../organizations/organizations.errors'; @@ -19,16 +20,13 @@ import { handleStripeWebhookEvent } from './subscriptions.usecases'; const logger = createLogger({ namespace: 'subscriptions.routes' }); -export function registerSubscriptionsPrivateRoutes(context: RouteDefinitionContext) { +export function registerSubscriptionsRoutes(context: RouteDefinitionContext) { + setupStripeWebhookRoute(context); setupCreateCheckoutSessionRoute(context); setupGetCustomerPortalRoute(context); getOrganizationSubscriptionRoute(context); } -export function registerSubscriptionsPublicRoutes(context: RouteDefinitionContext) { - setupStripeWebhookRoute(context); -} - function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) { app.post('/api/stripe/webhook', async (context) => { const signature = getHeader({ context, name: 'stripe-signature' }); @@ -64,6 +62,7 @@ function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: Rou async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/checkout-session', + requireAuthentication(), validateJsonBody(z.object({ planId: z.enum([PLUS_PLAN_ID]), })), @@ -128,6 +127,7 @@ async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsS function setupGetCustomerPortalRoute({ app, db, subscriptionsServices }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/customer-portal', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -155,6 +155,7 @@ function setupGetCustomerPortalRoute({ app, db, subscriptionsServices }: RouteDe function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/subscription', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), diff --git a/apps/papra-server/src/modules/tagging-rules/tagging-rules.routes.ts b/apps/papra-server/src/modules/tagging-rules/tagging-rules.routes.ts index f7e3f4d..6b553c4 100644 --- a/apps/papra-server/src/modules/tagging-rules/tagging-rules.routes.ts +++ b/apps/papra-server/src/modules/tagging-rules/tagging-rules.routes.ts @@ -1,6 +1,7 @@ import type { RouteDefinitionContext } from '../app/server.types'; import type { TaggingRuleField, TaggingRuleOperator } from './tagging-rules.types'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { organizationIdSchema } from '../organizations/organization.schemas'; import { createOrganizationsRepository } from '../organizations/organizations.repository'; @@ -23,6 +24,7 @@ export function registerTaggingRulesRoutes(context: RouteDefinitionContext) { function setupGetOrganizationTaggingRulesRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/tagging-rules', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -48,6 +50,7 @@ function setupGetOrganizationTaggingRulesRoute({ app, db }: RouteDefinitionConte function setupCreateTaggingRuleRoute({ app, db }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/tagging-rules', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -83,6 +86,7 @@ function setupCreateTaggingRuleRoute({ app, db }: RouteDefinitionContext) { function setupDeleteTaggingRuleRoute({ app, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/tagging-rules/:taggingRuleId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, taggingRuleId: taggingRuleIdSchema, @@ -107,6 +111,7 @@ function setupDeleteTaggingRuleRoute({ app, db }: RouteDefinitionContext) { function setupGetTaggingRuleRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/tagging-rules/:taggingRuleId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, taggingRuleId: taggingRuleIdSchema, @@ -133,6 +138,7 @@ function setupGetTaggingRuleRoute({ app, db }: RouteDefinitionContext) { function setupUpdateTaggingRuleRoute({ app, db }: RouteDefinitionContext) { app.put( '/api/organizations/:organizationId/tagging-rules/:taggingRuleId', + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, taggingRuleId: taggingRuleIdSchema, diff --git a/apps/papra-server/src/modules/tags/tags.routes.ts b/apps/papra-server/src/modules/tags/tags.routes.ts index 7123473..e5fcdc5 100644 --- a/apps/papra-server/src/modules/tags/tags.routes.ts +++ b/apps/papra-server/src/modules/tags/tags.routes.ts @@ -1,5 +1,6 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { documentIdSchema } from '../documents/documents.schemas'; import { organizationIdSchema } from '../organizations/organization.schemas'; @@ -22,7 +23,7 @@ export function registerTagsRoutes(context: RouteDefinitionContext) { function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/tags', - + requireAuthentication({ apiKeyPermissions: ['tags:create'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -56,7 +57,7 @@ function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) { function setupGetOrganizationTagsRoute({ app, db }: RouteDefinitionContext) { app.get( '/api/organizations/:organizationId/tags', - + requireAuthentication({ apiKeyPermissions: ['tags:read'] }), validateParams(z.object({ organizationId: organizationIdSchema, })), @@ -78,7 +79,7 @@ function setupGetOrganizationTagsRoute({ app, db }: RouteDefinitionContext) { function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) { app.put( '/api/organizations/:organizationId/tags/:tagId', - + requireAuthentication({ apiKeyPermissions: ['tags:update'] }), validateParams(z.object({ organizationId: organizationIdSchema, tagId: tagIdSchema, @@ -113,7 +114,7 @@ function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) { function setupDeleteTagRoute({ app, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/tags/:tagId', - + requireAuthentication({ apiKeyPermissions: ['tags:delete'] }), validateParams(z.object({ organizationId: organizationIdSchema, tagId: tagIdSchema, @@ -139,7 +140,7 @@ function setupDeleteTagRoute({ app, db }: RouteDefinitionContext) { function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) { app.post( '/api/organizations/:organizationId/documents/:documentId/tags', - + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, @@ -170,7 +171,7 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) { function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId/documents/:documentId/tags/:tagId', - + requireAuthentication(), validateParams(z.object({ organizationId: organizationIdSchema, documentId: documentIdSchema, diff --git a/apps/papra-server/src/modules/users/users.routes.ts b/apps/papra-server/src/modules/users/users.routes.ts index 588b645..ad01482 100644 --- a/apps/papra-server/src/modules/users/users.routes.ts +++ b/apps/papra-server/src/modules/users/users.routes.ts @@ -1,53 +1,59 @@ import type { RouteDefinitionContext } from '../app/server.types'; import { pick } from 'lodash-es'; import { z } from 'zod'; +import { requireAuthentication } from '../app/auth/auth.middleware'; import { getUser } from '../app/auth/auth.models'; import { createRolesRepository } from '../roles/roles.repository'; import { validateJsonBody } from '../shared/validation/validation'; import { createUsersRepository } from './users.repository'; -export async function registerUsersPrivateRoutes(context: RouteDefinitionContext) { +export async function registerUsersRoutes(context: RouteDefinitionContext) { setupGetCurrentUserRoute(context); setupUpdateUserRoute(context); } function setupGetCurrentUserRoute({ app, db }: RouteDefinitionContext) { - app.get('/api/users/me', async (context) => { - const { userId } = getUser({ context }); + app.get( + '/api/users/me', + requireAuthentication(), + async (context) => { + const { userId } = getUser({ context }); - const usersRepository = createUsersRepository({ db }); - const rolesRepository = createRolesRepository({ db }); + const usersRepository = createUsersRepository({ db }); + const rolesRepository = createRolesRepository({ db }); - const [ - { user }, - { roles }, - ] = await Promise.all([ - usersRepository.getUserByIdOrThrow({ userId }), - rolesRepository.getUserRoles({ userId }), - ]); + const [ + { user }, + { roles }, + ] = await Promise.all([ + usersRepository.getUserByIdOrThrow({ userId }), + rolesRepository.getUserRoles({ userId }), + ]); - return context.json({ - user: { - roles, - ...pick( - user, - [ - 'id', - 'email', - 'name', - 'createdAt', - 'updatedAt', - 'planId', - ], - ), - }, - }); - }); + return context.json({ + user: { + roles, + ...pick( + user, + [ + 'id', + 'email', + 'name', + 'createdAt', + 'updatedAt', + 'planId', + ], + ), + }, + }); + }, + ); } function setupUpdateUserRoute({ app, db }: RouteDefinitionContext) { app.put( '/api/users/me', + requireAuthentication(), validateJsonBody(z.object({ name: z.string().min(1).max(50), })), diff --git a/docker/Dockerfile b/docker/Dockerfile index 0cd068c..3ca46ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,6 +34,7 @@ COPY --from=build /app/apps/papra-client/dist ./public EXPOSE 1221 +ENV NODE_ENV=production ENV SERVER_SERVE_PUBLIC_DIR=true ENV DATABASE_URL=file:./app-data/db/db.sqlite ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents diff --git a/docker/Dockerfile.rootless b/docker/Dockerfile.rootless index 07d895b..21bface 100644 --- a/docker/Dockerfile.rootless +++ b/docker/Dockerfile.rootless @@ -44,6 +44,7 @@ USER nonroot EXPOSE 1221 +ENV NODE_ENV=production ENV SERVER_SERVE_PUBLIC_DIR=true ENV DATABASE_URL=file:./app-data/db/db.sqlite ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cd41c7..2fe4aa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: mime-types: specifier: ^3.0.1 version: 3.0.1 + nanoid: + specifier: ^5.1.5 + version: 5.1.5 node-cron: specifier: ^3.0.3 version: 3.0.3 @@ -5240,6 +5243,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + nanostores@0.11.4: resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -12804,6 +12812,8 @@ snapshots: nanoid@3.3.10: {} + nanoid@5.1.5: {} + nanostores@0.11.4: {} napi-build-utils@2.0.0: {}