create-blitzpack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +452 -0
- package/package.json +57 -0
- package/template/.dockerignore +59 -0
- package/template/.github/workflows/ci.yml +157 -0
- package/template/.husky/pre-commit +1 -0
- package/template/.husky/pre-push +1 -0
- package/template/.lintstagedrc.cjs +4 -0
- package/template/.nvmrc +1 -0
- package/template/.prettierrc +9 -0
- package/template/.vscode/settings.json +13 -0
- package/template/CLAUDE.md +175 -0
- package/template/CONTRIBUTING.md +32 -0
- package/template/Dockerfile +90 -0
- package/template/GETTING_STARTED.md +35 -0
- package/template/LICENSE +21 -0
- package/template/README.md +116 -0
- package/template/apps/api/.dockerignore +51 -0
- package/template/apps/api/.env.local.example +62 -0
- package/template/apps/api/emails/account-deleted-email.tsx +69 -0
- package/template/apps/api/emails/components/email-layout.tsx +154 -0
- package/template/apps/api/emails/config.ts +22 -0
- package/template/apps/api/emails/password-changed-email.tsx +88 -0
- package/template/apps/api/emails/password-reset-email.tsx +86 -0
- package/template/apps/api/emails/verification-email.tsx +85 -0
- package/template/apps/api/emails/welcome-email.tsx +70 -0
- package/template/apps/api/package.json +84 -0
- package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
- package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
- package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
- package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
- package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
- package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
- package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
- package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
- package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
- package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +116 -0
- package/template/apps/api/prisma/seed.ts +159 -0
- package/template/apps/api/prisma.config.ts +14 -0
- package/template/apps/api/src/app.ts +377 -0
- package/template/apps/api/src/common/logger.service.ts +227 -0
- package/template/apps/api/src/config/env.ts +60 -0
- package/template/apps/api/src/config/rate-limit.ts +29 -0
- package/template/apps/api/src/hooks/auth.ts +122 -0
- package/template/apps/api/src/plugins/auth.ts +198 -0
- package/template/apps/api/src/plugins/database.ts +45 -0
- package/template/apps/api/src/plugins/logger.ts +33 -0
- package/template/apps/api/src/plugins/multipart.ts +16 -0
- package/template/apps/api/src/plugins/scalar.ts +20 -0
- package/template/apps/api/src/plugins/schedule.ts +52 -0
- package/template/apps/api/src/plugins/services.ts +66 -0
- package/template/apps/api/src/plugins/swagger.ts +56 -0
- package/template/apps/api/src/routes/accounts.ts +91 -0
- package/template/apps/api/src/routes/admin-sessions.ts +92 -0
- package/template/apps/api/src/routes/metrics.ts +71 -0
- package/template/apps/api/src/routes/password.ts +46 -0
- package/template/apps/api/src/routes/sessions.ts +53 -0
- package/template/apps/api/src/routes/stats.ts +38 -0
- package/template/apps/api/src/routes/uploads-serve.ts +27 -0
- package/template/apps/api/src/routes/uploads.ts +154 -0
- package/template/apps/api/src/routes/users.ts +114 -0
- package/template/apps/api/src/routes/verification.ts +90 -0
- package/template/apps/api/src/server.ts +34 -0
- package/template/apps/api/src/services/accounts.service.ts +125 -0
- package/template/apps/api/src/services/authorization.service.ts +162 -0
- package/template/apps/api/src/services/email.service.ts +170 -0
- package/template/apps/api/src/services/file-storage.service.ts +267 -0
- package/template/apps/api/src/services/metrics.service.ts +175 -0
- package/template/apps/api/src/services/password.service.ts +56 -0
- package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
- package/template/apps/api/src/services/sessions.service.ts +276 -0
- package/template/apps/api/src/services/stats.service.ts +273 -0
- package/template/apps/api/src/services/uploads.service.ts +163 -0
- package/template/apps/api/src/services/users.service.spec.ts +249 -0
- package/template/apps/api/src/services/users.service.ts +198 -0
- package/template/apps/api/src/utils/file-validation.ts +108 -0
- package/template/apps/api/start.sh +33 -0
- package/template/apps/api/test/helpers/fastify-app.ts +24 -0
- package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
- package/template/apps/api/test/helpers/mock-logger.ts +28 -0
- package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
- package/template/apps/api/test/helpers/test-db.ts +125 -0
- package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
- package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
- package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
- package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
- package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
- package/template/apps/api/test/setup.ts +31 -0
- package/template/apps/api/tsconfig.json +26 -0
- package/template/apps/api/vitest.config.ts +35 -0
- package/template/apps/web/.env.local.example +11 -0
- package/template/apps/web/components.json +24 -0
- package/template/apps/web/next.config.ts +22 -0
- package/template/apps/web/package.json +56 -0
- package/template/apps/web/postcss.config.js +5 -0
- package/template/apps/web/public/apple-icon.png +0 -0
- package/template/apps/web/public/icon.png +0 -0
- package/template/apps/web/public/robots.txt +3 -0
- package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
- package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
- package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
- package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
- package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
- package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
- package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
- package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
- package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
- package/template/apps/web/src/app/dashboard/page.tsx +296 -0
- package/template/apps/web/src/app/error.tsx +32 -0
- package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
- package/template/apps/web/src/app/favicon.ico +0 -0
- package/template/apps/web/src/app/global-error.tsx +96 -0
- package/template/apps/web/src/app/globals.css +22 -0
- package/template/apps/web/src/app/icon.png +0 -0
- package/template/apps/web/src/app/layout.tsx +34 -0
- package/template/apps/web/src/app/not-found.tsx +28 -0
- package/template/apps/web/src/app/page.tsx +192 -0
- package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
- package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
- package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
- package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
- package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
- package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
- package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
- package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
- package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
- package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
- package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
- package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
- package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
- package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
- package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
- package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
- package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
- package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
- package/template/apps/web/src/components/auth/github-button.tsx +40 -0
- package/template/apps/web/src/components/auth/google-button.tsx +54 -0
- package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
- package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
- package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
- package/template/apps/web/src/components/error/error-card.tsx +47 -0
- package/template/apps/web/src/components/error/forbidden.tsx +25 -0
- package/template/apps/web/src/components/landing/command-block.tsx +64 -0
- package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
- package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
- package/template/apps/web/src/components/landing/logo.tsx +41 -0
- package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
- package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
- package/template/apps/web/src/components/layout/footer.tsx +3 -0
- package/template/apps/web/src/config/landing-data.ts +152 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
- package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
- package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
- package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
- package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
- package/template/apps/web/src/hooks/api/use-users.ts +149 -0
- package/template/apps/web/src/hooks/use-mobile.ts +21 -0
- package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
- package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
- package/template/apps/web/src/lib/api.ts +151 -0
- package/template/apps/web/src/lib/auth.ts +13 -0
- package/template/apps/web/src/lib/env.ts +52 -0
- package/template/apps/web/src/lib/form-utils.ts +11 -0
- package/template/apps/web/src/lib/utils.ts +1 -0
- package/template/apps/web/src/providers.tsx +34 -0
- package/template/apps/web/src/store/atoms.ts +15 -0
- package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
- package/template/apps/web/src/test/setup.ts +8 -0
- package/template/apps/web/tailwind.config.ts +5 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/vitest.config.ts +32 -0
- package/template/assets/logo-512.png +0 -0
- package/template/assets/logo.svg +4 -0
- package/template/docker-compose.prod.yml +66 -0
- package/template/docker-compose.yml +36 -0
- package/template/eslint.config.ts +119 -0
- package/template/package.json +77 -0
- package/template/packages/tailwind-config/package.json +9 -0
- package/template/packages/tailwind-config/theme.css +179 -0
- package/template/packages/types/package.json +29 -0
- package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
- package/template/packages/types/src/api-response.ts +53 -0
- package/template/packages/types/src/health-check.ts +11 -0
- package/template/packages/types/src/pagination.ts +41 -0
- package/template/packages/types/src/role.ts +5 -0
- package/template/packages/types/src/session.ts +48 -0
- package/template/packages/types/src/stats.ts +113 -0
- package/template/packages/types/src/upload.ts +51 -0
- package/template/packages/types/src/user.ts +36 -0
- package/template/packages/types/tsconfig.json +5 -0
- package/template/packages/types/vitest.config.ts +21 -0
- package/template/packages/ui/components.json +21 -0
- package/template/packages/ui/package.json +108 -0
- package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
- package/template/packages/ui/src/alert-dialog.tsx +141 -0
- package/template/packages/ui/src/alert.tsx +66 -0
- package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
- package/template/packages/ui/src/avatar.tsx +53 -0
- package/template/packages/ui/src/badge.tsx +36 -0
- package/template/packages/ui/src/button.tsx +84 -0
- package/template/packages/ui/src/card.tsx +92 -0
- package/template/packages/ui/src/checkbox.tsx +32 -0
- package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
- package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
- package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
- package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
- package/template/packages/ui/src/data-table/data-table.tsx +167 -0
- package/template/packages/ui/src/dialog.tsx +143 -0
- package/template/packages/ui/src/dropdown-menu.tsx +257 -0
- package/template/packages/ui/src/empty-state.tsx +52 -0
- package/template/packages/ui/src/file-upload-input.tsx +202 -0
- package/template/packages/ui/src/form.tsx +168 -0
- package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
- package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
- package/template/packages/ui/src/input.tsx +21 -0
- package/template/packages/ui/src/label.tsx +24 -0
- package/template/packages/ui/src/lib/utils.ts +6 -0
- package/template/packages/ui/src/password-input.tsx +102 -0
- package/template/packages/ui/src/popover.tsx +48 -0
- package/template/packages/ui/src/radio-group.tsx +45 -0
- package/template/packages/ui/src/scroll-area.tsx +58 -0
- package/template/packages/ui/src/select.tsx +187 -0
- package/template/packages/ui/src/separator.tsx +28 -0
- package/template/packages/ui/src/sheet.tsx +139 -0
- package/template/packages/ui/src/sidebar.tsx +726 -0
- package/template/packages/ui/src/skeleton-variants.tsx +87 -0
- package/template/packages/ui/src/skeleton.tsx +13 -0
- package/template/packages/ui/src/slider.tsx +63 -0
- package/template/packages/ui/src/sonner.tsx +25 -0
- package/template/packages/ui/src/spinner.tsx +16 -0
- package/template/packages/ui/src/switch.tsx +31 -0
- package/template/packages/ui/src/table.tsx +116 -0
- package/template/packages/ui/src/tabs.tsx +66 -0
- package/template/packages/ui/src/textarea.tsx +18 -0
- package/template/packages/ui/src/tooltip.tsx +61 -0
- package/template/packages/ui/src/user-avatar.tsx +97 -0
- package/template/packages/ui/test-config.js +3 -0
- package/template/packages/ui/tsconfig.json +12 -0
- package/template/packages/ui/turbo.json +18 -0
- package/template/packages/ui/vitest.config.ts +17 -0
- package/template/packages/ui/vitest.setup.ts +1 -0
- package/template/packages/utils/package.json +23 -0
- package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
- package/template/packages/utils/src/array.ts +18 -0
- package/template/packages/utils/src/async.ts +3 -0
- package/template/packages/utils/src/date.ts +77 -0
- package/template/packages/utils/src/errors.ts +73 -0
- package/template/packages/utils/src/number.ts +11 -0
- package/template/packages/utils/src/string.ts +13 -0
- package/template/packages/utils/tsconfig.json +5 -0
- package/template/packages/utils/vitest.config.ts +21 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/tsconfig.base.json +32 -0
- package/template/turbo.json +133 -0
- package/template/vitest.shared.ts +26 -0
- package/template/vitest.workspace.ts +9 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
S3Client,
|
|
8
|
+
} from '@aws-sdk/client-s3';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
|
|
11
|
+
import type { LoggerService } from '@/common/logger.service';
|
|
12
|
+
import type { Env } from '@/config/env';
|
|
13
|
+
import { isImageFile } from '@/utils/file-validation';
|
|
14
|
+
|
|
15
|
+
export interface FileUploadResult {
|
|
16
|
+
filename: string;
|
|
17
|
+
url: string;
|
|
18
|
+
size: number;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileUploadOptions {
|
|
23
|
+
buffer: Buffer;
|
|
24
|
+
originalFilename: string;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
optimizeImage?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FileStorageService {
|
|
30
|
+
private s3Client?: S3Client;
|
|
31
|
+
private storageType: 'local' | 's3' | 'r2';
|
|
32
|
+
private localUploadDir: string;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly env: Env,
|
|
36
|
+
private readonly logger: LoggerService
|
|
37
|
+
) {
|
|
38
|
+
this.logger.setContext('FileStorageService');
|
|
39
|
+
this.storageType = env.STORAGE_TYPE || 'local';
|
|
40
|
+
this.localUploadDir = path.join(process.cwd(), 'public', 'uploads');
|
|
41
|
+
|
|
42
|
+
this.initializeStorage();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private initializeStorage(): void {
|
|
46
|
+
if (this.storageType === 'local') {
|
|
47
|
+
this.logger.info(
|
|
48
|
+
`Using local file storage. Upload directory: ${this.localUploadDir}`
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Initialize S3/R2 client
|
|
54
|
+
const {
|
|
55
|
+
S3_BUCKET,
|
|
56
|
+
S3_REGION,
|
|
57
|
+
S3_ACCESS_KEY_ID,
|
|
58
|
+
S3_SECRET_ACCESS_KEY,
|
|
59
|
+
S3_ENDPOINT,
|
|
60
|
+
} = this.env;
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
!S3_BUCKET ||
|
|
64
|
+
!S3_REGION ||
|
|
65
|
+
!S3_ACCESS_KEY_ID ||
|
|
66
|
+
!S3_SECRET_ACCESS_KEY
|
|
67
|
+
) {
|
|
68
|
+
this.logger.warn(
|
|
69
|
+
'S3 credentials incomplete, falling back to local storage'
|
|
70
|
+
);
|
|
71
|
+
this.storageType = 'local';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
|
|
76
|
+
region: S3_REGION,
|
|
77
|
+
credentials: {
|
|
78
|
+
accessKeyId: S3_ACCESS_KEY_ID,
|
|
79
|
+
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Add custom endpoint for MinIO/R2
|
|
84
|
+
if (S3_ENDPOINT) {
|
|
85
|
+
clientConfig.endpoint = S3_ENDPOINT;
|
|
86
|
+
clientConfig.forcePathStyle = true; // Required for MinIO
|
|
87
|
+
this.logger.info('Using S3-compatible storage', {
|
|
88
|
+
endpoint: S3_ENDPOINT,
|
|
89
|
+
type: this.storageType,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
this.logger.info('Using AWS S3 storage', { region: S3_REGION });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.s3Client = new S3Client(clientConfig);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async uploadFile(options: FileUploadOptions): Promise<FileUploadResult> {
|
|
99
|
+
let { buffer } = options;
|
|
100
|
+
const { originalFilename, mimeType, optimizeImage = true } = options;
|
|
101
|
+
|
|
102
|
+
// Optimize images if enabled
|
|
103
|
+
if (optimizeImage && isImageFile(mimeType)) {
|
|
104
|
+
buffer = await this.optimizeImage(buffer, mimeType);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const size = buffer.length;
|
|
108
|
+
|
|
109
|
+
if (this.storageType === 'local') {
|
|
110
|
+
return this.uploadToLocal(buffer, originalFilename, mimeType, size);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return this.uploadToS3(buffer, originalFilename, mimeType, size);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async uploadToLocal(
|
|
117
|
+
buffer: Buffer,
|
|
118
|
+
filename: string,
|
|
119
|
+
mimeType: string,
|
|
120
|
+
size: number
|
|
121
|
+
): Promise<FileUploadResult> {
|
|
122
|
+
// Ensure upload directory exists
|
|
123
|
+
await fs.mkdir(this.localUploadDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const filePath = path.join(this.localUploadDir, filename);
|
|
126
|
+
await fs.writeFile(filePath, buffer);
|
|
127
|
+
|
|
128
|
+
this.logger.info('File uploaded to local storage', { filename, size });
|
|
129
|
+
|
|
130
|
+
// Return URL relative to API server
|
|
131
|
+
const url = `${this.env.API_URL}/uploads/files/${filename}`;
|
|
132
|
+
|
|
133
|
+
return { filename, url, size, mimeType };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async uploadToS3(
|
|
137
|
+
buffer: Buffer,
|
|
138
|
+
filename: string,
|
|
139
|
+
mimeType: string,
|
|
140
|
+
size: number
|
|
141
|
+
): Promise<FileUploadResult> {
|
|
142
|
+
if (!this.s3Client) {
|
|
143
|
+
throw new Error('S3 client not initialized');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const bucket = this.env.S3_BUCKET!;
|
|
147
|
+
|
|
148
|
+
const command = new PutObjectCommand({
|
|
149
|
+
Bucket: bucket,
|
|
150
|
+
Key: filename,
|
|
151
|
+
Body: buffer,
|
|
152
|
+
ContentType: mimeType,
|
|
153
|
+
// Make files publicly readable (adjust based on your security needs)
|
|
154
|
+
ACL: 'public-read',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await this.s3Client.send(command);
|
|
158
|
+
|
|
159
|
+
this.logger.info('File uploaded to S3', { filename, size, bucket });
|
|
160
|
+
|
|
161
|
+
// Construct public URL
|
|
162
|
+
const url = this.getS3Url(filename);
|
|
163
|
+
|
|
164
|
+
return { filename, url, size, mimeType };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deleteFile(filename: string): Promise<void> {
|
|
168
|
+
if (this.storageType === 'local') {
|
|
169
|
+
return this.deleteFromLocal(filename);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return this.deleteFromS3(filename);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async deleteFromLocal(filename: string): Promise<void> {
|
|
176
|
+
const filePath = path.join(this.localUploadDir, filename);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await fs.unlink(filePath);
|
|
180
|
+
this.logger.info('File deleted from local storage', { filename });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const err = error as { code?: string };
|
|
183
|
+
if (err.code !== 'ENOENT') {
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
this.logger.warn('File not found for deletion', { filename });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async deleteFromS3(filename: string): Promise<void> {
|
|
191
|
+
if (!this.s3Client) {
|
|
192
|
+
throw new Error('S3 client not initialized');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const bucket = this.env.S3_BUCKET!;
|
|
196
|
+
|
|
197
|
+
const command = new DeleteObjectCommand({
|
|
198
|
+
Bucket: bucket,
|
|
199
|
+
Key: filename,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await this.s3Client.send(command);
|
|
203
|
+
|
|
204
|
+
this.logger.info('File deleted from S3', { filename, bucket });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async optimizeImage(
|
|
208
|
+
buffer: Buffer,
|
|
209
|
+
mimeType: string
|
|
210
|
+
): Promise<Buffer> {
|
|
211
|
+
try {
|
|
212
|
+
let transformer = sharp(buffer).rotate(); // Auto-rotate based on EXIF
|
|
213
|
+
|
|
214
|
+
// Resize if too large (max 2048px on longest side)
|
|
215
|
+
const metadata = await sharp(buffer).metadata();
|
|
216
|
+
const maxDimension = Math.max(metadata.width || 0, metadata.height || 0);
|
|
217
|
+
|
|
218
|
+
if (maxDimension > 2048) {
|
|
219
|
+
transformer = transformer.resize(2048, 2048, {
|
|
220
|
+
fit: 'inside',
|
|
221
|
+
withoutEnlargement: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Convert and compress based on type
|
|
226
|
+
if (mimeType === 'image/png') {
|
|
227
|
+
transformer = transformer.png({ quality: 90, compressionLevel: 9 });
|
|
228
|
+
} else if (mimeType === 'image/webp') {
|
|
229
|
+
transformer = transformer.webp({ quality: 90 });
|
|
230
|
+
} else {
|
|
231
|
+
// Default to JPEG for other formats
|
|
232
|
+
transformer = transformer.jpeg({ quality: 85 });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const optimized = await transformer.toBuffer();
|
|
236
|
+
|
|
237
|
+
this.logger.detailed().debug('Image optimized', {
|
|
238
|
+
originalSize: buffer.length,
|
|
239
|
+
optimizedSize: optimized.length,
|
|
240
|
+
reduction: Math.round(
|
|
241
|
+
((buffer.length - optimized.length) / buffer.length) * 100
|
|
242
|
+
),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return optimized;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
this.logger.warn('Image optimization failed, using original', { error });
|
|
248
|
+
return buffer;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private getS3Url(filename: string): string {
|
|
253
|
+
const { S3_BUCKET, S3_REGION, S3_ENDPOINT } = this.env;
|
|
254
|
+
|
|
255
|
+
// For custom endpoints (MinIO, R2, etc.)
|
|
256
|
+
if (S3_ENDPOINT) {
|
|
257
|
+
return `${S3_ENDPOINT}/${S3_BUCKET}/${filename}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Standard AWS S3 URL
|
|
261
|
+
return `https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com/${filename}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getStorageType(): string {
|
|
265
|
+
return this.storageType;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { RealtimeMetricsPoint } from '@repo/packages-types/stats';
|
|
2
|
+
import { cpus } from 'os';
|
|
3
|
+
|
|
4
|
+
const HISTORY_SIZE = 60;
|
|
5
|
+
const COLLECTION_INTERVAL_MS = 1000;
|
|
6
|
+
|
|
7
|
+
interface RequestMetric {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
responseTimeMs: number;
|
|
10
|
+
isError: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MetricsService {
|
|
14
|
+
private history: RealtimeMetricsPoint[] = [];
|
|
15
|
+
private requestMetrics: RequestMetric[] = [];
|
|
16
|
+
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
|
17
|
+
private lastCpuTime = 0;
|
|
18
|
+
private intervalId: NodeJS.Timeout | null = null;
|
|
19
|
+
private subscribers = new Set<(metrics: RealtimeMetricsPoint) => void>();
|
|
20
|
+
|
|
21
|
+
start() {
|
|
22
|
+
if (this.intervalId) return;
|
|
23
|
+
|
|
24
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
25
|
+
this.lastCpuTime = Date.now();
|
|
26
|
+
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
this.collectMetrics();
|
|
29
|
+
this.intervalId = setInterval(
|
|
30
|
+
() => this.collectMetrics(),
|
|
31
|
+
COLLECTION_INTERVAL_MS
|
|
32
|
+
);
|
|
33
|
+
}, COLLECTION_INTERVAL_MS);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
stop() {
|
|
37
|
+
if (this.intervalId) {
|
|
38
|
+
clearInterval(this.intervalId);
|
|
39
|
+
this.intervalId = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
recordRequest(responseTimeMs: number, statusCode: number) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
this.requestMetrics.push({
|
|
46
|
+
timestamp: now,
|
|
47
|
+
responseTimeMs,
|
|
48
|
+
isError: statusCode >= 400,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const cutoff = now - 5000;
|
|
52
|
+
this.requestMetrics = this.requestMetrics.filter(
|
|
53
|
+
(m) => m.timestamp > cutoff
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
subscribe(callback: (metrics: RealtimeMetricsPoint) => void) {
|
|
58
|
+
this.subscribers.add(callback);
|
|
59
|
+
return () => this.subscribers.delete(callback);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getHistory(): RealtimeMetricsPoint[] {
|
|
63
|
+
return [...this.history];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getLatest(): RealtimeMetricsPoint | null {
|
|
67
|
+
return this.history[this.history.length - 1] ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private collectMetrics() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const memUsage = process.memoryUsage();
|
|
73
|
+
|
|
74
|
+
const cpuPercentage = this.calculateCpuPercentage();
|
|
75
|
+
|
|
76
|
+
const { rps, avgResponseTime, errorRate } =
|
|
77
|
+
this.calculateRequestMetrics(now);
|
|
78
|
+
|
|
79
|
+
const heapUsedMB =
|
|
80
|
+
Math.round((memUsage.heapUsed / 1024 / 1024) * 100) / 100;
|
|
81
|
+
const heapTotalMB =
|
|
82
|
+
Math.round((memUsage.heapTotal / 1024 / 1024) * 100) / 100;
|
|
83
|
+
const usedPercent =
|
|
84
|
+
heapTotalMB > 0
|
|
85
|
+
? Math.round((heapUsedMB / heapTotalMB) * 100 * 100) / 100
|
|
86
|
+
: 0;
|
|
87
|
+
|
|
88
|
+
const metrics: RealtimeMetricsPoint = {
|
|
89
|
+
timestamp: now,
|
|
90
|
+
memory: {
|
|
91
|
+
heapUsedMB,
|
|
92
|
+
heapTotalMB,
|
|
93
|
+
rssMB: Math.round((memUsage.rss / 1024 / 1024) * 100) / 100,
|
|
94
|
+
usedPercent,
|
|
95
|
+
},
|
|
96
|
+
cpu: {
|
|
97
|
+
percentage: cpuPercentage,
|
|
98
|
+
},
|
|
99
|
+
errors: {
|
|
100
|
+
rate: errorRate,
|
|
101
|
+
},
|
|
102
|
+
requests: {
|
|
103
|
+
perSecond: rps,
|
|
104
|
+
avgResponseTimeMs: avgResponseTime,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.history.push(metrics);
|
|
109
|
+
if (this.history.length > HISTORY_SIZE) {
|
|
110
|
+
this.history.shift();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.subscribers.forEach((cb) => cb(metrics));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private calculateCpuPercentage(): number {
|
|
117
|
+
if (!this.lastCpuUsage) {
|
|
118
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
119
|
+
this.lastCpuTime = Date.now();
|
|
120
|
+
return 0.01;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const currentCpuUsage = process.cpuUsage(this.lastCpuUsage);
|
|
124
|
+
const currentTime = Date.now();
|
|
125
|
+
const elapsedMs = currentTime - this.lastCpuTime;
|
|
126
|
+
|
|
127
|
+
if (elapsedMs <= 0) {
|
|
128
|
+
return 0.01;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cpuCount = cpus().length;
|
|
132
|
+
const totalMicroseconds = currentCpuUsage.user + currentCpuUsage.system;
|
|
133
|
+
const percentage =
|
|
134
|
+
((totalMicroseconds / 1000 / elapsedMs) * 100) / cpuCount;
|
|
135
|
+
|
|
136
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
137
|
+
this.lastCpuTime = currentTime;
|
|
138
|
+
|
|
139
|
+
const rounded = Math.round(Math.min(100, percentage) * 100) / 100;
|
|
140
|
+
return Math.max(0.01, rounded);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private calculateRequestMetrics(now: number): {
|
|
144
|
+
rps: number;
|
|
145
|
+
avgResponseTime: number;
|
|
146
|
+
errorRate: number;
|
|
147
|
+
} {
|
|
148
|
+
const windowMs = 5000;
|
|
149
|
+
const cutoff = now - windowMs;
|
|
150
|
+
const recentRequests = this.requestMetrics.filter(
|
|
151
|
+
(m) => m.timestamp > cutoff
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (recentRequests.length === 0) {
|
|
155
|
+
return { rps: 0, avgResponseTime: 0, errorRate: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const rps =
|
|
159
|
+
Math.round((recentRequests.length / (windowMs / 1000)) * 100) / 100;
|
|
160
|
+
const avgResponseTime =
|
|
161
|
+
Math.round(
|
|
162
|
+
(recentRequests.reduce((sum, m) => sum + m.responseTimeMs, 0) /
|
|
163
|
+
recentRequests.length) *
|
|
164
|
+
100
|
|
165
|
+
) / 100;
|
|
166
|
+
|
|
167
|
+
const errorCount = recentRequests.filter((m) => m.isError).length;
|
|
168
|
+
const errorRate =
|
|
169
|
+
Math.round((errorCount / recentRequests.length) * 100 * 100) / 100;
|
|
170
|
+
|
|
171
|
+
return { rps, avgResponseTime, errorRate };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const metricsService = new MetricsService();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnauthorizedError,
|
|
3
|
+
ValidationError,
|
|
4
|
+
} from '@repo/packages-utils/errors';
|
|
5
|
+
import * as bcrypt from 'bcryptjs';
|
|
6
|
+
|
|
7
|
+
import type { PrismaClient } from '@/generated/client/client.js';
|
|
8
|
+
import { type SessionsService } from '@/services/sessions.service';
|
|
9
|
+
|
|
10
|
+
export class PasswordService {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly prisma: PrismaClient,
|
|
13
|
+
private readonly sessionsService: SessionsService
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async changePassword(
|
|
17
|
+
userId: string,
|
|
18
|
+
currentPassword: string,
|
|
19
|
+
newPassword: string
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const account = await this.prisma.account.findFirst({
|
|
22
|
+
where: {
|
|
23
|
+
userId,
|
|
24
|
+
providerId: 'credential',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!account || !account.password) {
|
|
29
|
+
throw new ValidationError('Password authentication not available', {
|
|
30
|
+
userId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isValidPassword = await bcrypt.compare(
|
|
35
|
+
currentPassword,
|
|
36
|
+
account.password
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!isValidPassword) {
|
|
40
|
+
throw new UnauthorizedError('Current password is incorrect');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
|
44
|
+
|
|
45
|
+
await this.prisma.account.update({
|
|
46
|
+
where: {
|
|
47
|
+
id: account.id,
|
|
48
|
+
},
|
|
49
|
+
data: {
|
|
50
|
+
password: hashedPassword,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await this.sessionsService.revokeAllUserSessions(userId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createMockPrisma } from '@test/helpers/mock-prisma';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { PrismaClient } from '@/generated/client/client.js';
|
|
5
|
+
|
|
6
|
+
import { SessionsService } from './sessions.service';
|
|
7
|
+
|
|
8
|
+
describe('SessionsService', () => {
|
|
9
|
+
let service: SessionsService;
|
|
10
|
+
let prisma: PrismaClient;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
prisma = createMockPrisma();
|
|
14
|
+
service = new SessionsService(prisma);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('getUserSessions', () => {
|
|
18
|
+
const mockSessions = [
|
|
19
|
+
{
|
|
20
|
+
id: 'session-1',
|
|
21
|
+
userId: 'user-1',
|
|
22
|
+
token: 'token-1',
|
|
23
|
+
ipAddress: '192.168.1.1',
|
|
24
|
+
userAgent: 'Mozilla/5.0',
|
|
25
|
+
createdAt: new Date(),
|
|
26
|
+
updatedAt: new Date(),
|
|
27
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'session-2',
|
|
31
|
+
userId: 'user-1',
|
|
32
|
+
token: 'token-2',
|
|
33
|
+
ipAddress: '192.168.1.2',
|
|
34
|
+
userAgent: 'Chrome/120.0',
|
|
35
|
+
createdAt: new Date(),
|
|
36
|
+
updatedAt: new Date(),
|
|
37
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
it('should return all user sessions', async () => {
|
|
42
|
+
vi.mocked(prisma.session.findMany).mockResolvedValue(mockSessions);
|
|
43
|
+
|
|
44
|
+
const result = await service.getUserSessions('user-1');
|
|
45
|
+
|
|
46
|
+
expect(result).toHaveLength(2);
|
|
47
|
+
expect(result[0].id).toBe('session-1');
|
|
48
|
+
expect(result[1].id).toBe('session-2');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return empty array when user has no sessions', async () => {
|
|
52
|
+
vi.mocked(prisma.session.findMany).mockResolvedValue([]);
|
|
53
|
+
|
|
54
|
+
const result = await service.getUserSessions('user-1');
|
|
55
|
+
|
|
56
|
+
expect(result).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('revokeSession', () => {
|
|
61
|
+
const mockSession = {
|
|
62
|
+
id: 'session-1',
|
|
63
|
+
userId: 'user-1',
|
|
64
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
65
|
+
token: 'token-1',
|
|
66
|
+
ipAddress: '192.168.1.1',
|
|
67
|
+
userAgent: 'Mozilla/5.0',
|
|
68
|
+
createdAt: new Date(),
|
|
69
|
+
updatedAt: new Date(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
it('should revoke session successfully', async () => {
|
|
73
|
+
vi.mocked(prisma.session.findFirst).mockResolvedValue(mockSession);
|
|
74
|
+
vi.mocked(prisma.session.delete).mockResolvedValue(mockSession);
|
|
75
|
+
|
|
76
|
+
await service.revokeSession('user-1', 'session-1');
|
|
77
|
+
|
|
78
|
+
expect(prisma.session.delete).toHaveBeenCalledWith({
|
|
79
|
+
where: { id: 'session-1' },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should throw error when session not found', async () => {
|
|
84
|
+
vi.mocked(prisma.session.findFirst).mockResolvedValue(null);
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
service.revokeSession('user-1', 'non-existent')
|
|
88
|
+
).rejects.toThrow('Session not found');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should throw error when session belongs to different user', async () => {
|
|
92
|
+
vi.mocked(prisma.session.findFirst).mockResolvedValue(null);
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
service.revokeSession('user-1', 'session-1')
|
|
96
|
+
).rejects.toThrow('Session not found');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('revokeAllSessions', () => {
|
|
101
|
+
it('should revoke all sessions except current one', async () => {
|
|
102
|
+
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 3 });
|
|
103
|
+
|
|
104
|
+
await service.revokeAllSessions('user-1', 'current-session-id');
|
|
105
|
+
|
|
106
|
+
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
107
|
+
where: {
|
|
108
|
+
userId: 'user-1',
|
|
109
|
+
id: { not: 'current-session-id' },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should handle case when no other sessions exist', async () => {
|
|
115
|
+
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 0 });
|
|
116
|
+
|
|
117
|
+
await service.revokeAllSessions('user-1', 'current-session-id');
|
|
118
|
+
|
|
119
|
+
expect(prisma.session.deleteMany).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should revoke all sessions when no current session provided', async () => {
|
|
123
|
+
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 5 });
|
|
124
|
+
|
|
125
|
+
await service.revokeAllSessions('user-1');
|
|
126
|
+
|
|
127
|
+
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
128
|
+
where: {
|
|
129
|
+
userId: 'user-1',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|