mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-31 16:30:57 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d70a7b3c3 | ||
|
|
7448a170af |
5
.changeset/silent-gifts-enjoy.md
Normal file
5
.changeset/silent-gifts-enjoy.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Properly cleanup orphan file when the same document exists in trash
|
||||
5
.changeset/true-olives-beam.md
Normal file
5
.changeset/true-olives-beam.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added api endpoint to check current API key (GET /api/api-keys/current)
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user