mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Pluggable storage service (local + S3 compatible)
This commit is contained in:
21
.env.example
21
.env.example
@@ -14,6 +14,25 @@ REDIS_PORT=6379
|
||||
MEILI_MASTER_KEY=aSampleMasterKey
|
||||
MEILI_HOST=http://meilisearch:7700
|
||||
|
||||
# Storage
|
||||
# Choose your storage backend. Valid options are 'local' or 's3'.
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# --- Local Storage Settings ---
|
||||
# The absolute path on the server where files will be stored.
|
||||
# This is only used if STORAGE_TYPE is 'local'.
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archive
|
||||
|
||||
# --- S3-Compatible Storage Settings ---
|
||||
# These are only used if STORAGE_TYPE is 's3'.
|
||||
STORAGE_S3_ENDPOINT=
|
||||
STORAGE_S3_BUCKET=
|
||||
STORAGE_S3_ACCESS_KEY_ID=
|
||||
STORAGE_S3_SECRET_ACCESS_KEY=
|
||||
STORAGE_S3_REGION=
|
||||
# Set to 'true' for MinIO and other non-AWS S3 services
|
||||
STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="a-very-secret-key"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
@@ -23,4 +42,4 @@ JWT_EXPIRES_IN="7d"
|
||||
# Admin users
|
||||
ADMIN_EMAIL=admin@local.com
|
||||
ADMIN_PASSWORD=a_strong_pass
|
||||
SUPER_API_KEY=
|
||||
SUPER_API_KEY=
|
||||
|
||||
132
docs/services/storage-service.md
Normal file
132
docs/services/storage-service.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Pluggable Storage Service (`StorageService`)
|
||||
|
||||
## Overview
|
||||
|
||||
The `StorageService` provides a unified, abstract interface for handling file storage across different backends. Its primary purpose is to decouple the application's core logic from the underlying storage technology. This design allows administrators to switch between storage providers (e.g., from the local filesystem to an S3-compatible object store) with only a configuration change, requiring no modifications to the application code.
|
||||
|
||||
The service is built around a standardized `IStorageProvider` interface, which guarantees that all storage providers have a consistent API for common operations like storing, retrieving, and deleting files.
|
||||
|
||||
## Configuration
|
||||
|
||||
The `StorageService` is configured via environment variables in the `.env` file. You must specify the storage backend you wish to use and provide the necessary credentials and settings for it.
|
||||
|
||||
### 1. Choosing the Backend
|
||||
|
||||
The `STORAGE_TYPE` variable determines which provider the service will use.
|
||||
|
||||
- `STORAGE_TYPE=local`: Uses the local server's filesystem.
|
||||
- `STORAGE_TYPE=s3`: Uses an S3-compatible object storage service (e.g., AWS S3, MinIO, Google Cloud Storage).
|
||||
|
||||
### 2. Local Filesystem Configuration
|
||||
|
||||
When `STORAGE_TYPE` is set to `local`, you must also provide the root path where files will be stored.
|
||||
|
||||
```env
|
||||
# .env
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archive
|
||||
```
|
||||
|
||||
- `STORAGE_LOCAL_ROOT_PATH`: The absolute path on the server where the archive will be created. The service will create subdirectories within this path as needed.
|
||||
|
||||
### 3. S3-Compatible Storage Configuration
|
||||
|
||||
When `STORAGE_TYPE` is set to `s3`, you must provide the credentials and endpoint for your object storage provider.
|
||||
|
||||
```env
|
||||
# .env
|
||||
STORAGE_TYPE=s3
|
||||
STORAGE_S3_ENDPOINT=http://127.0.0.1:9000
|
||||
STORAGE_S3_BUCKET=email-archive
|
||||
STORAGE_S3_ACCESS_KEY_ID=minioadmin
|
||||
STORAGE_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
STORAGE_S3_REGION=us-east-1
|
||||
STORAGE_S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
- `STORAGE_S3_ENDPOINT`: The full URL of the S3 API endpoint.
|
||||
- `STORAGE_S3_BUCKET`: The name of the bucket to use for storage.
|
||||
- `STORAGE_S3_ACCESS_KEY_ID`: The access key for your S3 user.
|
||||
- `STORAGE_S3_SECRET_ACCESS_KEY`: The secret key for your S3 user.
|
||||
- `STORAGE_S3_REGION` (Optional): The AWS region of your bucket. Recommended for AWS S3.
|
||||
- `STORAGE_S3_FORCE_PATH_STYLE` (Optional): Set to `true` when using non-AWS S3 services like MinIO.
|
||||
|
||||
## How to Use the Service
|
||||
|
||||
The `StorageService` is designed to be used via dependency injection in other services. You should never instantiate the providers (`LocalFileSystemProvider` or `S3StorageProvider`) directly. Instead, create an instance of `StorageService` and the factory will provide the correct provider based on your `.env` configuration.
|
||||
|
||||
### Example: Usage in `IngestionService`
|
||||
|
||||
```typescript
|
||||
import { StorageService } from './StorageService';
|
||||
|
||||
class IngestionService {
|
||||
private storageService: StorageService;
|
||||
|
||||
constructor() {
|
||||
// The StorageService is instantiated without any arguments.
|
||||
// It automatically reads the configuration from the environment.
|
||||
this.storageService = new StorageService();
|
||||
}
|
||||
|
||||
public async archiveEmail(
|
||||
rawEmail: Buffer,
|
||||
userId: string,
|
||||
messageId: string
|
||||
): Promise<void> {
|
||||
// Define a structured, unique path for the email.
|
||||
const archivePath = `${userId}/messages/${messageId}.eml`;
|
||||
|
||||
try {
|
||||
// Use the service. It doesn't know or care if this is writing
|
||||
// to a local disk or an S3 bucket.
|
||||
await this.storageService.put(archivePath, rawEmail);
|
||||
console.log(`Successfully archived email to ${archivePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to archive email ${messageId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
The `StorageService` implements the `IStorageProvider` interface. All methods are asynchronous and return a `Promise`.
|
||||
|
||||
---
|
||||
|
||||
### `put(path, content)`
|
||||
|
||||
Stores a file at the specified path. If a file already exists at that path, it will be overwritten.
|
||||
|
||||
- **`path: string`**: A unique identifier for the file, including its directory structure (e.g., `"user-123/emails/message-abc.eml"`).
|
||||
- **`content: Buffer | NodeJS.ReadableStream`**: The content of the file. It can be a `Buffer` for small files or a `ReadableStream` for large files to ensure memory efficiency.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file has been successfully stored.
|
||||
|
||||
---
|
||||
|
||||
### `get(path)`
|
||||
|
||||
Retrieves a file from the specified path as a readable stream.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to retrieve.
|
||||
- **Returns**: `Promise<NodeJS.ReadableStream>` - A promise that resolves with a readable stream of the file's content.
|
||||
- **Throws**: An `Error` if the file is not found at the specified path.
|
||||
|
||||
---
|
||||
|
||||
### `delete(path)`
|
||||
|
||||
Deletes a file from the storage backend.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to delete.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file is deleted. If the file does not exist, the promise will still resolve successfully without throwing an error.
|
||||
|
||||
---
|
||||
|
||||
### `exists(path)`
|
||||
|
||||
Checks for the existence of a file.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to check.
|
||||
- **Returns**: `Promise<boolean>` - A promise that resolves with `true` if the file exists, and `false` otherwise.
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts ",
|
||||
"build": "tsc",
|
||||
"prestart": "npm run build",
|
||||
"start": "node dist/index.js",
|
||||
"start:worker": "ts-node-dev --respawn --transpile-only src/workers/ingestion.worker.ts",
|
||||
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
@@ -13,6 +14,8 @@
|
||||
"db:migrate": "ts-node-dev src/database/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/lib-storage": "^3.844.0",
|
||||
"@open-archive/types": "workspace:*",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.56.3",
|
||||
|
||||
5
packages/backend/src/config/index.ts
Normal file
5
packages/backend/src/config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { storage } from './storage';
|
||||
|
||||
export const config = {
|
||||
storage,
|
||||
};
|
||||
38
packages/backend/src/config/storage.ts
Normal file
38
packages/backend/src/config/storage.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { StorageConfig } from '@open-archive/types';
|
||||
import 'dotenv/config';
|
||||
|
||||
const storageType = process.env.STORAGE_TYPE;
|
||||
|
||||
let storageConfig: StorageConfig;
|
||||
|
||||
if (storageType === 'local') {
|
||||
if (!process.env.STORAGE_LOCAL_ROOT_PATH) {
|
||||
throw new Error('STORAGE_LOCAL_ROOT_PATH is not defined in the environment variables');
|
||||
}
|
||||
storageConfig = {
|
||||
type: 'local',
|
||||
rootPath: process.env.STORAGE_LOCAL_ROOT_PATH,
|
||||
};
|
||||
} else if (storageType === 's3') {
|
||||
if (
|
||||
!process.env.STORAGE_S3_ENDPOINT ||
|
||||
!process.env.STORAGE_S3_BUCKET ||
|
||||
!process.env.STORAGE_S3_ACCESS_KEY_ID ||
|
||||
!process.env.STORAGE_S3_SECRET_ACCESS_KEY
|
||||
) {
|
||||
throw new Error('One or more S3 storage environment variables are not defined');
|
||||
}
|
||||
storageConfig = {
|
||||
type: 's3',
|
||||
endpoint: process.env.STORAGE_S3_ENDPOINT,
|
||||
bucket: process.env.STORAGE_S3_BUCKET,
|
||||
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.STORAGE_S3_SECRET_ACCESS_KEY,
|
||||
region: process.env.STORAGE_S3_REGION,
|
||||
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true',
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Invalid STORAGE_TYPE: ${storageType}`);
|
||||
}
|
||||
|
||||
export const storage = storageConfig;
|
||||
@@ -9,6 +9,7 @@ import { AuthService } from './services/AuthService';
|
||||
import { AdminUserService } from './services/UserService';
|
||||
|
||||
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
|
||||
37
packages/backend/src/services/StorageService.ts
Normal file
37
packages/backend/src/services/StorageService.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IStorageProvider, StorageConfig } from '@open-archive/types';
|
||||
import { LocalFileSystemProvider } from './storage/LocalFileSystemProvider';
|
||||
import { S3StorageProvider } from './storage/S3StorageProvider';
|
||||
import { config } from '../config/index';
|
||||
|
||||
export class StorageService implements IStorageProvider {
|
||||
private provider: IStorageProvider;
|
||||
|
||||
constructor(storageConfig: StorageConfig = config.storage) {
|
||||
switch (storageConfig.type) {
|
||||
case 'local':
|
||||
this.provider = new LocalFileSystemProvider(storageConfig);
|
||||
break;
|
||||
case 's3':
|
||||
this.provider = new S3StorageProvider(storageConfig);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid storage provider type');
|
||||
}
|
||||
}
|
||||
|
||||
put(path: string, content: Buffer | NodeJS.ReadableStream): Promise<void> {
|
||||
return this.provider.put(path, content);
|
||||
}
|
||||
|
||||
get(path: string): Promise<NodeJS.ReadableStream> {
|
||||
return this.provider.get(path);
|
||||
}
|
||||
|
||||
delete(path: string): Promise<void> {
|
||||
return this.provider.delete(path);
|
||||
}
|
||||
|
||||
exists(path: string): Promise<boolean> {
|
||||
return this.provider.exists(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IStorageProvider, LocalStorageConfig } from '@open-archive/types';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
export class LocalFileSystemProvider implements IStorageProvider {
|
||||
private readonly rootPath: string;
|
||||
|
||||
constructor(config: LocalStorageConfig) {
|
||||
this.rootPath = config.rootPath;
|
||||
}
|
||||
|
||||
async put(filePath: string, content: Buffer | NodeJS.ReadableStream): Promise<void> {
|
||||
const fullPath = path.join(this.rootPath, filePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
if (Buffer.isBuffer(content)) {
|
||||
await fs.writeFile(fullPath, content);
|
||||
} else {
|
||||
const writeStream = createWriteStream(fullPath);
|
||||
await pipeline(content, writeStream);
|
||||
}
|
||||
}
|
||||
|
||||
async get(filePath: string): Promise<NodeJS.ReadableStream> {
|
||||
const fullPath = path.join(this.rootPath, filePath);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return createReadStream(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
const fullPath = path.join(this.rootPath, filePath);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
const fullPath = path.join(this.rootPath, filePath);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/backend/src/services/storage/S3StorageProvider.ts
Normal file
86
packages/backend/src/services/storage/S3StorageProvider.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { IStorageProvider, S3StorageConfig } from '@open-archive/types';
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
NotFound,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export class S3StorageProvider implements IStorageProvider {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(config: S3StorageConfig) {
|
||||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
});
|
||||
this.bucket = config.bucket;
|
||||
}
|
||||
|
||||
async put(path: string, content: Buffer | NodeJS.ReadableStream): Promise<void> {
|
||||
const upload = new Upload({
|
||||
client: this.client,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
Body: content instanceof Readable ? content : Readable.from(content),
|
||||
},
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
}
|
||||
|
||||
async get(path: string): Promise<NodeJS.ReadableStream> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.client.send(command);
|
||||
if (response.Body instanceof Readable) {
|
||||
return response.Body;
|
||||
}
|
||||
throw new Error('Readable stream not found in S3 response');
|
||||
} catch (error) {
|
||||
if (error instanceof NotFound) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
});
|
||||
await this.client.send(command);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.client.send(command);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFound) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/backend/tsconfig.tsbuildinfo
Normal file
1
packages/backend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-archive/types": "workspace:*",
|
||||
"jose": "^6.0.1",
|
||||
"lucide-svelte": "^0.525.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
9
packages/frontend/src/app.d.ts
vendored
9
packages/frontend/src/app.d.ts
vendored
@@ -1,13 +1,18 @@
|
||||
import type { User } from '@open-archive/types';
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: Omit<User, 'passwordHash'> | null;
|
||||
accessToken: string | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
||||
26
packages/frontend/src/hooks.server.ts
Normal file
26
packages/frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { jwtVerify } from 'jose';
|
||||
import type { User } from '@open-archive/types';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode('a-very-secret-key');
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const token = event.cookies.get('accessToken');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
event.locals.user = payload as Omit<User, 'passwordHash'>;
|
||||
event.locals.accessToken = token;
|
||||
} catch (error) {
|
||||
console.error('JWT verification failed:', error);
|
||||
event.locals.user = null;
|
||||
event.locals.accessToken = null;
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.locals.accessToken = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -1,23 +1,20 @@
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import type { User } from '@open-archive/types';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const BASE_URL = '/api/v1'; // Using a relative URL for proxying
|
||||
|
||||
/**
|
||||
* A custom fetch wrapper to automatically handle authentication headers.
|
||||
* A custom fetch wrapper for the client-side to automatically handle authentication headers.
|
||||
* @param url The URL to fetch, relative to the API base.
|
||||
* @param options The standard Fetch API options.
|
||||
* @returns A Promise that resolves to the Fetch Response.
|
||||
*/
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
export const api = async (
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
customFetch: Fetch = fetch
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> => {
|
||||
const { accessToken } = get(authStore);
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
@@ -34,5 +31,5 @@ export const api = async (
|
||||
}
|
||||
};
|
||||
|
||||
return customFetch(`${BASE_URL}${url}`, mergedOptions);
|
||||
return fetch(`${BASE_URL}${url}`, mergedOptions);
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Root from "./navigation-menu.svelte";
|
||||
import Content from "./navigation-menu-content.svelte";
|
||||
import Indicator from "./navigation-menu-indicator.svelte";
|
||||
import Item from "./navigation-menu-item.svelte";
|
||||
import Link from "./navigation-menu-link.svelte";
|
||||
import List from "./navigation-menu-list.svelte";
|
||||
import Trigger from "./navigation-menu-trigger.svelte";
|
||||
import Viewport from "./navigation-menu-viewport.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Indicator,
|
||||
Item,
|
||||
Link,
|
||||
List,
|
||||
Trigger,
|
||||
Viewport,
|
||||
//
|
||||
Root as NavigationMenuRoot,
|
||||
Content as NavigationMenuContent,
|
||||
Indicator as NavigationMenuIndicator,
|
||||
Item as NavigationMenuItem,
|
||||
Link as NavigationMenuLink,
|
||||
List as NavigationMenuList,
|
||||
Trigger as NavigationMenuTrigger,
|
||||
Viewport as NavigationMenuViewport,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="navigation-menu-content"
|
||||
class={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 left-0 top-0 w-full md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.IndicatorProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
bind:ref
|
||||
data-slot="navigation-menu-indicator"
|
||||
class={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"></div>
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="navigation-menu-item"
|
||||
class={cn("relative", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.LinkProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Link
|
||||
bind:ref
|
||||
data-slot="navigation-menu-link"
|
||||
class={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.List
|
||||
bind:ref
|
||||
data-slot="navigation-menu-list"
|
||||
class={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" module>
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { tv } from "tailwind-variants";
|
||||
|
||||
export const navigationMenuTriggerStyle = tv({
|
||||
base: "bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="navigation-menu-trigger"
|
||||
class={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
<ChevronDownIcon
|
||||
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ViewportProps = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("absolute left-0 top-full isolate z-50 flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
bind:ref
|
||||
data-slot="navigation-menu-viewport"
|
||||
class={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--bits-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--bits-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
viewport = true,
|
||||
children,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.RootProps & {
|
||||
viewport?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
class={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
{#if viewport}
|
||||
<NavigationMenuViewport />
|
||||
{/if}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
36
packages/frontend/src/lib/server/api.ts
Normal file
36
packages/frontend/src/lib/server/api.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const BASE_URL = '/api/v1'; // Using a relative URL for proxying
|
||||
|
||||
/**
|
||||
* A custom fetch wrapper for the server-side to automatically handle authentication headers.
|
||||
* @param url The URL to fetch, relative to the API base.
|
||||
* @param event The SvelteKit request event.
|
||||
* @param options The standard Fetch API options.
|
||||
* @returns A Promise that resolves to the Fetch Response.
|
||||
*/
|
||||
export const api = async (
|
||||
url: string,
|
||||
event: RequestEvent,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> => {
|
||||
const accessToken = event.cookies.get('accessToken');
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const mergedOptions: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
return event.fetch(`${BASE_URL}${url}`, mergedOptions);
|
||||
};
|
||||
@@ -13,48 +13,33 @@ const initialValue: AuthState = {
|
||||
};
|
||||
|
||||
// Function to get the initial state from localStorage
|
||||
const getInitialState = (): AuthState => {
|
||||
if (!browser) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem('accessToken');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
return {
|
||||
accessToken: storedToken,
|
||||
user: JSON.parse(storedUser),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user from localStorage', e);
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
return initialValue;
|
||||
};
|
||||
|
||||
const createAuthStore = () => {
|
||||
const { subscribe, set } = writable<AuthState>(getInitialState());
|
||||
const { subscribe, set } = writable<AuthState>(initialValue);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
login: (accessToken: string, user: Omit<User, 'passwordHash'>) => {
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
document.cookie = `accessToken=${accessToken}; path=/; max-age=604800; samesite=strict`;
|
||||
}
|
||||
set({ accessToken, user });
|
||||
},
|
||||
logout: () => {
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('user');
|
||||
document.cookie = 'accessToken=; path=/; max-age=-1; samesite=strict';
|
||||
}
|
||||
set(initialValue);
|
||||
},
|
||||
syncWithServer: (
|
||||
user: Omit<User, 'passwordHash'> | null,
|
||||
accessToken: string | null
|
||||
) => {
|
||||
if (user && accessToken) {
|
||||
set({ accessToken, user });
|
||||
} else {
|
||||
set(initialValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
8
packages/frontend/src/routes/+layout.server.ts
Normal file
8
packages/frontend/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
accessToken: locals.accessToken
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
authStore.syncWithServer(data.user, data.accessToken);
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
|
||||
// List of routes that are accessible to unauthenticated users.
|
||||
const publicRoutes = ['/signin'];
|
||||
|
||||
export const load = ({ url }) => {
|
||||
// Route protection should only run on the client side where the authStore
|
||||
// is reliably hydrated from localStorage.
|
||||
if (browser) {
|
||||
const { accessToken } = get(authStore);
|
||||
const isPublicRoute = publicRoutes.includes(url.pathname);
|
||||
|
||||
// If the user is not logged in and trying to access a private route...
|
||||
if (!accessToken && !isPublicRoute) {
|
||||
// ...redirect them to the sign-in page.
|
||||
throw redirect(307, '/signin');
|
||||
}
|
||||
|
||||
// If the user is logged in and trying to access a public route (like /signin)...
|
||||
if (accessToken && isPublicRoute) {
|
||||
// ...redirect them to the dashboard.
|
||||
throw redirect(307, '/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// For all other cases, allow the page to load.
|
||||
return {};
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = () => {
|
||||
// This logic should only run on the client side where the authStore is hydrated
|
||||
// from localStorage.
|
||||
if (browser) {
|
||||
const { accessToken } = get(authStore);
|
||||
|
||||
if (accessToken) {
|
||||
// If logged in, go to the dashboard.
|
||||
throw redirect(307, '/dashboard');
|
||||
} else {
|
||||
// If not logged in, go to the sign-in page.
|
||||
throw redirect(307, '/signin');
|
||||
}
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
if (user) {
|
||||
throw redirect(307, '/dashboard');
|
||||
} else {
|
||||
throw redirect(307, '/signin');
|
||||
}
|
||||
|
||||
// On the server, we don't know the auth state, so we don't redirect.
|
||||
// The client-side navigation will take over.
|
||||
return {};
|
||||
};
|
||||
|
||||
11
packages/frontend/src/routes/dashboard/+layout.server.ts
Normal file
11
packages/frontend/src/routes/dashboard/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw redirect(302, '/signin');
|
||||
}
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
40
packages/frontend/src/routes/dashboard/+layout.svelte
Normal file
40
packages/frontend/src/routes/dashboard/+layout.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard' },
|
||||
{ href: '/dashboard/ingestions', label: 'Ingestions' }
|
||||
];
|
||||
let { children } = $props();
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
goto('/signin');
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="bg-background sticky top-0 z-40 border-b">
|
||||
<div class="container mx-auto flex h-16 flex-row items-center justify-between">
|
||||
<a href="/dashboard" class="text-primary text-lg font-semibold"> OpenArchive </a>
|
||||
<NavigationMenu.Root>
|
||||
<NavigationMenu.List class="flex items-center space-x-4">
|
||||
{#each navItems as item}
|
||||
<NavigationMenu.Item
|
||||
class={page.url.pathname === item.href ? 'bg-accent rounded-md' : ''}
|
||||
>
|
||||
<NavigationMenu.Link href={item.href}>
|
||||
{item.label}
|
||||
</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{/each}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
<Button onclick={handleLogout} variant="outline">Logout</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto my-10">
|
||||
{@render children()}
|
||||
</main>
|
||||
@@ -1,22 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
goto('/signin');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - OpenArchive</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<Button onclick={handleLogout}>Logout</Button>
|
||||
</div>
|
||||
<p class="mt-4">Welcome, {$authStore.user?.email}!</p>
|
||||
<p>You are logged in.</p>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
import { api } from '$lib/server/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { IngestionSource } from '@open-archive/types';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const response = await api('/ingestion-sources', {}, fetch);
|
||||
const response = await api('/ingestion-sources', event);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ingestion sources: ${response.statusText}`);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { MoreHorizontal } from 'lucide-svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import IngestionSourceForm from '$lib/components/custom/IngestionSourceForm.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api } from '$lib/api.client';
|
||||
import type { IngestionSource, CreateIngestionSourceDto } from '@open-archive/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -69,7 +69,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-10">
|
||||
<div class="">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Ingestion Sources</h1>
|
||||
<Button onclick={openCreateDialog}>Create New</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { api } from '$lib/api';
|
||||
import { api } from '$lib/api.client';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import type { LoginResponse } from '@open-archive/types';
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to login');
|
||||
@@ -29,7 +28,6 @@
|
||||
|
||||
const loginData: LoginResponse = await response.json();
|
||||
authStore.login(loginData.accessToken, loginData.user);
|
||||
|
||||
// Redirect to a protected page after login
|
||||
goto('/dashboard');
|
||||
} catch (e: any) {
|
||||
@@ -52,7 +50,7 @@
|
||||
<Card.Description>Enter your email below to login to your account.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid gap-4">
|
||||
<form onsubmit={handleSubmit}>
|
||||
<form onsubmit={handleSubmit} class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" bind:value={email} required />
|
||||
@@ -66,7 +64,7 @@
|
||||
<p class="mt-2 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" class="mt-4 w-full" disabled={isLoading}>
|
||||
<Button type="submit" class=" w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './auth.types';
|
||||
export * from './user.types';
|
||||
export * from './ingestion.types';
|
||||
export * from './storage.types';
|
||||
|
||||
69
packages/types/src/storage.types.ts
Normal file
69
packages/types/src/storage.types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// packages/types/src/storage.types.ts
|
||||
|
||||
/**
|
||||
* Defines the contract that all storage providers must implement.
|
||||
* It uses streams to efficiently handle potentially large files without
|
||||
* loading them entirely into memory.
|
||||
*/
|
||||
export interface IStorageProvider {
|
||||
/**
|
||||
* Stores a file at the specified path.
|
||||
* @param path - The unique identifier for the file (e.g., "user-123/emails/message-abc.eml").
|
||||
* @param content - The file content as a Buffer or a ReadableStream.
|
||||
* @returns A promise that resolves when the file is successfully stored.
|
||||
*/
|
||||
put(path: string, content: Buffer | NodeJS.ReadableStream): Promise<void>;
|
||||
|
||||
/**
|
||||
* Retrieves a file from the specified path as a readable stream.
|
||||
* @param path - The unique identifier for the file to retrieve.
|
||||
* @returns A promise that resolves with a readable stream of the file's content.
|
||||
* @throws {Error} If the file is not found.
|
||||
*/
|
||||
get(path: string): Promise<NodeJS.ReadableStream>;
|
||||
|
||||
/**
|
||||
* Deletes a file from the storage backend.
|
||||
* @param path - The unique identifier for the file to delete.
|
||||
* @returns A promise that resolves when the file is deleted.
|
||||
*/
|
||||
delete(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Checks for the existence of a file.
|
||||
* @param path - The unique identifier for the file to check.
|
||||
* @returns A promise that resolves with true if the file exists, false otherwise.
|
||||
*/
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the Local Filesystem provider.
|
||||
*/
|
||||
export interface LocalStorageConfig {
|
||||
type: 'local';
|
||||
// The absolute root path on the server where the archive will be stored.
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for any S3-compatible provider (AWS S3, MinIO, etc.).
|
||||
*/
|
||||
export interface S3StorageConfig {
|
||||
type: 's3';
|
||||
// The API endpoint. For AWS S3, this is region-specific (e.g., 'https://s3.us-east-1.amazonaws.com').
|
||||
// For MinIO, this is the address of your MinIO server (e.g., 'http://localhost:9000').
|
||||
endpoint: string;
|
||||
// The name of the bucket to use.
|
||||
bucket: string;
|
||||
// The access key ID for authentication.
|
||||
accessKeyId: string;
|
||||
// The secret access key for authentication.
|
||||
secretAccessKey: string;
|
||||
// The AWS region (optional but recommended for AWS S3).
|
||||
region?: string;
|
||||
// Force path-style addressing, required for MinIO.
|
||||
forcePathStyle?: boolean;
|
||||
}
|
||||
|
||||
export type StorageConfig = LocalStorageConfig | S3StorageConfig;
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user