Skip to content

Commit

Permalink
Merge pull request #15 from erik1110/feature/api
Browse files Browse the repository at this point in the history
[feat] add image and shortenURL
  • Loading branch information
erik1110 committed Jan 22, 2024
2 parents 698d137 + 981c3fb commit 903c3f6
Show file tree
Hide file tree
Showing 14 changed files with 1,296 additions and 68 deletions.
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,14 @@ JWT_EXPIRATION=
EMAILER_USER=
EMAILER_PASSWORD=
PORT=
PRODUCTION_URL=
PRODUCTION_URL=
FIREBASE_TYPE=
FIREBASE_PROJECT_ID=
FIREBASE_PRIVATE_KEY_ID=
FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
FIREBASE_CLIENT_ID=
FIREBASE_AUTH_URI=
FIREBASE_TOKEN_URI=
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=
FIREBASE_CLIENT_X509_CERT_URL=
1,065 changes: 1,000 additions & 65 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@
"class-validator": "^0.14.0",
"cryptr": "^4.0.2",
"date-fns": "^3.0.6",
"firebase-admin": "^12.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.3",
"mongoose": "^8.1.0",
"mongoose-auto-increment": "^5.0.1",
"nestjs-object-id": "^1.2.0",
"nodemailer": "^6.9.7",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"request-ip": "^3.3.0",
"rxjs": "^7.8.1",
"shortid": "^2.2.16",
"validator": "^13.11.0"
},
"devDependencies": {
Expand Down
6 changes: 5 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { NewsModule } from './features/news/news.module';
import { CulinaryModule } from './features/culinary/culinary.module';
import { AppController } from './app.controller';
import { RoomModule } from './features/room/room.module';
import { OrderService } from './features/order/order.service';
import { OrderModule } from './features/order/order.module';
import { ImageModule } from './features/image/image.module';
import { UrlService } from './features/url/url.service';
import { UrlModule } from './features/url/url.module';

@Module({
imports: [
Expand All @@ -18,6 +20,8 @@ import { OrderModule } from './features/order/order.module';
NewsModule,
CulinaryModule,
RoomModule,
ImageModule,
UrlModule,
],
controllers: [AppController],
providers: [],
Expand Down
19 changes: 19 additions & 0 deletions src/features/image/dto/fileUpload.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";

export class FileUploadDto {
@ApiProperty({ type: 'string', format: 'binary' })
file: any;
}

export class GetImageSuccessDto {
@ApiProperty({ example: true })
status: boolean;

@ApiProperty({ example: '取得圖片網址' })
message: string;

@ApiProperty({
example: "http://localhost:3000/api/v1/url/HSIARDTXC"
})
data: object;
}
32 changes: 32 additions & 0 deletions src/features/image/firebase.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import * as admin from 'firebase-admin';

@Injectable()
export class FirebaseService {
private readonly storage: admin.storage.Storage;

readonly firebase_params = {
type: process.env.FIREBASE_TYPE,
project_id: process.env.FIREBASE_PROJECT_ID,
private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID,
private_key: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
client_email: process.env.FIREBASE_CLIENT_EMAIL,
client_id: process.env.FIREBASE_CLIENT_ID,
auth_uri: process.env.FIREBASE_AUTH_URI,
token_uri: process.env.FIREBASE_TOKEN_URI,
auth_provider_X509_cert_url: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
client_x509_cert_url: process.env.FIREBASE_CLIENT_X509_CERT_URL,
};

constructor(){
admin.initializeApp({
credential: admin.credential.cert(this.firebase_params as admin.ServiceAccount),
storageBucket: `${process.env.FIREBASE_PROJECT_ID}.appspot.com`,
});
this.storage = admin.storage();
}

getStorageInstance(): admin.storage.Storage{
return this.storage;
}
}
51 changes: 51 additions & 0 deletions src/features/image/image.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Controller, Get, HttpStatus, Param, Post, Res, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ImageService } from './image.service';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FileUploadDto, GetImageSuccessDto } from './dto/fileUpload.dto';
import { UrlService } from '../url/url.service';
import { AuthGuard } from '@nestjs/passport';
import { ApiErrorDecorator } from 'src/common/decorators/error/error.decorator';
import { getHttpResponse } from 'src/utils/successHandler';

@ApiTags('Image - 上傳圖片')
@ApiErrorDecorator(
HttpStatus.INTERNAL_SERVER_ERROR,
'CriticalError',
'系統錯誤,請洽系統管理員',
)
// @UseGuards(AuthGuard('jwt'))
// @ApiBearerAuth()
@Controller('/api/v1/image')
export class ImageController {
constructor(
private readonly imageService: ImageService,
private readonly urlService: UrlService,
) {}

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '上傳圖片 Upload an image' })
@ApiOkResponse({ type: GetImageSuccessDto })
@ApiBody({
description: "網址會由該服務進行轉址 The URL will be redirected by the service.",
type: FileUploadDto,
})
async uploadImage(@UploadedFile() file){
const imageUrl = await this.imageService.uploadImage(file);
let shortenUrl = await this.urlService.shortenUrl(imageUrl)
if (process.env.NODE_ENV === 'dev') {
shortenUrl = `http://localhost:${process.env.PORT}/api/v1/url/`;
} else {
shortenUrl = `https://${process.env.PRODUCTION_URL}/api/v1/url/`;
}

const shortenedUrl = await this.urlService.shortenUrl(imageUrl);
const redirectUrl = shortenUrl + shortenedUrl;
return getHttpResponse.successResponse({
message: '取得圖片網址',
data: redirectUrl,
});
}
}
13 changes: 13 additions & 0 deletions src/features/image/image.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FirebaseService } from './firebase.service';
import { ImageService } from './image.service';
import { ImageController } from './image.controller';
import { UrlService } from '../url/url.service';
import { UrlModule } from '../url/url.module';

