mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 08:41:57 +02:00
Compare commits
12 Commits
user-api-k
...
docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6800bc98 | ||
|
|
413188dc81 | ||
|
|
4b11cd931a | ||
|
|
a1239e6303 | ||
|
|
adb548e184 | ||
|
|
f1c33b548e | ||
|
|
0a21ad14cd | ||
|
|
63d3960f79 | ||
|
|
1b59af64c6 | ||
|
|
85a526d1b6 | ||
|
|
52a1a11973 | ||
|
|
4048f47777 |
@@ -55,8 +55,8 @@ STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
# --- Security & Authentication ---
|
||||
|
||||
# Rate Limiting
|
||||
# The window in milliseconds for which API requests are checked. Defaults to 900000 (15 minutes).
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
# The window in milliseconds for which API requests are checked. Defaults to 60000 (1 minute).
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
# The maximum number of API requests allowed from an IP within the window. Defaults to 100.
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/LogicLabs-OU/OpenArchiver.git
|
||||
cd open-archiver
|
||||
cd OpenArchiver
|
||||
```
|
||||
|
||||
2. **Configure your environment:**
|
||||
|
||||
@@ -71,6 +71,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Overview', link: '/api/' },
|
||||
{ text: 'Authentication', link: '/api/authentication' },
|
||||
{ text: 'Rate Limiting', link: '/api/rate-limiting' },
|
||||
{ text: 'Auth', link: '/api/auth' },
|
||||
{ text: 'Archived Email', link: '/api/archived-email' },
|
||||
{ text: 'Dashboard', link: '/api/dashboard' },
|
||||
|
||||
51
docs/api/rate-limiting.md
Normal file
51
docs/api/rate-limiting.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Rate Limiting
|
||||
|
||||
The API implements rate limiting as a security measure to protect your instance from denial-of-service (DoS) and brute-force attacks. This is a crucial feature for maintaining the security and stability of the application.
|
||||
|
||||
## How It Works
|
||||
|
||||
The rate limiter restricts the number of requests an IP address can make within a specific time frame. These limits are configurable via environment variables to suit your security needs.
|
||||
|
||||
By default, the limits are:
|
||||
|
||||
- **100 requests** per **1 minute** per IP address.
|
||||
|
||||
If this limit is exceeded, the API will respond with an HTTP `429 Too Many Requests` status code.
|
||||
|
||||
### Response Body
|
||||
|
||||
When an IP address is rate-limited, the API will return a JSON response with the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 429,
|
||||
"message": "Too many requests from this IP, please try again after 15 minutes"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can customize the rate-limiting settings by setting the following environment variables in your `.env` file:
|
||||
|
||||
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds. Defaults to `60000` (1 minute).
|
||||
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed per IP address within the time window. Defaults to `100`.
|
||||
|
||||
## Handling Rate Limits
|
||||
|
||||
If you are developing a client that interacts with the API, you should handle rate limiting gracefully:
|
||||
|
||||
1. **Check the Status Code**: Monitor for a `429` HTTP status code in responses.
|
||||
2. **Implement a Retry Mechanism**: When you receive a `429` response, it is best practice to wait before retrying the request. Implementing an exponential backoff strategy is recommended.
|
||||
3. **Check Headers**: The response will include the following standard headers to help you manage your request rate:
|
||||
- `RateLimit-Limit`: The maximum number of requests allowed in the current window.
|
||||
- `RateLimit-Remaining`: The number of requests you have left in the current window.
|
||||
- `RateLimit-Reset`: The time when the rate limit window will reset, in UTC epoch seconds.
|
||||
|
||||
## Excluded Endpoints
|
||||
|
||||
Certain essential endpoints are excluded from rate limiting to ensure the application's UI remains responsive. These are:
|
||||
|
||||
- `/auth/status`
|
||||
- `/settings/system`
|
||||
|
||||
These endpoints can be called as needed without affecting your rate limit count.
|
||||
@@ -1,50 +1,66 @@
|
||||
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'),
|
||||
expiresInDays: z.number().int().positive('Only positive number is allowed').max(730, "The API key must expire within 2 years / 730 days."),
|
||||
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'),
|
||||
expiresInDays: z
|
||||
.number()
|
||||
.int()
|
||||
.positive('Only positive number is allowed')
|
||||
.max(730, 'The API key must expire within 2 years / 730 days.'),
|
||||
});
|
||||
|
||||
export class ApiKeyController {
|
||||
public async generateApiKey(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
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) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
|
||||
const key = await ApiKeyService.generate(userId, name, expiresInDays);
|
||||
const key = await ApiKeyService.generate(userId, name, expiresInDays);
|
||||
|
||||
res.status(201).json({ key });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
}
|
||||
res.status(201).json({ key });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
}
|
||||
|
||||
public async getApiKeys(req: Request, res: Response) {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const keys = await ApiKeyService.getKeys(userId);
|
||||
public async getApiKeys(req: Request, res: Response) {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const keys = await ApiKeyService.getKeys(userId);
|
||||
|
||||
res.status(200).json(keys);
|
||||
}
|
||||
res.status(200).json(keys);
|
||||
}
|
||||
|
||||
public async deleteApiKey(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
await ApiKeyService.deleteKey(id, userId);
|
||||
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' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
await ApiKeyService.deleteKey(id, userId);
|
||||
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
}
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const requireAuth = (authService: AuthService) => {
|
||||
req.user = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles: user.role ? [user.role.name] : []
|
||||
roles: user.role ? [user.role.name] : [],
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { requireAuth } from '../middleware/requireAuth';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const apiKeyRoutes = (authService: AuthService) => {
|
||||
const router = Router();
|
||||
const controller = new ApiKeyController();
|
||||
const router = Router();
|
||||
const controller = new ApiKeyController();
|
||||
|
||||
router.post('/', requireAuth(authService), controller.generateApiKey);
|
||||
router.get('/', requireAuth(authService), controller.getApiKeys);
|
||||
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
|
||||
router.post('/', requireAuth(authService), controller.generateApiKey);
|
||||
router.get('/', requireAuth(authService), controller.getApiKeys);
|
||||
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
|
||||
|
||||
return router;
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
export const apiConfig = {
|
||||
rateLimit: {
|
||||
windowMs: process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000, // 15 minutes
|
||||
max: process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) : 100, // limit each IP to 100 requests per windowMs
|
||||
}
|
||||
rateLimit: {
|
||||
windowMs: process.env.RATE_LIMIT_WINDOW_MS
|
||||
? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10)
|
||||
: 1 * 60 * 1000, // 1 minutes
|
||||
max: process.env.RATE_LIMIT_MAX_REQUESTS
|
||||
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10)
|
||||
: 100, // limit each IP to 100 requests per windowMs
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,146 +1,146 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1755961566627,
|
||||
"tag": "0017_tranquil_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1756911118035,
|
||||
"tag": "0018_flawless_owl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1756937533843,
|
||||
"tag": "0019_confused_scream",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1755961566627,
|
||||
"tag": "0017_tranquil_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1756911118035,
|
||||
"tag": "0018_flawless_owl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1756937533843,
|
||||
"tag": "0019_confused_scream",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const apiKeys = pgTable('api_keys', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(), // Encrypted API key
|
||||
keyHash: text('key_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(), // Encrypted API key
|
||||
keyHash: text('key_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -93,7 +93,16 @@ 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 }));
|
||||
|
||||
|
||||
@@ -6,67 +6,67 @@ import { and, eq } from 'drizzle-orm';
|
||||
import { ApiKey } from '@open-archiver/types';
|
||||
|
||||
export class ApiKeyService {
|
||||
public static async generate(
|
||||
userId: string,
|
||||
name: string,
|
||||
expiresInDays: number
|
||||
): Promise<string> {
|
||||
const key = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
public static async generate(
|
||||
userId: string,
|
||||
name: string,
|
||||
expiresInDays: number
|
||||
): Promise<string> {
|
||||
const key = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
userId,
|
||||
name,
|
||||
key: CryptoService.encrypt(key),
|
||||
keyHash,
|
||||
expiresAt
|
||||
});
|
||||
await db.insert(apiKeys).values({
|
||||
userId,
|
||||
name,
|
||||
key: CryptoService.encrypt(key),
|
||||
keyHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
public static async getKeys(userId: string): Promise<ApiKey[]> {
|
||||
const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
public static async getKeys(userId: string): Promise<ApiKey[]> {
|
||||
const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
|
||||
return keys
|
||||
.map((apiKey) => {
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (!decryptedKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...apiKey,
|
||||
key: decryptedKey.slice(0, 5) + "*****",
|
||||
expiresAt: apiKey.expiresAt.toISOString(),
|
||||
createdAt: apiKey.createdAt.toISOString()
|
||||
};
|
||||
})
|
||||
.filter((k): k is NonNullable<typeof k> => k !== null);
|
||||
}
|
||||
return keys
|
||||
.map((apiKey) => {
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (!decryptedKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...apiKey,
|
||||
key: decryptedKey.slice(0, 5) + '*****',
|
||||
expiresAt: apiKey.expiresAt.toISOString(),
|
||||
createdAt: apiKey.createdAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((k): k is NonNullable<typeof k> => k !== null);
|
||||
}
|
||||
|
||||
public static async deleteKey(id: string, userId: string) {
|
||||
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param key API key
|
||||
* @returns The owner user ID or null. null means the API key is not found.
|
||||
*/
|
||||
public static async validateKey(key: string): Promise<string | null> {
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));
|
||||
if (!apiKey || apiKey.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
public static async deleteKey(id: string, userId: string) {
|
||||
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param key API key
|
||||
* @returns The owner user ID or null. null means the API key is not found.
|
||||
*/
|
||||
public static async validateKey(key: string): Promise<string | null> {
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));
|
||||
if (!apiKey || apiKey.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (decryptedKey !== key) {
|
||||
// This should not happen if the hash matches, but as a security measure, we double-check.
|
||||
return null;
|
||||
}
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (decryptedKey !== key) {
|
||||
// This should not happen if the hash matches, but as a security measure, we double-check.
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey.userId;
|
||||
}
|
||||
return apiKey.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
let finalTheme = $theme;
|
||||
|
||||
if (finalTheme === 'system') {
|
||||
finalTheme = data.settings?.theme || 'system';
|
||||
finalTheme = data.systemSettings?.theme || 'system';
|
||||
}
|
||||
|
||||
const isDark =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,49 +2,48 @@ import { api } from '$lib/server/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const response = await api('/api-keys', event);
|
||||
const apiKeys = await response.json();
|
||||
const response = await api('/api-keys', event);
|
||||
const apiKeys = await response.json();
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
};
|
||||
return {
|
||||
apiKeys,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
generate: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const name = data.get('name') as string;
|
||||
const expiresInDays = Number(data.get('expiresInDays'));
|
||||
generate: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const name = data.get('name') as string;
|
||||
const expiresInDays = Number(data.get('expiresInDays'));
|
||||
|
||||
const response = await api('/api-keys', event, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, expiresInDays }),
|
||||
});
|
||||
const response = await api('/api-keys', event, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, expiresInDays }),
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
message: responseBody.message || '',
|
||||
errors: responseBody.errors
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
message: responseBody.message || '',
|
||||
errors: responseBody.errors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
newApiKey: responseBody.key,
|
||||
};
|
||||
},
|
||||
delete: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
|
||||
return {
|
||||
newApiKey: responseBody.key,
|
||||
};
|
||||
},
|
||||
delete: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
await api(`/api-keys/${id}`, event, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
await api(`/api-keys/${id}`, event, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
Reference in New Issue
Block a user