Compare commits

...

6 Commits

Author SHA1 Message Date
Wayne
0f7c56da51 JSON rate limiting message & status code 2025-09-04 17:30:40 +03:00
Wayne
faa50885d8 Exclude public API endpoints from rate limiting 2025-09-04 17:26:28 +03:00
Wayne
d66e302f5c Resolve conflict 2025-09-04 16:56:25 +03:00
Wayne
5c30ffbeaa Disable API operation in demo mode 2025-09-04 16:53:45 +03:00
Wayne
daa18d64e9 Add configurable API rate limiting
Two new variables are added to `.env.example`:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds for which requests are checked (defaults to 15 minutes).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed from an IP within the window (defaults to 100).

The installation documentation has been updated to reflect these new configuration options.
2025-09-04 15:04:59 +03:00
Wayne
d56259656d feat(auth): Implement API key authentication
This commit enables API access with an API key system. This change provides a better experience for programmatic access and third-party integrations.

Key changes include:
- **API Key Management:** Users can now generate, manage, and revoke persistent API keys through a new "API Keys" section in the settings UI.
- **Authentication Middleware:** API requests are now authenticated via an `X-API-KEY` header instead of the previous `Authorization: Bearer` token.
- **Backend Implementation:** Adds a new `api_keys` database table, along with corresponding services, controllers, and routes to manage the key lifecycle securely.
- **Rate Limiting:** The API rate limiter now uses the API key to identify and track requests.
- **Documentation:** The API authentication documentation has been updated to reflect the new method.
2025-09-04 13:28:02 +03:00
8 changed files with 46 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService';
import { z } from 'zod';
import { config } from '../../config';
const generateApiKeySchema = z.object({
name: z.string().min(1, 'API kay name must be more than 1 characters').max(255, 'API kay name must not be more than 255 characters'),
@@ -9,6 +10,9 @@ const generateApiKeySchema = z.object({
export class ApiKeyController {
public async generateApiKey(req: Request, res: Response) {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
try {
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
if (!req.user || !req.user.sub) {
@@ -38,6 +42,9 @@ export class ApiKeyController {
}
public async deleteApiKey(req: Request, res: Response) {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { id } = req.params;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });

View File

@@ -6,7 +6,11 @@ const windowInMinutes = Math.ceil(config.api.rateLimit.windowMs / 60000);
export const rateLimiter = rateLimit({
windowMs: config.api.rateLimit.windowMs,
max: config.api.rateLimit.max,
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`,
message: {
status: 429,
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`
},
statusCode: 429,
standardHeaders: true,
legacyHeaders: false,
legacyHeaders: false
});

View File

@@ -93,7 +93,19 @@ const apiKeyRouter = apiKeyRoutes(authService);
app.use('/v1/upload', uploadRouter);
// Middleware for all other routes
app.use(rateLimiter);
app.use((req, res, next) => {
// exclude certain API endpoints from the rate limiter, for example status, system settings
const excludedPatterns = [
/^\/v\d+\/auth\/status$/,
/^\/v\d+\/settings\/system$/
];
for (const pattern of excludedPatterns) {
if (pattern.test(req.path)) {
return next();
}
}
rateLimiter(req, res, next);
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

View File

@@ -6,8 +6,9 @@ import type { SystemSettings } from '@open-archiver/types';
export const load: LayoutServerLoad = async (event) => {
const { locals, url } = event;
try {
const response = await api('/auth/status', event);
const response = await api('/auth/status', event);
if (response.ok) {
const { needsSetup } = await response.json();
if (needsSetup && url.pathname !== '/setup') {
@@ -17,19 +18,24 @@ export const load: LayoutServerLoad = async (event) => {
if (!needsSetup && url.pathname === '/setup') {
throw redirect(307, '/signin');
}
} catch (error) {
throw error;
} else {
// if auth status check fails, we can't know if the setup is complete,
// so we redirect to signin page as a safe fallback.
if (url.pathname !== '/signin') {
console.error('Failed to get auth status:', await response.text());
throw redirect(307, '/signin');
}
}
const settingsResponse = await api('/settings', event);
const settings: SystemSettings | null = settingsResponse.ok
? await settingsResponse.json()
const systemSettingsResponse = await api('/settings/system', event);
const systemSettings: SystemSettings | null = systemSettingsResponse.ok
? await systemSettingsResponse.json()
: null;
return {
user: locals.user,
accessToken: locals.accessToken,
isDemo: process.env.IS_DEMO === 'true',
settings,
systemSettings,
};
};

View File

@@ -18,7 +18,7 @@
let finalTheme = $theme;
if (finalTheme === 'system') {
finalTheme = data.settings?.theme || 'system';
finalTheme = data.systemSettings?.theme || 'system';
}
const isDark =

View File

@@ -8,8 +8,8 @@ export const load: LayoutLoad = async ({ url, data }) => {
let initLocale: SupportedLanguage = 'en'; // Default fallback
if (data.settings?.language) {
initLocale = data.settings.language;
if (data.systemSettings?.language) {
initLocale = data.systemSettings.language;
}
console.log(initLocale);

View File

@@ -11,9 +11,9 @@ export const load: PageServerLoad = async (event) => {
throw error(response.status, message || 'Failed to fetch system settings');
}
const settings: SystemSettings = await response.json();
const systemSettings: SystemSettings = await response.json();
return {
settings,
systemSettings,
};
};

View File

@@ -11,7 +11,7 @@
import { t } from '$lib/translations';
let { data, form }: { data: PageData; form: any } = $props();
let settings = $state(data.settings);
let settings = $state(data.systemSettings);
let isSaving = $state(false);
const languageOptions: { value: SupportedLanguage; label: string }[] = [