Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
138
src/lib/storage/index.ts
Normal file
138
src/lib/storage/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { StorageProvider, StorageProviderType } from './types'
|
||||
import { S3StorageProvider } from './s3-provider'
|
||||
import { LocalStorageProvider } from './local-provider'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type { StorageProvider, StorageProviderType } from './types'
|
||||
export { S3StorageProvider } from './s3-provider'
|
||||
export { LocalStorageProvider } from './local-provider'
|
||||
|
||||
// Cached provider instance
|
||||
let cachedProvider: StorageProvider | null = null
|
||||
let cachedProviderType: StorageProviderType | null = null
|
||||
|
||||
/**
|
||||
* Get the configured storage provider type from system settings
|
||||
*/
|
||||
async function getProviderTypeFromSettings(): Promise<StorageProviderType> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'storage_provider' },
|
||||
})
|
||||
const value = setting?.value as StorageProviderType | undefined
|
||||
return value === 'local' ? 'local' : 's3' // Default to S3
|
||||
} catch {
|
||||
// If settings table doesn't exist or error, default to S3
|
||||
return 's3'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current storage provider type from settings
|
||||
*/
|
||||
export async function getCurrentProviderType(): Promise<StorageProviderType> {
|
||||
return getProviderTypeFromSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a storage provider instance based on system settings
|
||||
* Caches the provider for performance
|
||||
*/
|
||||
export async function getStorageProvider(): Promise<StorageProvider> {
|
||||
const providerType = await getProviderTypeFromSettings()
|
||||
|
||||
// Return cached provider if type hasn't changed
|
||||
if (cachedProvider && cachedProviderType === providerType) {
|
||||
return cachedProvider
|
||||
}
|
||||
|
||||
// Create new provider
|
||||
if (providerType === 'local') {
|
||||
cachedProvider = new LocalStorageProvider()
|
||||
} else {
|
||||
cachedProvider = new S3StorageProvider()
|
||||
}
|
||||
cachedProviderType = providerType
|
||||
|
||||
return cachedProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a storage provider and its type together
|
||||
*/
|
||||
export async function getStorageProviderWithType(): Promise<{
|
||||
provider: StorageProvider
|
||||
providerType: StorageProviderType
|
||||
}> {
|
||||
const providerType = await getProviderTypeFromSettings()
|
||||
const provider = await getStorageProvider()
|
||||
return { provider, providerType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a specific storage provider (bypasses settings)
|
||||
*/
|
||||
export function createStorageProvider(type: StorageProviderType): StorageProvider {
|
||||
if (type === 'local') {
|
||||
return new LocalStorageProvider()
|
||||
}
|
||||
return new S3StorageProvider()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached provider (call when settings change)
|
||||
*/
|
||||
export function clearStorageProviderCache(): void {
|
||||
cachedProvider = null
|
||||
cachedProviderType = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique storage key for avatars
|
||||
*/
|
||||
export function generateAvatarKey(userId: string, fileName: string): string {
|
||||
const timestamp = Date.now()
|
||||
const ext = fileName.split('.').pop() || 'jpg'
|
||||
return `avatars/${userId}/${timestamp}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique storage key for project logos
|
||||
*/
|
||||
export function generateLogoKey(projectId: string, fileName: string): string {
|
||||
const timestamp = Date.now()
|
||||
const ext = fileName.split('.').pop() || 'png'
|
||||
return `logos/${projectId}/${timestamp}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
*/
|
||||
export function getContentType(fileName: string): string {
|
||||
const ext = fileName.toLowerCase().split('.').pop()
|
||||
const types: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
}
|
||||
return types[ext || ''] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image file type
|
||||
*/
|
||||
export function isValidImageType(contentType: string): boolean {
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
return validTypes.includes(contentType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image file size (default 5MB max)
|
||||
*/
|
||||
export function isValidImageSize(sizeBytes: number, maxMB: number = 5): boolean {
|
||||
return sizeBytes <= maxMB * 1024 * 1024
|
||||
}
|
||||
127
src/lib/storage/local-provider.ts
Normal file
127
src/lib/storage/local-provider.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { StorageProvider } from './types'
|
||||
|
||||
const SECRET_KEY = process.env.NEXTAUTH_SECRET || 'local-storage-secret'
|
||||
const DEFAULT_BASE_PATH = './uploads'
|
||||
|
||||
/**
|
||||
* Local Filesystem Storage Provider
|
||||
*
|
||||
* Stores files in the local filesystem with signed URLs for access control.
|
||||
* Suitable for development/testing or deployments without S3.
|
||||
*/
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
private basePath: string
|
||||
private baseUrl: string
|
||||
|
||||
constructor(basePath?: string) {
|
||||
this.basePath = basePath || process.env.LOCAL_STORAGE_PATH || DEFAULT_BASE_PATH
|
||||
this.baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed URL for secure file access
|
||||
*/
|
||||
private generateSignedUrl(
|
||||
key: string,
|
||||
action: 'upload' | 'download',
|
||||
expirySeconds: number
|
||||
): string {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
|
||||
const payload = `${action}:${key}:${expiresAt}`
|
||||
const signature = createHmac('sha256', SECRET_KEY)
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
key,
|
||||
action,
|
||||
expires: expiresAt.toString(),
|
||||
sig: signature,
|
||||
})
|
||||
|
||||
return `${this.baseUrl}/api/storage/local?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed URL signature
|
||||
*/
|
||||
static verifySignature(
|
||||
key: string,
|
||||
action: string,
|
||||
expiresAt: number,
|
||||
signature: string
|
||||
): boolean {
|
||||
const payload = `${action}:${key}:${expiresAt}`
|
||||
const expectedSignature = createHmac('sha256', SECRET_KEY)
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
|
||||
return signature === expectedSignature && expiresAt > Date.now() / 1000
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
// Sanitize key to prevent path traversal
|
||||
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '')
|
||||
return path.join(this.basePath, sanitizedKey)
|
||||
}
|
||||
|
||||
private async ensureDirectory(filePath: string): Promise<void> {
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
async getUploadUrl(
|
||||
key: string,
|
||||
_contentType: string,
|
||||
expirySeconds: number = 900
|
||||
): Promise<string> {
|
||||
return this.generateSignedUrl(key, 'upload', expirySeconds)
|
||||
}
|
||||
|
||||
async getDownloadUrl(key: string, expirySeconds: number = 900): Promise<string> {
|
||||
return this.generateSignedUrl(key, 'download', expirySeconds)
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key)
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error) {
|
||||
// Ignore if file doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async putObject(key: string, data: Buffer, _contentType: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key)
|
||||
await this.ensureDirectory(filePath)
|
||||
await fs.writeFile(filePath, data)
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<Buffer> {
|
||||
const filePath = this.getFilePath(key)
|
||||
return fs.readFile(filePath)
|
||||
}
|
||||
|
||||
async objectExists(key: string): Promise<boolean> {
|
||||
const filePath = this.getFilePath(key)
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for direct file serving (used by API route)
|
||||
*/
|
||||
getAbsoluteFilePath(key: string): string {
|
||||
return path.resolve(this.getFilePath(key))
|
||||
}
|
||||
}
|
||||
64
src/lib/storage/s3-provider.ts
Normal file
64
src/lib/storage/s3-provider.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { StorageProvider } from './types'
|
||||
import {
|
||||
minio,
|
||||
BUCKET_NAME,
|
||||
getPresignedUrl,
|
||||
deleteObject as minioDeleteObject,
|
||||
ensureBucket,
|
||||
} from '@/lib/minio'
|
||||
|
||||
/**
|
||||
* S3/MinIO Storage Provider
|
||||
*
|
||||
* Uses the existing MinIO client for S3-compatible storage.
|
||||
* Pre-signed URLs use MINIO_PUBLIC_ENDPOINT for browser access.
|
||||
*/
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
private bucket: string
|
||||
|
||||
constructor(bucket?: string) {
|
||||
this.bucket = bucket || BUCKET_NAME
|
||||
}
|
||||
|
||||
async getUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expirySeconds: number = 900
|
||||
): Promise<string> {
|
||||
await ensureBucket(this.bucket)
|
||||
return getPresignedUrl(this.bucket, key, 'PUT', expirySeconds)
|
||||
}
|
||||
|
||||
async getDownloadUrl(key: string, expirySeconds: number = 900): Promise<string> {
|
||||
return getPresignedUrl(this.bucket, key, 'GET', expirySeconds)
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
await minioDeleteObject(this.bucket, key)
|
||||
}
|
||||
|
||||
async putObject(key: string, data: Buffer, contentType: string): Promise<void> {
|
||||
await ensureBucket(this.bucket)
|
||||
await minio.putObject(this.bucket, key, data, data.length, {
|
||||
'Content-Type': contentType,
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<Buffer> {
|
||||
const stream = await minio.getObject(this.bucket, key)
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk as Buffer)
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
async objectExists(key: string): Promise<boolean> {
|
||||
try {
|
||||
await minio.statObject(this.bucket, key)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/lib/storage/types.ts
Normal file
57
src/lib/storage/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Storage Provider Interface
|
||||
*
|
||||
* Abstracts file storage operations to support multiple backends:
|
||||
* - S3/MinIO: Production storage with pre-signed URLs
|
||||
* - Local: Development/testing with filesystem storage
|
||||
*/
|
||||
|
||||
export type StorageProvider = {
|
||||
/**
|
||||
* Get a pre-signed URL for uploading a file
|
||||
*/
|
||||
getUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expirySeconds?: number
|
||||
): Promise<string>
|
||||
|
||||
/**
|
||||
* Get a pre-signed URL for downloading/viewing a file
|
||||
*/
|
||||
getDownloadUrl(key: string, expirySeconds?: number): Promise<string>
|
||||
|
||||
/**
|
||||
* Delete an object from storage
|
||||
*/
|
||||
deleteObject(key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Upload data directly (server-side upload)
|
||||
*/
|
||||
putObject(key: string, data: Buffer, contentType: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Download data directly (server-side download)
|
||||
*/
|
||||
getObject(key: string): Promise<Buffer>
|
||||
|
||||
/**
|
||||
* Check if an object exists
|
||||
*/
|
||||
objectExists(key: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export type StorageProviderType = 's3' | 'local'
|
||||
|
||||
export type StorageConfig = {
|
||||
provider: StorageProviderType
|
||||
bucket: string
|
||||
// S3-specific
|
||||
s3Endpoint?: string
|
||||
s3PublicEndpoint?: string
|
||||
s3AccessKey?: string
|
||||
s3SecretKey?: string
|
||||
// Local-specific
|
||||
localBasePath?: string
|
||||
}
|
||||
Reference in New Issue
Block a user