@Module({
imports: [UrlModule],
controllers: [ImageController],
providers: [FirebaseService, ImageService]
})
export class ImageModule {}
68 changes: 68 additions & 0 deletions src/features/image/image.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { HttpStatus, Injectable } from "@nestjs/common";
import { FirebaseService } from "./firebase.service";
import { v4 as uuidv4 } from 'uuid';
import { GetSignedUrlConfig } from '@google-cloud/storage';
import { AppError } from "src/utils/appError";

@Injectable()
export class ImageService {
constructor(private readonly firebaseService: FirebaseService){}

async uploadImage(file): Promise<string> {
const maxSize = 3 * 1024 * 1024; // 3 MB in bytes
if (file.size > maxSize) {
throw new AppError(HttpStatus.BAD_REQUEST, 'UserError', '超過 3 MB');
}
const storage = this.firebaseService.getStorageInstance();
const bucket = storage.bucket();
const encodedOriginalName = encodeURIComponent(file.originalname);
const fileName = `${uuidv4()}_${encodedOriginalName}`;
const fileUpload = bucket.file(fileName);
const stream = fileUpload.createWriteStream({
metadata: {
contentType: file.mimetype,
}
});

return new Promise((resolve, reject) => {
stream.on('error', (error) =>{
reject(error);
});

stream.on('finish', async () => {
const imageUrl = await this.getFirebaseUrl(fileUpload);
resolve(imageUrl);
});

stream.end(file.buffer);
});
}

private async getFirebaseUrl(fileUpload): Promise<string> {
return new Promise((resolve, reject) => {
const blobStream = fileUpload.createReadStream();

const bucket = this.firebaseService.getStorageInstance().bucket();
const blob = bucket.file(fileUpload.name);

blobStream.pipe(blob.createWriteStream())
.on('finish', () => {
const config: GetSignedUrlConfig = {
action: 'read',
expires: '12-31-2024',
};

blob.getSignedUrl(config, (err, imgUrl) => {
if (err) {
reject(err);
} else {
resolve(imgUrl);
}
});
})
.on('error', (err) => {
reject(err);
});
});
}
}
6 changes: 6 additions & 0 deletions src/features/url/interfaces/url.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Document } from 'mongoose';

export interface IUrl extends Document {
originalUrl: string;
shortUrl: string,
};
15 changes: 15 additions & 0 deletions src/features/url/schemas/url.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { IUrl } from '../interfaces/url.interface';

@Schema({ timestamps: true, versionKey: false })
export class Url extends Document implements IUrl {
@Prop({ required: true })
originalUrl: string;

@Prop({ required: true })
shortUrl: string;

}

export const UrlSchema = SchemaFactory.createForClass(Url);
29 changes: 29 additions & 0 deletions src/features/url/url.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Controller, Get, HttpStatus, Param, Redirect, UseGuards } from '@nestjs/common';
import { UrlService } from './url.service';
import { ApiErrorDecorator } from 'src/common/decorators/error/error.decorator';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppError } from 'src/utils/appError';

@ApiTags('ShortenURL - 短網址')
@ApiErrorDecorator(
HttpStatus.INTERNAL_SERVER_ERROR,
'CriticalError',
'系統錯誤,請洽系統管理員',
)
@Controller('/api/v1/url')
export class UrlController {
constructor(private readonly urlService: UrlService) {}

@Get(':shortUrl')
@ApiOperation({ summary: '轉址短網址 Redirect short URL' })
@Redirect()
async redirectToOriginalUrl(@Param('shortUrl') shortUrl: string): Promise<{ url: string }> {
const originalUrl = await this.urlService.getOriginalUrl(shortUrl);

if (originalUrl) {
return { url: originalUrl };
} else {
throw new AppError(HttpStatus.BAD_REQUEST, 'UserError', '無此網址或網址已失效');
}
}
}
13 changes: 13 additions & 0 deletions src/features/url/url.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UrlSchema } from './schemas/url.schemas';
import { UrlService } from './url.service';
import { UrlController } from './url.controller';

@Module({
imports: [MongooseModule.forFeature([{ name: 'Url', schema: UrlSchema }])],
controllers: [UrlController],
providers: [UrlService],
exports: [UrlService],
})
export class UrlModule {}
30 changes: 30 additions & 0 deletions src/features/url/url.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { IUrl } from './interfaces/url.interface';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import * as shortid from 'shortid';

@Injectable()
export class UrlService {
constructor(@InjectModel('Url') private readonly urlModel: Model<IUrl>){}

async shortenUrl(originalUrl: string): Promise<string> {
const existingUrl = await this.urlModel.findOne({ originalUrl }).exec();

if (existingUrl) {
return existingUrl.shortUrl;
}

const shortUrl = shortid.generate();
const newUrl = new this.urlModel({ originalUrl, shortUrl });
await newUrl.save();

return shortUrl;
}

async getOriginalUrl(shortUrl: string): Promise<string | null> {
const url = await this.urlModel.findOne({ shortUrl }).exec();

return url ? url.originalUrl : null;
}
}

0 comments on commit 903c3f6

Please sign in to comment.