Compare commits

...

2 Commits
2fa ... main

Author SHA1 Message Date
Corentin Thomasset
8d70a7b3c3 feat(api-keys): add endpoint to check current API key (#718) 2025-12-31 15:52:37 +01:00
Arpit Garg
7448a170af fix(documents): delete orphan file when same document exists in trash (#715)
* fix: delete orphan file when duplicate hash is found

* test(documents): add test to highlight fixed issue

* chore(version): add changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-12-31 14:08:05 +01:00
8 changed files with 317 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Properly cleanup orphan file when the same document exists in trash

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added api endpoint to check current API key (GET /api/api-keys/current)

View File

@@ -66,6 +66,19 @@ When creating an API key, you can select from the following permissions:
## Endpoints
### Check current API key
**GET** `/api/api-keys/current`
Get information about the currently used API key.
- Required API key permissions: none
- Response (JSON)
- `apiKey`: The current API key information.
- `id`: The API key ID.
- `name`: The API key name.
- `permissions`: The list of permissions associated with the API key.
### List organizations
**GET** `/api/organizations`

View File

@@ -0,0 +1,8 @@
import { createErrorFactory } from '../shared/errors/errors';
// Error when the authentication is not using an API key but the route is api-key only
export const createNotApiKeyAuthError = createErrorFactory({
code: 'api_keys.authentication_not_api_key',
message: 'Authentication must be done using an API key to access this resource',
statusCode: 401,
});

View File

@@ -1,17 +1,21 @@
import type { RouteDefinitionContext } from '../app/server.types';
import type { ApiKeyPermissions } from './api-keys.types';
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 { createError } from '../shared/errors/errors';
import { isNil } from '../shared/utils';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
import { createNotApiKeyAuthError } from './api-keys.errors';
import { createApiKeysRepository } from './api-keys.repository';
import { apiKeyIdSchema } from './api-keys.schemas';
import { createApiKey } from './api-keys.usecases';
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
setupCreateApiKeyRoute(context);
setupGetCurrentApiKeyRoute(context); // Should be before the get api keys route otherwise it conflicts ("current" as apiKeyId)
setupGetApiKeysRoute(context);
setupDeleteApiKeyRoute(context);
}
@@ -82,6 +86,38 @@ function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) {
);
}
// Mainly use for authentication verification in client SDKs
function setupGetCurrentApiKeyRoute({ app }: RouteDefinitionContext) {
app.get(
'/api/api-keys/current',
async (context) => {
const authType = context.get('authType');
const apiKey = context.get('apiKey');
if (isNil(authType)) {
throw createUnauthorizedError();
}
if (authType !== 'api-key') {
throw createNotApiKeyAuthError();
}
if (isNil(apiKey)) {
// Should not happen as authType is 'api-key', but for type safety
throw createUnauthorizedError();
}
return context.json({
apiKey: {
id: apiKey.id,
name: apiKey.name,
permissions: apiKey.permissions,
},
});
},
);
}
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/api-keys/:apiKeyId',

