Skip to content

Commit 3fec3e9

Browse files
authored
feat: add auth, upload file (#3)
* feat: add auth, upload file * add message when password is incorrect
1 parent 5bcf5d1 commit 3fec3e9

File tree

30 files changed

+2394
-50
lines changed

30 files changed

+2394
-50
lines changed

.env.example

+9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ APP_NAME=Master File Service
33
APP_ENV=local
44
APP_PORT_HTTP=3000
55
APP_LOG=info
6+
APP_SECRET=
67

78
#database
89
DB_HOST=127.0.0.1
@@ -12,6 +13,14 @@ DB_PASSWORD=mongo
1213
DB_NAME=
1314
DB_AUTH_SOURCE=
1415

16+
#JWT
17+
JWT_ACCESS_SECRET=
18+
1519
#File
1620
FILE_URL=
1721

22+
#AWS
23+
AWS_ACCESS_KEY_ID=
24+
AWS_SECRET_ACCESS_KEY=
25+
AWS_REGION=
26+
AWS_BUCKET=

package-lock.json

+1,834-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@types/i18n": "^0.13.6",
3030
"@types/jest": "^29.5.0",
3131
"@types/jsonwebtoken": "^9.0.1",
32+
"@types/multer": "^1.4.11",
3233
"@types/node": "^18.11.18",
3334
"jest": "^29.5.0",
3435
"nodemon": "^2.0.20",
@@ -39,6 +40,7 @@
3940
"typescript": "^4.9.4"
4041
},
4142
"dependencies": {
43+
"@aws-sdk/client-s3": "^3.310.0",
4244
"body-parser": "^1.20.1",
4345
"compression": "^1.7.4",
4446
"cors": "^2.8.5",
@@ -52,7 +54,9 @@
5254
"luxon": "^3.3.0",
5355
"mime-types": "^2.1.35",
5456
"mongoose": "^6.8.3",
57+
"multer": "^1.4.5-lts.1",
5558
"redis": "^4.5.1",
59+
"slugify": "^1.6.6",
5660
"winston": "^3.8.2"
5761
}
5862
}

src/config/config.interface.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface Config {
66
http: number
77
}
88
log: string
9+
secret: string
910
}
1011
db: {
1112
host: string
@@ -25,6 +26,14 @@ export interface Config {
2526
ttl: number
2627
}
2728
file: {
28-
url: string
29+
max: number
30+
type: string[]
31+
uri: string
32+
}
33+
aws: {
34+
access_key_id: string
35+
secret_access_key: string
36+
bucket: string
37+
region: string
2938
}
3039
}

src/config/config.schema.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@ export default Joi.object({
77
.default('local'),
88
APP_PORT_HTTP: Joi.number().required(),
99
APP_LOG: Joi.string().valid('info', 'error', 'warn').required(),
10-
FILE_URL: Joi.string().uri().optional(),
10+
APP_SECRET: Joi.string().required(),
11+
FILE_URI: Joi.string().uri().optional(),
12+
FILE_TYPE: Joi.string()
13+
.optional()
14+
.default('image/jpg,image/png,image/jpeg,image/svg+xml'),
15+
FILE_MAX: Joi.number().optional().default(10),
1116
DB_HOST: Joi.string().required(),
1217
DB_PORT: Joi.number().required(),
1318
DB_USERNAME: Joi.string().required(),
1419
DB_PASSWORD: Joi.string().required(),
1520
DB_NAME: Joi.string().required(),
1621
DB_AUTH_SOURCE: Joi.string().optional(),
22+
AWS_ACCESS_KEY_ID: Joi.string().optional(),
23+
AWS_SECRET_ACCESS_KEY: Joi.string().optional(),
24+
AWS_BUCKET: Joi.string().optional(),
25+
AWS_REGION: Joi.string().optional(),
26+
JWT_ACCESS_SECRET: Joi.string().required(),
27+
JWT_ALGORITHM: Joi.string().default('HS256'),
1728
})

