@m5kdev/backend 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.
Files changed (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,57 @@
1
+ import BIP32Factory from "bip32";
2
+ import * as bip39 from "bip39";
3
+ import * as bitcoin from "bitcoinjs-lib";
4
+ import * as ecc from "tiny-secp256k1";
5
+ import { ok } from "neverthrow";
6
+ import { BaseService } from "#modules/base/base.service";
7
+ import type { ServerResult } from "#modules/base/base.dto";
8
+ import type { CryptoRepository } from "#modules/crypto/crypto.repository";
9
+ import type { BIP32Interface } from "bip32";
10
+
11
+ const BITCOIN_NATIVE_SEGWIT_PATH = "m/84'/0'/0'/0";
12
+
13
+ const bip32 = BIP32Factory(ecc);
14
+
15
+ export class CryptoService extends BaseService<
16
+ { crypto: CryptoRepository },
17
+ Record<string, never>
18
+ > {
19
+ private root: BIP32Interface | null = null;
20
+
21
+ private getRoot(): BIP32Interface {
22
+ if (this.root) return this.root;
23
+ const seed = process.env.BITCOIN_SEED;
24
+ if (!seed?.trim()) {
25
+ throw new Error("BITCOIN_SEED environment variable is not set");
26
+ }
27
+ if (!bip39.validateMnemonic(seed.trim())) {
28
+ throw new Error("BITCOIN_SEED is not a valid BIP39 mnemonic");
29
+ }
30
+ const seedBuffer = bip39.mnemonicToSeedSync(seed.trim());
31
+ this.root = bip32.fromSeed(seedBuffer);
32
+ return this.root;
33
+ }
34
+
35
+ createBitcoinAddress(derivationIndex: number): ServerResult<string> {
36
+ return this.throwable(() => {
37
+ if (derivationIndex < 0 || !Number.isInteger(derivationIndex)) {
38
+ return this.error("BAD_REQUEST", "derivationIndex must be a non-negative integer");
39
+ }
40
+ const root = this.getRoot();
41
+ const path = `${BITCOIN_NATIVE_SEGWIT_PATH}/${derivationIndex}`;
42
+ const child = root.derivePath(path);
43
+ if (!child.publicKey) {
44
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to derive public key");
45
+ }
46
+ const payment = bitcoin.payments.p2wpkh({
47
+ pubkey: child.publicKey,
48
+ network: bitcoin.networks.bitcoin,
49
+ });
50
+ const address = payment.address;
51
+ if (!address) {
52
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to generate address");
53
+ }
54
+ return ok(address);
55
+ });
56
+ }
57
+ }
@@ -0,0 +1,222 @@
1
+ import { ok } from "neverthrow";
2
+ import { createElement, type FunctionComponent } from "react";
3
+ import { Resend } from "resend";
4
+ import { BaseService } from "#modules/base/base.service";
5
+
6
+ type Brand = {
7
+ name: string;
8
+ logo: string;
9
+ tagline: string;
10
+ };
11
+
12
+ type OverrideOptions = {
13
+ from?: string;
14
+ subject?: string;
15
+ previewText?: string;
16
+ };
17
+
18
+ type EmailTemplate = {
19
+ id: string;
20
+ subject?: string;
21
+ previewText?: string;
22
+ from?: string;
23
+ react: FunctionComponent<Record<string, unknown>>;
24
+ };
25
+
26
+ type EmailTemplates = {
27
+ accountDeletion: EmailTemplate;
28
+ verification: EmailTemplate;
29
+ waitlistConfirmation: EmailTemplate;
30
+ passwordReset: EmailTemplate;
31
+ systemWaitlistNotification: EmailTemplate;
32
+ waitlistInvite: EmailTemplate;
33
+ waitlistUserInvite: EmailTemplate;
34
+ organizationInvite: EmailTemplate;
35
+ [key: string]: EmailTemplate;
36
+ };
37
+
38
+ type EmailServiceProps = {
39
+ resendApiKey?: string;
40
+ brand: Brand;
41
+ noReplyFrom: string;
42
+ systemNotificationEmail: string;
43
+ templates: EmailTemplates;
44
+ };
45
+
46
+ export class EmailService extends BaseService<never, never> {
47
+ public client: Resend;
48
+ public brand: Brand;
49
+ public noReplyFrom: string;
50
+ public templates: EmailTemplates;
51
+ public systemNotificationEmail: string;
52
+
53
+ constructor(props: EmailServiceProps) {
54
+ super(undefined, undefined);
55
+ this.client = new Resend(props.resendApiKey || process.env.RESEND_API_KEY);
56
+ this.client = {} as unknown as Resend;
57
+ this.brand = props.brand;
58
+ this.noReplyFrom = props.noReplyFrom;
59
+ this.templates = props.templates;
60
+ this.systemNotificationEmail = props.systemNotificationEmail;
61
+ }
62
+
63
+ async sendTemplate(
64
+ to: string | string[],
65
+ templateKey: keyof EmailTemplates,
66
+ templateProps: Record<string, unknown>,
67
+ options?: OverrideOptions
68
+ ) {
69
+ const template = this.templates[templateKey];
70
+ if (!template) {
71
+ return this.error("NOT_FOUND", `Email template not found: ${String(templateKey)}`);
72
+ }
73
+ const from = options?.from || this.noReplyFrom;
74
+ const subject = options?.subject || template.subject || String(templateKey);
75
+ const previewText = options?.previewText || template.previewText || subject;
76
+
77
+ const { error } = await this.client.emails.send({
78
+ to,
79
+ subject,
80
+ from,
81
+ react: createElement(template.react, { ...templateProps, previewText }),
82
+ });
83
+ if (error)
84
+ return this.error("INTERNAL_SERVER_ERROR", `Failed to send email: ${templateKey}`, {
85
+ cause: error,
86
+ });
87
+ return ok();
88
+ }
89
+
90
+ async sendBrandTemplate(
91
+ to: string | string[],
92
+ templateKey: keyof EmailTemplates,
93
+ templateProps: Record<string, unknown>,
94
+ options?: OverrideOptions
95
+ ) {
96
+ return this.sendTemplate(
97
+ to,
98
+ templateKey,
99
+ {
100
+ brand: this.brand,
101
+ ...templateProps,
102
+ },
103
+ options
104
+ );
105
+ }
106
+
107
+ async sendWaitlistConfirmation(email: string, overrideOptions?: OverrideOptions) {
108
+ return this.sendTemplate(
109
+ email,
110
+ "waitlistConfirmation",
111
+ {
112
+ email,
113
+ brand: this.brand,
114
+ },
115
+ overrideOptions
116
+ );
117
+ }
118
+
119
+ async sendWaitlistInvite(email: string, code: string, overrideOptions?: OverrideOptions) {
120
+ const url = `${process.env.VITE_APP_URL}/signup?code=${code}&email=${email}`;
121
+ return this.sendTemplate(
122
+ email,
123
+ "waitlistInvite",
124
+ {
125
+ url,
126
+ brand: this.brand,
127
+ },
128
+ overrideOptions
129
+ );
130
+ }
131
+
132
+ async sendWaitlistUserInvite(
133
+ email: string,
134
+ code: string,
135
+ inviter: string,
136
+ name?: string,
137
+ overrideOptions?: OverrideOptions
138
+ ) {
139
+ const url = `${process.env.VITE_APP_URL}/signup?code=${code}&email=${email}`;
140
+ return this.sendTemplate(
141
+ email,
142
+ "waitlistUserInvite",
143
+ {
144
+ url,
145
+ brand: this.brand,
146
+ inviter,
147
+ name,
148
+ },
149
+ {
150
+ previewText: `Create your ${this.brand.name} account today!`,
151
+ subject: `${inviter} has invited you to join ${this.brand.name}!`,
152
+ ...overrideOptions,
153
+ }
154
+ );
155
+ }
156
+
157
+ async sendOrganizationInvite(
158
+ email: string,
159
+ organizationName: string,
160
+ inviterName: string,
161
+ role: string,
162
+ url: string,
163
+ overrideOptions?: OverrideOptions
164
+ ) {
165
+ return this.sendTemplate(
166
+ email,
167
+ "organizationInvite",
168
+ {
169
+ url,
170
+ brand: this.brand,
171
+ organizationName,
172
+ inviterName,
173
+ role,
174
+ },
175
+ {
176
+ previewText: `${inviterName} invited you to ${organizationName}`,
177
+ subject: `${inviterName} invited you to join ${organizationName}`,
178
+ ...overrideOptions,
179
+ }
180
+ );
181
+ }
182
+
183
+ async sendVerification(email: string, url: string, overrideOptions?: OverrideOptions) {
184
+ return this.sendTemplate(
185
+ email,
186
+ "verification",
187
+ {
188
+ url,
189
+ brand: this.brand,
190
+ },
191
+ overrideOptions
192
+ );
193
+ }
194
+
195
+ async sendResetPassword(email: string, url: string, overrideOptions?: OverrideOptions) {
196
+ return this.sendTemplate(
197
+ email,
198
+ "passwordReset",
199
+ {
200
+ url,
201
+ brand: this.brand,
202
+ },
203
+ overrideOptions
204
+ );
205
+ }
206
+
207
+ async sendDeleteAccountVerification(
208
+ email: string,
209
+ url: string,
210
+ overrideOptions?: OverrideOptions
211
+ ) {
212
+ return this.sendTemplate(
213
+ email,
214
+ "accountDeletion",
215
+ {
216
+ url,
217
+ brand: this.brand,
218
+ },
219
+ overrideOptions
220
+ );
221
+ }
222
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ type GetObjectCommandOutput,
5
+ PutObjectCommand,
6
+ S3Client,
7
+ } from "@aws-sdk/client-s3";
8
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
9
+ import { ok } from "neverthrow";
10
+ import type { ServerResultAsync } from "#modules/base/base.dto";
11
+ import { BaseExternaRepository } from "#modules/base/base.repository";
12
+
13
+ export class FileRepository extends BaseExternaRepository {
14
+ private readonly s3: S3Client;
15
+ constructor() {
16
+ super();
17
+
18
+ if (
19
+ !process.env.AWS_REGION ||
20
+ !process.env.AWS_ACCESS_KEY_ID ||
21
+ !process.env.AWS_SECRET_ACCESS_KEY
22
+ ) {
23
+ throw new Error("Missing AWS environment variables");
24
+ }
25
+
26
+ this.s3 = new S3Client({
27
+ region: process.env.AWS_REGION,
28
+ credentials: {
29
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
30
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
31
+ },
32
+ ...(process.env.AWS_S3_ENDPOINT ? { endpoint: process.env.AWS_S3_ENDPOINT } : {}),
33
+ forcePathStyle: !!process.env.AWS_S3_ENDPOINT, // Path style is often required for non-AWS S3 providers
34
+ });
35
+ }
36
+ async getS3UploadUrl(
37
+ filename: string,
38
+ filetype: string,
39
+ expiresIn = 60 * 5
40
+ ): ServerResultAsync<string> {
41
+ return this.throwableAsync(async () => {
42
+ const command = new PutObjectCommand({
43
+ Bucket: process.env.AWS_S3_BUCKET,
44
+ Key: filename,
45
+ ContentType: filetype,
46
+ });
47
+ const url = await getSignedUrl(this.s3, command, { expiresIn });
48
+ return ok(url);
49
+ });
50
+ }
51
+
52
+ async getS3DownloadUrl(path: string, expiresIn = 60 * 5): ServerResultAsync<string> {
53
+ return this.throwableAsync(async () => {
54
+ const command = new GetObjectCommand({
55
+ Bucket: process.env.AWS_S3_BUCKET,
56
+ Key: path,
57
+ });
58
+ const url = await getSignedUrl(this.s3, command, { expiresIn });
59
+ return ok(url);
60
+ });
61
+ }
62
+
63
+ async getS3Object(path: string): ServerResultAsync<GetObjectCommandOutput> {
64
+ return this.throwableAsync(async () => {
65
+ const command = new GetObjectCommand({
66
+ Bucket: process.env.AWS_S3_BUCKET,
67
+ Key: path,
68
+ });
69
+ const data = await this.s3.send(command);
70
+ return ok(data);
71
+ });
72
+ }
73
+
74
+ async getS3ObjectT(path: string): ServerResultAsync<GetObjectCommandOutput> {
75
+ return this.throwableAsync(async () => {
76
+ const command = new GetObjectCommand({
77
+ Bucket: process.env.AWS_S3_BUCKET,
78
+ Key: path,
79
+ });
80
+ const data = await this.s3.send(command);
81
+ return ok(data);
82
+ });
83
+ }
84
+
85
+ async deleteS3Object(path: string): ServerResultAsync<void> {
86
+ return this.throwableAsync(async () => {
87
+ const command = new DeleteObjectCommand({
88
+ Bucket: process.env.AWS_S3_BUCKET,
89
+ Key: path,
90
+ });
91
+ await this.s3.send(command);
92
+ return ok(undefined);
93
+ });
94
+ }
95
+ }
@@ -0,0 +1,108 @@
1
+ import path from "node:path";
2
+ import { fileTypes } from "@m5kdev/commons/modules/file/file.constants";
3
+ import bodyParser from "body-parser";
4
+ import express, { type Request, type Response } from "express";
5
+ import multer from "multer";
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import { FileRepository } from "#modules/file/file.repository";
8
+ import { FileService } from "#modules/file/file.service";
9
+
10
+ const fileRepository = new FileRepository();
11
+ const fileService = new FileService({ file: fileRepository });
12
+
13
+ function validateMimeType(type: string, file: Express.Multer.File): boolean {
14
+ return fileTypes[type]?.mimetypes.includes(file.mimetype);
15
+ }
16
+
17
+ function getFileExtension(file: Express.Multer.File): string | undefined {
18
+ return file.originalname.split(".").pop();
19
+ }
20
+
21
+ const storage = multer.diskStorage({
22
+ destination: (_req, _file, cb) => {
23
+ cb(null, path.join(__dirname, "..", "uploads"));
24
+ },
25
+ filename: (_req, file, cb) => {
26
+ cb(null, `${uuidv4()}.${getFileExtension(file)}`);
27
+ },
28
+ });
29
+
30
+ const fileFilter = (
31
+ req: Request,
32
+ file: Express.Multer.File,
33
+ cb: multer.FileFilterCallback
34
+ ): void => {
35
+ const { type } = req.params;
36
+ if (type && validateMimeType(type, file)) {
37
+ cb(null, true);
38
+ } else {
39
+ cb(new Error("Invalid file type"));
40
+ }
41
+ };
42
+
43
+ const upload = multer({ storage, fileFilter });
44
+ const uploadRouter: express.Router = express.Router();
45
+
46
+ uploadRouter.post("/file/:type", upload.single("file"), (req: Request, res: Response) => {
47
+ const { file } = req;
48
+ if (!file) {
49
+ return res.status(400).json({ error: "No file uploaded" });
50
+ }
51
+ return res.json({
52
+ url: `${process.env.VITE_SERVER_URL}/upload/file/${file.filename}`,
53
+ mimetype: file.mimetype,
54
+ size: file.size,
55
+ });
56
+ });
57
+
58
+ uploadRouter.get("/file/:filename", (req: Request, res: Response) => {
59
+ res.sendFile(path.join(__dirname, "..", "uploads", req.params.filename!));
60
+ });
61
+
62
+ uploadRouter.get("/files/:path", async (req: Request, res: Response) => {
63
+ try {
64
+ const url = await fileService.getS3DownloadUrl(req.params.path!);
65
+ if (url.isErr()) {
66
+ console.error(url.error);
67
+ return res.status(500).json({ error: url.error.message });
68
+ }
69
+ return res.json({ url: url.value });
70
+ } catch (err: any) {
71
+ console.error(err);
72
+ return res.status(500).json({ error: err.message || "Failed to generate presigned URL" });
73
+ }
74
+ });
75
+
76
+ uploadRouter.post("/s3-presigned-url", bodyParser.json(), async (req: Request, res: Response) => {
77
+ const { filename, filetype } = req.body;
78
+
79
+ if (!filename || !filetype) {
80
+ return res.status(400).json({ error: "Missing filename or filetype" });
81
+ }
82
+ try {
83
+ const url = await fileService.getS3UploadUrl(filename, filetype);
84
+ if (url.isErr()) {
85
+ return res.status(500).json({ error: url.error.message });
86
+ }
87
+ return res.json({ url: url.value });
88
+ } catch (err: any) {
89
+ console.error(err);
90
+ return res.status(500).json({ error: err.message || "Failed to generate presigned URL" });
91
+ }
92
+ });
93
+
94
+ uploadRouter.delete("/files/:path(*)", async (req: Request, res: Response) => {
95
+ try {
96
+ const result = await fileService.deleteS3Object(req.params.path!);
97
+ if (result.isErr()) {
98
+ console.error(result.error);
99
+ return res.status(500).json({ error: result.error.message });
100
+ }
101
+ return res.json({ success: true });
102
+ } catch (err: any) {
103
+ console.error(err);
104
+ return res.status(500).json({ error: err.message || "Failed to delete S3 object" });
105
+ }
106
+ });
107
+
108
+ export { uploadRouter };
@@ -0,0 +1,186 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path, { dirname } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { fileTypes } from "@m5kdev/commons/modules/file/file.constants";
8
+ import { err, ok } from "neverthrow";
9
+ import { v4 as uuidv4 } from "uuid";
10
+ import type { ServerResult, ServerResultAsync } from "#modules/base/base.dto";
11
+ import { BaseService } from "#modules/base/base.service";
12
+ import type { FileRepository } from "#modules/file/file.repository";
13
+
14
+ export class FileService extends BaseService<{ file: FileRepository }, never> {
15
+ isS3Path(path: string): boolean {
16
+ return path.startsWith("s3::");
17
+ }
18
+
19
+ parseS3Path(S3Path: string): ServerResult<{ bucket: string; path: string }> {
20
+ if (!this.isS3Path(S3Path)) {
21
+ return this.error("BAD_REQUEST", "Invalid S3 path");
22
+ }
23
+ const [bucket, path] = S3Path.split("s3::")[1].split("//");
24
+ return ok({ bucket, path });
25
+ }
26
+
27
+ wrapS3Path(path: string, bucket: string): string {
28
+ return `s3::${bucket}//${path}`;
29
+ }
30
+
31
+ getS3UploadUrl(
32
+ filename: string,
33
+ filetype: string,
34
+ expiresIn = 60 * 5
35
+ ): ServerResultAsync<string> {
36
+ return this.repository.file.getS3UploadUrl(filename, filetype, expiresIn);
37
+ }
38
+
39
+ getS3DownloadUrl(path: string, expiresIn = 60 * 5): ServerResultAsync<string> {
40
+ return this.repository.file.getS3DownloadUrl(path, expiresIn);
41
+ }
42
+
43
+ getS3Object(path: string) {
44
+ return this.repository.file.getS3Object(path);
45
+ }
46
+
47
+ deleteS3Object(path: string) {
48
+ return this.repository.file.deleteS3Object(path);
49
+ }
50
+
51
+ async uploadFileToS3(localPath: string, returnDownloadUrl = false): ServerResultAsync<string> {
52
+ return this.throwableAsync(async () => {
53
+ const extension = localPath.split(".").pop()?.toLowerCase();
54
+ const filename = `${uuidv4()}${extension ? `.${extension}` : ""}`;
55
+
56
+ const mimeByExt: Record<string, string> = {
57
+ jpg: "image/jpeg",
58
+ jpeg: "image/jpeg",
59
+ png: "image/png",
60
+ webp: "image/webp",
61
+ mp4: "video/mp4",
62
+ mov: "video/mov",
63
+ avi: "video/avi",
64
+ mkv: "video/mkv",
65
+ webm: "video/webm",
66
+ mp3: "audio/mp3",
67
+ wav: "audio/wav",
68
+ m4a: "audio/m4a",
69
+ };
70
+ const filetype = (extension && mimeByExt[extension]) || "application/octet-stream";
71
+
72
+ const presigned = await this.getS3UploadUrl(filename, filetype);
73
+ if (presigned.isErr()) return err(presigned.error);
74
+
75
+ const file = await readFile(localPath);
76
+ const res = await fetch(presigned.value, {
77
+ method: "PUT",
78
+ body: file,
79
+ headers: { "Content-Type": filetype },
80
+ });
81
+ if (!res.ok) {
82
+ return this.error("INTERNAL_SERVER_ERROR", `Failed to upload to S3: ${res.status}`);
83
+ }
84
+ if (returnDownloadUrl) {
85
+ const downloadUrl = await this.getS3DownloadUrl(filename);
86
+ if (downloadUrl.isErr()) return err(downloadUrl.error);
87
+ return ok(downloadUrl.value);
88
+ }
89
+
90
+ return ok(filename);
91
+ });
92
+ }
93
+
94
+ async downloadS3ToFile(s3Path: string): ServerResultAsync<string> {
95
+ return this.throwableAsync(async () => {
96
+ const extension = s3Path.split(".").pop();
97
+ const destinationPath = path.join(
98
+ tmpdir(),
99
+ "s3-downloads",
100
+ `${uuidv4()}${extension ? `.${extension}` : ""}`
101
+ );
102
+
103
+ const result = await this.repository.file.getS3Object(s3Path);
104
+ if (result.isErr()) return err(result.error);
105
+
106
+ const body = result.value.Body;
107
+ if (!body) return this.error("NOT_FOUND", "S3 object body is empty");
108
+
109
+ await mkdir(dirname(destinationPath), { recursive: true });
110
+
111
+ // AWS SDK v3 SdkStream has transformToByteArray method - use it for reliable handling
112
+ if (
113
+ typeof body === "object" &&
114
+ "transformToByteArray" in body &&
115
+ typeof body.transformToByteArray === "function"
116
+ ) {
117
+ const bytes = await body.transformToByteArray();
118
+ await writeFile(destinationPath, bytes);
119
+ return ok(destinationPath);
120
+ }
121
+
122
+ // Fallback: try streaming approaches
123
+ const writeStream = createWriteStream(destinationPath);
124
+ let input: NodeJS.ReadableStream | null = null;
125
+ const unknownBody: unknown = body;
126
+
127
+ if (
128
+ typeof unknownBody === "object" &&
129
+ unknownBody !== null &&
130
+ "pipe" in unknownBody &&
131
+ typeof (unknownBody as { pipe?: unknown }).pipe === "function"
132
+ ) {
133
+ input = unknownBody as NodeJS.ReadableStream;
134
+ } else if (
135
+ typeof unknownBody === "object" &&
136
+ unknownBody !== null &&
137
+ "getReader" in unknownBody &&
138
+ typeof (unknownBody as { getReader?: unknown }).getReader === "function"
139
+ ) {
140
+ input = Readable.fromWeb(unknownBody as unknown as globalThis.ReadableStream);
141
+ } else if (
142
+ typeof unknownBody === "object" &&
143
+ unknownBody !== null &&
144
+ "stream" in unknownBody &&
145
+ typeof (unknownBody as { stream?: unknown }).stream === "function"
146
+ ) {
147
+ input = Readable.fromWeb(
148
+ (unknownBody as { stream: () => globalThis.ReadableStream }).stream()
149
+ );
150
+ }
151
+
152
+ if (input) {
153
+ await pipeline(input, writeStream);
154
+ return ok(destinationPath);
155
+ }
156
+
157
+ if (
158
+ typeof unknownBody === "object" &&
159
+ unknownBody !== null &&
160
+ "arrayBuffer" in unknownBody &&
161
+ typeof (unknownBody as { arrayBuffer?: unknown }).arrayBuffer === "function"
162
+ ) {
163
+ const buffer = Buffer.from(
164
+ await (unknownBody as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer()
165
+ );
166
+ await pipeline(Readable.from(buffer), writeStream);
167
+ return ok(destinationPath);
168
+ }
169
+
170
+ return this.error("INTERNAL_SERVER_ERROR", "Unsupported S3 body type");
171
+ });
172
+ }
173
+
174
+ getFileType(path: string): { fileType: keyof typeof fileTypes; extension: string } | undefined {
175
+ // determine the type of the file
176
+ const extension = path.split(".").pop();
177
+ if (!extension) return undefined;
178
+
179
+ for (const [key, value] of Object.entries(fileTypes)) {
180
+ if (value.extensions.includes(extension)) {
181
+ return { fileType: key, extension };
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ }