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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

138
src/lib/storage/index.ts Normal file
View 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
}

View 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))
}
}

View 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
View 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
}