Compare commits

...

7 Commits

Author SHA1 Message Date
Corentin Thomasset
1bfdb8aa66 chore(release): update versions (#525)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-04 11:47:47 +02:00
Corentin Thomasset
2e2bb6fbbd chore(changeset): added changeset for ip env variable (#531) 2025-10-02 22:48:38 +00:00
Corentin Thomasset
d09b9ed70d feat(auth): add IP address header configuration and logging support (#530) 2025-10-03 00:41:42 +02:00
Corentin Thomasset
e1571d2b87 fix(auth): enhance logging to include additional arguments in log messages (#529) 2025-10-02 22:36:06 +00:00
Corentin Thomasset
c9a66e4aa8 fix(docs): update env variable name for OwlRelay configuration (#528) 2025-10-02 20:29:55 +00:00
Corentin Thomasset
9fa2df4235 feat(package): add module type to root package.json (#526) 2025-10-01 14:15:23 +00:00
Corentin Thomasset
c84a921988 feat(tags): update tag color validation to allow uppercase letters (#524)
* feat(tags): update tag color validation to allow uppercase letters

* Update .changeset/quiet-peas-mate.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-01 14:09:49 +00:00
15 changed files with 83 additions and 17 deletions

View File

@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
3. **Configure your Papra instance**
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
```bash
# Enable intake emails

View File

@@ -1,5 +1,7 @@
# @papra/app-client
## 0.9.6
## 0.9.5
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.5",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",

View File

@@ -1,5 +1,13 @@
# @papra/app-server
## 0.9.6
### Patch Changes
- [#531](https://github.com/papra-hq/papra/pull/531) [`2e2bb6f`](https://github.com/papra-hq/papra/commit/2e2bb6fbbdd02f6b8352ef2653bef0447948c1f0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added env variable to configure ip header for rate limit
- [#524](https://github.com/papra-hq/papra/pull/524) [`c84a921`](https://github.com/papra-hq/papra/commit/c84a9219886ecb2a77c67d904cf8c8d15b50747b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the api validation of tag colors to make it case incensitive
## 0.9.5
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.9.5",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",

View File

@@ -55,6 +55,17 @@ export const authConfig = {
default: false,
env: 'AUTH_SHOW_LEGAL_LINKS',
},
ipAddressHeaders: {
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
- If behind Cloudflare, you might want to set this to "cf-connecting-ip".`,
schema: z.union([
z.string(),
z.array(z.string()),
]).transform(value => (typeof value === 'string' ? value.split(',').map(v => v.trim()) : value)),
default: ['x-forwarded-for'],
env: 'AUTH_IP_ADDRESS_HEADERS',
},
providers: {
email: {
isEnabled: {

View File

@@ -37,8 +37,8 @@ export function getAuth({
trustedOrigins,
logger: {
disabled: false,
log: (baseLevel, message) => {
logger[baseLevel ?? 'info'](message);
log: (baseLevel, message, ...args: unknown[]) => {
logger[baseLevel ?? 'info']({ ...args }, message);
},
},
emailAndPassword: {
@@ -85,6 +85,9 @@ export function getAuth({
advanced: {
// Drizzle tables handle the id generation
database: { generateId: false },
ipAddress: {
ipAddressHeaders: config.auth.ipAddressHeaders,
},
},
socialProviders: {
github: {

View File

@@ -46,7 +46,7 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
const app = new Hono<ServerInstanceGenerics>({ strict: true });
app.use(createLoggerMiddleware());
app.use(createLoggerMiddleware({ config }));
app.use(createCorsMiddleware({ config }));
app.use(createTimeoutMiddleware({ config }));
app.use(secureHeaders());

View File

@@ -26,3 +26,15 @@ export function getContentLengthHeader({ headers }: { headers: Record<string, st
return Number(contentLengthHeaderValue);
}
export function getIpFromHeaders({ context, headerNames }: { context: Context; headerNames: string[] }): string | undefined {
for (const headerName of headerNames) {
const headerValue = getHeader({ context, name: headerName });
if (!isNil(headerValue)) {
return headerValue;
}
}
return undefined;
}

View File

@@ -1,18 +1,21 @@
import type { Context } from '../../app/server.types';
import type { Config } from '../../config/config.types';
import { createMiddleware } from 'hono/factory';
import { getHeader } from '../headers/headers.models';
import { routePath } from 'hono/route';
import { getHeader, getIpFromHeaders } from '../headers/headers.models';
import { generateId } from '../random/ids';
import { createLogger, wrapWithLoggerContext } from './logger';
const logger = createLogger({ namespace: 'app' });
export function createLoggerMiddleware() {
export function createLoggerMiddleware({ config }: { config: Config }) {
return createMiddleware(async (context: Context, next) => {
const requestId = getHeader({ context, name: 'x-request-id' });
const requestId = getHeader({ context, name: 'x-request-id' }) ?? generateId({ prefix: 'req' });
const ip = getIpFromHeaders({ context, headerNames: config.auth.ipAddressHeaders });
await wrapWithLoggerContext(
{
requestId: requestId ?? generateId({ prefix: 'req' }),
requestId,
},
async () => {
const requestedAt = new Date();
@@ -26,9 +29,10 @@ export function createLoggerMiddleware() {
status: context.res.status,
method: context.req.method,
path: context.req.path,
routePath: context.req.routePath,
routePath: routePath(context),
userAgent: getHeader({ context, name: 'User-Agent' }),
durationMs,
ip,
},
'Request completed',
);

View File

@@ -1,6 +1,6 @@
import { createPrefixedIdRegex } from '../shared/random/ids';
export const TagColorRegex = /^#[0-9a-f]{6}$/;
export const TagColorRegex = /^#[0-9A-F]{6}$/;
export const tagIdPrefix = 'tag';
export const tagIdRegex = createPrefixedIdRegex({ prefix: tagIdPrefix });

View File

@@ -14,10 +14,9 @@ import { ensureUserIsInOrganization } from '../organizations/organizations.useca
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
import { TagColorRegex } from './tags.constants';
import { createTagNotFoundError } from './tags.errors';
import { createTagsRepository } from './tags.repository';
import { tagIdSchema } from './tags.schemas';
import { tagColorSchema, tagIdSchema } from './tags.schemas';
export function registerTagsRoutes(context: RouteDefinitionContext) {
setupCreateNewTagRoute(context);
@@ -38,7 +37,7 @@ function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) {
validateJsonBody(z.object({
name: z.string().min(1).max(50),
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000'),
color: tagColorSchema,
description: z.string().max(256).optional(),
})),
@@ -95,7 +94,7 @@ function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) {
validateJsonBody(z.object({
name: z.string().min(1).max(64).optional(),
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000').optional(),
color: tagColorSchema.optional(),
description: z.string().max(256).optional(),
})),

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { tagColorSchema } from './tags.schemas';
describe('tags schemas', () => {
describe('tagColorSchema', () => {
test('the color of a tag is a 6 digits hex color code', () => {
expect(() => tagColorSchema.parse('#FFFFFF')).not.toThrow();
expect(() => tagColorSchema.parse('#000000')).not.toThrow();
expect(() => tagColorSchema.parse('#123ABC')).not.toThrow();
expect(() => tagColorSchema.parse('#abcdef')).not.toThrow();
expect(() => tagColorSchema.parse('FFFFFF')).toThrow();
expect(() => tagColorSchema.parse('#FFF')).toThrow();
expect(() => tagColorSchema.parse('#123ABCG')).toThrow();
expect(() => tagColorSchema.parse('#123AB')).toThrow();
expect(() => tagColorSchema.parse('blue')).toThrow();
});
test('the color of a tag is always uppercased', () => {
expect(tagColorSchema.parse('#abcdef')).toBe('#ABCDEF');
expect(tagColorSchema.parse('#abCdEf')).toBe('#ABCDEF');
expect(tagColorSchema.parse('#123abc')).toBe('#123ABC');
});
});
});

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import { tagIdRegex } from './tags.constants';
import { TagColorRegex, tagIdRegex } from './tags.constants';
export const tagIdSchema = z.string().regex(tagIdRegex);
export const tagColorSchema = z.string().toUpperCase().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000');

View File

@@ -1,5 +1,6 @@
{
"name": "@papra/root",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra document management monorepo root",