src/config/config.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const config: Config = {
1414
http: env.APP_PORT_HTTP,
1515
},
1616
log: env.APP_LOG,
17+
secret: env.APP_SECRET,
1718
},
1819
db: {
1920
host: env.DB_HOST,
@@ -33,7 +34,15 @@ const config: Config = {
3334
ttl: env.REDIS_TTL,
3435
},
3536
file: {
36-
url: env.FILE_URL,
37+
max: Number(env.FILE_MAX) * 1024 * 1024, // MB
38+
type: env.FILE_TYPE.split(','),
39+
uri: env.FILE_URI,
40+
},
41+
aws: {
42+
access_key_id: env.AWS_ACCESS_KEY_ID,
43+
secret_access_key: env.AWS_SECRET_ACCESS_KEY,
44+
bucket: env.AWS_BUCKET,
45+
region: env.AWS_REGION,
3746
},
3847
}
3948

src/database/constant/image.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const categories = ['logo', 'aduan']

src/database/mongo/schemas/image.schema.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Schema } from 'mongoose'
22
import Mongo from '../mongo'
3+
import config from '../../../config/config'
34

45
const schema = new Schema(
56
{
@@ -13,20 +14,15 @@ const schema = new Schema(
1314
},
1415
caption: {
1516
type: String,
16-
default: null,
1717
},
1818
category: {
1919
type: String,
20-
required: true,
2120
},
2221
title: {
2322
type: String,
24-
required: true,
2523
},
2624
description: {
2725
type: String,
28-
required: false,
29-
default: null,
3026
},
3127
tags: {
3228
type: [String],
@@ -42,4 +38,9 @@ const schema = new Schema(
4238
}
4339
)
4440

41+
schema.pre('save', function (next) {
42+
if (this.file?.path) this.file.uri = `${config.file.uri}${this.file.path}`
43+
next()
44+
})
45+
4546
export default Mongo.Model('images', schema)

src/database/seeds/logo.seed.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const run = async () => {
2424
mimetype: 'image/svg+xml',
2525
originalname: filename,
2626
filename,
27-
uri: GetFileUrl(config.file.url, path),
27+
uri: GetFileUrl(config.file.uri, path),
2828
},
2929
category,
3030
tags: [title, category],

src/external/s3.ts

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
S3Client,
3+
PutObjectCommand,
4+
DeleteObjectCommand,
5+
DeleteObjectsCommand,
6+
} from '@aws-sdk/client-s3'
7+
import { Config } from '../config/config.interface'
8+
import error from '../pkg/error'
9+
10+
class S3 {
11+
private client: S3Client
12+
constructor(private config: Config) {
13+
this.client = new S3Client({
14+
region: config.aws.region,
15+
credentials: {
16+
accessKeyId: config.aws.access_key_id,
17+
secretAccessKey: config.aws.secret_access_key,
18+
},
19+
})
20+
}
21+
22+
public async Upload(source: Buffer, path: string, ContentType: string) {
23+
try {
24+
const params = {
25+
Bucket: this.config.aws.bucket,
26+
Key: path,
27+
Body: source,
28+
ContentType,
29+
CacheControl: 'no-cache',
30+
}
31+
const command = new PutObjectCommand(params)
32+
const result = await this.client.send(command)
33+
34+
return result
35+
} catch (err: any) {
36+
const code = err.$metadata.httpStatusCode as number
37+
throw new error(code, 'cloud storage: ' + err.message)
38+
}
39+
}
40+
41+
public async Delete(path: string) {
42+
try {
43+
const command = new DeleteObjectCommand({
44+
Bucket: this.config.aws.bucket,
45+
Key: path,
46+
})
47+
const result = await this.client.send(command)
48+
return result
49+
} catch (err: any) {
50+
const code = err.$metadata.httpStatusCode as number
51+
throw new error(code, 'cloud storage: ' + err.message)
52+
}
53+
}
54+
55+
public async Deletes(paths: string[]) {
56+
try {
57+
const command = new DeleteObjectsCommand({
58+
Bucket: this.config.aws.bucket,
59+
Delete: {
60+
Objects: paths.map((Key) => {
61+
return { Key }
62+
}),
63+
},
64+
})
65+
const result = await this.client.send(command)
66+
return result
67+
} catch (err: any) {
68+
const code = err.$metadata.httpStatusCode as number
69+
throw new error(code, 'cloud storage: ' + err.message)
70+
}
71+
}
72+
}
73+
74+
export default S3

src/helpers/file.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import path from 'path'
2+
3+
export const CustomPathFile = (newPath: string, file: any) => {
4+
const ext = path.extname(file.filename)
5+
if (!ext) file.filename = file.filename + path.extname(file.originalname)
6+
return `${newPath}/${file.filename}`
7+
}

src/helpers/regex.test.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
RegexWordScript,
3+
RegexSubdomain,
4+
RegexSanitize,
5+
RegexObjectID,
6+
RegexContentTypeImage,
7+
} from './regex'
8+
9+
describe('RegexWordScript', () => {
10+
test('should not match "script"', () => {
11+
expect(RegexWordScript.test('script')).toBe(false)
12+
})
13+
14+
test('should match valid words', () => {
15+
expect(RegexWordScript.test('validWord')).toBe(true)
16+
expect(RegexWordScript.test('anotherWord')).toBe(true)
17+
})
18+
})
19+
20+
describe('RegexSubdomain', () => {
21+
test('should match valid subdomains', () => {
22+
expect(RegexSubdomain.test('example')).toBe(true)
23+
expect(RegexSubdomain.test('sub-domain')).toBe(true)
24+
expect(RegexSubdomain.test('123')).toBe(true)
25+
})
26+
27+
test('should not match invalid subdomains', () => {
28+
expect(RegexSubdomain.test('Invalid Subdomain')).toBe(false)
29+
expect(RegexSubdomain.test('sub_domain')).toBe(false)
30+
expect(RegexSubdomain.test('invalid@subdomain')).toBe(false)
31+
})
32+
})
33+
34+
describe('RegexSanitize', () => {
35+
test('should match valid sanitized strings', () => {
36+
expect(RegexSanitize.test('Valid String 123,.-()\'"&')).toBe(true)
37+
expect(RegexSanitize.test('Another String')).toBe(true)
38+
})
39+
40+
test('should not match invalid sanitized strings', () => {
41+
expect(RegexSanitize.test('Invalid_String@123')).toBe(false)
42+
expect(RegexSanitize.test('Invalid!@#String')).toBe(false)
43+
})
44+
})
45+
46+
describe('RegexObjectID Test', () => {
47+
test('Valid ObjectID should match the regex', () => {
48+
const validObjectID = '5f63a32b49ce3c4a8c4d8d1a'
49+
expect(validObjectID).toMatch(RegexObjectID)
50+
})
51+
52+
test('Invalid ObjectID should not match the regex', () => {
53+
const invalidObjectID = 'invalidObjectID'
54+
expect(invalidObjectID).not.toMatch(RegexObjectID)
55+
})
56+
57+
test('Empty string should not match the regex', () => {
58+
const emptyString = ''
59+
expect(emptyString).not.toMatch(RegexObjectID)
60+
})
61+
})
62+
63+
describe('RegexContentTypeImage', () => {
64+
it('should match valid image content type', () => {
65+
const validContentTypes = [
66+
'image/jpeg',
67+
'image/png',
68+
'image/gif',
69+
'image/svg+xml',
70+
]
71+
72+
validContentTypes.forEach((contentType) => {
73+
expect(RegexContentTypeImage.test(contentType)).toBe(true)
74+
})
75+
})
76+
77+
it('should not match invalid content types', () => {
78+
const invalidContentTypes = [
79+
'text/plain',
80+
'application/json',
81+
'image',
82+
'',
83+
]
84+
85+
invalidContentTypes.forEach((contentType) => {
86+
expect(RegexContentTypeImage.test(contentType)).toBe(false)
87+
})
88+
})
89+
})

src/helpers/regex.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const RegexWordScript = /\b(?!script\b)\w+\b/i
2+
export const RegexSubdomain = /^[ a-z0-9-]+$/
3+
export const RegexSanitize = /^[ a-zA-Z0-9_,.()'"&\?\-/]+$/
4+
export const RegexObjectID = /^[0-9a-fA-F]{24}$/
5+
export const RegexContentTypeImage = /^image\//
6+
export const RegexExtensionImage = /.png|.jpg|.jpeg/i

src/helpers/slug.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import slugify from 'slugify'
2+
3+
export const getSlug = (str: string) => {
4+
return slugify(str).toLowerCase()
5+
}

src/main.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import config from './config/config'
22
import Mongo from './database/mongo/mongo'
3+
import Auth from './modules/auth/auth'
34
import Images from './modules/images/images'
45
import Logger from './pkg/logger'
56
import Http from './transport/http/http'
@@ -11,6 +12,7 @@ const main = async () => {
1112

1213
// Start Load Modules
1314
new Images(logger, http, config)
15+
new Auth(logger, http, config)
1416
// End Load Modules
1517

1618
http.Run(config.app.port.http)

0 commit comments

Comments
 (0)