View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
import { createServer } from '../../app/server';
import { createTestServerDependencies } from '../../app/server.test-utils';
import { overrideConfig } from '../../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
import { API_KEY_ID_PREFIX, API_KEY_TOKEN_LENGTH } from '../api-keys.constants';
describe('api-key e2e', () => {
describe('get /api/api-keys/current', () => {
test('when using an api key, one can request the /api/api-keys/current route to check that the api key is valid', 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 } = createServer(createTestServerDependencies({
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, apiKey } = await createApiKeyResponse.json() as { token: string; apiKey: { id: string } };
const getCurrentApiKeyResponse = await app.request(
'/api/api-keys/current',
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
);
const response = await getCurrentApiKeyResponse.json();
expect(response).to.deep.equal({
apiKey: {
id: apiKey.id,
name: 'Test API Key',
permissions: ['documents:create'],
},
});
expect(getCurrentApiKeyResponse.status).toBe(200);
});
test('when not using an api key, requesting the /api/api-keys/current route returns an error', 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 } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
}));
const getCurrentApiKeyResponse = await app.request(
'/api/api-keys/current',
{
method: 'GET',
},
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(getCurrentApiKeyResponse.status).toBe(401);
const response = await getCurrentApiKeyResponse.json();
expect(response).to.deep.equal({
error: {
code: 'api_keys.authentication_not_api_key',
message: 'Authentication must be done using an API key to access this resource',
},
});
});
test('when not authenticated at all, requesting the /api/api-keys/current route returns an error', async () => {
const { db } = await createInMemoryDatabase();
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
}));
const getCurrentApiKeyResponse = await app.request(
'/api/api-keys/current',
{
method: 'GET',
},
);
expect(getCurrentApiKeyResponse.status).toBe(401);
const response = await getCurrentApiKeyResponse.json();
expect(response).to.deep.equal({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
test('if the api key used is invalid, requesting the /api/api-keys/current route returns an error', async () => {
const { db } = await createInMemoryDatabase();
const invalidButLegitApiKeyToken = `${API_KEY_ID_PREFIX}_${'x'.repeat(API_KEY_TOKEN_LENGTH)}`;
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
}));
const getCurrentApiKeyResponse = await app.request(
'/api/api-keys/current',
{
method: 'GET',
headers: {
Authorization: `Bearer ${invalidButLegitApiKeyToken}`,
},
},
);
expect(getCurrentApiKeyResponse.status).toBe(401);
const response = await getCurrentApiKeyResponse.json();
expect(response).to.deep.equal({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

View File

@@ -6,6 +6,7 @@ import { createTestEventServices } from '../app/events/events.test-utils';
import { overrideConfig } from '../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
import { createDeterministicIdGenerator } from '../shared/random/ids';
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createTagsRepository } from '../tags/tags.repository';
@@ -244,6 +245,83 @@ describe('documents usecases', () => {
}]);
});
test('when restoring a deleted document via duplicate upload, the optimistically saved new file should be cleaned up to prevent orphan files', async () => {
const taskServices = createInMemoryTaskServices();
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user-1@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
});
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
const documentsRepository = createDocumentsRepository({ db });
const inMemoryDocumentsStorageService = inMemoryStorageDriverFactory();
const createDocument = createDocumentCreationUsecase({
db,
config,
generateDocumentId: createDeterministicIdGenerator({ prefix: 'doc' }),
documentsStorageService: inMemoryDocumentsStorageService,
taskServices,
eventServices: createTestEventServices(),
});
const userId = 'user-1';
const organizationId = 'organization-1';
// Step 1: Upload a file
const { document: document1 } = await createDocument({
fileStream: createReadableStream({ content: 'Hello, world!' }),
fileName: 'file.pdf',
mimeType: 'application/pdf',
userId,
organizationId,
});
expect(document1.id).to.eql('doc_000000000000000000000001');
expect(
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
).to.eql([
'organization-1/originals/doc_000000000000000000000001.pdf',
]);
// Step 2: Delete the document (soft delete)
await trashDocument({
documentId: document1.id,
organizationId,
userId,
documentsRepository,
eventServices: createTestEventServices(),
});
const { document: trashedDoc } = await documentsRepository.getDocumentById({ documentId: document1.id, organizationId });
expect(trashedDoc?.isDeleted).to.eql(true);
// Step 3: Upload the same file again - this should restore the original document
const { document: restoredDocument } = await createDocument({
fileStream: createReadableStream({ content: 'Hello, world!' }),
fileName: 'file.pdf',
mimeType: 'application/pdf',
userId,
organizationId,
});
// The document should be restored (same ID)
expect(restoredDocument.id).to.eql('doc_000000000000000000000001');
expect(restoredDocument.isDeleted).to.eql(false);
// Step 5: Verify no orphan files remain in storage
// The optimistically saved file (doc_2.pdf) should have been cleaned up during restoration
expect(
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
).to.eql([
'organization-1/originals/doc_000000000000000000000001.pdf',
]);
});
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
const taskServices = createInMemoryTaskServices();
const { db } = await createInMemoryDatabase({

View File

@@ -235,9 +235,10 @@ async function handleExistingDocument({
newDocumentStorageKey: string;
logger: Logger;
}) {
if (!existingDocument.isDeleted) {
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
// Delete the newly uploaded file since we'll be using the existing document's file
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
if (!existingDocument.isDeleted) {
throw createDocumentAlreadyExistsError();
}